mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-03-25 01:22:45 +00:00
Compare commits
12 Commits
nikg/group
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b20a5ebf69 | ||
|
|
8645adb807 | ||
|
|
2425bd4d8d | ||
|
|
333b2b19cb | ||
|
|
44895b3bd6 | ||
|
|
78c2ecf99f | ||
|
|
e3e0e04edc | ||
|
|
a19fe03bd8 | ||
|
|
415c05b5f8 | ||
|
|
352fd19f0a | ||
|
|
41ae039bfa | ||
|
|
782c734287 |
@@ -115,14 +115,8 @@ def fetch_user_group_token_rate_limits_for_user(
|
||||
ordered: bool = True,
|
||||
get_editable: bool = True,
|
||||
) -> Sequence[TokenRateLimit]:
|
||||
stmt = (
|
||||
select(TokenRateLimit)
|
||||
.join(
|
||||
TokenRateLimit__UserGroup,
|
||||
TokenRateLimit.id == TokenRateLimit__UserGroup.rate_limit_id,
|
||||
)
|
||||
.where(TokenRateLimit__UserGroup.user_group_id == group_id)
|
||||
)
|
||||
stmt = select(TokenRateLimit)
|
||||
stmt = stmt.where(User__UserGroup.user_group_id == group_id)
|
||||
stmt = _add_user_filters(stmt, user, get_editable)
|
||||
|
||||
if enabled_only:
|
||||
|
||||
@@ -4,6 +4,7 @@ from fastapi import HTTPException
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ee.onyx.db.persona import update_persona_access
|
||||
from ee.onyx.db.user_group import add_users_to_user_group
|
||||
from ee.onyx.db.user_group import delete_user_group as db_delete_user_group
|
||||
from ee.onyx.db.user_group import fetch_user_group
|
||||
@@ -17,6 +18,7 @@ from ee.onyx.db.user_group import update_user_group
|
||||
from ee.onyx.server.user_group.models import AddUsersToUserGroupRequest
|
||||
from ee.onyx.server.user_group.models import MinimalUserGroupSnapshot
|
||||
from ee.onyx.server.user_group.models import SetCuratorRequest
|
||||
from ee.onyx.server.user_group.models import UpdateGroupAgentsRequest
|
||||
from ee.onyx.server.user_group.models import UserGroup
|
||||
from ee.onyx.server.user_group.models import UserGroupCreate
|
||||
from ee.onyx.server.user_group.models import UserGroupRename
|
||||
@@ -29,6 +31,7 @@ from onyx.configs.constants import PUBLIC_API_TAGS
|
||||
from onyx.db.engine.sql_engine import get_session
|
||||
from onyx.db.models import User
|
||||
from onyx.db.models import UserRole
|
||||
from onyx.db.persona import get_persona_by_id
|
||||
from onyx.error_handling.error_codes import OnyxErrorCode
|
||||
from onyx.error_handling.exceptions import OnyxError
|
||||
from onyx.utils.logger import setup_logger
|
||||
@@ -191,3 +194,38 @@ def delete_user_group(
|
||||
user_group = fetch_user_group(db_session, user_group_id)
|
||||
if user_group:
|
||||
db_delete_user_group(db_session, user_group)
|
||||
|
||||
|
||||
@router.patch("/admin/user-group/{user_group_id}/agents")
|
||||
def update_group_agents(
|
||||
user_group_id: int,
|
||||
request: UpdateGroupAgentsRequest,
|
||||
user: User = Depends(current_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> None:
|
||||
for agent_id in request.added_agent_ids:
|
||||
persona = get_persona_by_id(
|
||||
persona_id=agent_id, user=user, db_session=db_session
|
||||
)
|
||||
current_group_ids = [g.id for g in persona.groups]
|
||||
if user_group_id not in current_group_ids:
|
||||
update_persona_access(
|
||||
persona_id=agent_id,
|
||||
creator_user_id=user.id,
|
||||
db_session=db_session,
|
||||
group_ids=current_group_ids + [user_group_id],
|
||||
)
|
||||
|
||||
for agent_id in request.removed_agent_ids:
|
||||
persona = get_persona_by_id(
|
||||
persona_id=agent_id, user=user, db_session=db_session
|
||||
)
|
||||
current_group_ids = [g.id for g in persona.groups]
|
||||
update_persona_access(
|
||||
persona_id=agent_id,
|
||||
creator_user_id=user.id,
|
||||
db_session=db_session,
|
||||
group_ids=[gid for gid in current_group_ids if gid != user_group_id],
|
||||
)
|
||||
|
||||
db_session.commit()
|
||||
|
||||
@@ -112,3 +112,8 @@ class UserGroupRename(BaseModel):
|
||||
class SetCuratorRequest(BaseModel):
|
||||
user_id: UUID
|
||||
is_curator: bool
|
||||
|
||||
|
||||
class UpdateGroupAgentsRequest(BaseModel):
|
||||
added_agent_ids: list[int]
|
||||
removed_agent_ids: list[int]
|
||||
|
||||
@@ -59,6 +59,7 @@ from onyx.db.chat import create_new_chat_message
|
||||
from onyx.db.chat import get_chat_session_by_id
|
||||
from onyx.db.chat import get_or_create_root_message
|
||||
from onyx.db.chat import reserve_message_id
|
||||
from onyx.db.enums import HookPoint
|
||||
from onyx.db.memory import get_memories
|
||||
from onyx.db.models import ChatMessage
|
||||
from onyx.db.models import ChatSession
|
||||
@@ -68,11 +69,19 @@ from onyx.db.models import UserFile
|
||||
from onyx.db.projects import get_user_files_from_project
|
||||
from onyx.db.tools import get_tools
|
||||
from onyx.deep_research.dr_loop import run_deep_research_llm_loop
|
||||
from onyx.error_handling.error_codes import OnyxErrorCode
|
||||
from onyx.error_handling.exceptions import log_onyx_error
|
||||
from onyx.error_handling.exceptions import OnyxError
|
||||
from onyx.file_processing.extract_file_text import extract_file_text
|
||||
from onyx.file_store.models import ChatFileType
|
||||
from onyx.file_store.models import InMemoryChatFile
|
||||
from onyx.file_store.utils import load_in_memory_chat_files
|
||||
from onyx.file_store.utils import verify_user_files
|
||||
from onyx.hooks.executor import execute_hook
|
||||
from onyx.hooks.executor import HookSkipped
|
||||
from onyx.hooks.executor import HookSoftFailed
|
||||
from onyx.hooks.points.query_processing import QueryProcessingPayload
|
||||
from onyx.hooks.points.query_processing import QueryProcessingResponse
|
||||
from onyx.llm.factory import get_llm_for_persona
|
||||
from onyx.llm.factory import get_llm_token_counter
|
||||
from onyx.llm.interfaces import LLM
|
||||
@@ -424,6 +433,28 @@ def determine_search_params(
|
||||
)
|
||||
|
||||
|
||||
def _resolve_query_processing_hook_result(
|
||||
hook_result: QueryProcessingResponse | HookSkipped | HookSoftFailed,
|
||||
message_text: str,
|
||||
) -> str:
|
||||
"""Apply the Query Processing hook result to the message text.
|
||||
|
||||
Returns the (possibly rewritten) message text, or raises OnyxError with
|
||||
QUERY_REJECTED if the hook signals rejection (query is null or empty).
|
||||
HookSkipped and HookSoftFailed are pass-throughs — the original text is
|
||||
returned unchanged.
|
||||
"""
|
||||
if isinstance(hook_result, (HookSkipped, HookSoftFailed)):
|
||||
return message_text
|
||||
if not (hook_result.query and hook_result.query.strip()):
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.QUERY_REJECTED,
|
||||
hook_result.rejection_message
|
||||
or "The hook extension for query processing did not return a valid query. No rejection reason was provided.",
|
||||
)
|
||||
return hook_result.query.strip()
|
||||
|
||||
|
||||
def handle_stream_message_objects(
|
||||
new_msg_req: SendMessageRequest,
|
||||
user: User,
|
||||
@@ -491,6 +522,7 @@ def handle_stream_message_objects(
|
||||
persona = chat_session.persona
|
||||
|
||||
message_text = new_msg_req.message
|
||||
|
||||
user_identity = LLMUserIdentity(
|
||||
user_id=llm_user_identifier, session_id=str(chat_session.id)
|
||||
)
|
||||
@@ -582,6 +614,28 @@ def handle_stream_message_objects(
|
||||
if parent_message.message_type == MessageType.USER:
|
||||
user_message = parent_message
|
||||
else:
|
||||
# New message — run the Query Processing hook before saving to DB.
|
||||
# Skipped on regeneration: the message already exists and was accepted previously.
|
||||
# Skip the hook for empty/whitespace-only messages — no meaningful query
|
||||
# to process, and SendMessageRequest.message has no min_length guard.
|
||||
if message_text.strip():
|
||||
hook_result = execute_hook(
|
||||
db_session=db_session,
|
||||
hook_point=HookPoint.QUERY_PROCESSING,
|
||||
payload=QueryProcessingPayload(
|
||||
query=message_text,
|
||||
# Pass None for anonymous users or authenticated users without an email
|
||||
# (e.g. some SSO flows). QueryProcessingPayload.user_email is str | None,
|
||||
# so None is accepted and serialised as null in both cases.
|
||||
user_email=None if user.is_anonymous else user.email,
|
||||
chat_session_id=str(chat_session.id),
|
||||
).model_dump(),
|
||||
response_type=QueryProcessingResponse,
|
||||
)
|
||||
message_text = _resolve_query_processing_hook_result(
|
||||
hook_result, message_text
|
||||
)
|
||||
|
||||
user_message = create_new_chat_message(
|
||||
chat_session_id=chat_session.id,
|
||||
parent_message=parent_message,
|
||||
@@ -921,6 +975,17 @@ def handle_stream_message_objects(
|
||||
state_container=state_container,
|
||||
)
|
||||
|
||||
except OnyxError as e:
|
||||
if e.error_code is not OnyxErrorCode.QUERY_REJECTED:
|
||||
log_onyx_error(e)
|
||||
yield StreamingError(
|
||||
error=e.detail,
|
||||
error_code=e.error_code.code,
|
||||
is_retryable=e.status_code >= 500,
|
||||
)
|
||||
db_session.rollback()
|
||||
return
|
||||
|
||||
except ValueError as e:
|
||||
logger.exception("Failed to process chat message.")
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ class OnyxErrorCode(Enum):
|
||||
VALIDATION_ERROR = ("VALIDATION_ERROR", 400)
|
||||
INVALID_INPUT = ("INVALID_INPUT", 400)
|
||||
MISSING_REQUIRED_FIELD = ("MISSING_REQUIRED_FIELD", 400)
|
||||
QUERY_REJECTED = ("QUERY_REJECTED", 400)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Not Found (404)
|
||||
|
||||
@@ -5,6 +5,7 @@ Usage (Celery tasks and FastAPI handlers):
|
||||
db_session=db_session,
|
||||
hook_point=HookPoint.QUERY_PROCESSING,
|
||||
payload={"query": "...", "user_email": "...", "chat_session_id": "..."},
|
||||
response_type=QueryProcessingResponse,
|
||||
)
|
||||
|
||||
if isinstance(result, HookSkipped):
|
||||
@@ -14,7 +15,7 @@ Usage (Celery tasks and FastAPI handlers):
|
||||
# hook failed but fail strategy is SOFT — continue with original behavior
|
||||
...
|
||||
else:
|
||||
# result is the response payload dict from the customer's endpoint
|
||||
# result is a validated Pydantic model instance (spec.response_model)
|
||||
...
|
||||
|
||||
is_reachable update policy
|
||||
@@ -53,9 +54,11 @@ The executor uses three sessions:
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
from typing import TypeVar
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.db.engine.sql_engine import get_session_with_current_tenant
|
||||
@@ -81,6 +84,9 @@ class HookSoftFailed:
|
||||
"""Hook was called but failed with SOFT fail strategy — continuing."""
|
||||
|
||||
|
||||
T = TypeVar("T", bound=BaseModel)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Private helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -268,22 +274,21 @@ def _persist_result(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def execute_hook(
|
||||
*,
|
||||
db_session: Session,
|
||||
hook_point: HookPoint,
|
||||
def _execute_hook_inner(
|
||||
hook: Hook,
|
||||
payload: dict[str, Any],
|
||||
) -> dict[str, Any] | HookSkipped | HookSoftFailed:
|
||||
"""Execute the hook for the given hook point synchronously."""
|
||||
hook = _lookup_hook(db_session, hook_point)
|
||||
if isinstance(hook, HookSkipped):
|
||||
return hook
|
||||
response_type: type[T],
|
||||
) -> T | HookSoftFailed:
|
||||
"""Make the HTTP call, validate the response, and return a typed model.
|
||||
|
||||
Raises OnyxError on HARD failure. Returns HookSoftFailed on SOFT failure.
|
||||
"""
|
||||
timeout = hook.timeout_seconds
|
||||
hook_id = hook.id
|
||||
fail_strategy = hook.fail_strategy
|
||||
endpoint_url = hook.endpoint_url
|
||||
current_is_reachable: bool | None = hook.is_reachable
|
||||
|
||||
if not endpoint_url:
|
||||
raise ValueError(
|
||||
f"hook_id={hook_id} is active but has no endpoint_url — "
|
||||
@@ -300,13 +305,36 @@ def execute_hook(
|
||||
headers: dict[str, str] = {"Content-Type": "application/json"}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
with httpx.Client(timeout=timeout) as client:
|
||||
with httpx.Client(
|
||||
timeout=timeout, follow_redirects=False
|
||||
) as client: # SSRF guard: never follow redirects
|
||||
response = client.post(endpoint_url, json=payload, headers=headers)
|
||||
except Exception as e:
|
||||
exc = e
|
||||
duration_ms = int((time.monotonic() - start) * 1000)
|
||||
|
||||
outcome = _process_response(response=response, exc=exc, timeout=timeout)
|
||||
|
||||
# Validate the response payload against response_type.
|
||||
# A validation failure downgrades the outcome to a failure so it is logged,
|
||||
# is_reachable is left unchanged (server responded — just a bad payload),
|
||||
# and fail_strategy is respected below.
|
||||
validated_model: T | None = None
|
||||
if outcome.is_success and outcome.response_payload is not None:
|
||||
try:
|
||||
validated_model = response_type.model_validate(outcome.response_payload)
|
||||
except ValidationError as e:
|
||||
msg = (
|
||||
f"Hook response failed validation against {response_type.__name__}: {e}"
|
||||
)
|
||||
outcome = _HttpOutcome(
|
||||
is_success=False,
|
||||
updated_is_reachable=None, # server responded — reachability unchanged
|
||||
status_code=outcome.status_code,
|
||||
error_message=msg,
|
||||
response_payload=None,
|
||||
)
|
||||
|
||||
# Skip the is_reachable write when the value would not change — avoids a
|
||||
# no-op DB round-trip on every call when the hook is already in the expected state.
|
||||
if outcome.updated_is_reachable == current_is_reachable:
|
||||
@@ -323,8 +351,41 @@ def execute_hook(
|
||||
f"Hook execution failed (soft fail) for hook_id={hook_id}: {outcome.error_message}"
|
||||
)
|
||||
return HookSoftFailed()
|
||||
if outcome.response_payload is None:
|
||||
raise ValueError(
|
||||
f"response_payload is None for successful hook call (hook_id={hook_id})"
|
||||
|
||||
if validated_model is None:
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.INTERNAL_ERROR,
|
||||
f"validated_model is None for successful hook call (hook_id={hook_id})",
|
||||
)
|
||||
return outcome.response_payload
|
||||
return validated_model
|
||||
|
||||
|
||||
def execute_hook(
|
||||
*,
|
||||
db_session: Session,
|
||||
hook_point: HookPoint,
|
||||
payload: dict[str, Any],
|
||||
response_type: type[T],
|
||||
) -> T | HookSkipped | HookSoftFailed:
|
||||
"""Execute the hook for the given hook point synchronously.
|
||||
|
||||
Returns HookSkipped if no active hook is configured, HookSoftFailed if the
|
||||
hook failed with SOFT fail strategy, or a validated response model on success.
|
||||
Raises OnyxError on HARD failure or if the hook is misconfigured.
|
||||
"""
|
||||
hook = _lookup_hook(db_session, hook_point)
|
||||
if isinstance(hook, HookSkipped):
|
||||
return hook
|
||||
|
||||
fail_strategy = hook.fail_strategy
|
||||
hook_id = hook.id
|
||||
|
||||
try:
|
||||
return _execute_hook_inner(hook, payload, response_type)
|
||||
except Exception:
|
||||
if fail_strategy == HookFailStrategy.SOFT:
|
||||
logger.exception(
|
||||
f"Unexpected error in hook execution (soft fail) for hook_id={hook_id}"
|
||||
)
|
||||
return HookSoftFailed()
|
||||
raise
|
||||
|
||||
@@ -51,13 +51,12 @@ class HookPointSpec:
|
||||
output_schema: ClassVar[dict[str, Any]]
|
||||
|
||||
def __init_subclass__(cls, **kwargs: object) -> None:
|
||||
"""Enforce that every concrete subclass declares all required class attributes.
|
||||
"""Enforce that every subclass declares all required class attributes.
|
||||
|
||||
Called automatically by Python whenever a class inherits from HookPointSpec.
|
||||
Abstract subclasses (those still carrying unimplemented abstract methods) are
|
||||
skipped — they are intermediate base classes and may not yet define everything.
|
||||
Only fully concrete subclasses are validated, ensuring a clear TypeError at
|
||||
import time rather than a confusing AttributeError at runtime.
|
||||
Raises TypeError at import time if any required attribute is missing or if
|
||||
payload_model / response_model are not Pydantic BaseModel subclasses.
|
||||
input_schema and output_schema are derived automatically from the models.
|
||||
"""
|
||||
super().__init_subclass__(**kwargs)
|
||||
missing = [attr for attr in _REQUIRED_ATTRS if not hasattr(cls, attr)]
|
||||
|
||||
@@ -26,6 +26,8 @@ class DocumentIngestionSpec(HookPointSpec):
|
||||
default_timeout_seconds = 30.0
|
||||
fail_hard_description = "The document will not be indexed."
|
||||
default_fail_strategy = HookFailStrategy.HARD
|
||||
# TODO(Bo-Onyx): update later
|
||||
docs_url = "https://docs.google.com/document/d/1pGhB8Wcnhhj8rS4baEJL6CX05yFhuIDNk1gbBRiWu94/edit?tab=t.ue263ual5vdi"
|
||||
|
||||
payload_model = DocumentIngestionPayload
|
||||
response_model = DocumentIngestionResponse
|
||||
|
||||
@@ -15,7 +15,7 @@ class QueryProcessingPayload(BaseModel):
|
||||
description="Email of the user submitting the query, or null if unauthenticated."
|
||||
)
|
||||
chat_session_id: str = Field(
|
||||
description="UUID of the chat session. Always present — the session is guaranteed to exist by the time this hook fires."
|
||||
description="UUID of the chat session, formatted as a hyphenated lowercase string (e.g. '550e8400-e29b-41d4-a716-446655440000'). Always present — the session is guaranteed to exist by the time this hook fires."
|
||||
)
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ class QueryProcessingResponse(BaseModel):
|
||||
default=None,
|
||||
description=(
|
||||
"The query to use in the pipeline. "
|
||||
"Null, empty string, or absent = reject the query."
|
||||
"Null, empty string, whitespace-only, or absent = reject the query."
|
||||
),
|
||||
)
|
||||
rejection_message: str | None = Field(
|
||||
@@ -65,6 +65,8 @@ class QueryProcessingSpec(HookPointSpec):
|
||||
"The query will be blocked and the user will see an error message."
|
||||
)
|
||||
default_fail_strategy = HookFailStrategy.HARD
|
||||
# TODO(Bo-Onyx): update later
|
||||
docs_url = "https://docs.google.com/document/d/1pGhB8Wcnhhj8rS4baEJL6CX05yFhuIDNk1gbBRiWu94/edit?tab=t.g2r1a1699u87"
|
||||
|
||||
payload_model = QueryProcessingPayload
|
||||
response_model = QueryProcessingResponse
|
||||
|
||||
@@ -17,6 +17,7 @@ from onyx.db.models import User
|
||||
from onyx.db.notification import dismiss_all_notifications
|
||||
from onyx.db.notification import get_notifications
|
||||
from onyx.db.notification import update_notification_last_shown
|
||||
from onyx.hooks.utils import HOOKS_AVAILABLE
|
||||
from onyx.key_value_store.factory import get_kv_store
|
||||
from onyx.key_value_store.interface import KvKeyNotFoundError
|
||||
from onyx.server.features.build.utils import is_onyx_craft_enabled
|
||||
@@ -80,6 +81,7 @@ def fetch_settings(
|
||||
needs_reindexing=needs_reindexing,
|
||||
onyx_craft_enabled=onyx_craft_enabled_for_user,
|
||||
vector_db_enabled=not DISABLE_VECTOR_DB,
|
||||
hooks_enabled=HOOKS_AVAILABLE,
|
||||
version=onyx_version,
|
||||
)
|
||||
|
||||
|
||||
@@ -104,5 +104,7 @@ class UserSettings(Settings):
|
||||
# False when DISABLE_VECTOR_DB is set — connectors, RAG search, and
|
||||
# document sets are unavailable.
|
||||
vector_db_enabled: bool = True
|
||||
# True when hooks are available: single-tenant deployment with HOOK_ENABLED=true.
|
||||
hooks_enabled: bool = False
|
||||
# Application version, read from the ONYX_VERSION env var at startup.
|
||||
version: str | None = None
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import pytest
|
||||
|
||||
from onyx.chat.process_message import _resolve_query_processing_hook_result
|
||||
from onyx.chat.process_message import remove_answer_citations
|
||||
from onyx.error_handling.error_codes import OnyxErrorCode
|
||||
from onyx.error_handling.exceptions import OnyxError
|
||||
from onyx.hooks.executor import HookSkipped
|
||||
from onyx.hooks.executor import HookSoftFailed
|
||||
from onyx.hooks.points.query_processing import QueryProcessingResponse
|
||||
|
||||
|
||||
def test_remove_answer_citations_strips_http_markdown_citation() -> None:
|
||||
@@ -32,3 +40,81 @@ def test_remove_answer_citations_preserves_non_citation_markdown_links() -> None
|
||||
remove_answer_citations(answer)
|
||||
== "See [reference](https://example.com/Function_(mathematics)) for context."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Query Processing hook response handling (_resolve_query_processing_hook_result)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_hook_skipped_leaves_message_text_unchanged() -> None:
|
||||
result = _resolve_query_processing_hook_result(HookSkipped(), "original query")
|
||||
assert result == "original query"
|
||||
|
||||
|
||||
def test_hook_soft_failed_leaves_message_text_unchanged() -> None:
|
||||
result = _resolve_query_processing_hook_result(HookSoftFailed(), "original query")
|
||||
assert result == "original query"
|
||||
|
||||
|
||||
def test_null_query_raises_query_rejected() -> None:
|
||||
with pytest.raises(OnyxError) as exc_info:
|
||||
_resolve_query_processing_hook_result(
|
||||
QueryProcessingResponse(query=None), "original query"
|
||||
)
|
||||
assert exc_info.value.error_code is OnyxErrorCode.QUERY_REJECTED
|
||||
|
||||
|
||||
def test_empty_string_query_raises_query_rejected() -> None:
|
||||
"""Empty string is falsy — must be treated as rejection, same as None."""
|
||||
with pytest.raises(OnyxError) as exc_info:
|
||||
_resolve_query_processing_hook_result(
|
||||
QueryProcessingResponse(query=""), "original query"
|
||||
)
|
||||
assert exc_info.value.error_code is OnyxErrorCode.QUERY_REJECTED
|
||||
|
||||
|
||||
def test_whitespace_only_query_raises_query_rejected() -> None:
|
||||
"""Whitespace-only string is truthy but meaningless — must be treated as rejection."""
|
||||
with pytest.raises(OnyxError) as exc_info:
|
||||
_resolve_query_processing_hook_result(
|
||||
QueryProcessingResponse(query=" "), "original query"
|
||||
)
|
||||
assert exc_info.value.error_code is OnyxErrorCode.QUERY_REJECTED
|
||||
|
||||
|
||||
def test_absent_query_field_raises_query_rejected() -> None:
|
||||
"""query defaults to None when not provided."""
|
||||
with pytest.raises(OnyxError) as exc_info:
|
||||
_resolve_query_processing_hook_result(
|
||||
QueryProcessingResponse(), "original query"
|
||||
)
|
||||
assert exc_info.value.error_code is OnyxErrorCode.QUERY_REJECTED
|
||||
|
||||
|
||||
def test_rejection_message_surfaced_in_error_when_provided() -> None:
|
||||
with pytest.raises(OnyxError) as exc_info:
|
||||
_resolve_query_processing_hook_result(
|
||||
QueryProcessingResponse(
|
||||
query=None, rejection_message="Queries about X are not allowed."
|
||||
),
|
||||
"original query",
|
||||
)
|
||||
assert "Queries about X are not allowed." in str(exc_info.value)
|
||||
|
||||
|
||||
def test_fallback_rejection_message_when_none() -> None:
|
||||
"""No rejection_message → generic fallback used in OnyxError detail."""
|
||||
with pytest.raises(OnyxError) as exc_info:
|
||||
_resolve_query_processing_hook_result(
|
||||
QueryProcessingResponse(query=None, rejection_message=None),
|
||||
"original query",
|
||||
)
|
||||
assert "No rejection reason was provided." in str(exc_info.value)
|
||||
|
||||
|
||||
def test_nonempty_query_rewrites_message_text() -> None:
|
||||
result = _resolve_query_processing_hook_result(
|
||||
QueryProcessingResponse(query="rewritten query"), "original query"
|
||||
)
|
||||
assert result == "rewritten query"
|
||||
|
||||
@@ -7,6 +7,7 @@ from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from onyx.db.enums import HookFailStrategy
|
||||
from onyx.db.enums import HookPoint
|
||||
@@ -15,13 +16,15 @@ from onyx.error_handling.exceptions import OnyxError
|
||||
from onyx.hooks.executor import execute_hook
|
||||
from onyx.hooks.executor import HookSkipped
|
||||
from onyx.hooks.executor import HookSoftFailed
|
||||
from onyx.hooks.points.query_processing import QueryProcessingResponse
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PAYLOAD: dict[str, Any] = {"query": "test", "user_email": "u@example.com"}
|
||||
_RESPONSE_PAYLOAD: dict[str, Any] = {"rewritten_query": "better test"}
|
||||
# A valid QueryProcessingResponse payload — used by success-path tests.
|
||||
_RESPONSE_PAYLOAD: dict[str, Any] = {"query": "better test"}
|
||||
|
||||
|
||||
def _make_hook(
|
||||
@@ -33,6 +36,7 @@ def _make_hook(
|
||||
fail_strategy: HookFailStrategy = HookFailStrategy.SOFT,
|
||||
hook_id: int = 1,
|
||||
is_reachable: bool | None = None,
|
||||
hook_point: HookPoint = HookPoint.QUERY_PROCESSING,
|
||||
) -> MagicMock:
|
||||
hook = MagicMock()
|
||||
hook.is_active = is_active
|
||||
@@ -42,6 +46,7 @@ def _make_hook(
|
||||
hook.id = hook_id
|
||||
hook.fail_strategy = fail_strategy
|
||||
hook.is_reachable = is_reachable
|
||||
hook.hook_point = hook_point
|
||||
return hook
|
||||
|
||||
|
||||
@@ -140,6 +145,7 @@ def test_early_exit_returns_skipped_with_no_db_writes(
|
||||
db_session=db_session,
|
||||
hook_point=HookPoint.QUERY_PROCESSING,
|
||||
payload=_PAYLOAD,
|
||||
response_type=QueryProcessingResponse,
|
||||
)
|
||||
|
||||
assert isinstance(result, HookSkipped)
|
||||
@@ -152,7 +158,9 @@ def test_early_exit_returns_skipped_with_no_db_writes(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_success_returns_payload_and_sets_reachable(db_session: MagicMock) -> None:
|
||||
def test_success_returns_validated_model_and_sets_reachable(
|
||||
db_session: MagicMock,
|
||||
) -> None:
|
||||
hook = _make_hook()
|
||||
|
||||
with (
|
||||
@@ -171,9 +179,11 @@ def test_success_returns_payload_and_sets_reachable(db_session: MagicMock) -> No
|
||||
db_session=db_session,
|
||||
hook_point=HookPoint.QUERY_PROCESSING,
|
||||
payload=_PAYLOAD,
|
||||
response_type=QueryProcessingResponse,
|
||||
)
|
||||
|
||||
assert result == _RESPONSE_PAYLOAD
|
||||
assert isinstance(result, QueryProcessingResponse)
|
||||
assert result.query == _RESPONSE_PAYLOAD["query"]
|
||||
_, update_kwargs = mock_update.call_args
|
||||
assert update_kwargs["is_reachable"] is True
|
||||
mock_log.assert_not_called()
|
||||
@@ -200,9 +210,11 @@ def test_success_skips_reachable_write_when_already_true(db_session: MagicMock)
|
||||
db_session=db_session,
|
||||
hook_point=HookPoint.QUERY_PROCESSING,
|
||||
payload=_PAYLOAD,
|
||||
response_type=QueryProcessingResponse,
|
||||
)
|
||||
|
||||
assert result == _RESPONSE_PAYLOAD
|
||||
assert isinstance(result, QueryProcessingResponse)
|
||||
assert result.query == _RESPONSE_PAYLOAD["query"]
|
||||
mock_update.assert_not_called()
|
||||
|
||||
|
||||
@@ -230,6 +242,7 @@ def test_non_dict_json_response_is_a_failure(db_session: MagicMock) -> None:
|
||||
db_session=db_session,
|
||||
hook_point=HookPoint.QUERY_PROCESSING,
|
||||
payload=_PAYLOAD,
|
||||
response_type=QueryProcessingResponse,
|
||||
)
|
||||
|
||||
assert isinstance(result, HookSoftFailed)
|
||||
@@ -265,6 +278,7 @@ def test_json_decode_failure_is_a_failure(db_session: MagicMock) -> None:
|
||||
db_session=db_session,
|
||||
hook_point=HookPoint.QUERY_PROCESSING,
|
||||
payload=_PAYLOAD,
|
||||
response_type=QueryProcessingResponse,
|
||||
)
|
||||
|
||||
assert isinstance(result, HookSoftFailed)
|
||||
@@ -388,6 +402,7 @@ def test_http_failure_paths(
|
||||
db_session=db_session,
|
||||
hook_point=HookPoint.QUERY_PROCESSING,
|
||||
payload=_PAYLOAD,
|
||||
response_type=QueryProcessingResponse,
|
||||
)
|
||||
assert exc_info.value.error_code is OnyxErrorCode.HOOK_EXECUTION_FAILED
|
||||
else:
|
||||
@@ -395,6 +410,7 @@ def test_http_failure_paths(
|
||||
db_session=db_session,
|
||||
hook_point=HookPoint.QUERY_PROCESSING,
|
||||
payload=_PAYLOAD,
|
||||
response_type=QueryProcessingResponse,
|
||||
)
|
||||
assert isinstance(result, expected_type)
|
||||
|
||||
@@ -442,6 +458,7 @@ def test_authorization_header(
|
||||
db_session=db_session,
|
||||
hook_point=HookPoint.QUERY_PROCESSING,
|
||||
payload=_PAYLOAD,
|
||||
response_type=QueryProcessingResponse,
|
||||
)
|
||||
|
||||
_, call_kwargs = mock_client.post.call_args
|
||||
@@ -457,16 +474,16 @@ def test_authorization_header(
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"http_exception,expected_result",
|
||||
"http_exception,expect_onyx_error",
|
||||
[
|
||||
pytest.param(None, _RESPONSE_PAYLOAD, id="success_path"),
|
||||
pytest.param(httpx.ConnectError("refused"), OnyxError, id="hard_fail_path"),
|
||||
pytest.param(None, False, id="success_path"),
|
||||
pytest.param(httpx.ConnectError("refused"), True, id="hard_fail_path"),
|
||||
],
|
||||
)
|
||||
def test_persist_session_failure_is_swallowed(
|
||||
db_session: MagicMock,
|
||||
http_exception: Exception | None,
|
||||
expected_result: Any,
|
||||
expect_onyx_error: bool,
|
||||
) -> None:
|
||||
"""DB session failure in _persist_result must not mask the real return value or OnyxError."""
|
||||
hook = _make_hook(fail_strategy=HookFailStrategy.HARD)
|
||||
@@ -489,12 +506,13 @@ def test_persist_session_failure_is_swallowed(
|
||||
side_effect=http_exception,
|
||||
)
|
||||
|
||||
if expected_result is OnyxError:
|
||||
if expect_onyx_error:
|
||||
with pytest.raises(OnyxError) as exc_info:
|
||||
execute_hook(
|
||||
db_session=db_session,
|
||||
hook_point=HookPoint.QUERY_PROCESSING,
|
||||
payload=_PAYLOAD,
|
||||
response_type=QueryProcessingResponse,
|
||||
)
|
||||
assert exc_info.value.error_code is OnyxErrorCode.HOOK_EXECUTION_FAILED
|
||||
else:
|
||||
@@ -502,8 +520,131 @@ def test_persist_session_failure_is_swallowed(
|
||||
db_session=db_session,
|
||||
hook_point=HookPoint.QUERY_PROCESSING,
|
||||
payload=_PAYLOAD,
|
||||
response_type=QueryProcessingResponse,
|
||||
)
|
||||
assert result == expected_result
|
||||
assert isinstance(result, QueryProcessingResponse)
|
||||
assert result.query == _RESPONSE_PAYLOAD["query"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Response model validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _StrictResponse(BaseModel):
|
||||
"""Strict model used to reliably trigger a ValidationError in tests."""
|
||||
|
||||
required_field: str # no default → missing key raises ValidationError
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"fail_strategy,expected_type",
|
||||
[
|
||||
pytest.param(
|
||||
HookFailStrategy.SOFT, HookSoftFailed, id="validation_failure_soft"
|
||||
),
|
||||
pytest.param(HookFailStrategy.HARD, OnyxError, id="validation_failure_hard"),
|
||||
],
|
||||
)
|
||||
def test_response_validation_failure_respects_fail_strategy(
|
||||
db_session: MagicMock,
|
||||
fail_strategy: HookFailStrategy,
|
||||
expected_type: type,
|
||||
) -> None:
|
||||
"""A response that fails response_model validation is treated like any other
|
||||
hook failure: logged, is_reachable left unchanged, fail_strategy respected."""
|
||||
hook = _make_hook(fail_strategy=fail_strategy)
|
||||
|
||||
with (
|
||||
patch("onyx.hooks.executor.HOOKS_AVAILABLE", True),
|
||||
patch(
|
||||
"onyx.hooks.executor.get_non_deleted_hook_by_hook_point",
|
||||
return_value=hook,
|
||||
),
|
||||
patch("onyx.hooks.executor.get_session_with_current_tenant"),
|
||||
patch("onyx.hooks.executor.update_hook__no_commit") as mock_update,
|
||||
patch("onyx.hooks.executor.create_hook_execution_log__no_commit") as mock_log,
|
||||
patch("httpx.Client") as mock_client_cls,
|
||||
):
|
||||
# Response payload is missing required_field → ValidationError
|
||||
_setup_client(mock_client_cls, response=_make_response(json_return={}))
|
||||
|
||||
if expected_type is OnyxError:
|
||||
with pytest.raises(OnyxError) as exc_info:
|
||||
execute_hook(
|
||||
db_session=db_session,
|
||||
hook_point=HookPoint.QUERY_PROCESSING,
|
||||
payload=_PAYLOAD,
|
||||
response_type=_StrictResponse,
|
||||
)
|
||||
assert exc_info.value.error_code is OnyxErrorCode.HOOK_EXECUTION_FAILED
|
||||
else:
|
||||
result = execute_hook(
|
||||
db_session=db_session,
|
||||
hook_point=HookPoint.QUERY_PROCESSING,
|
||||
payload=_PAYLOAD,
|
||||
response_type=_StrictResponse,
|
||||
)
|
||||
assert isinstance(result, HookSoftFailed)
|
||||
|
||||
# is_reachable must not be updated — server responded correctly
|
||||
mock_update.assert_not_called()
|
||||
# failure must be logged
|
||||
mock_log.assert_called_once()
|
||||
_, log_kwargs = mock_log.call_args
|
||||
assert log_kwargs["is_success"] is False
|
||||
assert "validation" in (log_kwargs["error_message"] or "").lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Outer soft-fail guard in execute_hook
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"fail_strategy,expected_type",
|
||||
[
|
||||
pytest.param(HookFailStrategy.SOFT, HookSoftFailed, id="unexpected_exc_soft"),
|
||||
pytest.param(HookFailStrategy.HARD, ValueError, id="unexpected_exc_hard"),
|
||||
],
|
||||
)
|
||||
def test_unexpected_exception_in_inner_respects_fail_strategy(
|
||||
db_session: MagicMock,
|
||||
fail_strategy: HookFailStrategy,
|
||||
expected_type: type,
|
||||
) -> None:
|
||||
"""An unexpected exception raised by _execute_hook_inner (not an OnyxError from
|
||||
HARD fail — e.g. a bug or an assertion error) must be swallowed and return
|
||||
HookSoftFailed for SOFT strategy, or re-raised for HARD strategy."""
|
||||
hook = _make_hook(fail_strategy=fail_strategy)
|
||||
|
||||
with (
|
||||
patch("onyx.hooks.executor.HOOKS_AVAILABLE", True),
|
||||
patch(
|
||||
"onyx.hooks.executor.get_non_deleted_hook_by_hook_point",
|
||||
return_value=hook,
|
||||
),
|
||||
patch(
|
||||
"onyx.hooks.executor._execute_hook_inner",
|
||||
side_effect=ValueError("unexpected bug"),
|
||||
),
|
||||
):
|
||||
if expected_type is HookSoftFailed:
|
||||
result = execute_hook(
|
||||
db_session=db_session,
|
||||
hook_point=HookPoint.QUERY_PROCESSING,
|
||||
payload=_PAYLOAD,
|
||||
response_type=QueryProcessingResponse,
|
||||
)
|
||||
assert isinstance(result, HookSoftFailed)
|
||||
else:
|
||||
with pytest.raises(ValueError, match="unexpected bug"):
|
||||
execute_hook(
|
||||
db_session=db_session,
|
||||
hook_point=HookPoint.QUERY_PROCESSING,
|
||||
payload=_PAYLOAD,
|
||||
response_type=QueryProcessingResponse,
|
||||
)
|
||||
|
||||
|
||||
def test_is_reachable_failure_does_not_prevent_log(db_session: MagicMock) -> None:
|
||||
@@ -535,6 +676,7 @@ def test_is_reachable_failure_does_not_prevent_log(db_session: MagicMock) -> Non
|
||||
db_session=db_session,
|
||||
hook_point=HookPoint.QUERY_PROCESSING,
|
||||
payload=_PAYLOAD,
|
||||
response_type=QueryProcessingResponse,
|
||||
)
|
||||
|
||||
assert isinstance(result, HookSoftFailed)
|
||||
|
||||
@@ -103,7 +103,7 @@ opensearch:
|
||||
- name: OPENSEARCH_INITIAL_ADMIN_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: onyx-opensearch # Must match auth.opensearch.secretName.
|
||||
name: onyx-opensearch # Must match auth.opensearch.secretName or auth.opensearch.existingSecret if defined.
|
||||
key: opensearch_admin_password # Must match auth.opensearch.secretKeys value.
|
||||
|
||||
resources:
|
||||
|
||||
@@ -36,9 +36,6 @@ type ButtonProps = InteractiveStatelessProps &
|
||||
*/
|
||||
size?: ContainerSizeVariants;
|
||||
|
||||
/** HTML button type. When provided, Container renders a `<button>` element. */
|
||||
type?: "submit" | "button" | "reset";
|
||||
|
||||
/** Tooltip text shown on hover. */
|
||||
tooltip?: string;
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { FilterButton } from "@opal/components";
|
||||
import { Disabled as DisabledProvider } from "@opal/core";
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import {
|
||||
Interactive,
|
||||
type InteractiveStatefulState,
|
||||
type InteractiveStatefulInteraction,
|
||||
type InteractiveStatefulProps,
|
||||
InteractiveContainerRoundingVariant,
|
||||
} from "@opal/core";
|
||||
@@ -21,40 +19,26 @@ type ContentPassthroughProps = DistributiveOmit<
|
||||
"paddingVariant" | "widthVariant" | "ref" | "withInteractive"
|
||||
>;
|
||||
|
||||
type LineItemButtonOwnProps = {
|
||||
type LineItemButtonOwnProps = Pick<
|
||||
InteractiveStatefulProps,
|
||||
| "state"
|
||||
| "interaction"
|
||||
| "onClick"
|
||||
| "href"
|
||||
| "target"
|
||||
| "group"
|
||||
| "ref"
|
||||
| "type"
|
||||
> & {
|
||||
/** Interactive select variant. @default "select-light" */
|
||||
selectVariant?: "select-light" | "select-heavy";
|
||||
|
||||
/** Value state. @default "empty" */
|
||||
state?: InteractiveStatefulState;
|
||||
|
||||
/** JS-controllable interaction state override. @default "rest" */
|
||||
interaction?: InteractiveStatefulInteraction;
|
||||
|
||||
/** Click handler. */
|
||||
onClick?: InteractiveStatefulProps["onClick"];
|
||||
|
||||
/** When provided, renders an anchor instead of a div. */
|
||||
href?: string;
|
||||
|
||||
/** Anchor target (e.g. "_blank"). */
|
||||
target?: string;
|
||||
|
||||
/** Interactive group key. */
|
||||
group?: string;
|
||||
|
||||
/** Forwarded ref. */
|
||||
ref?: React.Ref<HTMLElement>;
|
||||
|
||||
/** Corner rounding preset (height is always content-driven). @default "default" */
|
||||
roundingVariant?: InteractiveContainerRoundingVariant;
|
||||
|
||||
/** Container width. @default "full" */
|
||||
width?: ExtremaSizeVariants;
|
||||
|
||||
/** HTML button type. @default "button" */
|
||||
type?: "submit" | "button" | "reset";
|
||||
|
||||
/** Tooltip text shown on hover. */
|
||||
tooltip?: string;
|
||||
|
||||
@@ -78,11 +62,11 @@ function LineItemButton({
|
||||
target,
|
||||
group,
|
||||
ref,
|
||||
type = "button",
|
||||
|
||||
// Sizing
|
||||
roundingVariant = "default",
|
||||
width = "full",
|
||||
type = "button",
|
||||
tooltip,
|
||||
tooltipSide = "top",
|
||||
|
||||
|
||||
@@ -40,13 +40,6 @@ export const Open: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
disabled: true,
|
||||
children: "Disabled",
|
||||
},
|
||||
};
|
||||
|
||||
export const Foldable: Story = {
|
||||
args: {
|
||||
foldable: true,
|
||||
|
||||
@@ -49,9 +49,6 @@ type SelectButtonProps = InteractiveStatefulProps &
|
||||
*/
|
||||
size?: ContainerSizeVariants;
|
||||
|
||||
/** HTML button type. Container renders a `<button>` element. */
|
||||
type?: "submit" | "button" | "reset";
|
||||
|
||||
/** Tooltip text shown on hover. */
|
||||
tooltip?: string;
|
||||
|
||||
|
||||
@@ -145,8 +145,6 @@ export function Table<TData>(props: DataTableProps<TData>) {
|
||||
pageSize,
|
||||
initialSorting,
|
||||
initialColumnVisibility,
|
||||
initialRowSelection,
|
||||
initialViewSelected,
|
||||
draggable,
|
||||
footer,
|
||||
size = "lg",
|
||||
@@ -223,8 +221,6 @@ export function Table<TData>(props: DataTableProps<TData>) {
|
||||
pageSize: effectivePageSize,
|
||||
initialSorting,
|
||||
initialColumnVisibility,
|
||||
initialRowSelection,
|
||||
initialViewSelected,
|
||||
getRowId,
|
||||
onSelectionChange,
|
||||
searchTerm,
|
||||
|
||||
@@ -103,10 +103,6 @@ interface UseDataTableOptions<TData extends RowData> {
|
||||
initialSorting?: SortingState;
|
||||
/** Initial column visibility state. @default {} */
|
||||
initialColumnVisibility?: VisibilityState;
|
||||
/** Initial row selection state. Keys are row IDs (from `getRowId`), values are `true`. @default {} */
|
||||
initialRowSelection?: RowSelectionState;
|
||||
/** When true AND `initialRowSelection` is non-empty, start in view-selected mode (filtered to selected rows). @default false */
|
||||
initialViewSelected?: boolean;
|
||||
/** Called whenever the set of selected row IDs changes. */
|
||||
onSelectionChange?: (selectedIds: string[]) => void;
|
||||
/** Search term for global text filtering. Rows are filtered to those containing
|
||||
@@ -199,8 +195,6 @@ export default function useDataTable<TData extends RowData>(
|
||||
columnResizeMode = "onChange",
|
||||
initialSorting = [],
|
||||
initialColumnVisibility = {},
|
||||
initialRowSelection = {},
|
||||
initialViewSelected = false,
|
||||
getRowId,
|
||||
onSelectionChange,
|
||||
searchTerm,
|
||||
@@ -212,8 +206,7 @@ export default function useDataTable<TData extends RowData>(
|
||||
|
||||
// ---- internal state -----------------------------------------------------
|
||||
const [sorting, setSorting] = useState<SortingState>(initialSorting);
|
||||
const [rowSelection, setRowSelection] =
|
||||
useState<RowSelectionState>(initialRowSelection);
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [columnSizing, setColumnSizing] = useState<ColumnSizingState>({});
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
|
||||
initialColumnVisibility
|
||||
@@ -223,12 +216,8 @@ export default function useDataTable<TData extends RowData>(
|
||||
pageSize: pageSizeOption,
|
||||
});
|
||||
/** Combined global filter: view-mode (selected IDs) + text search. */
|
||||
const initialSelectedIds =
|
||||
initialViewSelected && Object.keys(initialRowSelection).length > 0
|
||||
? new Set(Object.keys(initialRowSelection))
|
||||
: null;
|
||||
const [globalFilter, setGlobalFilter] = useState<GlobalFilterValue>({
|
||||
selectedIds: initialSelectedIds,
|
||||
selectedIds: null,
|
||||
searchTerm: "",
|
||||
});
|
||||
|
||||
@@ -395,31 +384,6 @@ export default function useDataTable<TData extends RowData>(
|
||||
: data.length;
|
||||
const isPaginated = isFinite(pagination.pageSize);
|
||||
|
||||
// ---- keep view-mode filter in sync with selection ----------------------
|
||||
// When in view-selected mode, deselecting a row should remove it from
|
||||
// the visible set so it disappears immediately.
|
||||
useEffect(() => {
|
||||
if (isServerSide) return;
|
||||
if (globalFilter.selectedIds == null) return;
|
||||
|
||||
const currentIds = new Set(Object.keys(rowSelection));
|
||||
// Remove any ID from the filter that is no longer selected
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
globalFilter.selectedIds.forEach((id) => {
|
||||
if (currentIds.has(id)) {
|
||||
next.add(id);
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
if (changed) {
|
||||
setGlobalFilter((prev) => ({ ...prev, selectedIds: next }));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- only react to
|
||||
// selection changes while in view mode
|
||||
}, [rowSelection, isServerSide]);
|
||||
|
||||
// ---- selection change callback ------------------------------------------
|
||||
const isFirstRenderRef = useRef(true);
|
||||
const onSelectionChangeRef = useRef(onSelectionChange);
|
||||
@@ -428,10 +392,6 @@ export default function useDataTable<TData extends RowData>(
|
||||
useEffect(() => {
|
||||
if (isFirstRenderRef.current) {
|
||||
isFirstRenderRef.current = false;
|
||||
// Still fire the callback on first render if there's an initial selection
|
||||
if (selectedRowIds.length > 0) {
|
||||
onSelectionChangeRef.current?.(selectedRowIds);
|
||||
}
|
||||
return;
|
||||
}
|
||||
onSelectionChangeRef.current?.(selectedRowIds);
|
||||
|
||||
@@ -146,10 +146,6 @@ export interface DataTableProps<TData> {
|
||||
initialSorting?: SortingState;
|
||||
/** Initial column visibility state. */
|
||||
initialColumnVisibility?: VisibilityState;
|
||||
/** Initial row selection state. Keys are row IDs (from `getRowId`), values are `true`. */
|
||||
initialRowSelection?: Record<string, boolean>;
|
||||
/** When true AND `initialRowSelection` is non-empty, start in view-selected mode. @default false */
|
||||
initialViewSelected?: boolean;
|
||||
/** Enable drag-and-drop row reordering. */
|
||||
draggable?: DataTableDraggableConfig;
|
||||
/** Footer configuration. */
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Route } from "next";
|
||||
import "@opal/core/interactive/shared.css";
|
||||
import React from "react";
|
||||
import { cn } from "@opal/utils";
|
||||
import type { WithoutStyles } from "@opal/types";
|
||||
import type { ButtonType, WithoutStyles } from "@opal/types";
|
||||
import {
|
||||
containerSizeVariants,
|
||||
type ContainerSizeVariants,
|
||||
@@ -52,7 +52,7 @@ interface InteractiveContainerProps
|
||||
*
|
||||
* Mutually exclusive with `href`.
|
||||
*/
|
||||
type?: "submit" | "button" | "reset";
|
||||
type?: ButtonType;
|
||||
|
||||
/**
|
||||
* When `true`, applies a 1px border using the theme's border color.
|
||||
|
||||
21
web/lib/opal/src/icons/file-broadcast.tsx
Normal file
21
web/lib/opal/src/icons/file-broadcast.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { IconProps } from "@opal/types";
|
||||
|
||||
const SvgFileBroadcast = ({ size, ...props }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 18 18"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
stroke="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M6.1875 2.25003H2.625C1.808 2.25003 1.125 2.93303 1.125 3.75003L1.125 14.25C1.125 15.067 1.808 15.75 2.625 15.75L9.37125 15.75C10.1883 15.75 10.8713 15.067 10.8713 14.25L10.8713 6.94128M6.1875 2.25003L10.8713 6.94128M6.1875 2.25003V6.94128H10.8713M10.3069 2.25L13.216 5.15914C13.6379 5.5811 13.875 6.15339 13.875 6.75013V13.875C13.875 14.5212 13.737 15.2081 13.4392 15.7538M16.4391 15.7538C16.737 15.2081 16.875 14.5213 16.875 13.8751L16.875 7.02481C16.875 5.53418 16.2833 4.10451 15.23 3.04982L14.4301 2.25003"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export default SvgFileBroadcast;
|
||||
21
web/lib/opal/src/icons/hook-nodes.tsx
Normal file
21
web/lib/opal/src/icons/hook-nodes.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { IconProps } from "@opal/types";
|
||||
|
||||
const SvgHookNodes = ({ 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="M10.0002 4C10.0002 3.99708 10.0002 3.99415 10.0001 3.99123C9.99542 2.8907 9.10181 2 8.00016 2C6.89559 2 6.00016 2.89543 6.00016 4C6.00016 4.73701 6.39882 5.38092 6.99226 5.72784L4.67276 9.70412M11.6589 13.7278C11.9549 13.9009 12.2993 14 12.6668 14C13.7714 14 14.6668 13.1046 14.6668 12C14.6668 10.8954 13.7714 10 12.6668 10C12.2993 10 11.9549 10.0991 11.6589 10.2722L9.33943 6.29588M2.33316 10.2678C1.73555 10.6136 1.3335 11.2599 1.3335 12C1.3335 13.1046 2.22893 14 3.3335 14C4.43807 14 5.3335 13.1046 5.3335 12H10.0002"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export default SvgHookNodes;
|
||||
@@ -69,8 +69,9 @@ export { default as SvgExternalLink } from "@opal/icons/external-link";
|
||||
export { default as SvgEye } from "@opal/icons/eye";
|
||||
export { default as SvgEyeClosed } from "@opal/icons/eye-closed";
|
||||
export { default as SvgEyeOff } from "@opal/icons/eye-off";
|
||||
export { default as SvgFiles } from "@opal/icons/files";
|
||||
export { default as SvgFileBraces } from "@opal/icons/file-braces";
|
||||
export { default as SvgFileBroadcast } from "@opal/icons/file-broadcast";
|
||||
export { default as SvgFiles } from "@opal/icons/files";
|
||||
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";
|
||||
@@ -90,6 +91,7 @@ export { default as SvgHashSmall } from "@opal/icons/hash-small";
|
||||
export { default as SvgHash } from "@opal/icons/hash";
|
||||
export { default as SvgHeadsetMic } from "@opal/icons/headset-mic";
|
||||
export { default as SvgHistory } from "@opal/icons/history";
|
||||
export { default as SvgHookNodes } from "@opal/icons/hook-nodes";
|
||||
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";
|
||||
@@ -121,7 +123,9 @@ export { default as SvgNetworkGraph } from "@opal/icons/network-graph";
|
||||
export { default as SvgNotificationBubble } from "@opal/icons/notification-bubble";
|
||||
export { default as SvgOllama } from "@opal/icons/ollama";
|
||||
export { default as SvgOnyxLogo } from "@opal/icons/onyx-logo";
|
||||
export { default as SvgOnyxLogoTyped } from "@opal/icons/onyx-logo-typed";
|
||||
export { default as SvgOnyxOctagon } from "@opal/icons/onyx-octagon";
|
||||
export { default as SvgOnyxTyped } from "@opal/icons/onyx-typed";
|
||||
export { default as SvgOpenai } from "@opal/icons/openai";
|
||||
export { default as SvgOpenrouter } from "@opal/icons/openrouter";
|
||||
export { default as SvgOrganization } from "@opal/icons/organization";
|
||||
|
||||
27
web/lib/opal/src/icons/onyx-logo-typed.tsx
Normal file
27
web/lib/opal/src/icons/onyx-logo-typed.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import SvgOnyxLogo from "@opal/icons/onyx-logo";
|
||||
import SvgOnyxTyped from "@opal/icons/onyx-typed";
|
||||
import { cn } from "@opal/utils";
|
||||
|
||||
interface OnyxLogoTypedProps {
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// # NOTE(@raunakab):
|
||||
// This ratio is not some random, magical number; it is available on Figma.
|
||||
const HEIGHT_TO_GAP_RATIO = 5 / 16;
|
||||
|
||||
const SvgOnyxLogoTyped = ({ size: height, className }: OnyxLogoTypedProps) => {
|
||||
const gap = height != null ? height * HEIGHT_TO_GAP_RATIO : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(`flex flex-row items-center`, className)}
|
||||
style={{ gap }}
|
||||
>
|
||||
<SvgOnyxLogo size={height} />
|
||||
<SvgOnyxTyped size={height} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default SvgOnyxLogoTyped;
|
||||
@@ -1,19 +1,27 @@
|
||||
import type { IconProps } from "@opal/types";
|
||||
|
||||
const SvgOnyxLogo = ({ size, ...props }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 56 56"
|
||||
viewBox="0 0 64 64"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
stroke="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M28 0 10.869 7.77 28 15.539l17.131-7.77L28 0Zm0 40.461-17.131 7.77L28 56l17.131-7.77L28 40.461Zm20.231-29.592L56 28.001l-7.769 17.131L40.462 28l7.769-17.131ZM15.538 28 7.77 10.869 0 28l7.769 17.131L15.538 28Z"
|
||||
fill="currentColor"
|
||||
d="M10.4014 13.25L18.875 32L10.3852 50.75L2 32L10.4014 13.25Z"
|
||||
fill="var(--theme-primary-05)"
|
||||
/>
|
||||
<path
|
||||
d="M53.5264 13.25L62 32L53.5102 50.75L45.125 32L53.5264 13.25Z"
|
||||
fill="var(--theme-primary-05)"
|
||||
/>
|
||||
<path
|
||||
d="M32 45.125L50.75 53.5625L32 62L13.25 53.5625L32 45.125Z"
|
||||
fill="var(--theme-primary-05)"
|
||||
/>
|
||||
<path
|
||||
d="M32 2L50.75 10.4375L32 18.875L13.25 10.4375L32 2Z"
|
||||
fill="var(--theme-primary-05)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
28
web/lib/opal/src/icons/onyx-typed.tsx
Normal file
28
web/lib/opal/src/icons/onyx-typed.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { IconProps } from "@opal/types";
|
||||
const SvgOnyxTyped = ({ size, ...props }: IconProps) => (
|
||||
<svg
|
||||
height={size}
|
||||
viewBox="0 0 152 64"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M19.1795 51.2136C15.6695 51.2136 12.4353 50.3862 9.47691 48.7315C6.56865 47.0768 4.2621 44.8454 2.55726 42.0374C0.85242 39.1793 0 36.0955 0 32.7861C0 30.279 0.451281 27.9223 1.35384 25.716C2.30655 23.4596 3.76068 21.3285 5.71623 19.3228L11.8085 13.08C12.4604 12.6789 13.4131 12.3529 14.6666 12.1022C15.9202 11.8014 17.2991 11.6509 18.8034 11.6509C22.3134 11.6509 25.5225 12.4783 28.4307 14.133C31.3891 15.7877 33.7208 18.0441 35.4256 20.9023C37.1304 23.7103 37.9829 26.794 37.9829 30.1536C37.9829 32.6106 37.5065 34.9673 36.5538 37.2237C35.6512 39.4802 34.147 41.6864 32.041 43.8426L26.3248 49.7845C25.3219 50.2358 24.2188 50.5868 23.0154 50.8375C21.8621 51.0882 20.5835 51.2136 19.1795 51.2136ZM20.1572 43.8426C21.8621 43.8426 23.4917 43.4164 25.0461 42.5639C26.6005 41.6614 27.8541 40.3577 28.8068 38.6528C29.8097 36.948 30.3111 34.9172 30.3111 32.5605C30.3111 30.0032 29.6843 27.6966 28.4307 25.6408C27.2273 23.5849 25.6478 21.9803 23.6923 20.8271C21.7869 19.6236 19.8313 19.0219 17.8256 19.0219C16.0706 19.0219 14.4159 19.4732 12.8615 20.3758C11.3573 21.2282 10.1288 22.5068 9.17606 24.2117C8.22335 25.9166 7.747 27.9473 7.747 30.304C7.747 32.8613 8.34871 35.1679 9.55212 37.2237C10.7555 39.2796 12.31 40.9092 14.2154 42.1127C16.1709 43.2659 18.1515 43.8426 20.1572 43.8426Z"
|
||||
fill="var(--theme-primary-05)"
|
||||
/>
|
||||
<path
|
||||
d="M42.6413 50.4614V12.4031H50.6891V17.7433L55.5028 12.7039C56.0544 12.4532 56.8065 12.2276 57.7592 12.027C58.7621 11.7763 59.8903 11.6509 61.1438 11.6509C64.0521 11.6509 66.5843 12.3028 68.7404 13.6065C70.9467 14.8601 72.6264 16.6401 73.7797 18.9467C74.9831 21.2533 75.5848 23.961 75.5848 27.0698V50.4614H67.6122V29.1006C67.6122 26.9946 67.2612 25.1895 66.5592 23.6852C65.9074 22.1308 64.9547 20.9775 63.7011 20.2253C62.4977 19.4231 61.0686 19.0219 59.4139 19.0219C56.7564 19.0219 54.6253 19.9245 53.0208 21.7296C51.4663 23.4846 50.6891 25.9416 50.6891 29.1006V50.4614H42.6413Z"
|
||||
fill="var(--theme-primary-05)"
|
||||
/>
|
||||
<path
|
||||
d="M82.3035 64V56.0273H89.9753C91.2288 56.0273 92.2066 55.7264 92.9086 55.1247C93.6607 54.523 94.2625 53.5452 94.7137 52.1913L108.027 12.4031H116.751L103.664 49.4084C103.062 51.1634 102.461 52.5173 101.859 53.47C101.307 54.4227 100.53 55.4506 99.5274 56.5538L92.4573 64H82.3035ZM90.7274 46.6255L76.9633 12.4031H85.989L99.4522 46.6255H90.7274Z"
|
||||
fill="var(--theme-primary-05)"
|
||||
/>
|
||||
<path
|
||||
d="M115.657 50.4614L129.045 31.2066L116.033 12.4031H125.435L134.085 24.8134L142.358 12.4031H151.308L138.372 31.0562L151.684 50.4614H142.358L133.332 37.3742L124.683 50.4614H115.657Z"
|
||||
fill="var(--theme-primary-05)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export default SvgOnyxTyped;
|
||||
@@ -260,7 +260,7 @@ export default function VoiceProviderSetupModal({
|
||||
<SvgArrowExchange className="size-3 text-text-04" />
|
||||
</div>
|
||||
<div className="flex items-center justify-center size-7 p-0.5 shrink-0 overflow-clip">
|
||||
<SvgOnyxLogo size={24} className="text-text-04 shrink-0" />
|
||||
<SvgOnyxLogo size={24} className="shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -69,7 +69,7 @@ export const WebProviderSetupModal = memo(
|
||||
<SvgArrowExchange className="size-3 text-text-04" />
|
||||
</div>
|
||||
<div className="flex items-center justify-center size-7 p-0.5 shrink-0 overflow-clip">
|
||||
<SvgOnyxLogo size={24} className="text-text-04 shrink-0" />
|
||||
<SvgOnyxLogo size={24} className="shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1372,7 +1372,7 @@ export default function Page() {
|
||||
} logo`,
|
||||
fallback:
|
||||
selectedContentProviderType === "onyx_web_crawler" ? (
|
||||
<SvgOnyxLogo size={24} className="text-text-05" />
|
||||
<SvgOnyxLogo size={24} />
|
||||
) : undefined,
|
||||
size: 24,
|
||||
containerSize: 28,
|
||||
|
||||
@@ -98,7 +98,7 @@ export default function IndexAttemptErrorsModal({
|
||||
|
||||
return (
|
||||
<Modal open onOpenChange={onClose}>
|
||||
<Modal.Content width="lg" height="full">
|
||||
<Modal.Content width="full" height="full">
|
||||
<Modal.Header
|
||||
icon={SvgAlertTriangle}
|
||||
title="Indexing Errors"
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import EditGroupPage from "@/refresh-pages/admin/GroupsPage/EditGroupPage";
|
||||
|
||||
export default function EditGroupRoute({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = use(params);
|
||||
const groupId = Number(id);
|
||||
if (Number.isNaN(groupId)) {
|
||||
return null;
|
||||
}
|
||||
return <EditGroupPage groupId={groupId} />;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from "@/refresh-pages/admin/GroupsPage/CreateGroupPage";
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from "@/refresh-pages/admin/GroupsPage";
|
||||
1
web/src/app/admin/hooks/page.tsx
Normal file
1
web/src/app/admin/hooks/page.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "@/refresh-pages/admin/HooksPage";
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type KeyboardEvent,
|
||||
} from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { getPastedFilesIfNoText } from "@/lib/clipboard";
|
||||
import { cn, isImageFile } from "@/lib/utils";
|
||||
import { Disabled } from "@opal/core";
|
||||
import {
|
||||
@@ -230,21 +231,11 @@ const InputBar = memo(
|
||||
|
||||
const handlePaste = useCallback(
|
||||
(event: ClipboardEvent) => {
|
||||
const items = event.clipboardData?.items;
|
||||
if (items) {
|
||||
const pastedFiles: File[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item && item.kind === "file") {
|
||||
const file = item.getAsFile();
|
||||
if (file) pastedFiles.push(file);
|
||||
}
|
||||
}
|
||||
if (pastedFiles.length > 0) {
|
||||
event.preventDefault();
|
||||
// Context handles session binding internally
|
||||
uploadFiles(pastedFiles);
|
||||
}
|
||||
const pastedFiles = getPastedFilesIfNoText(event.clipboardData);
|
||||
if (pastedFiles.length > 0) {
|
||||
event.preventDefault();
|
||||
// Context handles session binding internally
|
||||
uploadFiles(pastedFiles);
|
||||
}
|
||||
},
|
||||
[uploadFiles]
|
||||
|
||||
@@ -413,7 +413,7 @@ const MemoizedBuildSidebarInner = memo(
|
||||
return (
|
||||
<SidebarWrapper folded={folded} onFoldClick={onFoldClick}>
|
||||
<SidebarBody
|
||||
actionButtons={
|
||||
pinnedContent={
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{newBuildButton}
|
||||
{buildConfigurePanel}
|
||||
|
||||
@@ -151,7 +151,7 @@ export default function ConfigureConnectorModal({
|
||||
return (
|
||||
<>
|
||||
<Modal open={open} onOpenChange={onClose}>
|
||||
<Modal.Content width="md" height="fit">
|
||||
<Modal.Content width="xl" height="fit">
|
||||
<Modal.Header
|
||||
icon={SvgPlug}
|
||||
title={getStepTitle()}
|
||||
|
||||
@@ -263,7 +263,7 @@ export default function CredentialStep({
|
||||
open
|
||||
onOpenChange={() => setCreateCredentialFormToggle(false)}
|
||||
>
|
||||
<Modal.Content width="md" height="fit">
|
||||
<Modal.Content width="xl" height="fit">
|
||||
<Modal.Header
|
||||
icon={SvgKey}
|
||||
title={`Create a ${getSourceDisplayName(
|
||||
|
||||
@@ -215,7 +215,7 @@ export default function UserLibraryModal({
|
||||
return (
|
||||
<>
|
||||
<Modal open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<Modal.Content width="md" height="fit">
|
||||
<Modal.Content width="xl" height="fit">
|
||||
<Modal.Header
|
||||
icon={SvgFileText}
|
||||
title="Your Files"
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
/* Base layers */
|
||||
--z-base: 0;
|
||||
--z-content: 1;
|
||||
/* Settings header must sit above sticky table headers (--z-sticky: 10) so
|
||||
the page header scrolls over pinned columns without being obscured. */
|
||||
--z-settings-header: 11;
|
||||
--z-app-layout: 9;
|
||||
--z-sticky: 10;
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import EditGroupPage from "@/refresh-pages/admin/GroupsPage/EditGroupPage";
|
||||
|
||||
export default function EditGroupRoute({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = use(params);
|
||||
return <EditGroupPage groupId={Number(id)} />;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from "@/refresh-pages/admin/GroupsPage/CreateGroupPage";
|
||||
@@ -178,7 +178,7 @@ function PreviousQueryHistoryExportsModal({
|
||||
|
||||
return (
|
||||
<Modal open onOpenChange={() => setShowModal(false)}>
|
||||
<Modal.Content width="lg" height="full">
|
||||
<Modal.Content width="full" height="full">
|
||||
<Modal.Header
|
||||
icon={SvgFileText}
|
||||
title="Previous Query History Exports"
|
||||
|
||||
15
web/src/hooks/useHookSpecs.ts
Normal file
15
web/src/hooks/useHookSpecs.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import useSWR from "swr";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import { HookPointMeta } from "@/refresh-pages/admin/HooksPage/interfaces";
|
||||
|
||||
export function useHookSpecs() {
|
||||
const { data, isLoading, error } = useSWR<HookPointMeta[]>(
|
||||
"/api/admin/hooks/specs",
|
||||
errorHandlingFetcher,
|
||||
{ revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
return { specs: data, isLoading, error };
|
||||
}
|
||||
@@ -63,6 +63,9 @@ export interface Settings {
|
||||
// are unavailable.
|
||||
vector_db_enabled?: boolean;
|
||||
|
||||
// True when hooks are available: single-tenant deployment with HOOK_ENABLED=true.
|
||||
hooks_enabled?: boolean;
|
||||
|
||||
// Application version from the ONYX_VERSION env var on the server.
|
||||
version?: string | null;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
SvgActivity,
|
||||
SvgArrowExchange,
|
||||
SvgAudio,
|
||||
SvgHookNodes,
|
||||
SvgBarChart,
|
||||
SvgBookOpen,
|
||||
SvgBubbleText,
|
||||
@@ -227,6 +228,12 @@ export const ADMIN_ROUTES = {
|
||||
title: "Document Index Migration",
|
||||
sidebarLabel: "Document Index Migration",
|
||||
},
|
||||
HOOKS: {
|
||||
path: "/admin/hooks",
|
||||
icon: SvgHookNodes,
|
||||
title: "Hook Extensions",
|
||||
sidebarLabel: "Hook Extensions",
|
||||
},
|
||||
SCIM: {
|
||||
path: "/admin/scim",
|
||||
icon: SvgUserSync,
|
||||
|
||||
89
web/src/lib/clipboard.test.ts
Normal file
89
web/src/lib/clipboard.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { getPastedFilesIfNoText } from "./clipboard";
|
||||
|
||||
type MockClipboardData = Parameters<typeof getPastedFilesIfNoText>[0];
|
||||
|
||||
function makeClipboardData({
|
||||
textPlain = "",
|
||||
text = "",
|
||||
files = [],
|
||||
}: {
|
||||
textPlain?: string;
|
||||
text?: string;
|
||||
files?: File[];
|
||||
}): MockClipboardData {
|
||||
return {
|
||||
items: files.map((file) => ({
|
||||
kind: "file",
|
||||
getAsFile: () => file,
|
||||
})),
|
||||
getData: (format: string) => {
|
||||
if (format === "text/plain") {
|
||||
return textPlain;
|
||||
}
|
||||
|
||||
if (format === "text") {
|
||||
return text;
|
||||
}
|
||||
|
||||
return "";
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("getPastedFilesIfNoText", () => {
|
||||
it("prefers plain text over pasted files when both are present", () => {
|
||||
const imageFile = new File(["slide preview"], "slide.png", {
|
||||
type: "image/png",
|
||||
});
|
||||
|
||||
expect(
|
||||
getPastedFilesIfNoText(
|
||||
makeClipboardData({
|
||||
textPlain: "Welcome to PowerPoint for Mac",
|
||||
files: [imageFile],
|
||||
})
|
||||
)
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("falls back to text data when text/plain is empty", () => {
|
||||
const imageFile = new File(["slide preview"], "slide.png", {
|
||||
type: "image/png",
|
||||
});
|
||||
|
||||
expect(
|
||||
getPastedFilesIfNoText(
|
||||
makeClipboardData({
|
||||
text: "Welcome to PowerPoint for Mac",
|
||||
files: [imageFile],
|
||||
})
|
||||
)
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("still returns files for image-only pastes", () => {
|
||||
const imageFile = new File(["slide preview"], "slide.png", {
|
||||
type: "image/png",
|
||||
});
|
||||
|
||||
expect(
|
||||
getPastedFilesIfNoText(makeClipboardData({ files: [imageFile] }))
|
||||
).toEqual([imageFile]);
|
||||
});
|
||||
|
||||
it("ignores whitespace-only text and keeps file pastes working", () => {
|
||||
const imageFile = new File(["slide preview"], "slide.png", {
|
||||
type: "image/png",
|
||||
});
|
||||
|
||||
expect(
|
||||
getPastedFilesIfNoText(
|
||||
makeClipboardData({
|
||||
textPlain: " ",
|
||||
text: "\n",
|
||||
files: [imageFile],
|
||||
})
|
||||
)
|
||||
).toEqual([imageFile]);
|
||||
});
|
||||
});
|
||||
52
web/src/lib/clipboard.ts
Normal file
52
web/src/lib/clipboard.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
type ClipboardFileItem = {
|
||||
kind: string;
|
||||
getAsFile: () => File | null;
|
||||
};
|
||||
|
||||
type ClipboardDataLike = {
|
||||
items?: ArrayLike<ClipboardFileItem> | null;
|
||||
getData: (format: string) => string;
|
||||
};
|
||||
|
||||
function getClipboardText(
|
||||
clipboardData: ClipboardDataLike,
|
||||
format: "text/plain" | "text"
|
||||
): string {
|
||||
try {
|
||||
return clipboardData.getData(format);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function getPastedFilesIfNoText(
|
||||
clipboardData?: ClipboardDataLike | null
|
||||
): File[] {
|
||||
if (!clipboardData) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const plainText = getClipboardText(clipboardData, "text/plain").trim();
|
||||
const fallbackText = getClipboardText(clipboardData, "text").trim();
|
||||
|
||||
// Apps like PowerPoint on macOS can place both rendered image data and the
|
||||
// original text on the clipboard. Prefer letting the textarea consume text.
|
||||
if (plainText || fallbackText || !clipboardData.items) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const pastedFiles: File[] = [];
|
||||
for (let i = 0; i < clipboardData.items.length; i++) {
|
||||
const item = clipboardData.items[i];
|
||||
if (item?.kind !== "file") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
pastedFiles.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
return pastedFiles;
|
||||
}
|
||||
@@ -127,8 +127,7 @@ export const DESKTOP_SMALL_BREAKPOINT_PX = 912;
|
||||
export const DESKTOP_MEDIUM_BREAKPOINT_PX = 1232;
|
||||
export const DEFAULT_AVATAR_SIZE_PX = 18;
|
||||
export const HORIZON_DISTANCE_PX = 800;
|
||||
export const LOGO_FOLDED_SIZE_PX = 24;
|
||||
export const LOGO_UNFOLDED_SIZE_PX = 88;
|
||||
export const DEFAULT_LOGO_SIZE_PX = 24;
|
||||
|
||||
export const DEFAULT_CONTEXT_TOKENS = 120_000;
|
||||
export const MAX_CHUNKS_FED_TO_CHAT = 25;
|
||||
|
||||
@@ -43,7 +43,6 @@ export const config = {
|
||||
|
||||
// Enterprise Edition specific routes (ONLY these get /ee rewriting)
|
||||
const EE_ROUTES = [
|
||||
"/admin/groups2",
|
||||
"/admin/groups",
|
||||
"/admin/performance/usage",
|
||||
"/admin/performance/query-history",
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { OnyxIcon, OnyxLogoTypeIcon } from "@/components/icons/icons";
|
||||
import { useSettingsContext } from "@/providers/SettingsProvider";
|
||||
import {
|
||||
LOGO_FOLDED_SIZE_PX,
|
||||
LOGO_UNFOLDED_SIZE_PX,
|
||||
DEFAULT_LOGO_SIZE_PX,
|
||||
NEXT_PUBLIC_DO_NOT_USE_TOGGLE_OFF_DANSWER_POWERED,
|
||||
} from "@/lib/constants";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import Truncated from "@/refresh-components/texts/Truncated";
|
||||
import { useMemo } from "react";
|
||||
import { SvgOnyxLogo, SvgOnyxLogoTyped } from "@opal/icons";
|
||||
|
||||
export interface LogoProps {
|
||||
folded?: boolean;
|
||||
@@ -19,8 +18,7 @@ export interface LogoProps {
|
||||
}
|
||||
|
||||
export default function Logo({ folded, size, className }: LogoProps) {
|
||||
const foldedSize = size ?? LOGO_FOLDED_SIZE_PX;
|
||||
const unfoldedSize = size ?? LOGO_UNFOLDED_SIZE_PX;
|
||||
const resolvedSize = size ?? DEFAULT_LOGO_SIZE_PX;
|
||||
const settings = useSettingsContext();
|
||||
const logoDisplayStyle = settings.enterpriseSettings?.logo_display_style;
|
||||
const applicationName = settings.enterpriseSettings?.application_name;
|
||||
@@ -42,7 +40,7 @@ export default function Logo({ folded, size, className }: LogoProps) {
|
||||
"aspect-square rounded-full overflow-hidden relative flex-shrink-0",
|
||||
className
|
||||
)}
|
||||
style={{ height: foldedSize, width: foldedSize }}
|
||||
style={{ height: resolvedSize }}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
@@ -52,7 +50,10 @@ export default function Logo({ folded, size, className }: LogoProps) {
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<OnyxIcon size={foldedSize} className={cn("flex-shrink-0", className)} />
|
||||
<SvgOnyxLogo
|
||||
size={resolvedSize}
|
||||
className={cn("flex-shrink-0", className)}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderNameAndPoweredBy = (opts: {
|
||||
@@ -98,8 +99,11 @@ export default function Logo({ folded, size, className }: LogoProps) {
|
||||
return applicationName ? (
|
||||
renderNameAndPoweredBy({ includeLogo: true, includeName: true })
|
||||
) : folded ? (
|
||||
<OnyxIcon size={foldedSize} className={cn("flex-shrink-0", className)} />
|
||||
<SvgOnyxLogo
|
||||
size={resolvedSize}
|
||||
className={cn("flex-shrink-0", className)}
|
||||
/>
|
||||
) : (
|
||||
<OnyxLogoTypeIcon size={unfoldedSize} className={className} />
|
||||
<SvgOnyxLogoTyped size={resolvedSize} className={className} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ function LargeModalDemo() {
|
||||
<div style={{ padding: 32 }}>
|
||||
<Button onClick={() => setOpen(true)}>Open Large Modal</Button>
|
||||
<Modal open={open} onOpenChange={setOpen}>
|
||||
<Modal.Content width="lg" height="full">
|
||||
<Modal.Content width="full" height="full">
|
||||
<Modal.Header
|
||||
icon={SvgInfoSmall}
|
||||
title="Large Modal"
|
||||
|
||||
@@ -76,10 +76,11 @@ const useModalContext = () => {
|
||||
};
|
||||
|
||||
const widthClasses = {
|
||||
lg: "w-[80dvw]",
|
||||
md: "w-[60rem]",
|
||||
"md-sm": "w-[50rem]",
|
||||
sm: "w-[32rem]",
|
||||
full: "w-[80dvw]",
|
||||
xl: "w-[60rem]",
|
||||
lg: "w-[50rem]",
|
||||
md: "w-[40rem]",
|
||||
sm: "w-[30rem]",
|
||||
};
|
||||
|
||||
const heightClasses = {
|
||||
@@ -97,20 +98,20 @@ const heightClasses = {
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Using width and height props
|
||||
* <Modal.Content width="lg" height="full">
|
||||
* {/* Large modal: w-[80dvw] h-[80dvh] *\/}
|
||||
* <Modal.Content width="full" height="full">
|
||||
* {/* Full modal: w-[80dvw] h-[80dvh] *\/}
|
||||
* </Modal.Content>
|
||||
*
|
||||
* <Modal.Content width="md" height="fit">
|
||||
* {/* Medium modal: w-[60rem] h-fit *\/}
|
||||
* <Modal.Content width="xl" height="fit">
|
||||
* {/* XL modal: w-[60rem] h-fit *\/}
|
||||
* </Modal.Content>
|
||||
*
|
||||
* <Modal.Content width="sm" height="sm">
|
||||
* {/* Small modal: w-[32rem] max-h-[30rem] *\/}
|
||||
* {/* Small modal: w-[30rem] max-h-[30rem] *\/}
|
||||
* </Modal.Content>
|
||||
*
|
||||
* <Modal.Content width="sm" height="lg">
|
||||
* {/* Tall modal: w-[32rem] max-h-[calc(100dvh-4rem)] *\/}
|
||||
* {/* Tall modal: w-[30rem] max-h-[calc(100dvh-4rem)] *\/}
|
||||
* </Modal.Content>
|
||||
* ```
|
||||
*/
|
||||
@@ -138,7 +139,7 @@ const ModalContent = React.forwardRef<
|
||||
(
|
||||
{
|
||||
children,
|
||||
width = "md",
|
||||
width = "xl",
|
||||
height = "fit",
|
||||
position = "center",
|
||||
preventAccidentalClose = true,
|
||||
|
||||
61
web/src/refresh-components/inputs/InputSearch.stories.tsx
Normal file
61
web/src/refresh-components/inputs/InputSearch.stories.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import React from "react";
|
||||
import InputSearch from "./InputSearch";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
|
||||
const meta: Meta<typeof InputSearch> = {
|
||||
title: "refresh-components/inputs/InputSearch",
|
||||
component: InputSearch,
|
||||
tags: ["autodocs"],
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<TooltipPrimitive.Provider>
|
||||
<div style={{ width: 320 }}>
|
||||
<Story />
|
||||
</div>
|
||||
</TooltipPrimitive.Provider>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof InputSearch>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: function DefaultStory() {
|
||||
const [value, setValue] = React.useState("");
|
||||
return (
|
||||
<InputSearch
|
||||
placeholder="Search..."
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const WithValue: Story = {
|
||||
render: function WithValueStory() {
|
||||
const [value, setValue] = React.useState("Search Value");
|
||||
return (
|
||||
<InputSearch
|
||||
placeholder="Search..."
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: function DisabledStory() {
|
||||
return (
|
||||
<InputSearch
|
||||
placeholder="Search..."
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
disabled
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
70
web/src/refresh-components/inputs/InputSearch.tsx
Normal file
70
web/src/refresh-components/inputs/InputSearch.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import InputTypeIn, {
|
||||
InputTypeInProps,
|
||||
} from "@/refresh-components/inputs/InputTypeIn";
|
||||
|
||||
/**
|
||||
* InputSearch Component
|
||||
*
|
||||
* A subtle search input that follows the "Subtle Input Styles" spec:
|
||||
* no border by default, border appears on hover/focus/active.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Basic usage
|
||||
* <InputSearch
|
||||
* placeholder="Search..."
|
||||
* value={search}
|
||||
* onChange={(e) => setSearch(e.target.value)}
|
||||
* />
|
||||
*
|
||||
* // Disabled state
|
||||
* <InputSearch
|
||||
* disabled
|
||||
* placeholder="Search..."
|
||||
* value=""
|
||||
* onChange={() => {}}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export interface InputSearchProps
|
||||
extends Omit<InputTypeInProps, "variant" | "leftSearchIcon"> {
|
||||
/**
|
||||
* Ref to the underlying input element.
|
||||
*/
|
||||
ref?: React.Ref<HTMLInputElement>;
|
||||
/**
|
||||
* Whether the input is disabled.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function InputSearch({
|
||||
ref,
|
||||
disabled,
|
||||
className,
|
||||
...props
|
||||
}: InputSearchProps) {
|
||||
return (
|
||||
<InputTypeIn
|
||||
ref={ref}
|
||||
variant={disabled ? "disabled" : "internal"}
|
||||
leftSearchIcon
|
||||
className={cn(
|
||||
"[&_input]:font-main-ui-muted [&_input]:text-text-02 [&_input]:placeholder:text-text-02",
|
||||
!disabled && [
|
||||
"border border-transparent",
|
||||
"hover:border-border-03",
|
||||
"active:border-border-05",
|
||||
"focus-within:shadow-[0px_0px_0px_2px_var(--background-tint-04)]",
|
||||
"focus-within:hover:border-border-03",
|
||||
],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -321,7 +321,7 @@ export default function ExpandableTextDisplay({
|
||||
|
||||
{/* Expanded Modal */}
|
||||
<Modal open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<Modal.Content height="lg" width="md-sm" preventAccidentalClose={false}>
|
||||
<Modal.Content height="lg" width="lg" preventAccidentalClose={false}>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between px-4 py-3">
|
||||
<div className="flex flex-col">
|
||||
|
||||
@@ -114,6 +114,10 @@ function MCPServerCard({
|
||||
const allToolIds = tools.map((t) => t.id);
|
||||
const serverEnabled =
|
||||
tools.length > 0 && tools.some((t) => isToolEnabled(t.id));
|
||||
const needsAuth = !server.is_authenticated;
|
||||
const authTooltip = needsAuth
|
||||
? "Authenticate this MCP server before enabling its tools."
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<ExpandableCard.Root isFolded={isFolded} onFoldedChange={setIsFolded}>
|
||||
@@ -122,10 +126,13 @@ function MCPServerCard({
|
||||
description={server.description}
|
||||
icon={getActionIcon(server.server_url, server.name)}
|
||||
rightChildren={
|
||||
<Switch
|
||||
checked={serverEnabled}
|
||||
onCheckedChange={(checked) => onToggleTools(allToolIds, checked)}
|
||||
/>
|
||||
<SimpleTooltip tooltip={authTooltip} side="top">
|
||||
<Switch
|
||||
checked={serverEnabled}
|
||||
onCheckedChange={(checked) => onToggleTools(allToolIds, checked)}
|
||||
disabled={needsAuth}
|
||||
/>
|
||||
</SimpleTooltip>
|
||||
}
|
||||
>
|
||||
{tools.length > 0 && (
|
||||
@@ -158,12 +165,15 @@ function MCPServerCard({
|
||||
description={tool.description}
|
||||
icon={tool.icon}
|
||||
rightChildren={
|
||||
<Switch
|
||||
checked={isToolEnabled(tool.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
onToggleTool(tool.id, checked)
|
||||
}
|
||||
/>
|
||||
<SimpleTooltip tooltip={authTooltip} side="top">
|
||||
<Switch
|
||||
checked={isToolEnabled(tool.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
onToggleTool(tool.id, checked)
|
||||
}
|
||||
disabled={needsAuth}
|
||||
/>
|
||||
</SimpleTooltip>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
@@ -757,7 +767,7 @@ function ChatPreferencesForm() {
|
||||
open={systemPromptModalOpen}
|
||||
onOpenChange={setSystemPromptModalOpen}
|
||||
>
|
||||
<Modal.Content width="md" height="fit">
|
||||
<Modal.Content width="xl" height="fit">
|
||||
<Modal.Header
|
||||
icon={SvgAddLines}
|
||||
title="System Prompt"
|
||||
|
||||
@@ -16,14 +16,8 @@ import Separator from "@/refresh-components/Separator";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import useAdminUsers from "@/hooks/useAdminUsers";
|
||||
import type { ApiKeyDescriptor } from "./interfaces";
|
||||
import {
|
||||
createGroup,
|
||||
deleteGroup,
|
||||
updateAgentGroupSharing,
|
||||
updateDocSetGroupSharing,
|
||||
saveTokenLimits,
|
||||
} from "./svc";
|
||||
import type { ApiKeyDescriptor, MemberRow } from "./interfaces";
|
||||
import { createGroup } from "./svc";
|
||||
import { apiKeyToMemberRow, memberTableColumns, PAGE_SIZE } from "./shared";
|
||||
import SharedGroupResources from "@/refresh-pages/admin/GroupsPage/SharedGroupResources";
|
||||
import TokenLimitSection from "./TokenLimitSection";
|
||||
@@ -53,7 +47,7 @@ function CreateGroupPage() {
|
||||
const isLoading = usersLoading || apiKeysLoading;
|
||||
const error = usersError ?? apiKeysError;
|
||||
|
||||
const allRows = useMemo(() => {
|
||||
const allRows: MemberRow[] = useMemo(() => {
|
||||
const activeUsers = users.filter((u) => u.is_active);
|
||||
const serviceAccountRows = (apiKeys ?? []).map(apiKeyToMemberRow);
|
||||
return [...activeUsers, ...serviceAccountRows];
|
||||
@@ -67,27 +61,11 @@ function CreateGroupPage() {
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
let groupId: number | null = null;
|
||||
try {
|
||||
groupId = await createGroup(trimmed, selectedUserIds, selectedCcPairIds);
|
||||
|
||||
// Associate agents, doc sets, and token limits with the new group.
|
||||
// If any of these fail, we roll back by deleting the group.
|
||||
if (selectedAgentIds.length > 0) {
|
||||
await updateAgentGroupSharing(groupId, [], selectedAgentIds);
|
||||
}
|
||||
if (selectedDocSetIds.length > 0) {
|
||||
await updateDocSetGroupSharing(groupId, [], selectedDocSetIds);
|
||||
}
|
||||
await saveTokenLimits(groupId, tokenLimits, []);
|
||||
|
||||
await createGroup(trimmed, selectedUserIds, selectedCcPairIds);
|
||||
toast.success(`Group "${trimmed}" created`);
|
||||
router.push("/admin/groups2");
|
||||
router.push("/admin/groups");
|
||||
} catch (e) {
|
||||
// Roll back: delete the partially-created group so the user can retry
|
||||
if (groupId != null) {
|
||||
await deleteGroup(groupId).catch(() => {});
|
||||
}
|
||||
toast.error(e instanceof Error ? e.message : "Failed to create group");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
@@ -98,7 +76,7 @@ function CreateGroupPage() {
|
||||
<Section flexDirection="row" gap={0.5} width="auto" height="auto">
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
onClick={() => router.push("/admin/groups2")}
|
||||
onClick={() => router.push("/admin/groups")}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
@@ -166,7 +144,6 @@ function CreateGroupPage() {
|
||||
data={allRows}
|
||||
columns={memberTableColumns}
|
||||
getRowId={(row) => row.id ?? row.email}
|
||||
qualifier="avatar"
|
||||
pageSize={PAGE_SIZE}
|
||||
searchTerm={searchTerm}
|
||||
selectionBehavior="multi-select"
|
||||
|
||||
@@ -1,513 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
import { Table, Button } from "@opal/components";
|
||||
import { IllustrationContent } from "@opal/layouts";
|
||||
import { SvgUsers, SvgTrash, SvgMinusCircle, SvgPlusCircle } from "@opal/icons";
|
||||
import IconButton from "@/refresh-components/buttons/IconButton";
|
||||
import Card from "@/refresh-components/cards/Card";
|
||||
import * as InputLayouts from "@/layouts/input-layouts";
|
||||
import SvgNoResult from "@opal/illustrations/no-result";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
|
||||
import ConfirmationModalLayout from "@/refresh-components/layouts/ConfirmationModalLayout";
|
||||
import Separator from "@/refresh-components/Separator";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import useAdminUsers from "@/hooks/useAdminUsers";
|
||||
import type { UserGroup } from "@/lib/types";
|
||||
import type {
|
||||
ApiKeyDescriptor,
|
||||
MemberRow,
|
||||
TokenRateLimitDisplay,
|
||||
} from "./interfaces";
|
||||
import {
|
||||
apiKeyToMemberRow,
|
||||
baseColumns,
|
||||
memberTableColumns,
|
||||
tc,
|
||||
PAGE_SIZE,
|
||||
} from "./shared";
|
||||
import {
|
||||
USER_GROUP_URL,
|
||||
renameGroup,
|
||||
updateGroup,
|
||||
deleteGroup,
|
||||
updateAgentGroupSharing,
|
||||
updateDocSetGroupSharing,
|
||||
saveTokenLimits,
|
||||
} from "./svc";
|
||||
import SharedGroupResources from "@/refresh-pages/admin/GroupsPage/SharedGroupResources";
|
||||
import TokenLimitSection from "./TokenLimitSection";
|
||||
import type { TokenLimit } from "./TokenLimitSection";
|
||||
|
||||
const addModeColumns = memberTableColumns;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface EditGroupPageProps {
|
||||
groupId: number;
|
||||
}
|
||||
|
||||
function EditGroupPage({ groupId }: EditGroupPageProps) {
|
||||
const router = useRouter();
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
// Fetch the group data — poll every 5s while syncing so the UI updates
|
||||
// automatically when the backend finishes processing the previous edit.
|
||||
const {
|
||||
data: groups,
|
||||
isLoading: groupLoading,
|
||||
error: groupError,
|
||||
} = useSWR<UserGroup[]>(USER_GROUP_URL, errorHandlingFetcher, {
|
||||
refreshInterval: (latestData) => {
|
||||
const g = latestData?.find((g) => g.id === groupId);
|
||||
return g && !g.is_up_to_date ? 5000 : 0;
|
||||
},
|
||||
});
|
||||
|
||||
const group = useMemo(
|
||||
() => groups?.find((g) => g.id === groupId) ?? null,
|
||||
[groups, groupId]
|
||||
);
|
||||
|
||||
const isSyncing = group != null && !group.is_up_to_date;
|
||||
|
||||
// Fetch token rate limits for this group
|
||||
const { data: tokenRateLimits, isLoading: tokenLimitsLoading } = useSWR<
|
||||
TokenRateLimitDisplay[]
|
||||
>(`/api/admin/token-rate-limits/user-group/${groupId}`, errorHandlingFetcher);
|
||||
|
||||
// Form state
|
||||
const [groupName, setGroupName] = useState("");
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const isSubmittingRef = useRef(false);
|
||||
const [selectedCcPairIds, setSelectedCcPairIds] = useState<number[]>([]);
|
||||
const [selectedDocSetIds, setSelectedDocSetIds] = useState<number[]>([]);
|
||||
const [selectedAgentIds, setSelectedAgentIds] = useState<number[]>([]);
|
||||
const [tokenLimits, setTokenLimits] = useState<TokenLimit[]>([
|
||||
{ tokenBudget: null, periodHours: null },
|
||||
]);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [isAddingMembers, setIsAddingMembers] = useState(false);
|
||||
const initialAgentIdsRef = useRef<number[]>([]);
|
||||
const initialDocSetIdsRef = useRef<number[]>([]);
|
||||
|
||||
// Users and API keys
|
||||
const { users, isLoading: usersLoading, error: usersError } = useAdminUsers();
|
||||
|
||||
const {
|
||||
data: apiKeys,
|
||||
isLoading: apiKeysLoading,
|
||||
error: apiKeysError,
|
||||
} = useSWR<ApiKeyDescriptor[]>("/api/admin/api-key", errorHandlingFetcher);
|
||||
|
||||
const isLoading =
|
||||
groupLoading || usersLoading || apiKeysLoading || tokenLimitsLoading;
|
||||
const error = groupError ?? usersError ?? apiKeysError;
|
||||
|
||||
// Pre-populate form when group data loads
|
||||
useEffect(() => {
|
||||
if (group && !initialized) {
|
||||
setGroupName(group.name);
|
||||
setSelectedUserIds(group.users.map((u) => u.id));
|
||||
setSelectedCcPairIds(group.cc_pairs.map((cc) => cc.id));
|
||||
const docSetIds = group.document_sets.map((ds) => ds.id);
|
||||
setSelectedDocSetIds(docSetIds);
|
||||
initialDocSetIdsRef.current = docSetIds;
|
||||
const agentIds = group.personas.map((p) => p.id);
|
||||
setSelectedAgentIds(agentIds);
|
||||
initialAgentIdsRef.current = agentIds;
|
||||
setInitialized(true);
|
||||
}
|
||||
}, [group, initialized]);
|
||||
|
||||
// Pre-populate token limits when fetched
|
||||
useEffect(() => {
|
||||
if (tokenRateLimits && tokenRateLimits.length > 0) {
|
||||
setTokenLimits(
|
||||
tokenRateLimits.map((trl) => ({
|
||||
tokenBudget: trl.token_budget,
|
||||
periodHours: trl.period_hours,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}, [tokenRateLimits]);
|
||||
|
||||
const allRows = useMemo(() => {
|
||||
const activeUsers = users.filter((u) => u.is_active);
|
||||
const serviceAccountRows = (apiKeys ?? []).map(apiKeyToMemberRow);
|
||||
return [...activeUsers, ...serviceAccountRows];
|
||||
}, [users, apiKeys]);
|
||||
|
||||
const memberRows = useMemo(() => {
|
||||
const selected = new Set(selectedUserIds);
|
||||
return allRows.filter((r) => selected.has(r.id ?? r.email));
|
||||
}, [allRows, selectedUserIds]);
|
||||
|
||||
const currentRowSelection = useMemo(() => {
|
||||
const sel: Record<string, boolean> = {};
|
||||
for (const id of selectedUserIds) sel[id] = true;
|
||||
return sel;
|
||||
}, [selectedUserIds]);
|
||||
|
||||
const handleRemoveMember = useCallback((userId: string) => {
|
||||
setSelectedUserIds((prev) => prev.filter((id) => id !== userId));
|
||||
}, []);
|
||||
|
||||
const memberColumns = useMemo(
|
||||
() => [
|
||||
...baseColumns,
|
||||
tc.actions({
|
||||
showSorting: false,
|
||||
showColumnVisibility: false,
|
||||
cell: (row: MemberRow) => (
|
||||
<IconButton
|
||||
icon={SvgMinusCircle}
|
||||
tertiary
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveMember(row.id ?? row.email);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
],
|
||||
[handleRemoveMember]
|
||||
);
|
||||
|
||||
// IDs of members not visible in the add-mode table (e.g. inactive users).
|
||||
// We preserve these so they aren't silently removed when the table fires
|
||||
// onSelectionChange with only the visible rows.
|
||||
const hiddenMemberIds = useMemo(() => {
|
||||
const visibleIds = new Set(allRows.map((r) => r.id ?? r.email));
|
||||
return selectedUserIds.filter((id) => !visibleIds.has(id));
|
||||
}, [allRows, selectedUserIds]);
|
||||
|
||||
// Guard onSelectionChange: ignore updates until the form is fully initialized.
|
||||
// Without this, TanStack fires onSelectionChange before all rows are loaded,
|
||||
// which overwrites selectedUserIds with a partial set.
|
||||
const handleSelectionChange = useCallback(
|
||||
(ids: string[]) => {
|
||||
if (!initialized) return;
|
||||
setSelectedUserIds([...ids, ...hiddenMemberIds]);
|
||||
},
|
||||
[initialized, hiddenMemberIds]
|
||||
);
|
||||
|
||||
async function handleSave() {
|
||||
if (isSubmittingRef.current) return;
|
||||
|
||||
const trimmed = groupName.trim();
|
||||
if (!trimmed) {
|
||||
toast.error("Group name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-fetch group to check sync status before saving
|
||||
const freshGroups = await fetch(USER_GROUP_URL).then((r) => r.json());
|
||||
const freshGroup = freshGroups.find((g: UserGroup) => g.id === groupId);
|
||||
if (freshGroup && !freshGroup.is_up_to_date) {
|
||||
toast.error(
|
||||
"This group is currently syncing. Please wait a moment and try again."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmittingRef.current = true;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// Rename if name changed
|
||||
if (group && trimmed !== group.name) {
|
||||
await renameGroup(group.id, trimmed);
|
||||
}
|
||||
|
||||
// Update members and cc_pairs
|
||||
await updateGroup(groupId, selectedUserIds, selectedCcPairIds);
|
||||
|
||||
// Update agent sharing (add/remove this group from changed agents)
|
||||
await updateAgentGroupSharing(
|
||||
groupId,
|
||||
initialAgentIdsRef.current,
|
||||
selectedAgentIds
|
||||
);
|
||||
|
||||
// Update document set sharing (add/remove this group from changed doc sets)
|
||||
await updateDocSetGroupSharing(
|
||||
groupId,
|
||||
initialDocSetIdsRef.current,
|
||||
selectedDocSetIds
|
||||
);
|
||||
|
||||
// Save token rate limits (create/update/delete)
|
||||
await saveTokenLimits(groupId, tokenLimits, tokenRateLimits ?? []);
|
||||
|
||||
// Update refs so subsequent saves diff correctly
|
||||
initialAgentIdsRef.current = selectedAgentIds;
|
||||
initialDocSetIdsRef.current = selectedDocSetIds;
|
||||
|
||||
mutate(USER_GROUP_URL);
|
||||
mutate(`/api/admin/token-rate-limits/user-group/${groupId}`);
|
||||
toast.success(`Group "${trimmed}" updated`);
|
||||
router.push("/admin/groups2");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to update group");
|
||||
} finally {
|
||||
isSubmittingRef.current = false;
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await deleteGroup(groupId);
|
||||
mutate(USER_GROUP_URL);
|
||||
toast.success(`Group "${group?.name}" deleted`);
|
||||
router.push("/admin/groups2");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to delete group");
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setShowDeleteModal(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 404 state
|
||||
if (!isLoading && !error && !group) {
|
||||
return (
|
||||
<SettingsLayouts.Root width="sm">
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgUsers}
|
||||
title="Group Not Found"
|
||||
separator
|
||||
/>
|
||||
<SettingsLayouts.Body>
|
||||
<IllustrationContent
|
||||
illustration={SvgNoResult}
|
||||
title="Group not found"
|
||||
description="This group doesn't exist or may have been deleted."
|
||||
/>
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
|
||||
const headerActions = (
|
||||
<Section flexDirection="row" gap={0.5} width="auto" height="auto">
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
onClick={() => router.push("/admin/groups2")}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!groupName.trim() || isSubmitting || isSyncing}
|
||||
>
|
||||
{isSubmitting ? "Saving..." : isSyncing ? "Syncing..." : "Save Changes"}
|
||||
</Button>
|
||||
</Section>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsLayouts.Root width="sm">
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgUsers}
|
||||
title="Edit Group"
|
||||
separator
|
||||
rightChildren={headerActions}
|
||||
/>
|
||||
|
||||
<SettingsLayouts.Body>
|
||||
{isLoading && <SimpleLoader />}
|
||||
|
||||
{error && (
|
||||
<Text as="p" secondaryBody text03>
|
||||
Failed to load group data.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && group && (
|
||||
<>
|
||||
{/* Group Name */}
|
||||
<Section
|
||||
gap={0.5}
|
||||
height="auto"
|
||||
alignItems="stretch"
|
||||
justifyContent="start"
|
||||
>
|
||||
<Text mainUiBody text04>
|
||||
Group Name
|
||||
</Text>
|
||||
<InputTypeIn
|
||||
placeholder="Name your group"
|
||||
value={groupName}
|
||||
onChange={(e) => setGroupName(e.target.value)}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Separator noPadding />
|
||||
|
||||
{/* Members table */}
|
||||
<Section
|
||||
gap={0.75}
|
||||
height="auto"
|
||||
alignItems="stretch"
|
||||
justifyContent="start"
|
||||
>
|
||||
<Section
|
||||
flexDirection="row"
|
||||
gap={0.5}
|
||||
height="auto"
|
||||
alignItems="center"
|
||||
justifyContent="start"
|
||||
>
|
||||
<InputTypeIn
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder={
|
||||
isAddingMembers
|
||||
? "Search users and accounts..."
|
||||
: "Search members..."
|
||||
}
|
||||
leftSearchIcon
|
||||
className="flex-1"
|
||||
/>
|
||||
{isAddingMembers ? (
|
||||
<Button
|
||||
prominence="secondary"
|
||||
onClick={() => setIsAddingMembers(false)}
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
icon={SvgPlusCircle}
|
||||
onClick={() => setIsAddingMembers(true)}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{isAddingMembers ? (
|
||||
<Table
|
||||
key="add-members"
|
||||
data={allRows}
|
||||
columns={addModeColumns}
|
||||
getRowId={(row) => row.id ?? row.email}
|
||||
qualifier="avatar"
|
||||
pageSize={PAGE_SIZE}
|
||||
searchTerm={searchTerm}
|
||||
selectionBehavior="multi-select"
|
||||
initialRowSelection={currentRowSelection}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
footer={{}}
|
||||
emptyState={
|
||||
<IllustrationContent
|
||||
illustration={SvgNoResult}
|
||||
title="No users found"
|
||||
description="No users match your search."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Table
|
||||
data={memberRows}
|
||||
columns={memberColumns}
|
||||
getRowId={(row) => row.id ?? row.email}
|
||||
qualifier="avatar"
|
||||
pageSize={PAGE_SIZE}
|
||||
searchTerm={searchTerm}
|
||||
footer={{}}
|
||||
emptyState={
|
||||
<IllustrationContent
|
||||
illustration={SvgNoResult}
|
||||
title="No members"
|
||||
description="Add members to this group."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<SharedGroupResources
|
||||
selectedCcPairIds={selectedCcPairIds}
|
||||
onCcPairIdsChange={setSelectedCcPairIds}
|
||||
selectedDocSetIds={selectedDocSetIds}
|
||||
onDocSetIdsChange={setSelectedDocSetIds}
|
||||
selectedAgentIds={selectedAgentIds}
|
||||
onAgentIdsChange={setSelectedAgentIds}
|
||||
/>
|
||||
|
||||
<TokenLimitSection
|
||||
limits={tokenLimits}
|
||||
onLimitsChange={setTokenLimits}
|
||||
/>
|
||||
|
||||
{/* Delete This Group */}
|
||||
<Card>
|
||||
<InputLayouts.Horizontal
|
||||
title="Delete This Group"
|
||||
description="Members will lose access to any resources shared with this group."
|
||||
center
|
||||
>
|
||||
<Button
|
||||
variant="danger"
|
||||
prominence="secondary"
|
||||
icon={SvgTrash}
|
||||
onClick={() => setShowDeleteModal(true)}
|
||||
>
|
||||
Delete Group
|
||||
</Button>
|
||||
</InputLayouts.Horizontal>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
|
||||
{showDeleteModal && (
|
||||
<ConfirmationModalLayout
|
||||
icon={SvgTrash}
|
||||
title="Delete Group"
|
||||
onClose={() => setShowDeleteModal(false)}
|
||||
submit={
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Text as="p" text03>
|
||||
Members of group{" "}
|
||||
<Text as="span" text05>
|
||||
{group?.name}
|
||||
</Text>{" "}
|
||||
will lose access to any resources shared with this group, unless
|
||||
they have been granted access directly. Deletion cannot be undone.
|
||||
</Text>
|
||||
</ConfirmationModalLayout>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditGroupPage;
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { UserGroup } from "@/lib/types";
|
||||
import { SvgChevronRight, SvgUserManage, SvgUsers } from "@opal/icons";
|
||||
import { ContentAction } from "@opal/layouts";
|
||||
@@ -22,7 +21,6 @@ interface GroupCardProps {
|
||||
}
|
||||
|
||||
function GroupCard({ group }: GroupCardProps) {
|
||||
const router = useRouter();
|
||||
const { mutate } = useSWRConfig();
|
||||
const builtIn = isBuiltInGroup(group);
|
||||
const isAdmin = group.name === "Admin";
|
||||
@@ -41,7 +39,7 @@ function GroupCard({ group }: GroupCardProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card padding={0.5} data-card>
|
||||
<Card padding={0.5}>
|
||||
<ContentAction
|
||||
icon={isAdmin ? SvgUserManage : SvgUsers}
|
||||
title={group.name}
|
||||
@@ -55,16 +53,13 @@ function GroupCard({ group }: GroupCardProps) {
|
||||
<Section flexDirection="row" alignItems="start" gap={0}>
|
||||
<div className="py-1">
|
||||
<Text mainUiBody text03>
|
||||
{formatMemberCount(
|
||||
group.users.filter((u) => u.is_active).length
|
||||
)}
|
||||
{formatMemberCount(group.users.length)}
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
icon={SvgChevronRight}
|
||||
prominence="tertiary"
|
||||
tooltip="View group"
|
||||
onClick={() => router.push(`/admin/groups2/${group.id}`)}
|
||||
/>
|
||||
</Section>
|
||||
}
|
||||
|
||||
@@ -8,14 +8,8 @@ import Popover from "@/refresh-components/Popover";
|
||||
import Separator from "@/refresh-components/Separator";
|
||||
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import type { PopoverSection } from "./interfaces";
|
||||
|
||||
interface ResourcePopoverProps {
|
||||
placeholder: string;
|
||||
searchValue: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
sections: PopoverSection[];
|
||||
}
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ResourcePopoverProps } from "@/refresh-pages/admin/GroupsPage/SharedGroupResources/interfaces";
|
||||
|
||||
function ResourcePopover({
|
||||
placeholder,
|
||||
@@ -89,11 +83,12 @@ function ResourcePopover({
|
||||
{section.items.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className={
|
||||
className={cn(
|
||||
"rounded-08 cursor-pointer",
|
||||
item.disabled
|
||||
? "rounded-08 bg-background-tint-02 cursor-pointer"
|
||||
: "rounded-08 cursor-pointer hover:bg-background-tint-02 transition-colors"
|
||||
}
|
||||
? "bg-background-tint-02"
|
||||
: "hover:bg-background-tint-02 transition-colors"
|
||||
)}
|
||||
onClick={() => {
|
||||
item.onSelect();
|
||||
}}
|
||||
|
||||
@@ -40,11 +40,7 @@ function SharedBadge() {
|
||||
);
|
||||
}
|
||||
|
||||
interface SourceIconStackProps {
|
||||
sources: { source: ValidSources }[];
|
||||
}
|
||||
|
||||
function SourceIconStack({ sources }: SourceIconStackProps) {
|
||||
function SourceIconStack({ sources }: { sources: { source: ValidSources }[] }) {
|
||||
if (sources.length === 0) return null;
|
||||
|
||||
const unique = Array.from(
|
||||
@@ -52,7 +48,13 @@ function SourceIconStack({ sources }: SourceIconStackProps) {
|
||||
).slice(0, 3);
|
||||
|
||||
return (
|
||||
<div className="flex items-center shrink-0 px-0.5">
|
||||
<Section
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
height="auto"
|
||||
gap={0}
|
||||
className="shrink-0 px-0.5"
|
||||
>
|
||||
{unique.map((s, i) => {
|
||||
const Icon = getSourceMetadata(s.source).icon;
|
||||
return (
|
||||
@@ -65,7 +67,7 @@ function SourceIconStack({ sources }: SourceIconStackProps) {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,3 +10,10 @@ export interface PopoverSection {
|
||||
label?: string;
|
||||
items: PopoverItem[];
|
||||
}
|
||||
|
||||
export interface ResourcePopoverProps {
|
||||
placeholder: string;
|
||||
searchValue: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
sections: PopoverSection[];
|
||||
}
|
||||
|
||||
@@ -32,6 +32,16 @@ function TokenLimitSection({ limits, onLimitsChange }: TokenLimitSectionProps) {
|
||||
const nextKeyRef = useRef(limits.length);
|
||||
const keysRef = useRef<number[]>(limits.map((_, i) => i));
|
||||
|
||||
// Sync keys if the parent provides a different number of limits externally
|
||||
// (e.g. loaded from server after initial mount).
|
||||
if (keysRef.current.length < limits.length) {
|
||||
while (keysRef.current.length < limits.length) {
|
||||
keysRef.current.push(nextKeyRef.current++);
|
||||
}
|
||||
} else if (keysRef.current.length > limits.length) {
|
||||
keysRef.current = keysRef.current.slice(0, limits.length);
|
||||
}
|
||||
|
||||
function addLimit() {
|
||||
const emptyIndex = limits.findIndex(
|
||||
(l) => l.tokenBudget === null && l.periodHours === null
|
||||
@@ -107,7 +117,7 @@ function TokenLimitSection({ limits, onLimitsChange }: TokenLimitSectionProps) {
|
||||
<InputNumber
|
||||
value={limit.periodHours}
|
||||
onChange={(v) => updateLimit(i, "periodHours", v)}
|
||||
min={0}
|
||||
min={1}
|
||||
placeholder="24"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -44,7 +44,7 @@ function GroupsPage() {
|
||||
/>
|
||||
<Button
|
||||
icon={SvgPlusCircle}
|
||||
onClick={() => router.push("/admin/groups2/create")}
|
||||
onClick={() => router.push("/admin/groups/create")}
|
||||
>
|
||||
New Group
|
||||
</Button>
|
||||
|
||||
@@ -13,10 +13,3 @@ export interface ApiKeyDescriptor {
|
||||
export interface MemberRow extends UserRow {
|
||||
api_key_display?: string;
|
||||
}
|
||||
|
||||
export interface TokenRateLimitDisplay {
|
||||
token_id: number;
|
||||
enabled: boolean;
|
||||
token_budget: number;
|
||||
period_hours: number;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { SvgUser, SvgUserManage, SvgGlobe, SvgSlack } from "@opal/icons";
|
||||
import type { IconFunctionComponent } from "@opal/types";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { UserRole, UserStatus, USER_ROLE_LABELS } from "@/lib/types";
|
||||
import { getInitials } from "@/refresh-pages/admin/UsersPage/utils";
|
||||
import type { ApiKeyDescriptor, MemberRow } from "./interfaces";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -77,21 +76,15 @@ function renderAccountTypeColumn(_value: unknown, row: MemberRow) {
|
||||
const tc = createTableColumns<MemberRow>();
|
||||
|
||||
export const baseColumns = [
|
||||
tc.qualifier({
|
||||
content: "avatar-user",
|
||||
getInitials: (row) => getInitials(row.personal_name, row.email),
|
||||
selectable: true,
|
||||
}),
|
||||
tc.qualifier(),
|
||||
tc.column("email", {
|
||||
header: "Name",
|
||||
weight: 25,
|
||||
minWidth: 180,
|
||||
cell: renderNameColumn,
|
||||
}),
|
||||
tc.column("api_key_display", {
|
||||
header: "",
|
||||
weight: 15,
|
||||
minWidth: 100,
|
||||
enableSorting: false,
|
||||
cell: (value) =>
|
||||
value ? (
|
||||
@@ -103,7 +96,6 @@ export const baseColumns = [
|
||||
tc.column("role", {
|
||||
header: "Account Type",
|
||||
weight: 15,
|
||||
minWidth: 140,
|
||||
cell: renderAccountTypeColumn,
|
||||
}),
|
||||
];
|
||||
@@ -112,5 +104,3 @@ export const memberTableColumns = [
|
||||
...baseColumns,
|
||||
tc.actions({ showSorting: false }),
|
||||
];
|
||||
|
||||
export { tc };
|
||||
|
||||
@@ -40,27 +40,6 @@ async function createGroup(
|
||||
return group.id;
|
||||
}
|
||||
|
||||
async function updateGroup(
|
||||
groupId: number,
|
||||
userIds: string[],
|
||||
ccPairIds: number[]
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${USER_GROUP_URL}/${groupId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
user_ids: userIds,
|
||||
cc_pair_ids: ccPairIds,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = await res.json().catch(() => null);
|
||||
throw new Error(
|
||||
detail?.detail ?? `Failed to update group: ${res.statusText}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteGroup(groupId: number): Promise<void> {
|
||||
const res = await fetch(`${USER_GROUP_URL}/${groupId}`, {
|
||||
method: "DELETE",
|
||||
@@ -85,48 +64,21 @@ async function updateAgentGroupSharing(
|
||||
const initialSet = new Set(initialAgentIds);
|
||||
const currentSet = new Set(currentAgentIds);
|
||||
|
||||
const added = currentAgentIds.filter((id) => !initialSet.has(id));
|
||||
const removed = initialAgentIds.filter((id) => !currentSet.has(id));
|
||||
const added_agent_ids = currentAgentIds.filter((id) => !initialSet.has(id));
|
||||
const removed_agent_ids = initialAgentIds.filter((id) => !currentSet.has(id));
|
||||
|
||||
// For each added agent, add this group to its group_ids
|
||||
for (const agentId of added) {
|
||||
// Fetch current agent to get existing group associations
|
||||
const agentRes = await fetch(`/api/persona/${agentId}`);
|
||||
if (!agentRes.ok) {
|
||||
throw new Error(`Failed to fetch agent ${agentId}`);
|
||||
}
|
||||
const agent = await agentRes.json();
|
||||
const existingGroupIds: number[] = (agent.groups ?? []) as number[];
|
||||
if (!existingGroupIds.includes(groupId)) {
|
||||
existingGroupIds.push(groupId);
|
||||
}
|
||||
const shareRes = await fetch(`/api/persona/${agentId}/share`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ group_ids: existingGroupIds }),
|
||||
});
|
||||
if (!shareRes.ok) {
|
||||
throw new Error(`Failed to share agent ${agentId} with group`);
|
||||
}
|
||||
}
|
||||
if (added_agent_ids.length === 0 && removed_agent_ids.length === 0) return;
|
||||
|
||||
// For each removed agent, remove this group from its group_ids
|
||||
for (const agentId of removed) {
|
||||
const agentRes = await fetch(`/api/persona/${agentId}`);
|
||||
if (!agentRes.ok) {
|
||||
throw new Error(`Failed to fetch agent ${agentId}`);
|
||||
}
|
||||
const agent = await agentRes.json();
|
||||
const existingGroupIds: number[] = (agent.groups ?? []) as number[];
|
||||
const filtered = existingGroupIds.filter((id) => id !== groupId);
|
||||
const shareRes = await fetch(`/api/persona/${agentId}/share`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ group_ids: filtered }),
|
||||
});
|
||||
if (!shareRes.ok) {
|
||||
throw new Error(`Failed to update agent ${agentId} sharing`);
|
||||
}
|
||||
const res = await fetch(`${USER_GROUP_URL}/${groupId}/agents`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ added_agent_ids, removed_agent_ids }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = await res.json().catch(() => null);
|
||||
throw new Error(
|
||||
detail?.detail ?? `Failed to update agent sharing: ${res.statusText}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +90,7 @@ interface DocumentSetSummary {
|
||||
id: number;
|
||||
description: string;
|
||||
cc_pair_summaries: { id: number }[];
|
||||
federated_connector_summaries: { id: number }[];
|
||||
is_public: boolean;
|
||||
users: string[];
|
||||
groups: number[];
|
||||
@@ -179,6 +132,9 @@ async function updateDocSetGroupSharing(
|
||||
id: ds.id,
|
||||
description: ds.description,
|
||||
cc_pair_ids: ds.cc_pair_summaries.map((cc) => cc.id),
|
||||
federated_connectors: ds.federated_connector_summaries.map((fc) => ({
|
||||
federated_connector_id: fc.id,
|
||||
})),
|
||||
is_public: ds.is_public,
|
||||
users: ds.users,
|
||||
groups: updatedGroups,
|
||||
@@ -202,6 +158,9 @@ async function updateDocSetGroupSharing(
|
||||
id: ds.id,
|
||||
description: ds.description,
|
||||
cc_pair_ids: ds.cc_pair_summaries.map((cc) => cc.id),
|
||||
federated_connectors: ds.federated_connector_summaries.map((fc) => ({
|
||||
federated_connector_id: fc.id,
|
||||
})),
|
||||
is_public: ds.is_public,
|
||||
users: ds.users,
|
||||
groups: updatedGroups,
|
||||
@@ -303,7 +262,6 @@ export {
|
||||
USER_GROUP_URL,
|
||||
renameGroup,
|
||||
createGroup,
|
||||
updateGroup,
|
||||
deleteGroup,
|
||||
updateAgentGroupSharing,
|
||||
updateDocSetGroupSharing,
|
||||
|
||||
117
web/src/refresh-pages/admin/HooksPage/HooksContent.tsx
Normal file
117
web/src/refresh-pages/admin/HooksPage/HooksContent.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { useHookSpecs } from "@/hooks/useHookSpecs";
|
||||
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
|
||||
import { ContentAction } from "@opal/layouts";
|
||||
import { Button } from "@opal/components";
|
||||
import InputSearch from "@/refresh-components/inputs/InputSearch";
|
||||
import Card from "@/refresh-components/cards/Card";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import {
|
||||
SvgArrowExchange,
|
||||
SvgBubbleText,
|
||||
SvgExternalLink,
|
||||
SvgFileBroadcast,
|
||||
SvgHookNodes,
|
||||
} from "@opal/icons";
|
||||
import { IconFunctionComponent } from "@opal/types";
|
||||
|
||||
const HOOK_POINT_ICONS: Record<string, IconFunctionComponent> = {
|
||||
document_ingestion: SvgFileBroadcast,
|
||||
query_processing: SvgBubbleText,
|
||||
};
|
||||
|
||||
function getHookPointIcon(hookPoint: string): IconFunctionComponent {
|
||||
return HOOK_POINT_ICONS[hookPoint] ?? SvgHookNodes;
|
||||
}
|
||||
|
||||
export default function HooksContent() {
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const { specs, isLoading, error } = useHookSpecs();
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
toast.error("Failed to load hook specifications.");
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
if (isLoading) {
|
||||
return <SimpleLoader />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Text text03 secondaryBody>
|
||||
Failed to load hook specifications. Please refresh the page.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const filtered = (specs ?? []).filter(
|
||||
(spec) =>
|
||||
spec.display_name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
spec.description.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<InputSearch
|
||||
placeholder="Search hooks..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{filtered.length === 0 ? (
|
||||
<Text text03 secondaryBody>
|
||||
{search
|
||||
? "No hooks match your search."
|
||||
: "No hook points are available."}
|
||||
</Text>
|
||||
) : (
|
||||
filtered.map((spec) => (
|
||||
<Card
|
||||
key={spec.hook_point}
|
||||
variant="secondary"
|
||||
padding={0.5}
|
||||
gap={0}
|
||||
>
|
||||
<ContentAction
|
||||
icon={getHookPointIcon(spec.hook_point)}
|
||||
title={spec.display_name}
|
||||
description={spec.description}
|
||||
sizePreset="main-content"
|
||||
variant="section"
|
||||
paddingVariant="fit"
|
||||
rightChildren={
|
||||
// TODO(Bo-Onyx): wire up Connect — open modal to create/edit hook
|
||||
<Button prominence="tertiary" rightIcon={SvgArrowExchange}>
|
||||
Connect
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{spec.docs_url && (
|
||||
<div className="pl-7 pt-1">
|
||||
<a
|
||||
href={spec.docs_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 w-fit text-text-03"
|
||||
>
|
||||
<Text as="span" secondaryBody text03 className="underline">
|
||||
Documentation
|
||||
</Text>
|
||||
<SvgExternalLink size={16} className="text-text-02" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
web/src/refresh-pages/admin/HooksPage/index.tsx
Normal file
42
web/src/refresh-pages/admin/HooksPage/index.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { ADMIN_ROUTES } from "@/lib/admin-routes";
|
||||
import { useSettingsContext } from "@/providers/SettingsProvider";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
|
||||
import HooksContent from "./HooksContent";
|
||||
|
||||
const route = ADMIN_ROUTES.HOOKS;
|
||||
|
||||
export default function HooksPage() {
|
||||
const router = useRouter();
|
||||
const { settings, settingsLoading } = useSettingsContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (!settingsLoading && !settings.hooks_enabled) {
|
||||
toast.info("Hook Extensions are not enabled for this deployment.");
|
||||
router.replace("/");
|
||||
}
|
||||
}, [settingsLoading, settings.hooks_enabled, router]);
|
||||
|
||||
if (settingsLoading || !settings.hooks_enabled) {
|
||||
return <SimpleLoader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header
|
||||
icon={route.icon}
|
||||
title={route.title}
|
||||
description="Extend Onyx pipelines by registering external API endpoints as callbacks at predefined hook points."
|
||||
separator
|
||||
/>
|
||||
<SettingsLayouts.Body>
|
||||
<HooksContent />
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
63
web/src/refresh-pages/admin/HooksPage/interfaces.ts
Normal file
63
web/src/refresh-pages/admin/HooksPage/interfaces.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export type HookPoint = string;
|
||||
export type HookFailStrategy = "hard" | "soft";
|
||||
|
||||
export interface HookPointMeta {
|
||||
hook_point: HookPoint;
|
||||
display_name: string;
|
||||
description: string;
|
||||
docs_url: string | null;
|
||||
input_schema: Record<string, unknown>;
|
||||
output_schema: Record<string, unknown>;
|
||||
default_timeout_seconds: number;
|
||||
default_fail_strategy: HookFailStrategy;
|
||||
fail_hard_description: string;
|
||||
}
|
||||
|
||||
export interface HookResponse {
|
||||
id: number;
|
||||
name: string;
|
||||
hook_point: HookPoint;
|
||||
endpoint_url: string | null;
|
||||
fail_strategy: HookFailStrategy;
|
||||
timeout_seconds: number;
|
||||
is_active: boolean;
|
||||
is_reachable: boolean | null;
|
||||
creator_email: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface HookCreateRequest {
|
||||
name: string;
|
||||
hook_point: HookPoint;
|
||||
endpoint_url: string;
|
||||
api_key?: string;
|
||||
fail_strategy?: HookFailStrategy;
|
||||
timeout_seconds?: number;
|
||||
}
|
||||
|
||||
export interface HookUpdateRequest {
|
||||
name?: string;
|
||||
endpoint_url?: string;
|
||||
api_key?: string | null;
|
||||
fail_strategy?: HookFailStrategy;
|
||||
timeout_seconds?: number;
|
||||
}
|
||||
|
||||
export interface HookExecutionRecord {
|
||||
error_message: string | null;
|
||||
status_code: number | null;
|
||||
duration_ms: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type HookValidateStatus =
|
||||
| "passed"
|
||||
| "auth_failed"
|
||||
| "timeout"
|
||||
| "cannot_connect";
|
||||
|
||||
export interface HookValidateResponse {
|
||||
status: HookValidateStatus;
|
||||
error_message: string | null;
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { MinimalOnyxDocument } from "@/lib/search/interfaces";
|
||||
import { ChatState } from "@/app/app/interfaces";
|
||||
import { useForcedTools } from "@/lib/hooks/useForcedTools";
|
||||
import useAppFocus from "@/hooks/useAppFocus";
|
||||
import { getPastedFilesIfNoText } from "@/lib/clipboard";
|
||||
import { cn, isImageFile } from "@/lib/utils";
|
||||
import { Disabled } from "@opal/core";
|
||||
import { useUser } from "@/providers/UserProvider";
|
||||
@@ -300,20 +301,10 @@ const AppInputBar = React.memo(
|
||||
}, [showFiles, currentMessageFiles]);
|
||||
|
||||
function handlePaste(event: React.ClipboardEvent) {
|
||||
const items = event.clipboardData?.items;
|
||||
if (items) {
|
||||
const pastedFiles = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item && item.kind === "file") {
|
||||
const file = item.getAsFile();
|
||||
if (file) pastedFiles.push(file);
|
||||
}
|
||||
}
|
||||
if (pastedFiles.length > 0) {
|
||||
event.preventDefault();
|
||||
handleFileUpload(pastedFiles);
|
||||
}
|
||||
const pastedFiles = getPastedFilesIfNoText(event.clipboardData);
|
||||
if (pastedFiles.length > 0) {
|
||||
event.preventDefault();
|
||||
handleFileUpload(pastedFiles);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -237,7 +237,7 @@ export default function AgentViewerModal({ agent }: AgentViewerModalProps) {
|
||||
onOpenChange={agentViewerModal.toggle}
|
||||
>
|
||||
<Modal.Content
|
||||
width="md-sm"
|
||||
width="lg"
|
||||
height="lg"
|
||||
bottomSlot={<AgentChatInput agent={agent} onSubmit={handleStartChat} />}
|
||||
>
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function ExceptionTraceModal({
|
||||
}: ExceptionTraceModalProps) {
|
||||
return (
|
||||
<Modal open onOpenChange={onOutsideClick}>
|
||||
<Modal.Content width="lg" height="full">
|
||||
<Modal.Content width="full" height="full">
|
||||
<Modal.Header
|
||||
icon={SvgAlertTriangle}
|
||||
title="Full Exception Trace"
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
|
||||
export const codeVariant: PreviewVariant = {
|
||||
matches: (name) => !!getCodeLanguage(name || ""),
|
||||
width: "md",
|
||||
width: "xl",
|
||||
height: "lg",
|
||||
needsTextContent: true,
|
||||
codeBackground: true,
|
||||
|
||||
@@ -31,7 +31,7 @@ function parseCsv(content: string): CsvData {
|
||||
export const csvVariant: PreviewVariant = {
|
||||
matches: (name, mime) =>
|
||||
mime.startsWith("text/csv") || (name || "").toLowerCase().endsWith(".csv"),
|
||||
width: "lg",
|
||||
width: "full",
|
||||
height: "full",
|
||||
needsTextContent: true,
|
||||
codeBackground: false,
|
||||
|
||||
@@ -22,7 +22,7 @@ function formatContent(language: string, content: string): string {
|
||||
export const dataVariant: PreviewVariant = {
|
||||
matches: (name, mime) =>
|
||||
!!getDataLanguage(name || "") || !!getLanguageByMime(mime),
|
||||
width: "md",
|
||||
width: "xl",
|
||||
height: "lg",
|
||||
needsTextContent: true,
|
||||
codeBackground: true,
|
||||
|
||||
@@ -127,7 +127,7 @@ export const docxVariant: PreviewVariant = {
|
||||
const lower = (name || "").toLowerCase();
|
||||
return lower.endsWith(".docx") || lower.endsWith(".doc");
|
||||
},
|
||||
width: "lg",
|
||||
width: "full",
|
||||
height: "full",
|
||||
needsTextContent: false,
|
||||
codeBackground: false,
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
|
||||
export const imageVariant: PreviewVariant = {
|
||||
matches: (_name, mime) => mime.startsWith("image/"),
|
||||
width: "lg",
|
||||
width: "full",
|
||||
height: "full",
|
||||
needsTextContent: false,
|
||||
codeBackground: false,
|
||||
|
||||
@@ -19,7 +19,7 @@ export const markdownVariant: PreviewVariant = {
|
||||
if (MARKDOWN_MIMES.some((m) => mime.startsWith(m))) return true;
|
||||
return isMarkdownFile(name || "");
|
||||
},
|
||||
width: "lg",
|
||||
width: "full",
|
||||
height: "full",
|
||||
needsTextContent: true,
|
||||
codeBackground: false,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { DownloadButton } from "@/sections/modals/PreviewModal/variants/shared";
|
||||
|
||||
export const pdfVariant: PreviewVariant = {
|
||||
matches: (_name, mime) => mime === "application/pdf",
|
||||
width: "lg",
|
||||
width: "full",
|
||||
height: "full",
|
||||
needsTextContent: false,
|
||||
codeBackground: false,
|
||||
|
||||
@@ -25,7 +25,7 @@ export const textVariant: PreviewVariant = {
|
||||
const lowerName = (name || "").toLowerCase();
|
||||
return TEXT_EXTENSIONS.some((extension) => lowerName.endsWith(extension));
|
||||
},
|
||||
width: "md",
|
||||
width: "xl",
|
||||
height: "lg",
|
||||
needsTextContent: true,
|
||||
codeBackground: true,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { DownloadButton } from "@/sections/modals/PreviewModal/variants/shared";
|
||||
|
||||
export const unsupportedVariant: PreviewVariant = {
|
||||
matches: () => true,
|
||||
width: "md",
|
||||
width: "xl",
|
||||
height: "full",
|
||||
needsTextContent: false,
|
||||
codeBackground: false,
|
||||
|
||||
@@ -633,7 +633,7 @@ export function LLMConfigurationModalWrapper({
|
||||
|
||||
return (
|
||||
<Modal open onOpenChange={onClose}>
|
||||
<Modal.Content width="md-sm" height="lg">
|
||||
<Modal.Content width="lg" height="lg">
|
||||
<Form className="flex flex-col h-full min-h-0">
|
||||
<Modal.Header
|
||||
icon={providerIcon}
|
||||
|
||||
@@ -56,7 +56,8 @@ function buildItems(
|
||||
settings: CombinedSettings | null,
|
||||
kgExposed: boolean,
|
||||
customAnalyticsEnabled: boolean,
|
||||
hasSubscription: boolean
|
||||
hasSubscription: boolean,
|
||||
hooksEnabled: boolean
|
||||
): SidebarItemEntry[] {
|
||||
const vectorDbEnabled = settings?.settings.vector_db_enabled !== false;
|
||||
const items: SidebarItemEntry[] = [];
|
||||
@@ -122,6 +123,9 @@ function buildItems(
|
||||
add(SECTIONS.INTEGRATIONS, ADMIN_ROUTES.API_KEYS);
|
||||
add(SECTIONS.INTEGRATIONS, ADMIN_ROUTES.SLACK_BOTS);
|
||||
add(SECTIONS.INTEGRATIONS, ADMIN_ROUTES.DISCORD_BOTS);
|
||||
if (hooksEnabled) {
|
||||
add(SECTIONS.INTEGRATIONS, ADMIN_ROUTES.HOOKS);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Permissions
|
||||
@@ -202,6 +206,7 @@ export default function AdminSidebar({ enableCloudSS }: AdminSidebarProps) {
|
||||
(billingData && hasActiveSubscription(billingData)) ||
|
||||
licenseData?.has_license
|
||||
);
|
||||
const hooksEnabled = settings?.settings.hooks_enabled ?? false;
|
||||
|
||||
const allItems = buildItems(
|
||||
isCurator,
|
||||
@@ -210,7 +215,8 @@ export default function AdminSidebar({ enableCloudSS }: AdminSidebarProps) {
|
||||
settings,
|
||||
kgExposed,
|
||||
customAnalyticsEnabled,
|
||||
hasSubscriptionOrLicense
|
||||
hasSubscriptionOrLicense,
|
||||
hooksEnabled
|
||||
);
|
||||
|
||||
const itemExtractor = useCallback((item: SidebarItemEntry) => item.name, []);
|
||||
@@ -223,7 +229,7 @@ export default function AdminSidebar({ enableCloudSS }: AdminSidebarProps) {
|
||||
<SidebarWrapper>
|
||||
<SidebarBody
|
||||
scrollKey="admin-sidebar"
|
||||
actionButtons={
|
||||
pinnedContent={
|
||||
<div className="flex flex-col w-full">
|
||||
<SidebarTab
|
||||
icon={({ className }) => <SvgX className={className} size={16} />}
|
||||
|
||||
@@ -678,21 +678,18 @@ const MemoizedAppSidebarInner = memo(
|
||||
<SidebarBody
|
||||
scrollKey="app-sidebar"
|
||||
footer={settingsButton}
|
||||
actionButtons={
|
||||
pinnedContent={
|
||||
<div className="flex flex-col">
|
||||
{newSessionButton}
|
||||
{searchChatsButton}
|
||||
{isOnyxCraftEnabled && buildButton}
|
||||
{folded && moreAgentsButton}
|
||||
{folded && newProjectButton}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* When folded, show icons immediately without waiting for data */}
|
||||
{folded ? (
|
||||
<>
|
||||
{moreAgentsButton}
|
||||
{newProjectButton}
|
||||
</>
|
||||
) : isLoadingDynamicContent ? null : (
|
||||
{/* When folded, all nav buttons are in pinnedContent — nothing here */}
|
||||
{folded ? null : isLoadingDynamicContent ? null : (
|
||||
<>
|
||||
{/* Agents */}
|
||||
<DndContext
|
||||
|
||||
@@ -4,7 +4,7 @@ import React from "react";
|
||||
import OverflowDiv from "@/refresh-components/OverflowDiv";
|
||||
|
||||
export interface SidebarBodyProps {
|
||||
actionButtons?: React.ReactNode;
|
||||
pinnedContent?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
/**
|
||||
@@ -15,21 +15,14 @@ export interface SidebarBodyProps {
|
||||
}
|
||||
|
||||
export default function SidebarBody({
|
||||
actionButtons,
|
||||
pinnedContent,
|
||||
children,
|
||||
footer,
|
||||
scrollKey,
|
||||
}: SidebarBodyProps) {
|
||||
return (
|
||||
<div className="flex flex-col min-h-0 h-full gap-3">
|
||||
<div className="flex flex-col gap-1.5 px-2">
|
||||
{actionButtons &&
|
||||
(Array.isArray(actionButtons)
|
||||
? actionButtons.map((button, index) => (
|
||||
<div key={index}>{button}</div>
|
||||
))
|
||||
: actionButtons)}
|
||||
</div>
|
||||
{pinnedContent && <div className="px-2">{pinnedContent}</div>}
|
||||
<OverflowDiv className="gap-3 px-2" scrollKey={scrollKey}>
|
||||
{children}
|
||||
</OverflowDiv>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback } from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@opal/components";
|
||||
import Logo from "@/refresh-components/Logo";
|
||||
@@ -12,53 +12,48 @@ interface LogoSectionProps {
|
||||
|
||||
function LogoSection({ folded, onFoldClick }: LogoSectionProps) {
|
||||
const settings = useSettingsContext();
|
||||
const applicationName = settings.enterpriseSettings?.application_name;
|
||||
const logoDisplayStyle = settings.enterpriseSettings?.logo_display_style;
|
||||
|
||||
const logo = useCallback(
|
||||
(className?: string) => <Logo folded={folded} className={className} />,
|
||||
const logo = useMemo(
|
||||
() => (
|
||||
<div className="px-1">
|
||||
<Logo folded={folded} size={28} />
|
||||
</div>
|
||||
),
|
||||
[folded]
|
||||
);
|
||||
const closeButton = useCallback(
|
||||
(shouldFold: boolean) => (
|
||||
<Button
|
||||
icon={SvgSidebar}
|
||||
prominence="tertiary"
|
||||
tooltip="Close Sidebar"
|
||||
onClick={onFoldClick}
|
||||
/>
|
||||
const closeButton = useMemo(
|
||||
() => (
|
||||
<div className="px-1">
|
||||
<Button
|
||||
icon={SvgSidebar}
|
||||
prominence="tertiary"
|
||||
tooltip="Close Sidebar"
|
||||
size="md"
|
||||
onClick={onFoldClick}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
[onFoldClick]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
/* px-2.5 => 2 for the standard sidebar padding + 0.5 for internal padding specific to this component. */
|
||||
"flex px-2.5 py-2",
|
||||
folded ? "justify-center" : "justify-between",
|
||||
applicationName
|
||||
? "h-[3.75rem] min-h-[3.75rem]"
|
||||
: "h-[3.25rem] min-h-[3.25rem]"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-row justify-between items-start pt-3 px-2">
|
||||
{folded === undefined ? (
|
||||
<div className="p-1">{logo()}</div>
|
||||
logo
|
||||
) : folded && logoDisplayStyle !== "name_only" ? (
|
||||
<>
|
||||
<div className="group-hover/SidebarWrapper:hidden pt-1.5">
|
||||
{logo()}
|
||||
</div>
|
||||
<div className="w-full justify-center hidden group-hover/SidebarWrapper:flex">
|
||||
{closeButton(false)}
|
||||
<div className="group-hover/SidebarWrapper:hidden">{logo}</div>
|
||||
<div className="hidden group-hover/SidebarWrapper:flex">
|
||||
{closeButton}
|
||||
</div>
|
||||
</>
|
||||
) : folded ? (
|
||||
<div className="flex w-full justify-center">{closeButton(false)}</div>
|
||||
closeButton
|
||||
) : (
|
||||
<>
|
||||
<div className="p-1"> {logo()}</div>
|
||||
{closeButton(true)}
|
||||
{logo}
|
||||
{closeButton}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user