mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-03-25 01:22:45 +00:00
Compare commits
16 Commits
run-ci/959
...
bo/hook_ui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9990dd450b | ||
|
|
92199d2e44 | ||
|
|
25efb8d1e0 | ||
|
|
b20a5ebf69 | ||
|
|
8645adb807 | ||
|
|
2425bd4d8d | ||
|
|
333b2b19cb | ||
|
|
44895b3bd6 | ||
|
|
78c2ecf99f | ||
|
|
e3e0e04edc | ||
|
|
a19fe03bd8 | ||
|
|
415c05b5f8 | ||
|
|
352fd19f0a | ||
|
|
41ae039bfa | ||
|
|
782c734287 | ||
|
|
728cdb0715 |
@@ -800,6 +800,33 @@ def update_user_group(
|
||||
return db_user_group
|
||||
|
||||
|
||||
def rename_user_group(
|
||||
db_session: Session,
|
||||
user_group_id: int,
|
||||
new_name: str,
|
||||
) -> UserGroup:
|
||||
stmt = select(UserGroup).where(UserGroup.id == user_group_id)
|
||||
db_user_group = db_session.scalar(stmt)
|
||||
if db_user_group is None:
|
||||
raise ValueError(f"UserGroup with id '{user_group_id}' not found")
|
||||
|
||||
_check_user_group_is_modifiable(db_user_group)
|
||||
|
||||
db_user_group.name = new_name
|
||||
db_user_group.time_last_modified_by_user = func.now()
|
||||
|
||||
# CC pair documents in Vespa contain the group name, so we need to
|
||||
# trigger a sync to update them with the new name.
|
||||
_mark_user_group__cc_pair_relationships_outdated__no_commit(
|
||||
db_session=db_session, user_group_id=user_group_id
|
||||
)
|
||||
if not DISABLE_VECTOR_DB:
|
||||
db_user_group.is_up_to_date = False
|
||||
|
||||
db_session.commit()
|
||||
return db_user_group
|
||||
|
||||
|
||||
def prepare_user_group_for_deletion(db_session: Session, user_group_id: int) -> None:
|
||||
stmt = select(UserGroup).where(UserGroup.id == user_group_id)
|
||||
db_user_group = db_session.scalar(stmt)
|
||||
|
||||
@@ -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
|
||||
@@ -11,13 +12,16 @@ from ee.onyx.db.user_group import fetch_user_groups
|
||||
from ee.onyx.db.user_group import fetch_user_groups_for_user
|
||||
from ee.onyx.db.user_group import insert_user_group
|
||||
from ee.onyx.db.user_group import prepare_user_group_for_deletion
|
||||
from ee.onyx.db.user_group import rename_user_group
|
||||
from ee.onyx.db.user_group import update_user_curator_relationship
|
||||
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
|
||||
from ee.onyx.server.user_group.models import UserGroupUpdate
|
||||
from onyx.auth.users import current_admin_user
|
||||
from onyx.auth.users import current_curator_or_admin_user
|
||||
@@ -27,6 +31,9 @@ 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
|
||||
|
||||
logger = setup_logger()
|
||||
@@ -87,6 +94,32 @@ def create_user_group(
|
||||
return UserGroup.from_model(db_user_group)
|
||||
|
||||
|
||||
@router.patch("/admin/user-group/rename")
|
||||
def rename_user_group_endpoint(
|
||||
rename_request: UserGroupRename,
|
||||
_: User = Depends(current_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> UserGroup:
|
||||
try:
|
||||
return UserGroup.from_model(
|
||||
rename_user_group(
|
||||
db_session=db_session,
|
||||
user_group_id=rename_request.id,
|
||||
new_name=rename_request.name,
|
||||
)
|
||||
)
|
||||
except IntegrityError:
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.DUPLICATE_RESOURCE,
|
||||
f"User group with name '{rename_request.name}' already exists.",
|
||||
)
|
||||
except ValueError as e:
|
||||
msg = str(e)
|
||||
if "not found" in msg.lower():
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, msg)
|
||||
raise OnyxError(OnyxErrorCode.CONFLICT, msg)
|
||||
|
||||
|
||||
@router.patch("/admin/user-group/{user_group_id}")
|
||||
def patch_user_group(
|
||||
user_group_id: int,
|
||||
@@ -161,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()
|
||||
|
||||
@@ -104,6 +104,16 @@ class AddUsersToUserGroupRequest(BaseModel):
|
||||
user_ids: list[UUID]
|
||||
|
||||
|
||||
class UserGroupRename(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -91,6 +91,8 @@ class HookResponse(BaseModel):
|
||||
# Nullable to match the DB column — endpoint_url is required on creation but
|
||||
# future hook point types may not use an external endpoint (e.g. built-in handlers).
|
||||
endpoint_url: str | None
|
||||
# Partially-masked API key (e.g. "abcd••••••••wxyz"), or None if no key is set.
|
||||
api_key_masked: str | None
|
||||
fail_strategy: HookFailStrategy
|
||||
timeout_seconds: float # always resolved — None from request is replaced with spec default before DB write
|
||||
is_active: bool
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -62,6 +62,9 @@ def _hook_to_response(hook: Hook, creator_email: str | None = None) -> HookRespo
|
||||
name=hook.name,
|
||||
hook_point=hook.hook_point,
|
||||
endpoint_url=hook.endpoint_url,
|
||||
api_key_masked=(
|
||||
hook.api_key.get_value(apply_mask=True) if hook.api_key else None
|
||||
),
|
||||
fail_strategy=hook.fail_strategy,
|
||||
timeout_seconds=hook.timeout_seconds,
|
||||
is_active=hook.is_active,
|
||||
|
||||
@@ -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
|
||||
|
||||
53
backend/tests/unit/ee/onyx/db/test_user_group_rename.py
Normal file
53
backend/tests/unit/ee/onyx/db/test_user_group_rename.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Tests for user group rename DB operation."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from ee.onyx.db.user_group import rename_user_group
|
||||
from onyx.db.models import UserGroup
|
||||
|
||||
|
||||
class TestRenameUserGroup:
|
||||
"""Tests for rename_user_group function."""
|
||||
|
||||
@patch("ee.onyx.db.user_group.DISABLE_VECTOR_DB", False)
|
||||
@patch(
|
||||
"ee.onyx.db.user_group._mark_user_group__cc_pair_relationships_outdated__no_commit"
|
||||
)
|
||||
def test_rename_succeeds_and_triggers_sync(
|
||||
self, mock_mark_outdated: MagicMock
|
||||
) -> None:
|
||||
mock_session = MagicMock()
|
||||
mock_group = MagicMock(spec=UserGroup)
|
||||
mock_group.name = "Old Name"
|
||||
mock_group.is_up_to_date = True
|
||||
mock_session.scalar.return_value = mock_group
|
||||
|
||||
result = rename_user_group(mock_session, user_group_id=1, new_name="New Name")
|
||||
|
||||
assert result.name == "New Name"
|
||||
assert result.is_up_to_date is False
|
||||
mock_mark_outdated.assert_called_once()
|
||||
mock_session.commit.assert_called_once()
|
||||
|
||||
def test_rename_group_not_found(self) -> None:
|
||||
mock_session = MagicMock()
|
||||
mock_session.scalar.return_value = None
|
||||
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
rename_user_group(mock_session, user_group_id=999, new_name="New Name")
|
||||
|
||||
mock_session.commit.assert_not_called()
|
||||
|
||||
def test_rename_group_syncing_raises(self) -> None:
|
||||
mock_session = MagicMock()
|
||||
mock_group = MagicMock(spec=UserGroup)
|
||||
mock_group.is_up_to_date = False
|
||||
mock_session.scalar.return_value = mock_group
|
||||
|
||||
with pytest.raises(ValueError, match="currently syncing"):
|
||||
rename_user_group(mock_session, user_group_id=1, new_name="New Name")
|
||||
|
||||
mock_session.commit.assert_not_called()
|
||||
@@ -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)
|
||||
|
||||
@@ -10,7 +10,7 @@ data:
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
HOST="${POSTGRES_HOST:-localhost}"
|
||||
HOST="${PGINTO_HOST:-${POSTGRES_HOST:-localhost}}"
|
||||
PORT="${POSTGRES_PORT:-5432}"
|
||||
USER="${POSTGRES_USER:-postgres}"
|
||||
DB="${POSTGRES_DB:-postgres}"
|
||||
|
||||
@@ -83,6 +83,14 @@
|
||||
"scope": [],
|
||||
"rule": "Code changes must consider both regular Onyx deployments and Onyx lite deployments. Lite deployments disable the vector DB, Redis, model servers, and background workers by default, use PostgreSQL-backed cache/auth/file storage, and rely on the API server to handle background work. Do not assume those services are available unless the code path is explicitly limited to full deployments."
|
||||
},
|
||||
{
|
||||
"scope": ["web/**"],
|
||||
"rule": "In Onyx's Next.js app, the `app/ee/admin/` directory is a filesystem convention for Enterprise Edition route overrides — it does NOT add an `/ee/` prefix to the URL. Both `app/admin/groups/page.tsx` and `app/ee/admin/groups/page.tsx` serve the same URL `/admin/groups`. Hardcoded `/admin/...` paths in router.push() calls are correct and do NOT break EE deployments. Do not flag hardcoded admin paths as bugs."
|
||||
},
|
||||
{
|
||||
"scope": ["web/**"],
|
||||
"rule": "In Onyx, each API key creates a unique user row in the database with a unique `user_id` (UUID). There is a 1:1 mapping between API keys and their backing user records. Multiple API keys do NOT share the same `user_id`. Do not flag potential duplicate row IDs when using `user_id` from API key descriptors."
|
||||
},
|
||||
{
|
||||
"scope": ["backend/**/*.py"],
|
||||
"rule": "Never raise HTTPException directly in business code. Use `raise OnyxError(OnyxErrorCode.XXX, \"message\")` from `onyx.error_handling.exceptions`. A global FastAPI exception handler converts OnyxError into structured JSON responses with {\"error_code\": \"...\", \"detail\": \"...\"}. Error codes are defined in `onyx.error_handling.error_codes.OnyxErrorCode`. For upstream errors with dynamic HTTP status codes, use `status_code_override`: `raise OnyxError(OnyxErrorCode.BAD_GATEWAY, detail, status_code_override=upstream_status)`."
|
||||
|
||||
BIN
tmp/figma/hook-connected-card.png
Normal file
BIN
tmp/figma/hook-connected-card.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
tmp/figma/hooks-card.png
Normal file
BIN
tmp/figma/hooks-card.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 910 KiB |
@@ -1,4 +1,9 @@
|
||||
import { Interactive, type InteractiveStatelessProps } from "@opal/core";
|
||||
import "@opal/components/tooltip.css";
|
||||
import {
|
||||
Disabled,
|
||||
Interactive,
|
||||
type InteractiveStatelessProps,
|
||||
} from "@opal/core";
|
||||
import type { ContainerSizeVariants, ExtremaSizeVariants } from "@opal/types";
|
||||
import type { TooltipSide } from "@opal/components";
|
||||
import type { IconFunctionComponent } from "@opal/types";
|
||||
@@ -31,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;
|
||||
|
||||
@@ -42,6 +44,9 @@ type ButtonProps = InteractiveStatelessProps &
|
||||
|
||||
/** Which side the tooltip appears on. */
|
||||
tooltipSide?: TooltipSide;
|
||||
|
||||
/** Wraps the button in a Disabled context. `false` overrides parent contexts. */
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -58,6 +63,7 @@ function Button({
|
||||
tooltip,
|
||||
tooltipSide = "top",
|
||||
responsiveHideText = false,
|
||||
disabled,
|
||||
...interactiveProps
|
||||
}: ButtonProps) {
|
||||
const isLarge = size === "lg";
|
||||
@@ -101,9 +107,7 @@ function Button({
|
||||
</Interactive.Stateless>
|
||||
);
|
||||
|
||||
if (!tooltip) return button;
|
||||
|
||||
return (
|
||||
const result = tooltip ? (
|
||||
<TooltipPrimitive.Root>
|
||||
<TooltipPrimitive.Trigger asChild>{button}</TooltipPrimitive.Trigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
@@ -116,7 +120,15 @@ function Button({
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
</TooltipPrimitive.Root>
|
||||
) : (
|
||||
button
|
||||
);
|
||||
|
||||
if (disabled != null) {
|
||||
return <Disabled disabled={disabled}>{result}</Disabled>;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export { Button, type ButtonProps };
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -32,7 +32,13 @@ function ColumnVisibilityPopover<TData extends RowData>({
|
||||
// User-defined columns only (exclude internal qualifier/actions)
|
||||
const dataColumns = table
|
||||
.getAllLeafColumns()
|
||||
.filter((col) => !col.id.startsWith("__") && col.id !== "qualifier");
|
||||
.filter(
|
||||
(col) =>
|
||||
!col.id.startsWith("__") &&
|
||||
col.id !== "qualifier" &&
|
||||
typeof col.columnDef.header === "string" &&
|
||||
col.columnDef.header.trim() !== ""
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
|
||||
@@ -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;
|
||||
@@ -32,6 +32,8 @@ interface ContentMdPresetConfig {
|
||||
optionalFont: string;
|
||||
/** Aux icon size = lineHeight − 2 × p-0.5. */
|
||||
auxIconSize: string;
|
||||
/** Left indent for the description so it aligns with the title (past the icon). */
|
||||
descriptionIndent: string;
|
||||
}
|
||||
|
||||
interface ContentMdProps {
|
||||
@@ -85,6 +87,7 @@ const CONTENT_MD_PRESETS: Record<ContentMdSizePreset, ContentMdPresetConfig> = {
|
||||
editButtonPadding: "p-0",
|
||||
optionalFont: "font-main-content-muted",
|
||||
auxIconSize: "1.25rem",
|
||||
descriptionIndent: "1.625rem",
|
||||
},
|
||||
"main-ui": {
|
||||
iconSize: "1rem",
|
||||
@@ -97,6 +100,7 @@ const CONTENT_MD_PRESETS: Record<ContentMdSizePreset, ContentMdPresetConfig> = {
|
||||
editButtonPadding: "p-0",
|
||||
optionalFont: "font-main-ui-muted",
|
||||
auxIconSize: "1rem",
|
||||
descriptionIndent: "1.375rem",
|
||||
},
|
||||
secondary: {
|
||||
iconSize: "0.75rem",
|
||||
@@ -109,6 +113,7 @@ const CONTENT_MD_PRESETS: Record<ContentMdSizePreset, ContentMdPresetConfig> = {
|
||||
editButtonPadding: "p-0",
|
||||
optionalFont: "font-secondary-action",
|
||||
auxIconSize: "0.75rem",
|
||||
descriptionIndent: "1.125rem",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -163,22 +168,25 @@ function ContentMd({
|
||||
data-interactive={withInteractive || undefined}
|
||||
style={{ gap: config.gap }}
|
||||
>
|
||||
{Icon && (
|
||||
<div
|
||||
className={cn(
|
||||
"opal-content-md-icon-container shrink-0",
|
||||
config.iconContainerPadding
|
||||
)}
|
||||
style={{ minHeight: config.lineHeight }}
|
||||
>
|
||||
<Icon
|
||||
className={cn("opal-content-md-icon", config.iconColorClass)}
|
||||
style={{ width: config.iconSize, height: config.iconSize }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="opal-content-md-header"
|
||||
data-editing={editing || undefined}
|
||||
>
|
||||
{Icon && (
|
||||
<div
|
||||
className={cn(
|
||||
"opal-content-md-icon-container shrink-0",
|
||||
config.iconContainerPadding
|
||||
)}
|
||||
style={{ minHeight: config.lineHeight }}
|
||||
>
|
||||
<Icon
|
||||
className={cn("opal-content-md-icon", config.iconColorClass)}
|
||||
style={{ width: config.iconSize, height: config.iconSize }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="opal-content-md-body">
|
||||
<div className="opal-content-md-title-row">
|
||||
{editing ? (
|
||||
<div className="opal-content-md-input-sizer">
|
||||
@@ -274,13 +282,16 @@ function ContentMd({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{description && (
|
||||
<div className="opal-content-md-description font-secondary-body text-text-03">
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{description && (
|
||||
<div
|
||||
className="opal-content-md-description font-secondary-body text-text-03"
|
||||
style={Icon ? { paddingLeft: config.descriptionIndent } : undefined}
|
||||
>
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -224,7 +224,16 @@
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
.opal-content-md {
|
||||
@apply flex flex-row items-start;
|
||||
@apply flex flex-col items-start;
|
||||
}
|
||||
|
||||
.opal-content-md-header {
|
||||
@apply flex flex-row items-center w-full;
|
||||
}
|
||||
|
||||
.opal-content-md-header[data-editing] {
|
||||
@apply rounded-08;
|
||||
box-shadow: inset 0 0 0 1px var(--border-02);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
@@ -237,15 +246,6 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Body column
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
.opal-content-md-body {
|
||||
@apply flex flex-1 flex-col items-start;
|
||||
min-width: 0.0625rem;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Title row — title (or input) + edit button
|
||||
--------------------------------------------------------------------------- */
|
||||
@@ -267,6 +267,7 @@
|
||||
.opal-content-md-input-sizer {
|
||||
display: inline-grid;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.opal-content-md-input-sizer > * {
|
||||
|
||||
@@ -395,7 +395,7 @@ function SeatsCard({
|
||||
<InputLayouts.Vertical title="Seats">
|
||||
<InputNumber
|
||||
value={newSeatCount}
|
||||
onChange={setNewSeatCount}
|
||||
onChange={(v) => setNewSeatCount(v ?? 1)}
|
||||
min={1}
|
||||
defaultValue={totalSeats}
|
||||
showReset
|
||||
|
||||
@@ -230,7 +230,7 @@ export default function CheckoutView({ onAdjustPlan }: CheckoutViewProps) {
|
||||
>
|
||||
<InputNumber
|
||||
value={seats}
|
||||
onChange={setSeats}
|
||||
onChange={(v) => setSeats(v ?? minRequiredSeats)}
|
||||
min={minRequiredSeats}
|
||||
defaultValue={minRequiredSeats}
|
||||
showReset
|
||||
|
||||
@@ -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
web/src/app/admin/groups/create/page.tsx
Normal file
1
web/src/app/admin/groups/create/page.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "@/refresh-pages/admin/GroupsPage/CreateGroupPage";
|
||||
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"
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
.input-normal:focus:not(:active),
|
||||
.input-normal:focus-within:not(:active) {
|
||||
border-color: var(--border-05);
|
||||
box-shadow: inset 0px 0px 0px 2px var(--background-tint-04);
|
||||
box-shadow: 0px 0px 0px 2px rgba(204, 204, 207, 1);
|
||||
}
|
||||
|
||||
.input-error {
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
/* Base layers */
|
||||
--z-base: 0;
|
||||
--z-content: 1;
|
||||
--z-settings-header: 8;
|
||||
/* 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
web/src/app/ee/admin/groups/create/page.tsx
Normal file
1
web/src/app/ee/admin/groups/create/page.tsx
Normal file
@@ -0,0 +1 @@
|
||||
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 };
|
||||
}
|
||||
15
web/src/hooks/useHooks.ts
Normal file
15
web/src/hooks/useHooks.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import useSWR from "swr";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import { HookResponse } from "@/refresh-pages/admin/HooksPage/interfaces";
|
||||
|
||||
export function useHooks() {
|
||||
const { data, isLoading, error, mutate } = useSWR<HookResponse[]>(
|
||||
"/api/admin/hooks",
|
||||
errorHandlingFetcher,
|
||||
{ revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
return { hooks: data, isLoading, error, mutate };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -44,8 +44,8 @@ import { SvgChevronUp, SvgChevronDown, SvgRevert } from "@opal/icons";
|
||||
* ```
|
||||
*/
|
||||
export interface InputNumberProps {
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
value: number | null;
|
||||
onChange: (value: number | null) => void;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
@@ -54,6 +54,7 @@ export interface InputNumberProps {
|
||||
variant?: Variants;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export default function InputNumber({
|
||||
@@ -67,31 +68,36 @@ export default function InputNumber({
|
||||
variant = "primary",
|
||||
disabled = false,
|
||||
className,
|
||||
placeholder,
|
||||
}: InputNumberProps) {
|
||||
const inputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const [inputValue, setInputValue] = React.useState(String(value));
|
||||
const [inputValue, setInputValue] = React.useState(
|
||||
value === null ? "" : String(value)
|
||||
);
|
||||
const isDisabled = disabled || variant === "disabled";
|
||||
|
||||
// Sync input value when external value changes (e.g., from stepper buttons or reset)
|
||||
React.useEffect(() => {
|
||||
setInputValue(String(value));
|
||||
setInputValue(value === null ? "" : String(value));
|
||||
}, [value]);
|
||||
|
||||
const canIncrement = max === undefined || value < max;
|
||||
const canDecrement = min === undefined || value > min;
|
||||
const effectiveValue = value ?? 0;
|
||||
const canIncrement = max === undefined || effectiveValue < max;
|
||||
const canDecrement =
|
||||
value !== null && (min === undefined || effectiveValue > min);
|
||||
const canReset =
|
||||
showReset && defaultValue !== undefined && value !== defaultValue;
|
||||
|
||||
const handleIncrement = () => {
|
||||
if (canIncrement) {
|
||||
const newValue = value + step;
|
||||
const newValue = effectiveValue + step;
|
||||
onChange(max !== undefined ? Math.min(newValue, max) : newValue);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDecrement = () => {
|
||||
if (canDecrement) {
|
||||
const newValue = value - step;
|
||||
const newValue = effectiveValue - step;
|
||||
onChange(min !== undefined ? Math.max(newValue, min) : newValue);
|
||||
}
|
||||
};
|
||||
@@ -103,14 +109,11 @@ export default function InputNumber({
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
// On blur, if empty, set fallback value; otherwise sync display with actual value
|
||||
// On blur, if empty, keep as null so placeholder shows
|
||||
if (inputValue.trim() === "") {
|
||||
let fallback = min ?? 0;
|
||||
if (max !== undefined) fallback = Math.min(fallback, max);
|
||||
setInputValue(String(fallback));
|
||||
onChange(fallback);
|
||||
onChange(null);
|
||||
} else {
|
||||
setInputValue(String(value));
|
||||
setInputValue(value === null ? "" : String(value));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -152,6 +155,7 @@ export default function InputNumber({
|
||||
pattern="[0-9]*"
|
||||
disabled={isDisabled}
|
||||
value={inputValue}
|
||||
placeholder={placeholder}
|
||||
onChange={handleInputChange}
|
||||
onBlur={handleBlur}
|
||||
className={cn(
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export const wrapperClasses: ClassNamesMap = {
|
||||
|
||||
export const innerClasses: ClassNamesMap = {
|
||||
primary:
|
||||
"text-text-04 placeholder:!font-secondary-body placeholder:text-text-02",
|
||||
"text-text-04 placeholder:font-main-ui-body placeholder:text-text-02",
|
||||
internal: null,
|
||||
error: null,
|
||||
disabled: "text-text-02",
|
||||
|
||||
@@ -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"
|
||||
|
||||
180
web/src/refresh-pages/admin/GroupsPage/CreateGroupPage.tsx
Normal file
180
web/src/refresh-pages/admin/GroupsPage/CreateGroupPage.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
import { Table, Button } from "@opal/components";
|
||||
import { IllustrationContent } from "@opal/layouts";
|
||||
import { SvgUsers } from "@opal/icons";
|
||||
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 Separator from "@/refresh-components/Separator";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import useAdminUsers from "@/hooks/useAdminUsers";
|
||||
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";
|
||||
import type { TokenLimit } from "./TokenLimitSection";
|
||||
|
||||
function CreateGroupPage() {
|
||||
const router = useRouter();
|
||||
const [groupName, setGroupName] = useState("");
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(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 { users, isLoading: usersLoading, error: usersError } = useAdminUsers();
|
||||
|
||||
const {
|
||||
data: apiKeys,
|
||||
isLoading: apiKeysLoading,
|
||||
error: apiKeysError,
|
||||
} = useSWR<ApiKeyDescriptor[]>("/api/admin/api-key", errorHandlingFetcher);
|
||||
|
||||
const isLoading = usersLoading || apiKeysLoading;
|
||||
const error = usersError ?? apiKeysError;
|
||||
|
||||
const allRows: MemberRow[] = useMemo(() => {
|
||||
const activeUsers = users.filter((u) => u.is_active);
|
||||
const serviceAccountRows = (apiKeys ?? []).map(apiKeyToMemberRow);
|
||||
return [...activeUsers, ...serviceAccountRows];
|
||||
}, [users, apiKeys]);
|
||||
|
||||
async function handleCreate() {
|
||||
const trimmed = groupName.trim();
|
||||
if (!trimmed) {
|
||||
toast.error("Group name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createGroup(trimmed, selectedUserIds, selectedCcPairIds);
|
||||
toast.success(`Group "${trimmed}" created`);
|
||||
router.push("/admin/groups");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to create group");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const headerActions = (
|
||||
<Section flexDirection="row" gap={0.5} width="auto" height="auto">
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
onClick={() => router.push("/admin/groups")}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={!groupName.trim() || isSubmitting}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</Section>
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsLayouts.Root width="sm">
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgUsers}
|
||||
title="Create Group"
|
||||
separator
|
||||
rightChildren={headerActions}
|
||||
/>
|
||||
|
||||
<SettingsLayouts.Body>
|
||||
{/* 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 */}
|
||||
{isLoading && <SimpleLoader />}
|
||||
|
||||
{error && (
|
||||
<Text as="p" secondaryBody text03>
|
||||
Failed to load users.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && (
|
||||
<Section
|
||||
gap={0.75}
|
||||
height="auto"
|
||||
alignItems="stretch"
|
||||
justifyContent="start"
|
||||
>
|
||||
<InputTypeIn
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Search users and accounts..."
|
||||
leftSearchIcon
|
||||
/>
|
||||
<Table
|
||||
data={allRows}
|
||||
columns={memberTableColumns}
|
||||
getRowId={(row) => row.id ?? row.email}
|
||||
pageSize={PAGE_SIZE}
|
||||
searchTerm={searchTerm}
|
||||
selectionBehavior="multi-select"
|
||||
onSelectionChange={setSelectedUserIds}
|
||||
footer={{}}
|
||||
emptyState={
|
||||
<IllustrationContent
|
||||
illustration={SvgNoResult}
|
||||
title="No users found"
|
||||
description="No users match your search."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
<SharedGroupResources
|
||||
selectedCcPairIds={selectedCcPairIds}
|
||||
onCcPairIdsChange={setSelectedCcPairIds}
|
||||
selectedDocSetIds={selectedDocSetIds}
|
||||
onDocSetIdsChange={setSelectedDocSetIds}
|
||||
selectedAgentIds={selectedAgentIds}
|
||||
onAgentIdsChange={setSelectedAgentIds}
|
||||
/>
|
||||
|
||||
<TokenLimitSection
|
||||
limits={tokenLimits}
|
||||
onLimitsChange={setTokenLimits}
|
||||
/>
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateGroupPage;
|
||||
@@ -5,17 +5,38 @@ import { SvgChevronRight, SvgUserManage, SvgUsers } from "@opal/icons";
|
||||
import { ContentAction } from "@opal/layouts";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import Card from "@/refresh-components/cards/Card";
|
||||
import IconButton from "@/refresh-components/buttons/IconButton";
|
||||
import { Button } from "@opal/components";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { buildGroupDescription, formatMemberCount } from "./utils";
|
||||
import {
|
||||
isBuiltInGroup,
|
||||
buildGroupDescription,
|
||||
formatMemberCount,
|
||||
} from "./utils";
|
||||
import { renameGroup, USER_GROUP_URL } from "./svc";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { useSWRConfig } from "swr";
|
||||
|
||||
interface GroupCardProps {
|
||||
group: UserGroup;
|
||||
}
|
||||
|
||||
function GroupCard({ group }: GroupCardProps) {
|
||||
const isBasic = group.name === "Basic";
|
||||
const { mutate } = useSWRConfig();
|
||||
const builtIn = isBuiltInGroup(group);
|
||||
const isAdmin = group.name === "Admin";
|
||||
const isBasic = group.name === "Basic";
|
||||
const isSyncing = !group.is_up_to_date;
|
||||
|
||||
async function handleRename(newName: string) {
|
||||
try {
|
||||
await renameGroup(group.id, newName);
|
||||
mutate(USER_GROUP_URL);
|
||||
toast.success(`Group renamed to "${newName}"`);
|
||||
} catch (e) {
|
||||
console.error("Failed to rename group:", e);
|
||||
toast.error(e instanceof Error ? e.message : "Failed to rename group");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card padding={0.5}>
|
||||
@@ -26,12 +47,20 @@ function GroupCard({ group }: GroupCardProps) {
|
||||
sizePreset="main-content"
|
||||
variant="section"
|
||||
tag={isBasic ? { title: "Default" } : undefined}
|
||||
editable={!builtIn && !isSyncing}
|
||||
onTitleChange={!builtIn && !isSyncing ? handleRename : undefined}
|
||||
rightChildren={
|
||||
<Section flexDirection="row" alignItems="center">
|
||||
<Text mainUiBody text03>
|
||||
{formatMemberCount(group.users.length)}
|
||||
</Text>
|
||||
<IconButton icon={SvgChevronRight} tertiary tooltip="View group" />
|
||||
<Section flexDirection="row" alignItems="start" gap={0}>
|
||||
<div className="py-1">
|
||||
<Text mainUiBody text03>
|
||||
{formatMemberCount(group.users.length)}
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
icon={SvgChevronRight}
|
||||
prominence="tertiary"
|
||||
tooltip="View group"
|
||||
/>
|
||||
</Section>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { SvgX } from "@opal/icons";
|
||||
import type { IconFunctionComponent } from "@opal/types";
|
||||
import { Content } from "@opal/layouts";
|
||||
import IconButton from "@/refresh-components/buttons/IconButton";
|
||||
|
||||
interface ResourceContentProps {
|
||||
/** SVG icon for connectors/doc sets. */
|
||||
icon?: IconFunctionComponent;
|
||||
/** Custom ReactNode icon (e.g. AgentAvatar). Takes priority over `icon`. */
|
||||
leftContent?: ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
/** Inline info rendered after description (e.g. source icon stack). */
|
||||
infoContent?: ReactNode;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
function ResourceContent({
|
||||
icon,
|
||||
leftContent,
|
||||
title,
|
||||
description,
|
||||
infoContent,
|
||||
onRemove,
|
||||
}: ResourceContentProps) {
|
||||
return (
|
||||
<div className="flex flex-1 gap-0.5 items-start p-1.5 rounded-08 bg-background-tint-01 min-w-[240px] max-w-[302px]">
|
||||
<div className="flex flex-1 gap-1 p-0.5 items-start min-w-0">
|
||||
{leftContent ? (
|
||||
<>
|
||||
{leftContent}
|
||||
<Content
|
||||
title={title}
|
||||
description={description}
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Content
|
||||
icon={icon}
|
||||
title={title}
|
||||
description={description}
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
/>
|
||||
)}
|
||||
{infoContent}
|
||||
</div>
|
||||
<IconButton small icon={SvgX} onClick={onRemove} className="shrink-0" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ResourceContent;
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { SvgEmpty } from "@opal/icons";
|
||||
import { Content } from "@opal/layouts";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
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 { cn } from "@/lib/utils";
|
||||
import type { ResourcePopoverProps } from "@/refresh-pages/admin/GroupsPage/SharedGroupResources/interfaces";
|
||||
|
||||
function ResourcePopover({
|
||||
placeholder,
|
||||
searchValue,
|
||||
onSearchChange,
|
||||
sections,
|
||||
}: ResourcePopoverProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const totalItems = sections.reduce((sum, s) => sum + s.items.length, 0);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover.Trigger
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<InputTypeIn
|
||||
placeholder={placeholder}
|
||||
value={searchValue}
|
||||
onChange={(e) => {
|
||||
onSearchChange(e.target.value);
|
||||
if (!open) setOpen(true);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content
|
||||
width="trigger"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="flex flex-col gap-1 max-h-64 overflow-y-auto">
|
||||
{totalItems === 0 ? (
|
||||
<div className="px-3 py-3">
|
||||
<Content
|
||||
icon={SvgEmpty}
|
||||
title="No results found"
|
||||
sizePreset="secondary"
|
||||
variant="section"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
sections.map(
|
||||
(section, idx) =>
|
||||
section.items.length > 0 && (
|
||||
<div key={section.label ?? `section-${idx}`}>
|
||||
{section.label && (
|
||||
<Section
|
||||
flexDirection="row"
|
||||
gap={0.25}
|
||||
padding={0}
|
||||
height="auto"
|
||||
alignItems="center"
|
||||
justifyContent="start"
|
||||
className="px-2 pt-2 pb-1"
|
||||
>
|
||||
<Text secondaryBody text03 className="shrink-0">
|
||||
{section.label}
|
||||
</Text>
|
||||
<Separator noPadding className="flex-1" />
|
||||
</Section>
|
||||
)}
|
||||
<Section
|
||||
gap={0.25}
|
||||
alignItems="stretch"
|
||||
justifyContent="start"
|
||||
>
|
||||
{section.items.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className={cn(
|
||||
"rounded-08 cursor-pointer",
|
||||
item.disabled
|
||||
? "bg-background-tint-02"
|
||||
: "hover:bg-background-tint-02 transition-colors"
|
||||
)}
|
||||
onClick={() => {
|
||||
item.onSelect();
|
||||
}}
|
||||
>
|
||||
{item.render(!!item.disabled)}
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default ResourcePopover;
|
||||
@@ -0,0 +1,403 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { SvgEmpty, SvgFiles, SvgXOctagon } from "@opal/icons";
|
||||
import { Content } from "@opal/layouts";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import Card from "@/refresh-components/cards/Card";
|
||||
import LineItem from "@/refresh-components/buttons/LineItem";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import Separator from "@/refresh-components/Separator";
|
||||
import SimpleCollapsible from "@/refresh-components/SimpleCollapsible";
|
||||
import AgentAvatar from "@/refresh-components/avatars/AgentAvatar";
|
||||
import { useConnectorStatus } from "@/lib/hooks";
|
||||
import { useDocumentSets } from "@/lib/hooks/useDocumentSets";
|
||||
import { useAgents } from "@/hooks/useAgents";
|
||||
import { getSourceMetadata } from "@/lib/sources";
|
||||
import type { ValidSources } from "@/lib/types";
|
||||
import ResourceContent from "@/refresh-pages/admin/GroupsPage/SharedGroupResources/ResourceContent";
|
||||
import ResourcePopover from "@/refresh-pages/admin/GroupsPage/SharedGroupResources/ResourcePopover";
|
||||
import type { PopoverSection } from "@/refresh-pages/admin/GroupsPage/SharedGroupResources/interfaces";
|
||||
|
||||
interface SharedGroupResourcesProps {
|
||||
selectedCcPairIds: number[];
|
||||
onCcPairIdsChange: (ids: number[]) => void;
|
||||
selectedDocSetIds: number[];
|
||||
onDocSetIdsChange: (ids: number[]) => void;
|
||||
selectedAgentIds: number[];
|
||||
onAgentIdsChange: (ids: number[]) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SharedBadge() {
|
||||
return (
|
||||
<Text as="span" secondaryBody text03>
|
||||
Shared
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceIconStack({ sources }: { sources: { source: ValidSources }[] }) {
|
||||
if (sources.length === 0) return null;
|
||||
|
||||
const unique = Array.from(
|
||||
new Map(sources.map((s) => [s.source, s])).values()
|
||||
).slice(0, 3);
|
||||
|
||||
return (
|
||||
<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 (
|
||||
<div
|
||||
key={s.source}
|
||||
className="flex items-center justify-center size-4 rounded-04 bg-background-tint-00 border border-border-01 overflow-hidden"
|
||||
style={{ zIndex: unique.length - i, marginLeft: i > 0 ? -6 : 0 }}
|
||||
>
|
||||
<Icon />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SharedGroupResources({
|
||||
selectedCcPairIds,
|
||||
onCcPairIdsChange,
|
||||
selectedDocSetIds,
|
||||
onDocSetIdsChange,
|
||||
selectedAgentIds,
|
||||
onAgentIdsChange,
|
||||
}: SharedGroupResourcesProps) {
|
||||
const [connectorSearch, setConnectorSearch] = useState("");
|
||||
const [agentSearch, setAgentSearch] = useState("");
|
||||
|
||||
const { data: connectors = [] } = useConnectorStatus();
|
||||
const { documentSets } = useDocumentSets();
|
||||
const { agents } = useAgents();
|
||||
|
||||
// --- Derived data ---
|
||||
|
||||
const selectedCcPairSet = useMemo(
|
||||
() => new Set(selectedCcPairIds),
|
||||
[selectedCcPairIds]
|
||||
);
|
||||
const selectedDocSetSet = useMemo(
|
||||
() => new Set(selectedDocSetIds),
|
||||
[selectedDocSetIds]
|
||||
);
|
||||
const selectedAgentSet = useMemo(
|
||||
() => new Set(selectedAgentIds),
|
||||
[selectedAgentIds]
|
||||
);
|
||||
|
||||
const selectedPairs = useMemo(
|
||||
() => connectors.filter((p) => selectedCcPairSet.has(p.cc_pair_id)),
|
||||
[connectors, selectedCcPairSet]
|
||||
);
|
||||
const selectedDocSets = useMemo(
|
||||
() => documentSets.filter((ds) => selectedDocSetSet.has(ds.id)),
|
||||
[documentSets, selectedDocSetSet]
|
||||
);
|
||||
const selectedAgentObjects = useMemo(
|
||||
() => agents.filter((a) => selectedAgentSet.has(a.id)),
|
||||
[agents, selectedAgentSet]
|
||||
);
|
||||
|
||||
// --- Popover sections ---
|
||||
|
||||
const connectorDocSetSections: PopoverSection[] = useMemo(() => {
|
||||
const q = connectorSearch.toLowerCase();
|
||||
|
||||
const connectorItems = connectors
|
||||
.filter((p) => !q || (p.name ?? "").toLowerCase().includes(q))
|
||||
.map((p) => {
|
||||
const isSelected = selectedCcPairSet.has(p.cc_pair_id);
|
||||
return {
|
||||
key: `c-${p.cc_pair_id}`,
|
||||
disabled: isSelected,
|
||||
onSelect: () =>
|
||||
isSelected
|
||||
? onCcPairIdsChange(
|
||||
selectedCcPairIds.filter((id) => id !== p.cc_pair_id)
|
||||
)
|
||||
: onCcPairIdsChange([...selectedCcPairIds, p.cc_pair_id]),
|
||||
render: (dimmed: boolean) => (
|
||||
<LineItem
|
||||
interactive={!dimmed}
|
||||
muted={dimmed}
|
||||
icon={getSourceMetadata(p.connector.source).icon}
|
||||
rightChildren={
|
||||
p.groups.length > 0 || dimmed ? <SharedBadge /> : undefined
|
||||
}
|
||||
>
|
||||
{p.name ?? `Connector #${p.cc_pair_id}`}
|
||||
</LineItem>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const docSetItems = documentSets
|
||||
.filter((ds) => !q || ds.name.toLowerCase().includes(q))
|
||||
.map((ds) => {
|
||||
const isSelected = selectedDocSetSet.has(ds.id);
|
||||
return {
|
||||
key: `d-${ds.id}`,
|
||||
disabled: isSelected,
|
||||
onSelect: () =>
|
||||
isSelected
|
||||
? onDocSetIdsChange(
|
||||
selectedDocSetIds.filter((id) => id !== ds.id)
|
||||
)
|
||||
: onDocSetIdsChange([...selectedDocSetIds, ds.id]),
|
||||
render: (dimmed: boolean) => (
|
||||
<LineItem
|
||||
interactive={!dimmed}
|
||||
muted={dimmed}
|
||||
icon={SvgFiles}
|
||||
rightChildren={
|
||||
ds.groups.length > 0 || dimmed ? <SharedBadge /> : undefined
|
||||
}
|
||||
>
|
||||
{ds.name}
|
||||
</LineItem>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
return [
|
||||
...(connectorItems.length > 0
|
||||
? [{ label: "Connectors", items: connectorItems }]
|
||||
: []),
|
||||
...(docSetItems.length > 0
|
||||
? [{ label: "Document Sets", items: docSetItems }]
|
||||
: []),
|
||||
];
|
||||
}, [
|
||||
connectors,
|
||||
documentSets,
|
||||
connectorSearch,
|
||||
selectedCcPairSet,
|
||||
selectedDocSetSet,
|
||||
selectedCcPairIds,
|
||||
selectedDocSetIds,
|
||||
onCcPairIdsChange,
|
||||
onDocSetIdsChange,
|
||||
]);
|
||||
|
||||
const agentSections: PopoverSection[] = useMemo(() => {
|
||||
const q = agentSearch.toLowerCase();
|
||||
|
||||
const items = agents
|
||||
.filter((a) => !q || a.name.toLowerCase().includes(q))
|
||||
.map((a) => {
|
||||
const isSelected = selectedAgentSet.has(a.id);
|
||||
return {
|
||||
key: `a-${a.id}`,
|
||||
disabled: isSelected,
|
||||
onSelect: () =>
|
||||
isSelected
|
||||
? onAgentIdsChange(selectedAgentIds.filter((id) => id !== a.id))
|
||||
: onAgentIdsChange([...selectedAgentIds, a.id]),
|
||||
render: (dimmed: boolean) => (
|
||||
<LineItem
|
||||
interactive={!dimmed}
|
||||
muted={dimmed}
|
||||
icon={(_props) => <AgentAvatar agent={a} size={16} />}
|
||||
description="agent"
|
||||
rightChildren={
|
||||
!a.is_public || dimmed ? <SharedBadge /> : undefined
|
||||
}
|
||||
>
|
||||
{a.name}
|
||||
</LineItem>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
return items.length > 0 ? [{ items }] : [];
|
||||
}, [
|
||||
agents,
|
||||
agentSearch,
|
||||
selectedAgentSet,
|
||||
selectedAgentIds,
|
||||
onAgentIdsChange,
|
||||
]);
|
||||
|
||||
// --- Handlers ---
|
||||
|
||||
function removeConnector(id: number) {
|
||||
onCcPairIdsChange(selectedCcPairIds.filter((cid) => cid !== id));
|
||||
}
|
||||
|
||||
function removeDocSet(id: number) {
|
||||
onDocSetIdsChange(selectedDocSetIds.filter((did) => did !== id));
|
||||
}
|
||||
|
||||
function removeAgent(id: number) {
|
||||
onAgentIdsChange(selectedAgentIds.filter((aid) => aid !== id));
|
||||
}
|
||||
|
||||
const hasSelectedResources =
|
||||
selectedPairs.length > 0 || selectedDocSets.length > 0;
|
||||
|
||||
return (
|
||||
<SimpleCollapsible>
|
||||
<SimpleCollapsible.Header
|
||||
title="Shared with This Group"
|
||||
description="Share connectors, document sets, agents with members of this group."
|
||||
/>
|
||||
<SimpleCollapsible.Content>
|
||||
<Card>
|
||||
<Section
|
||||
gap={1}
|
||||
height="auto"
|
||||
alignItems="stretch"
|
||||
justifyContent="start"
|
||||
width="full"
|
||||
>
|
||||
{/* Connectors & Document Sets */}
|
||||
<Section
|
||||
gap={0.5}
|
||||
height="auto"
|
||||
alignItems="stretch"
|
||||
justifyContent="start"
|
||||
>
|
||||
<Section
|
||||
gap={0.25}
|
||||
height="auto"
|
||||
alignItems="stretch"
|
||||
justifyContent="start"
|
||||
>
|
||||
<Text mainUiAction text04>
|
||||
Connectors & Document Sets
|
||||
</Text>
|
||||
<ResourcePopover
|
||||
placeholder="Add connectors, document sets"
|
||||
searchValue={connectorSearch}
|
||||
onSearchChange={setConnectorSearch}
|
||||
sections={connectorDocSetSections}
|
||||
/>
|
||||
</Section>
|
||||
{hasSelectedResources ? (
|
||||
<Section
|
||||
flexDirection="row"
|
||||
wrap
|
||||
gap={0.25}
|
||||
height="auto"
|
||||
alignItems="start"
|
||||
justifyContent="start"
|
||||
>
|
||||
{selectedPairs.map((pair) => (
|
||||
<ResourceContent
|
||||
key={`c-${pair.cc_pair_id}`}
|
||||
icon={getSourceMetadata(pair.connector.source).icon}
|
||||
title={pair.name ?? `Connector #${pair.cc_pair_id}`}
|
||||
description="Connector"
|
||||
onRemove={() => removeConnector(pair.cc_pair_id)}
|
||||
/>
|
||||
))}
|
||||
{selectedDocSets.map((ds) => (
|
||||
<ResourceContent
|
||||
key={`d-${ds.id}`}
|
||||
icon={SvgFiles}
|
||||
title={ds.name}
|
||||
description={`Document Set - ${ds.cc_pair_summaries.length} Sources`}
|
||||
infoContent={
|
||||
<SourceIconStack sources={ds.cc_pair_summaries} />
|
||||
}
|
||||
onRemove={() => removeDocSet(ds.id)}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
) : (
|
||||
<Content
|
||||
icon={SvgEmpty}
|
||||
title="No connectors or document sets added"
|
||||
description="Add connectors or document set to share with this group."
|
||||
sizePreset="secondary"
|
||||
variant="section"
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Separator noPadding />
|
||||
|
||||
{/* Agents */}
|
||||
<Section
|
||||
gap={0.5}
|
||||
height="auto"
|
||||
alignItems="stretch"
|
||||
justifyContent="start"
|
||||
>
|
||||
<Section
|
||||
gap={0.25}
|
||||
height="auto"
|
||||
alignItems="stretch"
|
||||
justifyContent="start"
|
||||
>
|
||||
<Text mainUiAction text04>
|
||||
Agents
|
||||
</Text>
|
||||
<ResourcePopover
|
||||
placeholder="Add agents"
|
||||
searchValue={agentSearch}
|
||||
onSearchChange={setAgentSearch}
|
||||
sections={agentSections}
|
||||
/>
|
||||
</Section>
|
||||
{selectedAgentObjects.length > 0 ? (
|
||||
<Section
|
||||
flexDirection="row"
|
||||
wrap
|
||||
gap={0.25}
|
||||
height="auto"
|
||||
alignItems="start"
|
||||
justifyContent="start"
|
||||
>
|
||||
{selectedAgentObjects.map((agent) => (
|
||||
<ResourceContent
|
||||
key={agent.id}
|
||||
leftContent={
|
||||
<div className="flex items-center justify-center shrink-0 size-5 p-0.5 rounded-04">
|
||||
<AgentAvatar agent={agent} size={16} />
|
||||
</div>
|
||||
}
|
||||
title={agent.name}
|
||||
description="agent"
|
||||
onRemove={() => removeAgent(agent.id)}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
) : (
|
||||
<Content
|
||||
icon={SvgXOctagon}
|
||||
title="No agents added"
|
||||
description="Add agents to share with this group."
|
||||
sizePreset="secondary"
|
||||
variant="section"
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
</Section>
|
||||
</Card>
|
||||
</SimpleCollapsible.Content>
|
||||
</SimpleCollapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export default SharedGroupResources;
|
||||
@@ -0,0 +1,19 @@
|
||||
export interface PopoverItem {
|
||||
key: string;
|
||||
render: (disabled: boolean) => React.ReactNode;
|
||||
onSelect: () => void;
|
||||
/** When true, the item is already selected — shown dimmed with bg-tint-02. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface PopoverSection {
|
||||
label?: string;
|
||||
items: PopoverItem[];
|
||||
}
|
||||
|
||||
export interface ResourcePopoverProps {
|
||||
placeholder: string;
|
||||
searchValue: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
sections: PopoverSection[];
|
||||
}
|
||||
148
web/src/refresh-pages/admin/GroupsPage/TokenLimitSection.tsx
Normal file
148
web/src/refresh-pages/admin/GroupsPage/TokenLimitSection.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { SvgPlusCircle, SvgMinusCircle } from "@opal/icons";
|
||||
import { Button } from "@opal/components";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import Card from "@/refresh-components/cards/Card";
|
||||
import InputNumber from "@/refresh-components/inputs/InputNumber";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import IconButton from "@/refresh-components/buttons/IconButton";
|
||||
import SimpleCollapsible from "@/refresh-components/SimpleCollapsible";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TokenLimit {
|
||||
tokenBudget: number | null;
|
||||
periodHours: number | null;
|
||||
}
|
||||
|
||||
interface TokenLimitSectionProps {
|
||||
limits: TokenLimit[];
|
||||
onLimitsChange: (limits: TokenLimit[]) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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
|
||||
);
|
||||
if (emptyIndex !== -1) return;
|
||||
const key = nextKeyRef.current++;
|
||||
keysRef.current = [...keysRef.current, key];
|
||||
onLimitsChange([...limits, { tokenBudget: null, periodHours: null }]);
|
||||
}
|
||||
|
||||
function removeLimit(index: number) {
|
||||
keysRef.current = keysRef.current.filter((_, i) => i !== index);
|
||||
onLimitsChange(limits.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
function updateLimit(
|
||||
index: number,
|
||||
field: keyof TokenLimit,
|
||||
value: number | null
|
||||
) {
|
||||
onLimitsChange(
|
||||
limits.map((l, i) => (i === index ? { ...l, [field]: value } : l))
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SimpleCollapsible>
|
||||
<SimpleCollapsible.Header
|
||||
title="Token Rate Limit"
|
||||
description="Limit number of tokens this group can use within a given time period."
|
||||
/>
|
||||
<SimpleCollapsible.Content>
|
||||
<Card>
|
||||
<Section
|
||||
gap={0.5}
|
||||
height="auto"
|
||||
alignItems="stretch"
|
||||
justifyContent="start"
|
||||
width="full"
|
||||
>
|
||||
{/* Column headers */}
|
||||
<div className="flex flex-wrap items-center gap-1 pr-[40px]">
|
||||
<div className="flex-1 flex items-center min-w-[160px]">
|
||||
<Text mainUiAction text04>
|
||||
Token Limit
|
||||
</Text>
|
||||
<Text mainUiMuted text03 className="ml-0.5">
|
||||
(thousand tokens)
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center min-w-[160px]">
|
||||
<Text mainUiAction text04>
|
||||
Time Window
|
||||
</Text>
|
||||
<Text mainUiMuted text03 className="ml-0.5">
|
||||
(hours)
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Limit rows */}
|
||||
{limits.map((limit, i) => (
|
||||
<div key={keysRef.current[i]} className="flex items-center gap-1">
|
||||
<div className="flex-1">
|
||||
<InputNumber
|
||||
value={limit.tokenBudget}
|
||||
onChange={(v) => updateLimit(i, "tokenBudget", v)}
|
||||
min={0}
|
||||
placeholder="Token limit in thousands"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<InputNumber
|
||||
value={limit.periodHours}
|
||||
onChange={(v) => updateLimit(i, "periodHours", v)}
|
||||
min={1}
|
||||
placeholder="24"
|
||||
/>
|
||||
</div>
|
||||
<IconButton
|
||||
small
|
||||
icon={SvgMinusCircle}
|
||||
onClick={() => removeLimit(i)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add button */}
|
||||
<Button
|
||||
icon={SvgPlusCircle}
|
||||
prominence="secondary"
|
||||
size="md"
|
||||
onClick={addLimit}
|
||||
>
|
||||
Add Limit
|
||||
</Button>
|
||||
</Section>
|
||||
</Card>
|
||||
</SimpleCollapsible.Content>
|
||||
</SimpleCollapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export default TokenLimitSection;
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
import { SvgPlusCircle, SvgUsers } from "@opal/icons";
|
||||
import { Button } from "@opal/components";
|
||||
@@ -16,6 +17,7 @@ import { IllustrationContent } from "@opal/layouts";
|
||||
import SvgNoResult from "@opal/illustrations/no-result";
|
||||
|
||||
function GroupsPage() {
|
||||
const router = useRouter();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const {
|
||||
@@ -25,7 +27,7 @@ function GroupsPage() {
|
||||
} = useSWR<UserGroup[]>(USER_GROUP_URL, errorHandlingFetcher);
|
||||
|
||||
return (
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Root width="sm">
|
||||
{/* This is the sticky header for the groups page. It is used to display
|
||||
* the groups page title and search input when scrolling down.
|
||||
*/}
|
||||
@@ -40,7 +42,12 @@ function GroupsPage() {
|
||||
leftSearchIcon
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<Button icon={SvgPlusCircle}>New Group</Button>
|
||||
<Button
|
||||
icon={SvgPlusCircle}
|
||||
onClick={() => router.push("/admin/groups/create")}
|
||||
>
|
||||
New Group
|
||||
</Button>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
|
||||
15
web/src/refresh-pages/admin/GroupsPage/interfaces.ts
Normal file
15
web/src/refresh-pages/admin/GroupsPage/interfaces.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { UserRole } from "@/lib/types";
|
||||
import type { UserRow } from "@/refresh-pages/admin/UsersPage/interfaces";
|
||||
|
||||
export interface ApiKeyDescriptor {
|
||||
api_key_id: number;
|
||||
api_key_display: string;
|
||||
api_key_name: string | null;
|
||||
api_key_role: UserRole;
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
/** Extends UserRow with an optional API key display for service accounts. */
|
||||
export interface MemberRow extends UserRow {
|
||||
api_key_display?: string;
|
||||
}
|
||||
106
web/src/refresh-pages/admin/GroupsPage/shared.tsx
Normal file
106
web/src/refresh-pages/admin/GroupsPage/shared.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { createTableColumns } from "@opal/components";
|
||||
import { Content } from "@opal/layouts";
|
||||
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 type { ApiKeyDescriptor, MemberRow } from "./interfaces";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const PAGE_SIZE = 10;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function apiKeyToMemberRow(key: ApiKeyDescriptor): MemberRow {
|
||||
return {
|
||||
id: key.user_id,
|
||||
email: "Service Account",
|
||||
role: key.api_key_role,
|
||||
status: UserStatus.ACTIVE,
|
||||
is_active: true,
|
||||
is_scim_synced: false,
|
||||
personal_name: key.api_key_name ?? "Unnamed Key",
|
||||
created_at: null,
|
||||
updated_at: null,
|
||||
groups: [],
|
||||
api_key_display: key.api_key_display,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Role icon mapping (mirrors UsersPage/UserRoleCell)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ROLE_ICONS: Partial<Record<UserRole, IconFunctionComponent>> = {
|
||||
[UserRole.ADMIN]: SvgUserManage,
|
||||
[UserRole.GLOBAL_CURATOR]: SvgGlobe,
|
||||
[UserRole.SLACK_USER]: SvgSlack,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Column renderers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderNameColumn(email: string, row: MemberRow) {
|
||||
return (
|
||||
<Content
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
title={row.personal_name ?? email}
|
||||
description={row.personal_name ? email : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderAccountTypeColumn(_value: unknown, row: MemberRow) {
|
||||
const Icon = (row.role && ROLE_ICONS[row.role]) || SvgUser;
|
||||
return (
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
<Icon className="w-4 h-4 text-text-03" />
|
||||
<Text as="span" mainUiBody text03>
|
||||
{row.role ? USER_ROLE_LABELS[row.role] ?? row.role : "\u2014"}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Columns
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const tc = createTableColumns<MemberRow>();
|
||||
|
||||
export const baseColumns = [
|
||||
tc.qualifier(),
|
||||
tc.column("email", {
|
||||
header: "Name",
|
||||
weight: 25,
|
||||
cell: renderNameColumn,
|
||||
}),
|
||||
tc.column("api_key_display", {
|
||||
header: "",
|
||||
weight: 15,
|
||||
enableSorting: false,
|
||||
cell: (value) =>
|
||||
value ? (
|
||||
<Text as="span" secondaryBody text03>
|
||||
{value}
|
||||
</Text>
|
||||
) : null,
|
||||
}),
|
||||
tc.column("role", {
|
||||
header: "Account Type",
|
||||
weight: 15,
|
||||
cell: renderAccountTypeColumn,
|
||||
}),
|
||||
];
|
||||
|
||||
export const memberTableColumns = [
|
||||
...baseColumns,
|
||||
tc.actions({ showSorting: false }),
|
||||
];
|
||||
@@ -1,5 +1,269 @@
|
||||
/** API helpers for the Groups list page. */
|
||||
/** API helpers for the Groups pages. */
|
||||
|
||||
const USER_GROUP_URL = "/api/manage/admin/user-group";
|
||||
|
||||
export { USER_GROUP_URL };
|
||||
async function renameGroup(groupId: number, newName: string): Promise<void> {
|
||||
const res = await fetch(`${USER_GROUP_URL}/rename`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id: groupId, name: newName }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = await res.json().catch(() => null);
|
||||
throw new Error(
|
||||
detail?.detail ?? `Failed to rename group: ${res.statusText}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function createGroup(
|
||||
name: string,
|
||||
userIds: string[],
|
||||
ccPairIds: number[] = []
|
||||
): Promise<number> {
|
||||
const res = await fetch(USER_GROUP_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
user_ids: userIds,
|
||||
cc_pair_ids: ccPairIds,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = await res.json().catch(() => null);
|
||||
throw new Error(
|
||||
detail?.detail ?? `Failed to create group: ${res.statusText}`
|
||||
);
|
||||
}
|
||||
const group = await res.json();
|
||||
return group.id;
|
||||
}
|
||||
|
||||
async function deleteGroup(groupId: number): Promise<void> {
|
||||
const res = await fetch(`${USER_GROUP_URL}/${groupId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = await res.json().catch(() => null);
|
||||
throw new Error(
|
||||
detail?.detail ?? `Failed to delete group: ${res.statusText}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent (persona) sharing — managed from the persona side
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function updateAgentGroupSharing(
|
||||
groupId: number,
|
||||
initialAgentIds: number[],
|
||||
currentAgentIds: number[]
|
||||
): Promise<void> {
|
||||
const initialSet = new Set(initialAgentIds);
|
||||
const currentSet = new Set(currentAgentIds);
|
||||
|
||||
const added_agent_ids = currentAgentIds.filter((id) => !initialSet.has(id));
|
||||
const removed_agent_ids = initialAgentIds.filter((id) => !currentSet.has(id));
|
||||
|
||||
if (added_agent_ids.length === 0 && removed_agent_ids.length === 0) return;
|
||||
|
||||
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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Document set sharing — managed from the document set side
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DocumentSetSummary {
|
||||
id: number;
|
||||
description: string;
|
||||
cc_pair_summaries: { id: number }[];
|
||||
federated_connector_summaries: { id: number }[];
|
||||
is_public: boolean;
|
||||
users: string[];
|
||||
groups: number[];
|
||||
}
|
||||
|
||||
async function updateDocSetGroupSharing(
|
||||
groupId: number,
|
||||
initialDocSetIds: number[],
|
||||
currentDocSetIds: number[]
|
||||
): Promise<void> {
|
||||
const initialSet = new Set(initialDocSetIds);
|
||||
const currentSet = new Set(currentDocSetIds);
|
||||
|
||||
const added = currentDocSetIds.filter((id) => !initialSet.has(id));
|
||||
const removed = initialDocSetIds.filter((id) => !currentSet.has(id));
|
||||
|
||||
if (added.length === 0 && removed.length === 0) return;
|
||||
|
||||
// Fetch all document sets to get their current state
|
||||
const allRes = await fetch("/api/manage/document-set");
|
||||
if (!allRes.ok) {
|
||||
throw new Error("Failed to fetch document sets");
|
||||
}
|
||||
const allDocSets: DocumentSetSummary[] = await allRes.json();
|
||||
const docSetMap = new Map(allDocSets.map((ds) => [ds.id, ds]));
|
||||
|
||||
for (const dsId of added) {
|
||||
const ds = docSetMap.get(dsId);
|
||||
if (!ds) {
|
||||
throw new Error(`Document set ${dsId} not found`);
|
||||
}
|
||||
const updatedGroups = ds.groups.includes(groupId)
|
||||
? ds.groups
|
||||
: [...ds.groups, groupId];
|
||||
const res = await fetch("/api/manage/admin/document-set", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
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,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to add group to document set ${dsId}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const dsId of removed) {
|
||||
const ds = docSetMap.get(dsId);
|
||||
if (!ds) {
|
||||
throw new Error(`Document set ${dsId} not found`);
|
||||
}
|
||||
const updatedGroups = ds.groups.filter((id) => id !== groupId);
|
||||
const res = await fetch("/api/manage/admin/document-set", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
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,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to remove group from document set ${dsId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token rate limits — create / update / delete
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TokenLimitPayload {
|
||||
tokenBudget: number | null;
|
||||
periodHours: number | null;
|
||||
}
|
||||
|
||||
interface ExistingTokenLimit {
|
||||
token_id: number;
|
||||
enabled: boolean;
|
||||
token_budget: number;
|
||||
period_hours: number;
|
||||
}
|
||||
|
||||
async function saveTokenLimits(
|
||||
groupId: number,
|
||||
limits: TokenLimitPayload[],
|
||||
existing: ExistingTokenLimit[]
|
||||
): Promise<void> {
|
||||
// Filter to only valid (non-null) limits
|
||||
const validLimits = limits.filter(
|
||||
(l): l is { tokenBudget: number; periodHours: number } =>
|
||||
l.tokenBudget != null && l.periodHours != null
|
||||
);
|
||||
|
||||
// Update existing limits (match by index position)
|
||||
const toUpdate = Math.min(validLimits.length, existing.length);
|
||||
for (let i = 0; i < toUpdate; i++) {
|
||||
const limit = validLimits[i]!;
|
||||
const existingLimit = existing[i]!;
|
||||
const updateRes = await fetch(
|
||||
`/api/admin/token-rate-limits/rate-limit/${existingLimit.token_id}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
enabled: existingLimit.enabled,
|
||||
token_budget: limit.tokenBudget,
|
||||
period_hours: limit.periodHours,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (!updateRes.ok) {
|
||||
throw new Error(
|
||||
`Failed to update token rate limit ${existingLimit.token_id}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create new limits beyond existing count
|
||||
for (let i = toUpdate; i < validLimits.length; i++) {
|
||||
const limit = validLimits[i]!;
|
||||
const createRes = await fetch(
|
||||
`/api/admin/token-rate-limits/user-group/${groupId}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
enabled: true,
|
||||
token_budget: limit.tokenBudget,
|
||||
period_hours: limit.periodHours,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (!createRes.ok) {
|
||||
throw new Error("Failed to create token rate limit");
|
||||
}
|
||||
}
|
||||
|
||||
// Delete excess existing limits
|
||||
for (let i = toUpdate; i < existing.length; i++) {
|
||||
const existingLimit = existing[i]!;
|
||||
const deleteRes = await fetch(
|
||||
`/api/admin/token-rate-limits/rate-limit/${existingLimit.token_id}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
if (!deleteRes.ok) {
|
||||
throw new Error(
|
||||
`Failed to delete token rate limit ${existingLimit.token_id}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
USER_GROUP_URL,
|
||||
renameGroup,
|
||||
createGroup,
|
||||
deleteGroup,
|
||||
updateAgentGroupSharing,
|
||||
updateDocSetGroupSharing,
|
||||
saveTokenLimits,
|
||||
};
|
||||
|
||||
334
web/src/refresh-pages/admin/HooksPage/HookFormModal.tsx
Normal file
334
web/src/refresh-pages/admin/HooksPage/HookFormModal.tsx
Normal file
@@ -0,0 +1,334 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@opal/components";
|
||||
import { Disabled } from "@opal/core";
|
||||
import { SvgHookNodes } from "@opal/icons";
|
||||
import Modal, { BasicModalFooter } from "@/refresh-components/Modal";
|
||||
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
|
||||
import InputSelect from "@/refresh-components/inputs/InputSelect";
|
||||
import PasswordInputTypeIn from "@/refresh-components/inputs/PasswordInputTypeIn";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { createHook, updateHook } from "@/refresh-pages/admin/HooksPage/svc";
|
||||
import type {
|
||||
HookFailStrategy,
|
||||
HookPointMeta,
|
||||
HookResponse,
|
||||
} from "@/refresh-pages/admin/HooksPage/interfaces";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface HookFormModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** When provided, the modal is in edit mode for this hook. */
|
||||
hook?: HookResponse;
|
||||
/** When provided (create mode), the hook point is pre-selected and locked. */
|
||||
spec?: HookPointMeta;
|
||||
onSuccess: (hook: HookResponse) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface FormState {
|
||||
name: string;
|
||||
endpoint_url: string;
|
||||
api_key: string;
|
||||
fail_strategy: HookFailStrategy;
|
||||
timeout_seconds: string;
|
||||
}
|
||||
|
||||
function buildInitialState(
|
||||
hook: HookResponse | undefined,
|
||||
spec: HookPointMeta | undefined
|
||||
): FormState {
|
||||
if (hook) {
|
||||
return {
|
||||
name: hook.name,
|
||||
endpoint_url: hook.endpoint_url ?? "",
|
||||
api_key: hook.api_key_masked ?? "",
|
||||
fail_strategy: hook.fail_strategy,
|
||||
timeout_seconds: String(hook.timeout_seconds),
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: "",
|
||||
endpoint_url: "",
|
||||
api_key: "",
|
||||
fail_strategy: spec?.default_fail_strategy ?? "soft",
|
||||
timeout_seconds: spec ? String(spec.default_timeout_seconds) : "5",
|
||||
};
|
||||
}
|
||||
|
||||
const SOFT_DESCRIPTION =
|
||||
"If the endpoint returns an error, Onyx logs it and continues the pipeline as normal, ignoring the hook result.";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface FieldProps {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function Field({ label, required, description, children }: FieldProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
<span className="font-main-ui-action text-text-04 px-[0.125rem]">
|
||||
{label}
|
||||
{required && <span className="text-status-error-05 ml-0.5">*</span>}
|
||||
</span>
|
||||
{children}
|
||||
{description && (
|
||||
<Text secondaryBody text03>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function HookFormModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
hook,
|
||||
spec,
|
||||
onSuccess,
|
||||
}: HookFormModalProps) {
|
||||
const isEdit = !!hook;
|
||||
const [form, setForm] = useState<FormState>(() =>
|
||||
buildInitialState(hook, spec)
|
||||
);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
function handleOpenChange(next: boolean) {
|
||||
if (!next) {
|
||||
setTimeout(() => {
|
||||
setForm(buildInitialState(hook, spec));
|
||||
setIsSubmitting(false);
|
||||
}, 200);
|
||||
}
|
||||
onOpenChange(next);
|
||||
}
|
||||
|
||||
function set<K extends keyof FormState>(key: K, value: FormState[K]) {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
const timeoutNum = parseFloat(form.timeout_seconds);
|
||||
const isValid =
|
||||
form.name.trim().length > 0 &&
|
||||
form.endpoint_url.trim().length > 0 &&
|
||||
!isNaN(timeoutNum) &&
|
||||
timeoutNum > 0;
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!isValid) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
let result: HookResponse;
|
||||
if (isEdit && hook) {
|
||||
const req: Record<string, unknown> = {};
|
||||
if (form.name !== hook.name) req.name = form.name;
|
||||
if (form.endpoint_url !== (hook.endpoint_url ?? ""))
|
||||
req.endpoint_url = form.endpoint_url;
|
||||
if (form.fail_strategy !== hook.fail_strategy)
|
||||
req.fail_strategy = form.fail_strategy;
|
||||
if (timeoutNum !== hook.timeout_seconds)
|
||||
req.timeout_seconds = timeoutNum;
|
||||
const maskedPlaceholder = hook.api_key_masked ?? "";
|
||||
if (form.api_key !== maskedPlaceholder) {
|
||||
req.api_key = form.api_key || null;
|
||||
}
|
||||
if (Object.keys(req).length === 0) {
|
||||
handleOpenChange(false);
|
||||
return;
|
||||
}
|
||||
result = await updateHook(hook.id, req);
|
||||
} else {
|
||||
const hookPoint = spec!.hook_point;
|
||||
result = await createHook({
|
||||
name: form.name,
|
||||
hook_point: hookPoint,
|
||||
endpoint_url: form.endpoint_url,
|
||||
...(form.api_key ? { api_key: form.api_key } : {}),
|
||||
fail_strategy: form.fail_strategy,
|
||||
timeout_seconds: timeoutNum,
|
||||
});
|
||||
}
|
||||
toast.success(isEdit ? "Hook updated." : "Hook created.");
|
||||
onSuccess(result);
|
||||
handleOpenChange(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Something went wrong.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const hookPointDisplayName = isEdit
|
||||
? hook!.hook_point
|
||||
: spec?.display_name ?? spec?.hook_point ?? "";
|
||||
const hookPointDescription = isEdit ? undefined : spec?.description;
|
||||
const docsUrl = isEdit ? undefined : spec?.docs_url;
|
||||
|
||||
const failStrategyDescription =
|
||||
form.fail_strategy === "soft"
|
||||
? SOFT_DESCRIPTION
|
||||
: spec?.fail_hard_description ?? undefined;
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={handleOpenChange}>
|
||||
<Modal.Content width="md" height="fit">
|
||||
<Modal.Header
|
||||
icon={SvgHookNodes}
|
||||
title="Set Up Hook Extension"
|
||||
description="Connect a external API endpoints to extend the hook point."
|
||||
onClose={() => handleOpenChange(false)}
|
||||
/>
|
||||
|
||||
<Modal.Body>
|
||||
{/* Hook point section header */}
|
||||
<div className="flex flex-row items-start justify-between gap-1">
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<span className="font-main-ui-action text-text-04 px-[0.125rem]">
|
||||
{hookPointDisplayName}
|
||||
</span>
|
||||
{hookPointDescription && (
|
||||
<span className="font-secondary-body text-text-03 px-[0.125rem]">
|
||||
{hookPointDescription}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end shrink-0 gap-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<SvgHookNodes
|
||||
style={{ width: "1rem", height: "1rem" }}
|
||||
className="text-text-03 shrink-0 p-0.5"
|
||||
/>
|
||||
<span className="font-secondary-body text-text-03">
|
||||
Hook Point
|
||||
</span>
|
||||
</div>
|
||||
{docsUrl && (
|
||||
<a
|
||||
href={docsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-secondary-body text-text-03 underline"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field label="Display Name" required>
|
||||
<InputTypeIn
|
||||
value={form.name}
|
||||
onChange={(e) => set("name", e.target.value)}
|
||||
placeholder="Name your extension at this hook point"
|
||||
variant={isSubmitting ? "disabled" : undefined}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Fail Strategy" description={failStrategyDescription}>
|
||||
<InputSelect
|
||||
value={form.fail_strategy}
|
||||
onValueChange={(v) => set("fail_strategy", v as HookFailStrategy)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<InputSelect.Trigger placeholder="Select strategy" />
|
||||
<InputSelect.Content>
|
||||
<InputSelect.Item value="soft">
|
||||
Log Error and Continue (Default)
|
||||
</InputSelect.Item>
|
||||
<InputSelect.Item value="hard">
|
||||
Block Pipeline on Failure
|
||||
</InputSelect.Item>
|
||||
</InputSelect.Content>
|
||||
</InputSelect>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Timeout (seconds)"
|
||||
required
|
||||
description="Maximum time Onyx will wait for the endpoint to respond before applying the fail strategy."
|
||||
>
|
||||
<InputTypeIn
|
||||
type="number"
|
||||
value={form.timeout_seconds}
|
||||
onChange={(e) => set("timeout_seconds", e.target.value)}
|
||||
placeholder="5"
|
||||
variant={isSubmitting ? "disabled" : undefined}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="External API Endpoint URL"
|
||||
required
|
||||
description="Only connect to servers you trust. You are responsible for actions taken and data shared with this connection."
|
||||
>
|
||||
<InputTypeIn
|
||||
value={form.endpoint_url}
|
||||
onChange={(e) => set("endpoint_url", e.target.value)}
|
||||
placeholder="https://your-api-endpoint.com"
|
||||
variant={isSubmitting ? "disabled" : undefined}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="API Key"
|
||||
description="Onyx will use this key to authenticate with your API endpoint."
|
||||
>
|
||||
<PasswordInputTypeIn
|
||||
value={form.api_key}
|
||||
onChange={(e) => set("api_key", e.target.value)}
|
||||
placeholder={
|
||||
isEdit && hook?.api_key_masked
|
||||
? "Leave blank to keep current key"
|
||||
: undefined
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</Field>
|
||||
</Modal.Body>
|
||||
|
||||
<Modal.Footer>
|
||||
<BasicModalFooter
|
||||
cancel={
|
||||
<Disabled disabled={isSubmitting}>
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Disabled>
|
||||
}
|
||||
submit={
|
||||
<Disabled disabled={isSubmitting || !isValid}>
|
||||
<Button onClick={handleSubmit}>
|
||||
{isEdit ? "Save" : "Connect"}
|
||||
</Button>
|
||||
</Disabled>
|
||||
}
|
||||
/>
|
||||
</Modal.Footer>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
372
web/src/refresh-pages/admin/HooksPage/HooksContent.tsx
Normal file
372
web/src/refresh-pages/admin/HooksPage/HooksContent.tsx
Normal file
@@ -0,0 +1,372 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { useHookSpecs } from "@/hooks/useHookSpecs";
|
||||
import { useHooks } from "@/hooks/useHooks";
|
||||
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
|
||||
import { Button } from "@opal/components";
|
||||
import { Disabled } from "@opal/core";
|
||||
import InputSearch from "@/refresh-components/inputs/InputSearch";
|
||||
import Card from "@/refresh-components/cards/Card";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import {
|
||||
SvgArrowExchange,
|
||||
SvgBubbleText,
|
||||
SvgCheckCircle,
|
||||
SvgExternalLink,
|
||||
SvgFileBroadcast,
|
||||
SvgHookNodes,
|
||||
SvgRefreshCw,
|
||||
SvgSettings,
|
||||
SvgXCircle,
|
||||
} from "@opal/icons";
|
||||
import { IconFunctionComponent } from "@opal/types";
|
||||
import HookFormModal from "@/refresh-pages/admin/HooksPage/HookFormModal";
|
||||
import type {
|
||||
HookPointMeta,
|
||||
HookResponse,
|
||||
} from "@/refresh-pages/admin/HooksPage/interfaces";
|
||||
import {
|
||||
activateHook,
|
||||
deactivateHook,
|
||||
} from "@/refresh-pages/admin/HooksPage/svc";
|
||||
|
||||
const HOOK_POINT_ICONS: Record<string, IconFunctionComponent> = {
|
||||
document_ingestion: SvgFileBroadcast,
|
||||
query_processing: SvgBubbleText,
|
||||
};
|
||||
|
||||
function getHookPointIcon(hookPoint: string): IconFunctionComponent {
|
||||
return HOOK_POINT_ICONS[hookPoint] ?? SvgHookNodes;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-component: connected hook card
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ConnectedHookCardProps {
|
||||
hook: HookResponse;
|
||||
spec: HookPointMeta | undefined;
|
||||
onEdit: () => void;
|
||||
onDeleted: () => void;
|
||||
onToggled: (updated: HookResponse) => void;
|
||||
}
|
||||
|
||||
function ConnectedHookCard({
|
||||
hook,
|
||||
spec,
|
||||
onEdit,
|
||||
onDeleted: _onDeleted,
|
||||
onToggled,
|
||||
}: ConnectedHookCardProps) {
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
|
||||
async function handleToggle() {
|
||||
setIsBusy(true);
|
||||
try {
|
||||
const updated = hook.is_active
|
||||
? await deactivateHook(hook.id)
|
||||
: await activateHook(hook.id);
|
||||
onToggled(updated);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to update hook status."
|
||||
);
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const HookIcon = getHookPointIcon(hook.hook_point);
|
||||
|
||||
return (
|
||||
<Card variant="primary" padding={0.5} gap={0}>
|
||||
<div className="flex flex-row items-start w-full">
|
||||
{/* Left: manually replicate ContentMd main-content layout so the docs
|
||||
link sits as a natural third line with zero artificial gap. */}
|
||||
<div className="flex flex-row flex-1 min-w-0 items-start gap-1 p-2">
|
||||
<div className="shrink-0 p-0.5 text-text-04">
|
||||
<HookIcon style={{ width: "1rem", height: "1rem" }} />
|
||||
</div>
|
||||
<div className="flex flex-col items-start min-w-0 flex-1">
|
||||
<span
|
||||
className="font-main-ui-action text-text-04"
|
||||
style={{ height: "1.25rem" }}
|
||||
>
|
||||
{hook.name}
|
||||
</span>
|
||||
{/* matches opal-content-md-description: font-secondary-body px-[0.125rem] */}
|
||||
<div className="font-secondary-body text-text-03">
|
||||
{`Hook Point: ${spec?.display_name ?? hook.hook_point}`}
|
||||
</div>
|
||||
{spec?.docs_url && (
|
||||
<div className="font-secondary-body text-text-03">
|
||||
<a
|
||||
href={spec.docs_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 w-fit"
|
||||
>
|
||||
<span className="underline">Documentation</span>
|
||||
<SvgExternalLink size={12} className="shrink-0" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: status + actions, top-aligned */}
|
||||
<div className="flex flex-col items-end shrink-0">
|
||||
<div className="flex items-center gap-1 p-2">
|
||||
<span className="font-main-ui-action text-text-03">
|
||||
{hook.is_active ? "Connected" : "Inactive"}
|
||||
</span>
|
||||
{hook.is_active ? (
|
||||
<SvgCheckCircle size={16} className="text-status-success-05" />
|
||||
) : (
|
||||
<SvgXCircle size={16} className="text-text-03" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 pl-2 pr-0.5">
|
||||
<Disabled disabled={isBusy}>
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
size="md"
|
||||
icon={SvgRefreshCw}
|
||||
onClick={handleToggle}
|
||||
tooltip={hook.is_active ? "Deactivate" : "Activate"}
|
||||
aria-label={
|
||||
hook.is_active ? "Deactivate hook" : "Activate hook"
|
||||
}
|
||||
/>
|
||||
</Disabled>
|
||||
<Disabled disabled={isBusy}>
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
size="md"
|
||||
icon={SvgSettings}
|
||||
onClick={onEdit}
|
||||
aria-label="Configure hook"
|
||||
/>
|
||||
</Disabled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function HooksContent() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [connectSpec, setConnectSpec] = useState<HookPointMeta | null>(null);
|
||||
const [editHook, setEditHook] = useState<HookResponse | null>(null);
|
||||
|
||||
const { specs, isLoading: specsLoading, error: specsError } = useHookSpecs();
|
||||
const {
|
||||
hooks,
|
||||
isLoading: hooksLoading,
|
||||
error: hooksError,
|
||||
mutate,
|
||||
} = useHooks();
|
||||
|
||||
useEffect(() => {
|
||||
if (specsError) toast.error("Failed to load hook specifications.");
|
||||
}, [specsError]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hooksError) toast.error("Failed to load hooks.");
|
||||
}, [hooksError]);
|
||||
|
||||
if (specsLoading || hooksLoading) {
|
||||
return <SimpleLoader />;
|
||||
}
|
||||
|
||||
if (specsError) {
|
||||
return (
|
||||
<Text text03 secondaryBody>
|
||||
Failed to load hook specifications. Please refresh the page.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const hooksByPoint: Record<string, HookResponse[]> = {};
|
||||
for (const hook of hooks ?? []) {
|
||||
(hooksByPoint[hook.hook_point] ??= []).push(hook);
|
||||
}
|
||||
|
||||
const searchLower = search.toLowerCase();
|
||||
|
||||
// Connected hooks sorted alphabetically by hook name
|
||||
const connectedHooks = (hooks ?? [])
|
||||
.filter(
|
||||
(hook) =>
|
||||
!searchLower ||
|
||||
hook.name.toLowerCase().includes(searchLower) ||
|
||||
(hooksByPoint[hook.hook_point] &&
|
||||
specs
|
||||
?.find((s) => s.hook_point === hook.hook_point)
|
||||
?.display_name.toLowerCase()
|
||||
.includes(searchLower))
|
||||
)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// Unconnected hook point specs sorted alphabetically
|
||||
const unconnectedSpecs = (specs ?? [])
|
||||
.filter(
|
||||
(spec) =>
|
||||
(hooksByPoint[spec.hook_point]?.length ?? 0) === 0 &&
|
||||
(!searchLower ||
|
||||
spec.display_name.toLowerCase().includes(searchLower) ||
|
||||
spec.description.toLowerCase().includes(searchLower))
|
||||
)
|
||||
.sort((a, b) => a.display_name.localeCompare(b.display_name));
|
||||
|
||||
function handleHookSuccess(updated: HookResponse) {
|
||||
mutate((prev) => {
|
||||
if (!prev) return [updated];
|
||||
const idx = prev.findIndex((h) => h.id === updated.id);
|
||||
if (idx >= 0) {
|
||||
const next = [...prev];
|
||||
next[idx] = updated;
|
||||
return next;
|
||||
}
|
||||
return [...prev, updated];
|
||||
});
|
||||
}
|
||||
|
||||
function handleHookDeleted(id: number) {
|
||||
mutate((prev) => prev?.filter((h) => h.id !== id));
|
||||
}
|
||||
|
||||
const connectSpec_ =
|
||||
connectSpec ??
|
||||
(editHook
|
||||
? specs?.find((s) => s.hook_point === editHook.hook_point)
|
||||
: undefined);
|
||||
|
||||
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">
|
||||
{connectedHooks.length === 0 && unconnectedSpecs.length === 0 ? (
|
||||
<Text text03 secondaryBody>
|
||||
{search
|
||||
? "No hooks match your search."
|
||||
: "No hook points are available."}
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
{connectedHooks.map((hook) => {
|
||||
const spec = specs?.find(
|
||||
(s) => s.hook_point === hook.hook_point
|
||||
);
|
||||
return (
|
||||
<ConnectedHookCard
|
||||
key={hook.id}
|
||||
hook={hook}
|
||||
spec={spec}
|
||||
onEdit={() => setEditHook(hook)}
|
||||
onDeleted={() => handleHookDeleted(hook.id)}
|
||||
onToggled={handleHookSuccess}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{unconnectedSpecs.map((spec) => {
|
||||
const UnconnectedIcon = getHookPointIcon(spec.hook_point);
|
||||
return (
|
||||
<Card
|
||||
key={spec.hook_point}
|
||||
variant="secondary"
|
||||
padding={0.5}
|
||||
gap={0}
|
||||
>
|
||||
<div className="flex flex-row items-start w-full">
|
||||
<div className="flex flex-row flex-1 min-w-0 items-start gap-1 p-2">
|
||||
<div className="shrink-0 p-0.5 text-text-04">
|
||||
<UnconnectedIcon
|
||||
style={{ width: "1rem", height: "1rem" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col items-start min-w-0 flex-1">
|
||||
<span
|
||||
className="font-main-ui-action text-text-04"
|
||||
style={{ height: "1.25rem" }}
|
||||
>
|
||||
{spec.display_name}
|
||||
</span>
|
||||
<div className="font-secondary-body text-text-03">
|
||||
{spec.description}
|
||||
</div>
|
||||
{spec.docs_url && (
|
||||
<div className="font-secondary-body text-text-03">
|
||||
<a
|
||||
href={spec.docs_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 w-fit"
|
||||
>
|
||||
<span className="underline">Documentation</span>
|
||||
<SvgExternalLink
|
||||
size={12}
|
||||
className="shrink-0"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-1 p-2 cursor-pointer"
|
||||
onClick={() => setConnectSpec(spec)}
|
||||
>
|
||||
<span className="font-main-ui-action text-text-03">
|
||||
Connect
|
||||
</span>
|
||||
<SvgArrowExchange
|
||||
size={16}
|
||||
className="text-text-03 shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create modal */}
|
||||
<HookFormModal
|
||||
open={!!connectSpec}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setConnectSpec(null);
|
||||
}}
|
||||
spec={connectSpec ?? undefined}
|
||||
onSuccess={handleHookSuccess}
|
||||
/>
|
||||
|
||||
{/* Edit modal */}
|
||||
<HookFormModal
|
||||
open={!!editHook}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditHook(null);
|
||||
}}
|
||||
hook={editHook ?? undefined}
|
||||
spec={connectSpec_ ?? undefined}
|
||||
onSuccess={handleHookSuccess}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
65
web/src/refresh-pages/admin/HooksPage/interfaces.ts
Normal file
65
web/src/refresh-pages/admin/HooksPage/interfaces.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
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;
|
||||
/** Partially-masked API key (e.g. "abcd••••••••wxyz"), or null if no key is set. */
|
||||
api_key_masked: 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;
|
||||
}
|
||||
81
web/src/refresh-pages/admin/HooksPage/svc.ts
Normal file
81
web/src/refresh-pages/admin/HooksPage/svc.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
HookCreateRequest,
|
||||
HookResponse,
|
||||
HookUpdateRequest,
|
||||
} from "@/refresh-pages/admin/HooksPage/interfaces";
|
||||
|
||||
async function parseErrorDetail(
|
||||
res: Response,
|
||||
fallback: string
|
||||
): Promise<string> {
|
||||
try {
|
||||
const body = await res.json();
|
||||
return body?.detail ?? fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listHooks(): Promise<HookResponse[]> {
|
||||
const res = await fetch("/api/admin/hooks");
|
||||
if (!res.ok) {
|
||||
throw new Error(await parseErrorDetail(res, "Failed to load hooks"));
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createHook(
|
||||
req: HookCreateRequest
|
||||
): Promise<HookResponse> {
|
||||
const res = await fetch("/api/admin/hooks", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(req),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(await parseErrorDetail(res, "Failed to create hook"));
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateHook(
|
||||
id: number,
|
||||
req: HookUpdateRequest
|
||||
): Promise<HookResponse> {
|
||||
const res = await fetch(`/api/admin/hooks/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(req),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(await parseErrorDetail(res, "Failed to update hook"));
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteHook(id: number): Promise<void> {
|
||||
const res = await fetch(`/api/admin/hooks/${id}`, { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
throw new Error(await parseErrorDetail(res, "Failed to delete hook"));
|
||||
}
|
||||
}
|
||||
|
||||
export async function activateHook(id: number): Promise<HookResponse> {
|
||||
const res = await fetch(`/api/admin/hooks/${id}/activate`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(await parseErrorDetail(res, "Failed to activate hook"));
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deactivateHook(id: number): Promise<HookResponse> {
|
||||
const res = await fetch(`/api/admin/hooks/${id}/deactivate`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(await parseErrorDetail(res, "Failed to deactivate hook"));
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
@@ -175,7 +175,7 @@ export function FileCard({
|
||||
>
|
||||
<div className="min-w-0 max-w-[12rem]">
|
||||
<Interactive.Container border heightVariant="fit">
|
||||
<div className="[&_.opal-content-md-body]:min-w-0 [&_.opal-content-md-title]:break-all">
|
||||
<div className="[&_.opal-content-md-title-row]:min-w-0 [&_.opal-content-md-title]:break-all">
|
||||
<AttachmentItemLayout
|
||||
icon={isProcessing ? SimpleLoader : SvgFileText}
|
||||
title={file.name}
|
||||
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user