mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-03-12 03:02:43 +00:00
Compare commits
19 Commits
jamison/Co
...
nikg/fix-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7001e92a3b | ||
|
|
f01f210af8 | ||
|
|
781219cf18 | ||
|
|
ca39da7de9 | ||
|
|
abf76cd747 | ||
|
|
a78607f1b5 | ||
|
|
e213853f63 | ||
|
|
8dc379c6fd | ||
|
|
787f117e17 | ||
|
|
665640fac8 | ||
|
|
d2d44c1e68 | ||
|
|
ffe04ab91f | ||
|
|
6499b21235 | ||
|
|
c5bfd5a152 | ||
|
|
a0329161b0 | ||
|
|
334b7a6d2f | ||
|
|
36196373a8 | ||
|
|
533aa8eff8 | ||
|
|
ecbb267f80 |
@@ -0,0 +1,43 @@
|
||||
"""add timestamps to user table
|
||||
|
||||
Revision ID: 27fb147a843f
|
||||
Revises: b5c4d7e8f9a1
|
||||
Create Date: 2026-03-08 17:18:40.828644
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "27fb147a843f"
|
||||
down_revision = "b5c4d7e8f9a1"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"user",
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"user",
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("user", "updated_at")
|
||||
op.drop_column("user", "created_at")
|
||||
@@ -339,6 +339,16 @@ class User(SQLAlchemyBaseUserTableUUID, Base):
|
||||
TIMESTAMPAware(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
default_model: Mapped[str] = mapped_column(Text, nullable=True)
|
||||
# organized in typical structured fashion
|
||||
# formatted as `displayName__provider__modelName`
|
||||
|
||||
@@ -24,6 +24,7 @@ from onyx.db.models import Persona__User
|
||||
from onyx.db.models import SamlAccount
|
||||
from onyx.db.models import User
|
||||
from onyx.db.models import User__UserGroup
|
||||
from onyx.db.models import UserGroup
|
||||
from onyx.utils.variable_functionality import fetch_ee_implementation_or_noop
|
||||
|
||||
|
||||
@@ -173,6 +174,21 @@ def _get_accepted_user_where_clause(
|
||||
return where_clause
|
||||
|
||||
|
||||
def get_all_accepted_users(
|
||||
db_session: Session,
|
||||
include_external: bool = False,
|
||||
) -> Sequence[User]:
|
||||
"""Returns all accepted users without pagination.
|
||||
Uses the same filtering as the paginated endpoint but without
|
||||
search, role, or active filters."""
|
||||
stmt = select(User)
|
||||
where_clause = _get_accepted_user_where_clause(
|
||||
include_external=include_external,
|
||||
)
|
||||
stmt = stmt.where(*where_clause).order_by(User.email)
|
||||
return db_session.scalars(stmt).unique().all()
|
||||
|
||||
|
||||
def get_page_of_filtered_users(
|
||||
db_session: Session,
|
||||
page_size: int,
|
||||
@@ -358,3 +374,28 @@ def delete_user_from_db(
|
||||
# NOTE: edge case may exist with race conditions
|
||||
# with this `invited user` scheme generally.
|
||||
remove_user_from_invited_users(user_to_delete.email)
|
||||
|
||||
|
||||
def batch_get_user_groups(
|
||||
db_session: Session,
|
||||
user_ids: list[UUID],
|
||||
) -> dict[UUID, list[tuple[int, str]]]:
|
||||
"""Fetch group memberships for a batch of users in a single query.
|
||||
Returns a mapping of user_id -> list of (group_id, group_name) tuples."""
|
||||
if not user_ids:
|
||||
return {}
|
||||
|
||||
rows = db_session.execute(
|
||||
select(
|
||||
User__UserGroup.user_id,
|
||||
UserGroup.id,
|
||||
UserGroup.name,
|
||||
)
|
||||
.join(UserGroup, UserGroup.id == User__UserGroup.user_group_id)
|
||||
.where(User__UserGroup.user_id.in_(user_ids))
|
||||
).all()
|
||||
|
||||
result: dict[UUID, list[tuple[int, str]]] = {uid: [] for uid in user_ids}
|
||||
for user_id, group_id, group_name in rows:
|
||||
result[user_id].append((group_id, group_name))
|
||||
return result
|
||||
|
||||
@@ -123,15 +123,11 @@ class DocumentIndexingBatchAdapter:
|
||||
}
|
||||
|
||||
doc_id_to_new_chunk_cnt: dict[str, int] = {
|
||||
document_id: len(
|
||||
[
|
||||
chunk
|
||||
for chunk in chunks_with_embeddings
|
||||
if chunk.source_document.id == document_id
|
||||
]
|
||||
)
|
||||
for document_id in updatable_ids
|
||||
doc_id: 0 for doc_id in updatable_ids
|
||||
}
|
||||
for chunk in chunks_with_embeddings:
|
||||
if chunk.source_document.id in doc_id_to_new_chunk_cnt:
|
||||
doc_id_to_new_chunk_cnt[chunk.source_document.id] += 1
|
||||
|
||||
# Get ancestor hierarchy node IDs for each document
|
||||
doc_id_to_ancestor_ids = self._get_ancestor_ids_for_documents(
|
||||
|
||||
@@ -16,6 +16,7 @@ from onyx.indexing.models import DocAwareChunk
|
||||
from onyx.indexing.models import IndexChunk
|
||||
from onyx.natural_language_processing.search_nlp_models import EmbeddingModel
|
||||
from onyx.utils.logger import setup_logger
|
||||
from onyx.utils.pydantic_util import shallow_model_dump
|
||||
from onyx.utils.timing import log_function_time
|
||||
from shared_configs.configs import INDEXING_MODEL_SERVER_HOST
|
||||
from shared_configs.configs import INDEXING_MODEL_SERVER_PORT
|
||||
@@ -210,8 +211,8 @@ class DefaultIndexingEmbedder(IndexingEmbedder):
|
||||
)[0]
|
||||
title_embed_dict[title] = title_embedding
|
||||
|
||||
new_embedded_chunk = IndexChunk(
|
||||
**chunk.model_dump(),
|
||||
new_embedded_chunk = IndexChunk.model_construct(
|
||||
**shallow_model_dump(chunk),
|
||||
embeddings=ChunkEmbedding(
|
||||
full_embedding=chunk_embeddings[0],
|
||||
mini_chunk_embeddings=chunk_embeddings[1:],
|
||||
|
||||
@@ -12,6 +12,7 @@ from onyx.connectors.models import Document
|
||||
from onyx.db.enums import EmbeddingPrecision
|
||||
from onyx.db.enums import SwitchoverType
|
||||
from onyx.utils.logger import setup_logger
|
||||
from onyx.utils.pydantic_util import shallow_model_dump
|
||||
from shared_configs.enums import EmbeddingProvider
|
||||
from shared_configs.model_server_models import Embedding
|
||||
|
||||
@@ -133,9 +134,8 @@ class DocMetadataAwareIndexChunk(IndexChunk):
|
||||
tenant_id: str,
|
||||
ancestor_hierarchy_node_ids: list[int] | None = None,
|
||||
) -> "DocMetadataAwareIndexChunk":
|
||||
index_chunk_data = index_chunk.model_dump()
|
||||
return cls(
|
||||
**index_chunk_data,
|
||||
return cls.model_construct(
|
||||
**shallow_model_dump(index_chunk),
|
||||
access=access,
|
||||
document_sets=document_sets,
|
||||
user_project=user_project,
|
||||
|
||||
@@ -43,6 +43,7 @@ WELL_KNOWN_PROVIDER_NAMES = [
|
||||
LlmProviderNames.AZURE,
|
||||
LlmProviderNames.OLLAMA_CHAT,
|
||||
LlmProviderNames.LM_STUDIO,
|
||||
LlmProviderNames.LITELLM_PROXY,
|
||||
]
|
||||
|
||||
|
||||
@@ -59,6 +60,7 @@ PROVIDER_DISPLAY_NAMES: dict[str, str] = {
|
||||
"ollama": "Ollama",
|
||||
LlmProviderNames.OLLAMA_CHAT: "Ollama",
|
||||
LlmProviderNames.LM_STUDIO: "LM Studio",
|
||||
LlmProviderNames.LITELLM_PROXY: "LiteLLM Proxy",
|
||||
"groq": "Groq",
|
||||
"anyscale": "Anyscale",
|
||||
"deepseek": "DeepSeek",
|
||||
@@ -109,6 +111,7 @@ AGGREGATOR_PROVIDERS: set[str] = {
|
||||
LlmProviderNames.LM_STUDIO,
|
||||
LlmProviderNames.VERTEX_AI,
|
||||
LlmProviderNames.AZURE,
|
||||
LlmProviderNames.LITELLM_PROXY,
|
||||
}
|
||||
|
||||
# Model family name mappings for display name generation
|
||||
|
||||
@@ -3782,16 +3782,6 @@
|
||||
"display_name": "Claude Sonnet 3.5",
|
||||
"model_vendor": "anthropic"
|
||||
},
|
||||
"vertex_ai/claude-3-5-sonnet-v2": {
|
||||
"display_name": "Claude Sonnet 3.5",
|
||||
"model_vendor": "anthropic",
|
||||
"model_version": "v2"
|
||||
},
|
||||
"vertex_ai/claude-3-5-sonnet-v2@20241022": {
|
||||
"display_name": "Claude Sonnet 3.5 v2",
|
||||
"model_vendor": "anthropic",
|
||||
"model_version": "20241022"
|
||||
},
|
||||
"vertex_ai/claude-3-5-sonnet@20240620": {
|
||||
"display_name": "Claude Sonnet 3.5",
|
||||
"model_vendor": "anthropic",
|
||||
|
||||
@@ -11,6 +11,8 @@ OLLAMA_API_KEY_CONFIG_KEY = "OLLAMA_API_KEY"
|
||||
LM_STUDIO_PROVIDER_NAME = "lm_studio"
|
||||
LM_STUDIO_API_KEY_CONFIG_KEY = "LM_STUDIO_API_KEY"
|
||||
|
||||
LITELLM_PROXY_PROVIDER_NAME = "litellm_proxy"
|
||||
|
||||
# Providers that use optional Bearer auth from custom_config
|
||||
PROVIDERS_WITH_SPECIAL_API_KEY_HANDLING: dict[str, str] = {
|
||||
LlmProviderNames.OLLAMA_CHAT: OLLAMA_API_KEY_CONFIG_KEY,
|
||||
|
||||
@@ -15,6 +15,7 @@ from onyx.llm.well_known_providers.auto_update_service import (
|
||||
from onyx.llm.well_known_providers.constants import ANTHROPIC_PROVIDER_NAME
|
||||
from onyx.llm.well_known_providers.constants import AZURE_PROVIDER_NAME
|
||||
from onyx.llm.well_known_providers.constants import BEDROCK_PROVIDER_NAME
|
||||
from onyx.llm.well_known_providers.constants import LITELLM_PROXY_PROVIDER_NAME
|
||||
from onyx.llm.well_known_providers.constants import LM_STUDIO_PROVIDER_NAME
|
||||
from onyx.llm.well_known_providers.constants import OLLAMA_PROVIDER_NAME
|
||||
from onyx.llm.well_known_providers.constants import OPENAI_PROVIDER_NAME
|
||||
@@ -47,6 +48,7 @@ def _get_provider_to_models_map() -> dict[str, list[str]]:
|
||||
OLLAMA_PROVIDER_NAME: [], # Dynamic - fetched from Ollama API
|
||||
LM_STUDIO_PROVIDER_NAME: [], # Dynamic - fetched from LM Studio API
|
||||
OPENROUTER_PROVIDER_NAME: [], # Dynamic - fetched from OpenRouter API
|
||||
LITELLM_PROXY_PROVIDER_NAME: [], # Dynamic - fetched from LiteLLM proxy API
|
||||
}
|
||||
|
||||
|
||||
@@ -331,6 +333,7 @@ def get_provider_display_name(provider_name: str) -> str:
|
||||
BEDROCK_PROVIDER_NAME: "Amazon Bedrock",
|
||||
VERTEXAI_PROVIDER_NAME: "Google Vertex AI",
|
||||
OPENROUTER_PROVIDER_NAME: "OpenRouter",
|
||||
LITELLM_PROXY_PROVIDER_NAME: "LiteLLM Proxy",
|
||||
}
|
||||
|
||||
if provider_name in _ONYX_PROVIDER_DISPLAY_NAMES:
|
||||
|
||||
@@ -92,8 +92,17 @@ def _split_text(text: str, limit: int = 3000) -> list[str]:
|
||||
split_at = limit
|
||||
|
||||
chunk = text[:split_at]
|
||||
|
||||
# If splitting inside an unclosed code fence, close it in this chunk
|
||||
# and reopen it in the next chunk so Slack renders both correctly.
|
||||
open_fences = chunk.count("```")
|
||||
if open_fences % 2 == 1:
|
||||
chunk += "\n```"
|
||||
text = "```\n" + text[split_at:].lstrip()
|
||||
else:
|
||||
text = text[split_at:].lstrip()
|
||||
|
||||
chunks.append(chunk)
|
||||
text = text[split_at:].lstrip() # Remove leading spaces from the next chunk
|
||||
|
||||
return chunks
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import re
|
||||
from enum import Enum
|
||||
|
||||
# Matches Slack channel references like <#C097NBWMY8Y> or <#C097NBWMY8Y|channel-name>
|
||||
SLACK_CHANNEL_REF_PATTERN = re.compile(r"<#([A-Z0-9]+)(?:\|([^>]+))?>")
|
||||
|
||||
LIKE_BLOCK_ACTION_ID = "feedback-like"
|
||||
DISLIKE_BLOCK_ACTION_ID = "feedback-dislike"
|
||||
SHOW_EVERYONE_ACTION_ID = "show-everyone"
|
||||
|
||||
@@ -18,15 +18,18 @@ from onyx.configs.onyxbot_configs import ONYX_BOT_DISPLAY_ERROR_MSGS
|
||||
from onyx.configs.onyxbot_configs import ONYX_BOT_NUM_RETRIES
|
||||
from onyx.configs.onyxbot_configs import ONYX_BOT_REACT_EMOJI
|
||||
from onyx.context.search.models import BaseFilters
|
||||
from onyx.context.search.models import Tag
|
||||
from onyx.db.engine.sql_engine import get_session_with_current_tenant
|
||||
from onyx.db.models import SlackChannelConfig
|
||||
from onyx.db.models import User
|
||||
from onyx.db.persona import get_persona_by_id
|
||||
from onyx.db.users import get_user_by_email
|
||||
from onyx.onyxbot.slack.blocks import build_slack_response_blocks
|
||||
from onyx.onyxbot.slack.constants import SLACK_CHANNEL_REF_PATTERN
|
||||
from onyx.onyxbot.slack.handlers.utils import send_team_member_message
|
||||
from onyx.onyxbot.slack.models import SlackMessageInfo
|
||||
from onyx.onyxbot.slack.models import ThreadMessage
|
||||
from onyx.onyxbot.slack.utils import get_channel_from_id
|
||||
from onyx.onyxbot.slack.utils import get_channel_name_from_id
|
||||
from onyx.onyxbot.slack.utils import respond_in_thread_or_channel
|
||||
from onyx.onyxbot.slack.utils import SlackRateLimiter
|
||||
@@ -41,6 +44,51 @@ srl = SlackRateLimiter()
|
||||
RT = TypeVar("RT") # return type
|
||||
|
||||
|
||||
def resolve_channel_references(
|
||||
message: str,
|
||||
client: WebClient,
|
||||
logger: OnyxLoggingAdapter,
|
||||
) -> tuple[str, list[Tag]]:
|
||||
"""Parse Slack channel references from a message, resolve IDs to names,
|
||||
replace the raw markup with readable #channel-name, and return channel tags
|
||||
for search filtering."""
|
||||
tags: list[Tag] = []
|
||||
channel_matches = SLACK_CHANNEL_REF_PATTERN.findall(message)
|
||||
seen_channel_ids: set[str] = set()
|
||||
|
||||
for channel_id, channel_name_from_markup in channel_matches:
|
||||
if channel_id in seen_channel_ids:
|
||||
continue
|
||||
seen_channel_ids.add(channel_id)
|
||||
|
||||
channel_name = channel_name_from_markup or None
|
||||
|
||||
if not channel_name:
|
||||
try:
|
||||
channel_info = get_channel_from_id(client=client, channel_id=channel_id)
|
||||
channel_name = channel_info.get("name") or None
|
||||
except Exception:
|
||||
logger.warning(f"Failed to resolve channel name for ID: {channel_id}")
|
||||
|
||||
if not channel_name:
|
||||
continue
|
||||
|
||||
# Replace raw Slack markup with readable channel name
|
||||
if channel_name_from_markup:
|
||||
message = message.replace(
|
||||
f"<#{channel_id}|{channel_name_from_markup}>",
|
||||
f"#{channel_name}",
|
||||
)
|
||||
else:
|
||||
message = message.replace(
|
||||
f"<#{channel_id}>",
|
||||
f"#{channel_name}",
|
||||
)
|
||||
tags.append(Tag(tag_key="Channel", tag_value=channel_name))
|
||||
|
||||
return message, tags
|
||||
|
||||
|
||||
def rate_limits(
|
||||
client: WebClient, channel: str, thread_ts: Optional[str]
|
||||
) -> Callable[[Callable[..., RT]], Callable[..., RT]]:
|
||||
@@ -157,6 +205,20 @@ def handle_regular_answer(
|
||||
user_message = messages[-1]
|
||||
history_messages = messages[:-1]
|
||||
|
||||
# Resolve any <#CHANNEL_ID> references in the user message to readable
|
||||
# channel names and extract channel tags for search filtering
|
||||
resolved_message, channel_tags = resolve_channel_references(
|
||||
message=user_message.message,
|
||||
client=client,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
user_message = ThreadMessage(
|
||||
message=resolved_message,
|
||||
sender=user_message.sender,
|
||||
role=user_message.role,
|
||||
)
|
||||
|
||||
channel_name, _ = get_channel_name_from_id(
|
||||
client=client,
|
||||
channel_id=channel,
|
||||
@@ -207,6 +269,7 @@ def handle_regular_answer(
|
||||
source_type=None,
|
||||
document_set=document_set_names,
|
||||
time_cutoff=None,
|
||||
tags=channel_tags if channel_tags else None,
|
||||
)
|
||||
|
||||
new_message_request = SendMessageRequest(
|
||||
@@ -231,6 +294,16 @@ def handle_regular_answer(
|
||||
slack_context_str=slack_context_str,
|
||||
)
|
||||
|
||||
# If a channel filter was applied but no results were found, override
|
||||
# the LLM response to avoid hallucinated answers about unindexed channels
|
||||
if channel_tags and not answer.citation_info and not answer.top_documents:
|
||||
channel_names = ", ".join(f"#{tag.tag_value}" for tag in channel_tags)
|
||||
answer.answer = (
|
||||
f"No indexed data found for {channel_names}. "
|
||||
"This channel may not be indexed, or there may be no messages "
|
||||
"matching your query within it."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"Unable to process message - did not successfully answer "
|
||||
@@ -285,6 +358,7 @@ def handle_regular_answer(
|
||||
only_respond_if_citations
|
||||
and not answer.citation_info
|
||||
and not message_info.bypass_filters
|
||||
and not channel_tags
|
||||
):
|
||||
logger.error(
|
||||
f"Unable to find citations to answer: '{answer.answer}' - not answering!"
|
||||
|
||||
@@ -732,7 +732,7 @@ def get_webapp_info(
|
||||
return WebappInfo(**webapp_info)
|
||||
|
||||
|
||||
@router.get("/{session_id}/webapp/download")
|
||||
@router.get("/{session_id}/webapp-download")
|
||||
def download_webapp(
|
||||
session_id: UUID,
|
||||
user: User = Depends(current_user),
|
||||
|
||||
@@ -7424,9 +7424,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.5",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.5.tgz",
|
||||
"integrity": "sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==",
|
||||
"version": "4.12.7",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz",
|
||||
"integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
|
||||
@@ -67,7 +67,9 @@ from onyx.db.user_preferences import update_user_role
|
||||
from onyx.db.user_preferences import update_user_shortcut_enabled
|
||||
from onyx.db.user_preferences import update_user_temperature_override_enabled
|
||||
from onyx.db.user_preferences import update_user_theme_preference
|
||||
from onyx.db.users import batch_get_user_groups
|
||||
from onyx.db.users import delete_user_from_db
|
||||
from onyx.db.users import get_all_accepted_users
|
||||
from onyx.db.users import get_all_users
|
||||
from onyx.db.users import get_page_of_filtered_users
|
||||
from onyx.db.users import get_total_filtered_users_count
|
||||
@@ -98,6 +100,7 @@ from onyx.server.manage.models import UserSpecificAssistantPreferences
|
||||
from onyx.server.models import FullUserSnapshot
|
||||
from onyx.server.models import InvitedUserSnapshot
|
||||
from onyx.server.models import MinimalUserSnapshot
|
||||
from onyx.server.models import UserGroupInfo
|
||||
from onyx.server.usage_limits import is_tenant_on_trial_fn
|
||||
from onyx.server.utils import BasicAuthenticationError
|
||||
from onyx.utils.logger import setup_logger
|
||||
@@ -203,14 +206,51 @@ def list_accepted_users(
|
||||
total_items=0,
|
||||
)
|
||||
|
||||
user_ids = [user.id for user in filtered_accepted_users]
|
||||
groups_by_user = batch_get_user_groups(db_session, user_ids)
|
||||
|
||||
return PaginatedReturn(
|
||||
items=[
|
||||
FullUserSnapshot.from_user_model(user) for user in filtered_accepted_users
|
||||
FullUserSnapshot.from_user_model(
|
||||
user,
|
||||
groups=[
|
||||
UserGroupInfo(id=gid, name=gname)
|
||||
for gid, gname in groups_by_user.get(user.id, [])
|
||||
],
|
||||
)
|
||||
for user in filtered_accepted_users
|
||||
],
|
||||
total_items=total_accepted_users_count,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/manage/users/accepted/all", tags=PUBLIC_API_TAGS)
|
||||
def list_all_accepted_users(
|
||||
_: User = Depends(current_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> list[FullUserSnapshot]:
|
||||
"""Returns all accepted users without pagination.
|
||||
Used by the admin Users page for client-side filtering/sorting."""
|
||||
users = get_all_accepted_users(db_session=db_session)
|
||||
|
||||
if not users:
|
||||
return []
|
||||
|
||||
user_ids = [user.id for user in users]
|
||||
groups_by_user = batch_get_user_groups(db_session, user_ids)
|
||||
|
||||
return [
|
||||
FullUserSnapshot.from_user_model(
|
||||
user,
|
||||
groups=[
|
||||
UserGroupInfo(id=gid, name=gname)
|
||||
for gid, gname in groups_by_user.get(user.id, [])
|
||||
],
|
||||
)
|
||||
for user in users
|
||||
]
|
||||
|
||||
|
||||
@router.get("/manage/users/invited", tags=PUBLIC_API_TAGS)
|
||||
def list_invited_users(
|
||||
_: User = Depends(current_admin_user),
|
||||
@@ -269,24 +309,10 @@ def list_all_users(
|
||||
if accepted_page is None or invited_page is None or slack_users_page is None:
|
||||
return AllUsersResponse(
|
||||
accepted=[
|
||||
FullUserSnapshot(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
password_configured=user.password_configured,
|
||||
)
|
||||
for user in accepted_users
|
||||
FullUserSnapshot.from_user_model(user) for user in accepted_users
|
||||
],
|
||||
slack_users=[
|
||||
FullUserSnapshot(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
password_configured=user.password_configured,
|
||||
)
|
||||
for user in slack_users
|
||||
FullUserSnapshot.from_user_model(user) for user in slack_users
|
||||
],
|
||||
invited=[InvitedUserSnapshot(email=email) for email in invited_emails],
|
||||
accepted_pages=1,
|
||||
@@ -296,26 +322,10 @@ def list_all_users(
|
||||
|
||||
# Otherwise, return paginated results
|
||||
return AllUsersResponse(
|
||||
accepted=[
|
||||
FullUserSnapshot(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
password_configured=user.password_configured,
|
||||
)
|
||||
for user in accepted_users
|
||||
][accepted_page * USERS_PAGE_SIZE : (accepted_page + 1) * USERS_PAGE_SIZE],
|
||||
slack_users=[
|
||||
FullUserSnapshot(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
password_configured=user.password_configured,
|
||||
)
|
||||
for user in slack_users
|
||||
][
|
||||
accepted=[FullUserSnapshot.from_user_model(user) for user in accepted_users][
|
||||
accepted_page * USERS_PAGE_SIZE : (accepted_page + 1) * USERS_PAGE_SIZE
|
||||
],
|
||||
slack_users=[FullUserSnapshot.from_user_model(user) for user in slack_users][
|
||||
slack_users_page
|
||||
* USERS_PAGE_SIZE : (slack_users_page + 1)
|
||||
* USERS_PAGE_SIZE
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import datetime
|
||||
from typing import Generic
|
||||
from typing import Optional
|
||||
from typing import TypeVar
|
||||
@@ -31,21 +32,38 @@ class MinimalUserSnapshot(BaseModel):
|
||||
email: str
|
||||
|
||||
|
||||
class UserGroupInfo(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
|
||||
|
||||
class FullUserSnapshot(BaseModel):
|
||||
id: UUID
|
||||
email: str
|
||||
role: UserRole
|
||||
is_active: bool
|
||||
password_configured: bool
|
||||
personal_name: str | None
|
||||
created_at: datetime.datetime
|
||||
updated_at: datetime.datetime
|
||||
groups: list[UserGroupInfo]
|
||||
|
||||
@classmethod
|
||||
def from_user_model(cls, user: User) -> "FullUserSnapshot":
|
||||
def from_user_model(
|
||||
cls,
|
||||
user: User,
|
||||
groups: list[UserGroupInfo] | None = None,
|
||||
) -> "FullUserSnapshot":
|
||||
return cls(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
password_configured=user.password_configured,
|
||||
personal_name=user.personal_name,
|
||||
created_at=user.created_at,
|
||||
updated_at=user.updated_at,
|
||||
groups=groups or [],
|
||||
)
|
||||
|
||||
|
||||
|
||||
13
backend/onyx/utils/pydantic_util.py
Normal file
13
backend/onyx/utils/pydantic_util.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
def shallow_model_dump(model_instance: BaseModel) -> dict[str, Any]:
|
||||
"""Like model_dump(), but returns references to field values instead of
|
||||
deep copies. Use with model_construct() to avoid unnecessary memory
|
||||
duplication when building subclass instances."""
|
||||
return {
|
||||
field_name: getattr(model_instance, field_name)
|
||||
for field_name in model_instance.__class__.model_fields
|
||||
}
|
||||
@@ -750,7 +750,7 @@ pypandoc-binary==1.16.2
|
||||
# via onyx
|
||||
pyparsing==3.2.5
|
||||
# via httplib2
|
||||
pypdf==6.7.5
|
||||
pypdf==6.8.0
|
||||
# via
|
||||
# onyx
|
||||
# unstructured-client
|
||||
@@ -1020,7 +1020,7 @@ toolz==1.1.0
|
||||
# dask
|
||||
# distributed
|
||||
# partd
|
||||
tornado==6.5.2
|
||||
tornado==6.5.5
|
||||
# via distributed
|
||||
tqdm==4.67.1
|
||||
# via
|
||||
|
||||
@@ -406,7 +406,7 @@ referencing==0.36.2
|
||||
# jsonschema-specifications
|
||||
regex==2025.11.3
|
||||
# via tiktoken
|
||||
release-tag==0.4.3
|
||||
release-tag==0.5.2
|
||||
# via onyx
|
||||
reorder-python-imports-black==3.14.0
|
||||
# via onyx
|
||||
@@ -466,7 +466,7 @@ tokenizers==0.21.4
|
||||
# via
|
||||
# cohere
|
||||
# litellm
|
||||
tornado==6.5.2
|
||||
tornado==6.5.5
|
||||
# via
|
||||
# ipykernel
|
||||
# jupyter-client
|
||||
|
||||
@@ -26,14 +26,6 @@ class TestIsTrueOpenAIModel:
|
||||
"""Test that real OpenAI GPT-4o-mini model is correctly identified."""
|
||||
assert is_true_openai_model(LlmProviderNames.OPENAI, "gpt-4o-mini") is True
|
||||
|
||||
def test_real_openai_o1_preview(self) -> None:
|
||||
"""Test that real OpenAI o1-preview reasoning model is correctly identified."""
|
||||
assert is_true_openai_model(LlmProviderNames.OPENAI, "o1-preview") is True
|
||||
|
||||
def test_real_openai_o1_mini(self) -> None:
|
||||
"""Test that real OpenAI o1-mini reasoning model is correctly identified."""
|
||||
assert is_true_openai_model(LlmProviderNames.OPENAI, "o1-mini") is True
|
||||
|
||||
def test_openai_with_provider_prefix(self) -> None:
|
||||
"""Test that OpenAI model with provider prefix is correctly identified."""
|
||||
assert is_true_openai_model(LlmProviderNames.OPENAI, "openai/gpt-4") is False
|
||||
|
||||
204
backend/tests/unit/onyx/onyxbot/test_handle_regular_answer.py
Normal file
204
backend/tests/unit/onyx/onyxbot/test_handle_regular_answer.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""Tests for Slack channel reference resolution and tag filtering
|
||||
in handle_regular_answer.py."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from slack_sdk.errors import SlackApiError
|
||||
|
||||
from onyx.context.search.models import Tag
|
||||
from onyx.onyxbot.slack.constants import SLACK_CHANNEL_REF_PATTERN
|
||||
from onyx.onyxbot.slack.handlers.handle_regular_answer import resolve_channel_references
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _mock_client_with_channels(
|
||||
channel_map: dict[str, str],
|
||||
) -> MagicMock:
|
||||
"""Return a mock WebClient where conversations_info resolves IDs to names."""
|
||||
client = MagicMock()
|
||||
|
||||
def _conversations_info(channel: str) -> MagicMock:
|
||||
if channel in channel_map:
|
||||
resp = MagicMock()
|
||||
resp.validate = MagicMock()
|
||||
resp.__getitem__ = lambda _self, key: {
|
||||
"channel": {
|
||||
"name": channel_map[channel],
|
||||
"is_im": False,
|
||||
"is_mpim": False,
|
||||
}
|
||||
}[key]
|
||||
return resp
|
||||
raise SlackApiError("channel_not_found", response=MagicMock())
|
||||
|
||||
client.conversations_info = _conversations_info
|
||||
return client
|
||||
|
||||
|
||||
def _mock_logger() -> MagicMock:
|
||||
return MagicMock()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SLACK_CHANNEL_REF_PATTERN regex tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSlackChannelRefPattern:
|
||||
def test_matches_bare_channel_id(self) -> None:
|
||||
matches = SLACK_CHANNEL_REF_PATTERN.findall("<#C097NBWMY8Y>")
|
||||
assert matches == [("C097NBWMY8Y", "")]
|
||||
|
||||
def test_matches_channel_id_with_name(self) -> None:
|
||||
matches = SLACK_CHANNEL_REF_PATTERN.findall("<#C097NBWMY8Y|eng-infra>")
|
||||
assert matches == [("C097NBWMY8Y", "eng-infra")]
|
||||
|
||||
def test_matches_multiple_channels(self) -> None:
|
||||
msg = "compare <#C111AAA> and <#C222BBB|general>"
|
||||
matches = SLACK_CHANNEL_REF_PATTERN.findall(msg)
|
||||
assert len(matches) == 2
|
||||
assert ("C111AAA", "") in matches
|
||||
assert ("C222BBB", "general") in matches
|
||||
|
||||
def test_no_match_on_plain_text(self) -> None:
|
||||
matches = SLACK_CHANNEL_REF_PATTERN.findall("no channels here")
|
||||
assert matches == []
|
||||
|
||||
def test_no_match_on_user_mention(self) -> None:
|
||||
matches = SLACK_CHANNEL_REF_PATTERN.findall("<@U12345>")
|
||||
assert matches == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolve_channel_references tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResolveChannelReferences:
|
||||
def test_resolves_bare_channel_id_via_api(self) -> None:
|
||||
client = _mock_client_with_channels({"C097NBWMY8Y": "eng-infra"})
|
||||
logger = _mock_logger()
|
||||
|
||||
message, tags = resolve_channel_references(
|
||||
message="summary of <#C097NBWMY8Y> this week",
|
||||
client=client,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
assert message == "summary of #eng-infra this week"
|
||||
assert len(tags) == 1
|
||||
assert tags[0] == Tag(tag_key="Channel", tag_value="eng-infra")
|
||||
|
||||
def test_uses_name_from_pipe_format_without_api_call(self) -> None:
|
||||
client = MagicMock()
|
||||
logger = _mock_logger()
|
||||
|
||||
message, tags = resolve_channel_references(
|
||||
message="check <#C097NBWMY8Y|eng-infra> for updates",
|
||||
client=client,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
assert message == "check #eng-infra for updates"
|
||||
assert tags == [Tag(tag_key="Channel", tag_value="eng-infra")]
|
||||
# Should NOT have called the API since name was in the markup
|
||||
client.conversations_info.assert_not_called()
|
||||
|
||||
def test_multiple_channels(self) -> None:
|
||||
client = _mock_client_with_channels(
|
||||
{
|
||||
"C111AAA": "eng-infra",
|
||||
"C222BBB": "eng-general",
|
||||
}
|
||||
)
|
||||
logger = _mock_logger()
|
||||
|
||||
message, tags = resolve_channel_references(
|
||||
message="compare <#C111AAA> and <#C222BBB>",
|
||||
client=client,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
assert "#eng-infra" in message
|
||||
assert "#eng-general" in message
|
||||
assert "<#" not in message
|
||||
assert len(tags) == 2
|
||||
tag_values = {t.tag_value for t in tags}
|
||||
assert tag_values == {"eng-infra", "eng-general"}
|
||||
|
||||
def test_no_channel_references_returns_unchanged(self) -> None:
|
||||
client = MagicMock()
|
||||
logger = _mock_logger()
|
||||
|
||||
message, tags = resolve_channel_references(
|
||||
message="just a normal message with no channels",
|
||||
client=client,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
assert message == "just a normal message with no channels"
|
||||
assert tags == []
|
||||
|
||||
def test_api_failure_skips_channel_gracefully(self) -> None:
|
||||
# Client that fails for all channel lookups
|
||||
client = _mock_client_with_channels({})
|
||||
logger = _mock_logger()
|
||||
|
||||
message, tags = resolve_channel_references(
|
||||
message="check <#CBADID123>",
|
||||
client=client,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
# Message should remain unchanged for the failed channel
|
||||
assert "<#CBADID123>" in message
|
||||
assert tags == []
|
||||
logger.warning.assert_called_once()
|
||||
|
||||
def test_partial_failure_resolves_what_it_can(self) -> None:
|
||||
# Only one of two channels resolves
|
||||
client = _mock_client_with_channels({"C111AAA": "eng-infra"})
|
||||
logger = _mock_logger()
|
||||
|
||||
message, tags = resolve_channel_references(
|
||||
message="compare <#C111AAA> and <#CBADID123>",
|
||||
client=client,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
assert "#eng-infra" in message
|
||||
assert "<#CBADID123>" in message # failed one stays raw
|
||||
assert len(tags) == 1
|
||||
assert tags[0].tag_value == "eng-infra"
|
||||
|
||||
def test_duplicate_channel_produces_single_tag(self) -> None:
|
||||
client = _mock_client_with_channels({"C111AAA": "eng-infra"})
|
||||
logger = _mock_logger()
|
||||
|
||||
message, tags = resolve_channel_references(
|
||||
message="summarize <#C111AAA> and compare with <#C111AAA>",
|
||||
client=client,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
assert message == "summarize #eng-infra and compare with #eng-infra"
|
||||
assert len(tags) == 1
|
||||
assert tags[0].tag_value == "eng-infra"
|
||||
|
||||
def test_mixed_pipe_and_bare_formats(self) -> None:
|
||||
client = _mock_client_with_channels({"C222BBB": "random"})
|
||||
logger = _mock_logger()
|
||||
|
||||
message, tags = resolve_channel_references(
|
||||
message="see <#C111AAA|eng-infra> and <#C222BBB>",
|
||||
client=client,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
assert "#eng-infra" in message
|
||||
assert "#random" in message
|
||||
assert len(tags) == 2
|
||||
@@ -7,6 +7,7 @@ import timeago # type: ignore
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.context.search.models import SavedSearchDoc
|
||||
from onyx.onyxbot.slack.blocks import _build_documents_blocks
|
||||
from onyx.onyxbot.slack.blocks import _split_text
|
||||
|
||||
|
||||
def _make_saved_doc(updated_at: datetime | None) -> SavedSearchDoc:
|
||||
@@ -69,3 +70,50 @@ def test_build_documents_blocks_formats_naive_timestamp(
|
||||
formatted_timestamp: datetime = captured["doc"]
|
||||
expected_timestamp: datetime = naive_timestamp.replace(tzinfo=pytz.utc)
|
||||
assert formatted_timestamp == expected_timestamp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _split_text tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSplitText:
|
||||
def test_short_text_returns_single_chunk(self) -> None:
|
||||
result = _split_text("hello world", limit=100)
|
||||
assert result == ["hello world"]
|
||||
|
||||
def test_splits_at_space_boundary(self) -> None:
|
||||
text = "aaa bbb ccc ddd"
|
||||
result = _split_text(text, limit=8)
|
||||
assert len(result) >= 2
|
||||
# No chunk should exceed the limit (plus possible closing fence)
|
||||
for chunk in result:
|
||||
assert "aaa bbb ccc ddd" != chunk or len(text) <= 8
|
||||
|
||||
def test_code_block_not_split_when_fits(self) -> None:
|
||||
text = "before ```code here``` after"
|
||||
result = _split_text(text, limit=100)
|
||||
assert result == [text]
|
||||
|
||||
def test_code_block_closed_and_reopened_on_split(self) -> None:
|
||||
# Build text where a code block straddles the split point
|
||||
before = "intro text "
|
||||
code_content = "x " * 50 # ~100 chars of code
|
||||
text = f"{before}```\n{code_content}\n```\nafter"
|
||||
result = _split_text(text, limit=80)
|
||||
|
||||
assert len(result) >= 2
|
||||
# Every chunk must have balanced code fences
|
||||
for chunk in result:
|
||||
fence_count = chunk.count("```")
|
||||
assert (
|
||||
fence_count % 2 == 0
|
||||
), f"Unbalanced code fences in chunk: {chunk[:80]}..."
|
||||
|
||||
def test_no_code_fences_splits_normally(self) -> None:
|
||||
text = "word " * 100 # 500 chars
|
||||
result = _split_text(text, limit=100)
|
||||
assert len(result) >= 5
|
||||
for chunk in result:
|
||||
fence_count = chunk.count("```")
|
||||
assert fence_count == 0
|
||||
|
||||
54
backend/tests/unit/onyx/server/test_full_user_snapshot.py
Normal file
54
backend/tests/unit/onyx/server/test_full_user_snapshot.py
Normal file
@@ -0,0 +1,54 @@
|
||||
import datetime
|
||||
from unittest.mock import MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
from onyx.auth.schemas import UserRole
|
||||
from onyx.server.models import FullUserSnapshot
|
||||
from onyx.server.models import UserGroupInfo
|
||||
|
||||
|
||||
def _mock_user(
|
||||
personal_name: str | None = "Test User",
|
||||
created_at: datetime.datetime | None = None,
|
||||
updated_at: datetime.datetime | None = None,
|
||||
) -> MagicMock:
|
||||
user = MagicMock()
|
||||
user.id = uuid4()
|
||||
user.email = "test@example.com"
|
||||
user.role = UserRole.BASIC
|
||||
user.is_active = True
|
||||
user.password_configured = True
|
||||
user.personal_name = personal_name
|
||||
user.created_at = created_at or datetime.datetime(
|
||||
2025, 1, 1, tzinfo=datetime.timezone.utc
|
||||
)
|
||||
user.updated_at = updated_at or datetime.datetime(
|
||||
2025, 6, 15, tzinfo=datetime.timezone.utc
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def test_from_user_model_includes_new_fields() -> None:
|
||||
user = _mock_user(personal_name="Alice")
|
||||
groups = [UserGroupInfo(id=1, name="Engineering")]
|
||||
|
||||
snapshot = FullUserSnapshot.from_user_model(user, groups=groups)
|
||||
|
||||
assert snapshot.personal_name == "Alice"
|
||||
assert snapshot.created_at == user.created_at
|
||||
assert snapshot.updated_at == user.updated_at
|
||||
assert snapshot.groups == groups
|
||||
|
||||
|
||||
def test_from_user_model_defaults_groups_to_empty() -> None:
|
||||
user = _mock_user()
|
||||
snapshot = FullUserSnapshot.from_user_model(user)
|
||||
|
||||
assert snapshot.groups == []
|
||||
|
||||
|
||||
def test_from_user_model_personal_name_none() -> None:
|
||||
user = _mock_user(personal_name=None)
|
||||
snapshot = FullUserSnapshot.from_user_model(user)
|
||||
|
||||
assert snapshot.personal_name is None
|
||||
@@ -38,6 +38,11 @@ services:
|
||||
opensearch:
|
||||
ports:
|
||||
- "9200:9200"
|
||||
# Rootless Docker can reject the base OpenSearch ulimit settings, so clear
|
||||
# the inherited block entirely in the dev override.
|
||||
ulimits: !reset null
|
||||
environment:
|
||||
- bootstrap.memory_lock=false
|
||||
|
||||
inference_model_server:
|
||||
ports:
|
||||
|
||||
@@ -91,7 +91,7 @@ backend = [
|
||||
"python-gitlab==5.6.0",
|
||||
"python-pptx==0.6.23",
|
||||
"pypandoc_binary==1.16.2",
|
||||
"pypdf==6.7.5",
|
||||
"pypdf==6.8.0",
|
||||
"pytest-mock==3.12.0",
|
||||
"pytest-playwright==0.7.0",
|
||||
"python-docx==1.1.2",
|
||||
@@ -153,7 +153,7 @@ dev = [
|
||||
"pytest-repeat==0.9.4",
|
||||
"pytest-xdist==3.8.0",
|
||||
"pytest==8.3.5",
|
||||
"release-tag==0.4.3",
|
||||
"release-tag==0.5.2",
|
||||
"reorder-python-imports-black==3.14.0",
|
||||
"ruff==0.12.0",
|
||||
"types-beautifulsoup4==4.12.0.3",
|
||||
|
||||
36
tools/ods/cmd/print_latest.go
Normal file
36
tools/ods/cmd/print_latest.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/jmelahman/tag/git"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// NewLatestStableTagCommand creates the latest-stable-tag command.
|
||||
func NewLatestStableTagCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "latest-stable-tag",
|
||||
Short: "Print the git tag that should receive the 'latest' Docker tag",
|
||||
Long: `Print the highest stable (non-pre-release) semver tag in the repository.
|
||||
|
||||
This is used during deployment to decide whether a given tag should
|
||||
receive the "latest" tag on Docker Hub. Only the highest vX.Y.Z tag
|
||||
qualifies. Tags with pre-release suffixes (e.g. v1.2.3-beta,
|
||||
v1.2.3-cloud.1) are excluded.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(c *cobra.Command, _ []string) error {
|
||||
tag, err := git.GetLatestStableSemverTag("")
|
||||
if err != nil {
|
||||
return fmt.Errorf("get latest stable semver tag: %w", err)
|
||||
}
|
||||
if tag == "" {
|
||||
return fmt.Errorf("no stable semver tag found in repository")
|
||||
}
|
||||
fmt.Println(tag)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -52,6 +52,7 @@ func NewRootCommand() *cobra.Command {
|
||||
cmd.AddCommand(NewScreenshotDiffCommand())
|
||||
cmd.AddCommand(NewDesktopCommand())
|
||||
cmd.AddCommand(NewWebCommand())
|
||||
cmd.AddCommand(NewLatestStableTagCommand())
|
||||
cmd.AddCommand(NewWhoisCommand())
|
||||
|
||||
return cmd
|
||||
|
||||
@@ -3,12 +3,13 @@ module github.com/onyx-dot-app/onyx/tools/ods
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/jmelahman/tag v0.5.2
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/spf13/cobra v1.10.1
|
||||
github.com/spf13/pflag v1.0.9
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/pflag v1.0.10
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
)
|
||||
|
||||
@@ -4,20 +4,26 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jmelahman/tag v0.5.2 h1:g6A/aHehu5tkA31mPoDsXBNr1FigZ9A82Y8WVgb/WsM=
|
||||
github.com/jmelahman/tag v0.5.2/go.mod h1:qmuqk19B1BKkpcg3kn7l/Eey+UqucLxgOWkteUGiG4Q=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
|
||||
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
50
uv.lock
generated
50
uv.lock
generated
@@ -4466,7 +4466,7 @@ requires-dist = [
|
||||
{ name = "pygithub", marker = "extra == 'backend'", specifier = "==2.5.0" },
|
||||
{ name = "pympler", marker = "extra == 'backend'", specifier = "==1.1" },
|
||||
{ name = "pypandoc-binary", marker = "extra == 'backend'", specifier = "==1.16.2" },
|
||||
{ name = "pypdf", marker = "extra == 'backend'", specifier = "==6.7.5" },
|
||||
{ name = "pypdf", marker = "extra == 'backend'", specifier = "==6.8.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = "==8.3.5" },
|
||||
{ name = "pytest-alembic", marker = "extra == 'dev'", specifier = "==0.12.1" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = "==1.3.0" },
|
||||
@@ -4485,7 +4485,7 @@ requires-dist = [
|
||||
{ name = "pywikibot", marker = "extra == 'backend'", specifier = "==9.0.0" },
|
||||
{ name = "rapidfuzz", marker = "extra == 'backend'", specifier = "==3.13.0" },
|
||||
{ name = "redis", marker = "extra == 'backend'", specifier = "==5.0.8" },
|
||||
{ name = "release-tag", marker = "extra == 'dev'", specifier = "==0.4.3" },
|
||||
{ name = "release-tag", marker = "extra == 'dev'", specifier = "==0.5.2" },
|
||||
{ name = "reorder-python-imports-black", marker = "extra == 'dev'", specifier = "==3.14.0" },
|
||||
{ name = "requests", marker = "extra == 'backend'", specifier = "==2.32.5" },
|
||||
{ name = "requests-oauthlib", marker = "extra == 'backend'", specifier = "==1.3.1" },
|
||||
@@ -5713,11 +5713,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pypdf"
|
||||
version = "6.7.5"
|
||||
version = "6.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/52/37cc0aa9e9d1bf7729a737a0d83f8b3f851c8eb137373d9f71eafb0a3405/pypdf-6.7.5.tar.gz", hash = "sha256:40bb2e2e872078655f12b9b89e2f900888bb505e88a82150b64f9f34fa25651d", size = 5304278, upload-time = "2026-03-02T09:05:21.464Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b4/a3/e705b0805212b663a4c27b861c8a603dba0f8b4bb281f96f8e746576a50d/pypdf-6.8.0.tar.gz", hash = "sha256:cb7eaeaa4133ce76f762184069a854e03f4d9a08568f0e0623f7ea810407833b", size = 5307831, upload-time = "2026-03-09T13:37:40.591Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/05/89/336673efd0a88956562658aba4f0bbef7cb92a6fbcbcaf94926dbc82b408/pypdf-6.7.5-py3-none-any.whl", hash = "sha256:07ba7f1d6e6d9aa2a17f5452e320a84718d4ce863367f7ede2fd72280349ab13", size = 331421, upload-time = "2026-03-02T09:05:19.722Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/ec/4ccf3bb86b1afe5d7176e1c8abcdbf22b53dd682ec2eda50e1caadcf6846/pypdf-6.8.0-py3-none-any.whl", hash = "sha256:2a025080a8dd73f48123c89c57174a5ff3806c71763ee4e49572dc90454943c7", size = 332177, upload-time = "2026-03-09T13:37:38.774Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6338,16 +6338,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "release-tag"
|
||||
version = "0.4.3"
|
||||
version = "0.5.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/39/18/c1d17d973f73f0aa7e2c45f852839ab909756e1bd9727d03babe400fcef0/release_tag-0.4.3-py3-none-any.whl", hash = "sha256:4206f4fa97df930c8176bfee4d3976a7385150ed14b317bd6bae7101ac8b66dd", size = 1181112, upload-time = "2025-12-03T00:18:19.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/c7/ecc443953840ac313856b2181f55eb8d34fa2c733cdd1edd0bcceee0938d/release_tag-0.4.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a347a9ad3d2af16e5367e52b451fbc88a0b7b666850758e8f9a601554a8fb13", size = 1170517, upload-time = "2025-12-03T00:18:11.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/81/2f6ffa0d87c792364ca9958433fe088c8acc3d096ac9734040049c6ad506/release_tag-0.4.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2d1603aa37d8e4f5df63676bbfddc802fbc108a744ba28288ad25c997981c164", size = 1101663, upload-time = "2025-12-03T00:18:15.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/ed/9e4ebe400fc52e38dda6e6a45d9da9decd4535ab15e170b8d9b229a66730/release_tag-0.4.3-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:6db7b81a198e3ba6a87496a554684912c13f9297ea8db8600a80f4f971709d37", size = 1079322, upload-time = "2025-12-03T00:18:16.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/64/9e0ce6119e091ef9211fa82b9593f564eeec8bdd86eff6a97fe6e2fcb20f/release_tag-0.4.3-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:d79a9cf191dd2c29e1b3a35453fa364b08a7aadd15aeb2c556a7661c6cf4d5ad", size = 1181129, upload-time = "2025-12-03T00:18:15.82Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/09/d96acf18f0773b6355080a568ba48931faa9dbe91ab1abefc6f8c4df04a8/release_tag-0.4.3-py3-none-win_amd64.whl", hash = "sha256:3958b880375f2241d0cc2b9882363bf54b1d4d7ca8ffc6eecc63ab92f23307f0", size = 1260773, upload-time = "2025-12-03T00:18:14.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/da/ecb6346df1ffb0752fe213e25062f802c10df2948717f0d5f9816c2df914/release_tag-0.4.3-py3-none-win_arm64.whl", hash = "sha256:7d5b08000e6e398d46f05a50139031046348fba6d47909f01e468bb7600c19df", size = 1142155, upload-time = "2025-12-03T00:18:20.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/92/01192a540b29cfadaa23850c8f6a2041d541b83a3fa1dc52a5f55212b3b6/release_tag-0.5.2-py3-none-any.whl", hash = "sha256:1e9ca7618bcfc63ad7a0728c84bbad52ef82d07586c4cc11365b44ea8f588069", size = 1264752, upload-time = "2026-03-11T00:27:18.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/77/81fb42a23cd0de61caf84266f7aac1950b1c324883788b7c48e5344f61ae/release_tag-0.5.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8fbc61ff7bac2b96fab09566ec45c6508c201efc3f081f57702e1761bbc178d5", size = 1255075, upload-time = "2026-03-11T00:27:24.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/e6/769f8be94304529c1a531e995f2f3ac83f3c54738ce488b0abde75b20851/release_tag-0.5.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fa3d7e495a0c516858a81878d03803539712677a3d6e015503de21cce19bea5e", size = 1163627, upload-time = "2026-03-11T00:27:26.412Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/68/7543e9daa0dfd41c487bf140d91fd5879327bb7c001a96aa5264667c30a1/release_tag-0.5.2-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:e8b60453218d6926da1fdcb99c2e17c851be0d7ab1975e97951f0bff5f32b565", size = 1140133, upload-time = "2026-03-11T00:27:20.633Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/30/9087825696271012d889d136310dbdf0811976ae2b2f5a490f4e437903e1/release_tag-0.5.2-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:0e302ed60c2bf8b7ba5634842be28a27d83cec995869e112b0348b3f01a84ff5", size = 1264767, upload-time = "2026-03-11T00:27:28.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/a3/5b51b0cbdbf2299f545124beab182cfdfe01bf5b615efbc94aee3a64ea67/release_tag-0.5.2-py3-none-win_amd64.whl", hash = "sha256:e3c0629d373a16b9a3da965e89fca893640ce9878ec548865df3609b70989a89", size = 1340816, upload-time = "2026-03-11T00:27:22.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/6f/832c2023a8bd8414c93452bd8b43bf61cedfa5b9575f70c06fb911e51a29/release_tag-0.5.2-py3-none-win_arm64.whl", hash = "sha256:5f26b008e0be0c7a122acd8fcb1bb5c822f38e77fed0c0bf6c550cc226c6bf14", size = 1203191, upload-time = "2026-03-11T00:27:29.789Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7233,21 +7233,19 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "tornado"
|
||||
version = "6.5.2"
|
||||
version = "6.5.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/09/ce/1eb500eae19f4648281bb2186927bb062d2438c2e5093d1360391afd2f90/tornado-6.5.2.tar.gz", hash = "sha256:ab53c8f9a0fa351e2c0741284e06c7a45da86afb544133201c5cc8578eb076a0", size = 510821, upload-time = "2025-08-08T18:27:00.78Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f8/f1/3173dfa4a18db4a9b03e5d55325559dab51ee653763bb8745a75af491286/tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9", size = 516006, upload-time = "2026-03-10T21:31:02.067Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/48/6a7529df2c9cc12efd2e8f5dd219516184d703b34c06786809670df5b3bd/tornado-6.5.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:2436822940d37cde62771cff8774f4f00b3c8024fe482e16ca8387b8a2724db6", size = 442563, upload-time = "2025-08-08T18:26:42.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/b5/9b575a0ed3e50b00c40b08cbce82eb618229091d09f6d14bce80fc01cb0b/tornado-6.5.2-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:583a52c7aa94ee046854ba81d9ebb6c81ec0fd30386d96f7640c96dad45a03ef", size = 440729, upload-time = "2025-08-08T18:26:44.473Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/4e/619174f52b120efcf23633c817fd3fed867c30bff785e2cd5a53a70e483c/tornado-6.5.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0fe179f28d597deab2842b86ed4060deec7388f1fd9c1b4a41adf8af058907e", size = 444295, upload-time = "2025-08-08T18:26:46.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/fa/87b41709552bbd393c85dd18e4e3499dcd8983f66e7972926db8d96aa065/tornado-6.5.2-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b186e85d1e3536d69583d2298423744740986018e393d0321df7340e71898882", size = 443644, upload-time = "2025-08-08T18:26:47.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/41/fb15f06e33d7430ca89420283a8762a4e6b8025b800ea51796ab5e6d9559/tornado-6.5.2-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e792706668c87709709c18b353da1f7662317b563ff69f00bab83595940c7108", size = 443878, upload-time = "2025-08-08T18:26:50.599Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/92/fe6d57da897776ad2e01e279170ea8ae726755b045fe5ac73b75357a5a3f/tornado-6.5.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:06ceb1300fd70cb20e43b1ad8aaee0266e69e7ced38fa910ad2e03285009ce7c", size = 444549, upload-time = "2025-08-08T18:26:51.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/02/c8f4f6c9204526daf3d760f4aa555a7a33ad0e60843eac025ccfd6ff4a93/tornado-6.5.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:74db443e0f5251be86cbf37929f84d8c20c27a355dd452a5cfa2aada0d001ec4", size = 443973, upload-time = "2025-08-08T18:26:53.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/2d/f5f5707b655ce2317190183868cd0f6822a1121b4baeae509ceb9590d0bd/tornado-6.5.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b5e735ab2889d7ed33b32a459cac490eda71a1ba6857b0118de476ab6c366c04", size = 443954, upload-time = "2025-08-08T18:26:55.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/59/593bd0f40f7355806bf6573b47b8c22f8e1374c9b6fd03114bd6b7a3dcfd/tornado-6.5.2-cp39-abi3-win32.whl", hash = "sha256:c6f29e94d9b37a95013bb669616352ddb82e3bfe8326fccee50583caebc8a5f0", size = 445023, upload-time = "2025-08-08T18:26:56.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/2a/f609b420c2f564a748a2d80ebfb2ee02a73ca80223af712fca591386cafb/tornado-6.5.2-cp39-abi3-win_amd64.whl", hash = "sha256:e56a5af51cc30dd2cae649429af65ca2f6571da29504a07995175df14c18f35f", size = 445427, upload-time = "2025-08-08T18:26:57.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/4f/e1f65e8f8c76d73658b33d33b81eed4322fb5085350e4328d5c956f0c8f9/tornado-6.5.2-cp39-abi3-win_arm64.whl", hash = "sha256:d6c33dc3672e3a1f3618eb63b7ef4683a7688e7b9e6e8f0d9aa5726360a004af", size = 444456, upload-time = "2025-08-08T18:26:59.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/8c/77f5097695f4dd8255ecbd08b2a1ed8ba8b953d337804dd7080f199e12bf/tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa", size = 445983, upload-time = "2026-03-10T21:30:44.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/5e/7625b76cd10f98f1516c36ce0346de62061156352353ef2da44e5c21523c/tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521", size = 444246, upload-time = "2026-03-10T21:30:46.571Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/04/7b5705d5b3c0fab088f434f9c83edac1573830ca49ccf29fb83bf7178eec/tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5", size = 447229, upload-time = "2026-03-10T21:30:48.273Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/01/74e034a30ef59afb4097ef8659515e96a39d910b712a89af76f5e4e1f93c/tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07", size = 448192, upload-time = "2026-03-10T21:30:51.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/00/fe9e02c5a96429fce1a1d15a517f5d8444f9c412e0bb9eadfbe3b0fc55bf/tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e", size = 448039, upload-time = "2026-03-10T21:30:53.52Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/9e/656ee4cec0398b1d18d0f1eb6372c41c6b889722641d84948351ae19556d/tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca", size = 447445, upload-time = "2026-03-10T21:30:55.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/76/4921c00511f88af86a33de770d64141170f1cfd9c00311aea689949e274e/tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7", size = 448582, upload-time = "2026-03-10T21:30:57.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/23/f6c6112a04d28eed765e374435fb1a9198f73e1ec4b4024184f21faeb1ad/tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b", size = 448990, upload-time = "2026-03-10T21:30:58.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/c8/876602cbc96469911f0939f703453c1157b0c826ecb05bdd32e023397d4e/tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6", size = 448016, upload-time = "2026-03-10T21:31:00.43Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { OpenButton } from "@opal/components";
|
||||
import { Disabled as DisabledProvider } from "@opal/core";
|
||||
import { SvgSettings } from "@opal/icons";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
|
||||
@@ -32,16 +33,9 @@ export const WithIcon: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const Selected: Story = {
|
||||
args: {
|
||||
selected: true,
|
||||
children: "Selected",
|
||||
},
|
||||
};
|
||||
|
||||
export const Open: Story = {
|
||||
args: {
|
||||
transient: true,
|
||||
interaction: "hover",
|
||||
children: "Open state",
|
||||
},
|
||||
};
|
||||
@@ -53,18 +47,27 @@ export const Disabled: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const LightProminence: Story = {
|
||||
export const Foldable: Story = {
|
||||
args: {
|
||||
prominence: "light",
|
||||
children: "Light prominence",
|
||||
foldable: true,
|
||||
icon: SvgSettings,
|
||||
children: "Settings",
|
||||
},
|
||||
};
|
||||
|
||||
export const HeavyProminence: Story = {
|
||||
export const FoldableDisabled: Story = {
|
||||
args: {
|
||||
prominence: "heavy",
|
||||
children: "Heavy prominence",
|
||||
foldable: true,
|
||||
icon: SvgSettings,
|
||||
children: "Settings",
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<DisabledProvider disabled>
|
||||
<Story />
|
||||
</DisabledProvider>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export const Sizes: Story = {
|
||||
@@ -78,3 +81,12 @@ export const Sizes: Story = {
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithTooltip: Story = {
|
||||
args: {
|
||||
icon: SvgSettings,
|
||||
children: "Settings",
|
||||
tooltip: "Open settings",
|
||||
tooltipSide: "bottom",
|
||||
},
|
||||
};
|
||||
@@ -17,7 +17,9 @@ OpenButton is a **tighter, specialized use-case** of SelectButton:
|
||||
- It hardcodes `variant="select-heavy"` (SelectButton exposes `variant`)
|
||||
- It adds a built-in chevron with CSS-driven rotation (SelectButton has no chevron)
|
||||
- It auto-detects Radix `data-state="open"` to derive `interaction` (SelectButton has no Radix awareness)
|
||||
- It does not support `foldable` or `rightIcon` (SelectButton does)
|
||||
- It does not support `rightIcon` (SelectButton does)
|
||||
|
||||
Both components support `foldable` using the same pattern: `interactive-foldable-host` class + `Interactive.Foldable` wrapper around the label and trailing icon. When foldable, the left icon stays visible while the rest collapses. If you change the foldable implementation in one, update the other to match.
|
||||
|
||||
If you need a general-purpose stateful toggle, use `SelectButton`. If you need a popover/dropdown trigger with a chevron, use `OpenButton`.
|
||||
|
||||
@@ -26,10 +28,12 @@ If you need a general-purpose stateful toggle, use `SelectButton`. If you need a
|
||||
```
|
||||
Interactive.Stateful <- variant="select-heavy", interaction, state, disabled, onClick
|
||||
└─ Interactive.Container <- height, rounding, padding (from `size`)
|
||||
└─ div.opal-button.interactive-foreground
|
||||
└─ div.opal-button.interactive-foreground [.interactive-foldable-host]
|
||||
├─ div > Icon? (interactive-foreground-icon)
|
||||
├─ <span>? .opal-button-label
|
||||
└─ div > ChevronIcon .opal-open-button-chevron (interactive-foreground-icon)
|
||||
├─ [Foldable]? (wraps label + chevron when foldable)
|
||||
│ ├─ <span>? .opal-button-label
|
||||
│ └─ div > ChevronIcon .opal-open-button-chevron
|
||||
└─ <span>? / ChevronIcon (non-foldable)
|
||||
```
|
||||
|
||||
- **`interaction` controls both the chevron and the hover visual state.** When `interaction` is `"hover"` (explicitly or via Radix `data-state="open"`), the chevron rotates 180° and the hover background activates.
|
||||
@@ -44,6 +48,7 @@ Interactive.Stateful <- variant="select-heavy", interaction, state, di
|
||||
| `interaction` | `"rest" \| "hover" \| "active"` | auto | JS-controlled interaction override. Falls back to Radix `data-state="open"` when omitted. |
|
||||
| `icon` | `IconFunctionComponent` | — | Left icon component |
|
||||
| `children` | `string` | — | Content between icon and chevron |
|
||||
| `foldable` | `boolean` | `false` | When `true`, requires both `icon` and `children`; the left icon stays visible while the label + chevron collapse when not hovered. If `tooltip` is omitted on a disabled foldable button, the label text is used as the tooltip. |
|
||||
| `size` | `SizeVariant` | `"lg"` | Size preset controlling height, rounding, and padding |
|
||||
| `width` | `WidthVariant` | — | Width preset |
|
||||
| `tooltip` | `string` | — | Tooltip text shown on hover |
|
||||
|
||||
@@ -2,6 +2,7 @@ import "@opal/components/buttons/open-button/styles.css";
|
||||
import "@opal/components/tooltip.css";
|
||||
import {
|
||||
Interactive,
|
||||
useDisabled,
|
||||
type InteractiveStatefulProps,
|
||||
type InteractiveStatefulInteraction,
|
||||
} from "@opal/core";
|
||||
@@ -30,27 +31,46 @@ function ChevronIcon({ className, ...props }: IconProps) {
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type OpenButtonProps = Omit<InteractiveStatefulProps, "variant"> & {
|
||||
/** Left icon. */
|
||||
icon?: IconFunctionComponent;
|
||||
/**
|
||||
* Content props — a discriminated union on `foldable` that enforces:
|
||||
*
|
||||
* - `foldable: true` → `icon` and `children` are required (icon stays visible,
|
||||
* label + chevron fold away)
|
||||
* - `foldable?: false` → at least one of `icon` or `children` must be provided
|
||||
*/
|
||||
type OpenButtonContentProps =
|
||||
| {
|
||||
foldable: true;
|
||||
icon: IconFunctionComponent;
|
||||
children: string;
|
||||
}
|
||||
| {
|
||||
foldable?: false;
|
||||
icon?: IconFunctionComponent;
|
||||
children: string;
|
||||
}
|
||||
| {
|
||||
foldable?: false;
|
||||
icon: IconFunctionComponent;
|
||||
children?: string;
|
||||
};
|
||||
|
||||
/** Button label text. */
|
||||
children?: string;
|
||||
type OpenButtonProps = Omit<InteractiveStatefulProps, "variant"> &
|
||||
OpenButtonContentProps & {
|
||||
/**
|
||||
* Size preset — controls gap, text size, and Container height/rounding.
|
||||
*/
|
||||
size?: SizeVariant;
|
||||
|
||||
/**
|
||||
* Size preset — controls gap, text size, and Container height/rounding.
|
||||
*/
|
||||
size?: SizeVariant;
|
||||
/** Width preset. */
|
||||
width?: WidthVariant;
|
||||
|
||||
/** Width preset. */
|
||||
width?: WidthVariant;
|
||||
/** Tooltip text shown on hover. */
|
||||
tooltip?: string;
|
||||
|
||||
/** Tooltip text shown on hover. */
|
||||
tooltip?: string;
|
||||
|
||||
/** Which side the tooltip appears on. */
|
||||
tooltipSide?: TooltipSide;
|
||||
};
|
||||
/** Which side the tooltip appears on. */
|
||||
tooltipSide?: TooltipSide;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OpenButton
|
||||
@@ -60,12 +80,15 @@ function OpenButton({
|
||||
icon: Icon,
|
||||
children,
|
||||
size = "lg",
|
||||
foldable,
|
||||
width,
|
||||
tooltip,
|
||||
tooltipSide = "top",
|
||||
interaction,
|
||||
...statefulProps
|
||||
}: OpenButtonProps) {
|
||||
const { isDisabled } = useDisabled();
|
||||
|
||||
// Derive open state: explicit prop → Radix data-state (injected via Slot chain)
|
||||
const dataState = (statefulProps as Record<string, unknown>)["data-state"] as
|
||||
| string
|
||||
@@ -75,6 +98,17 @@ function OpenButton({
|
||||
|
||||
const isLarge = size === "lg";
|
||||
|
||||
const labelEl = children ? (
|
||||
<span
|
||||
className={cn(
|
||||
"opal-button-label whitespace-nowrap",
|
||||
isLarge ? "font-main-ui-body" : "font-secondary-body"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
const button = (
|
||||
<Interactive.Stateful
|
||||
variant="select-heavy"
|
||||
@@ -89,25 +123,34 @@ function OpenButton({
|
||||
isLarge ? "default" : size === "2xs" ? "mini" : "compact"
|
||||
}
|
||||
>
|
||||
<div className="opal-button interactive-foreground flex flex-row items-center gap-1">
|
||||
{iconWrapper(Icon, size, false)}
|
||||
{children && (
|
||||
<span
|
||||
className={cn(
|
||||
"opal-button-label whitespace-nowrap",
|
||||
isLarge ? "font-main-ui-body" : "font-secondary-body"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
<div
|
||||
className={cn(
|
||||
"opal-button interactive-foreground flex flex-row items-center gap-1",
|
||||
foldable && "interactive-foldable-host"
|
||||
)}
|
||||
>
|
||||
{iconWrapper(Icon, size, !foldable && !!children)}
|
||||
|
||||
{foldable ? (
|
||||
<Interactive.Foldable>
|
||||
{labelEl}
|
||||
{iconWrapper(ChevronIcon, size, !!children)}
|
||||
</Interactive.Foldable>
|
||||
) : (
|
||||
<>
|
||||
{labelEl}
|
||||
{iconWrapper(ChevronIcon, size, !!children)}
|
||||
</>
|
||||
)}
|
||||
{iconWrapper(ChevronIcon, size, false)}
|
||||
</div>
|
||||
</Interactive.Container>
|
||||
</Interactive.Stateful>
|
||||
);
|
||||
|
||||
if (!tooltip) return button;
|
||||
const resolvedTooltip =
|
||||
tooltip ?? (foldable && isDisabled && children ? children : undefined);
|
||||
|
||||
if (!resolvedTooltip) return button;
|
||||
|
||||
return (
|
||||
<TooltipPrimitive.Root>
|
||||
@@ -118,7 +161,7 @@ function OpenButton({
|
||||
side={tooltipSide}
|
||||
sideOffset={4}
|
||||
>
|
||||
{tooltip}
|
||||
{resolvedTooltip}
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
</TooltipPrimitive.Root>
|
||||
|
||||
@@ -17,7 +17,9 @@ Interactive.Stateful → Interactive.Container → content row (icon + label + t
|
||||
- OpenButton hardcodes `variant="select-heavy"` (SelectButton exposes `variant`)
|
||||
- OpenButton adds a built-in chevron with CSS-driven rotation (SelectButton has no chevron)
|
||||
- OpenButton auto-detects Radix `data-state="open"` to derive `interaction` (SelectButton has no Radix awareness)
|
||||
- OpenButton does not support `foldable` or `rightIcon` (SelectButton does)
|
||||
- OpenButton does not support `rightIcon` (SelectButton does)
|
||||
|
||||
Both components support `foldable` using the same pattern: `interactive-foldable-host` class + `Interactive.Foldable` wrapper around the label and trailing icon. When foldable, the left icon stays visible while the rest collapses. If you change the foldable implementation in one, update the other to match.
|
||||
|
||||
Use SelectButton for general-purpose stateful toggles. Use `OpenButton` for popover/dropdown triggers with a chevron.
|
||||
|
||||
|
||||
87
web/lib/opal/src/components/cards/card/Card.stories.tsx
Normal file
87
web/lib/opal/src/components/cards/card/Card.stories.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Card } from "@opal/components";
|
||||
|
||||
const BACKGROUND_VARIANTS = ["none", "light", "heavy"] as const;
|
||||
const BORDER_VARIANTS = ["none", "dashed", "solid"] as const;
|
||||
const SIZE_VARIANTS = ["lg", "md", "sm", "xs", "2xs", "fit"] as const;
|
||||
|
||||
const meta: Meta<typeof Card> = {
|
||||
title: "opal/components/Card",
|
||||
component: Card,
|
||||
tags: ["autodocs"],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Card>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<Card>
|
||||
<p>Default card with light background, no border, lg size.</p>
|
||||
</Card>
|
||||
),
|
||||
};
|
||||
|
||||
export const BackgroundVariants: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4 w-96">
|
||||
{BACKGROUND_VARIANTS.map((bg) => (
|
||||
<Card key={bg} backgroundVariant={bg} borderVariant="solid">
|
||||
<p>backgroundVariant: {bg}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const BorderVariants: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4 w-96">
|
||||
{BORDER_VARIANTS.map((border) => (
|
||||
<Card key={border} borderVariant={border}>
|
||||
<p>borderVariant: {border}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const SizeVariants: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4 w-96">
|
||||
{SIZE_VARIANTS.map((size) => (
|
||||
<Card key={size} sizeVariant={size} borderVariant="solid">
|
||||
<p>sizeVariant: {size}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const AllCombinations: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-8">
|
||||
{SIZE_VARIANTS.map((size) => (
|
||||
<div key={size}>
|
||||
<p className="font-bold pb-2">sizeVariant: {size}</p>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{BACKGROUND_VARIANTS.map((bg) =>
|
||||
BORDER_VARIANTS.map((border) => (
|
||||
<Card
|
||||
key={`${size}-${bg}-${border}`}
|
||||
sizeVariant={size}
|
||||
backgroundVariant={bg}
|
||||
borderVariant={border}
|
||||
>
|
||||
<p className="text-xs">
|
||||
bg: {bg}, border: {border}
|
||||
</p>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
67
web/lib/opal/src/components/cards/card/README.md
Normal file
67
web/lib/opal/src/components/cards/card/README.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Card
|
||||
|
||||
**Import:** `import { Card, type CardProps } from "@opal/components";`
|
||||
|
||||
A plain container component with configurable background, border, padding, and rounding. Uses a simple `<div>` internally with `overflow-clip`.
|
||||
|
||||
## Architecture
|
||||
|
||||
The `sizeVariant` controls both padding and border-radius, mirroring the same mapping used by `Button` and `Interactive.Container`:
|
||||
|
||||
| Size | Padding | Rounding |
|
||||
|-----------|---------|----------------|
|
||||
| `lg` | `p-2` | `rounded-12` |
|
||||
| `md` | `p-1` | `rounded-08` |
|
||||
| `sm` | `p-1` | `rounded-08` |
|
||||
| `xs` | `p-0.5` | `rounded-04` |
|
||||
| `2xs` | `p-0.5` | `rounded-04` |
|
||||
| `fit` | `p-0` | `rounded-12` |
|
||||
|
||||
## Props
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `sizeVariant` | `SizeVariant` | `"lg"` | Controls padding and border-radius |
|
||||
| `backgroundVariant` | `"none" \| "light" \| "heavy"` | `"light"` | Background fill intensity |
|
||||
| `borderVariant` | `"none" \| "dashed" \| "solid"` | `"none"` | Border style |
|
||||
| `ref` | `React.Ref<HTMLDivElement>` | — | Ref forwarded to the root div |
|
||||
| `children` | `React.ReactNode` | — | Card content |
|
||||
|
||||
## Background Variants
|
||||
|
||||
- **`none`** — Transparent background. Use for seamless inline content.
|
||||
- **`light`** — Subtle tinted background (`bg-background-tint-00`). The default, suitable for most cards.
|
||||
- **`heavy`** — Stronger tinted background (`bg-background-tint-01`). Use for emphasis or nested cards that need visual separation.
|
||||
|
||||
## Border Variants
|
||||
|
||||
- **`none`** — No border. Use when cards are visually grouped or in tight layouts.
|
||||
- **`dashed`** — Dashed border. Use for placeholder or empty states.
|
||||
- **`solid`** — Solid border. Use for prominent, standalone cards.
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import { Card } from "@opal/components";
|
||||
|
||||
// Default card (light background, no border, lg padding + rounding)
|
||||
<Card>
|
||||
<h2>Card Title</h2>
|
||||
<p>Card content</p>
|
||||
</Card>
|
||||
|
||||
// Compact card with solid border
|
||||
<Card borderVariant="solid" sizeVariant="sm">
|
||||
<p>Compact card</p>
|
||||
</Card>
|
||||
|
||||
// Empty state card
|
||||
<Card backgroundVariant="none" borderVariant="dashed">
|
||||
<p>No items yet</p>
|
||||
</Card>
|
||||
|
||||
// Heavy background, tight padding
|
||||
<Card backgroundVariant="heavy" sizeVariant="xs">
|
||||
<p>Highlighted content</p>
|
||||
</Card>
|
||||
```
|
||||
101
web/lib/opal/src/components/cards/card/components.tsx
Normal file
101
web/lib/opal/src/components/cards/card/components.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import "@opal/components/cards/card/styles.css";
|
||||
import type { SizeVariant } from "@opal/shared";
|
||||
import { sizeVariants } from "@opal/shared";
|
||||
import { cn } from "@opal/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type BackgroundVariant = "none" | "light" | "heavy";
|
||||
type BorderVariant = "none" | "dashed" | "solid";
|
||||
|
||||
type CardProps = {
|
||||
/**
|
||||
* Size preset — controls padding and border-radius.
|
||||
*
|
||||
* Padding comes from the shared size scale. Rounding follows the same
|
||||
* mapping as `Button` / `Interactive.Container`:
|
||||
*
|
||||
* | Size | Rounding |
|
||||
* |--------|------------|
|
||||
* | `lg` | `default` |
|
||||
* | `md`–`sm` | `compact` |
|
||||
* | `xs`–`2xs` | `mini` |
|
||||
* | `fit` | `default` |
|
||||
*
|
||||
* @default "lg"
|
||||
*/
|
||||
sizeVariant?: SizeVariant;
|
||||
|
||||
/**
|
||||
* Background fill intensity.
|
||||
* - `"none"`: transparent background.
|
||||
* - `"light"`: subtle tinted background (`bg-background-tint-00`).
|
||||
* - `"heavy"`: stronger tinted background (`bg-background-tint-01`).
|
||||
*
|
||||
* @default "light"
|
||||
*/
|
||||
backgroundVariant?: BackgroundVariant;
|
||||
|
||||
/**
|
||||
* Border style.
|
||||
* - `"none"`: no border.
|
||||
* - `"dashed"`: dashed border.
|
||||
* - `"solid"`: solid border.
|
||||
*
|
||||
* @default "none"
|
||||
*/
|
||||
borderVariant?: BorderVariant;
|
||||
|
||||
/** Ref forwarded to the root `<div>`. */
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rounding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Maps a size variant to a rounding class, mirroring the Button pattern. */
|
||||
const roundingForSize: Record<SizeVariant, string> = {
|
||||
lg: "rounded-12",
|
||||
md: "rounded-08",
|
||||
sm: "rounded-08",
|
||||
xs: "rounded-04",
|
||||
"2xs": "rounded-04",
|
||||
fit: "rounded-12",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Card
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function Card({
|
||||
sizeVariant = "lg",
|
||||
backgroundVariant = "light",
|
||||
borderVariant = "none",
|
||||
ref,
|
||||
children,
|
||||
}: CardProps) {
|
||||
const { padding } = sizeVariants[sizeVariant];
|
||||
const rounding = roundingForSize[sizeVariant];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("opal-card", padding, rounding)}
|
||||
data-background={backgroundVariant}
|
||||
data-border={borderVariant}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export { Card, type CardProps, type BackgroundVariant, type BorderVariant };
|
||||
29
web/lib/opal/src/components/cards/card/styles.css
Normal file
29
web/lib/opal/src/components/cards/card/styles.css
Normal file
@@ -0,0 +1,29 @@
|
||||
.opal-card {
|
||||
@apply w-full overflow-clip;
|
||||
}
|
||||
|
||||
/* Background variants */
|
||||
.opal-card[data-background="none"] {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
|
||||
.opal-card[data-background="light"] {
|
||||
@apply bg-background-tint-00;
|
||||
}
|
||||
|
||||
.opal-card[data-background="heavy"] {
|
||||
@apply bg-background-tint-01;
|
||||
}
|
||||
|
||||
/* Border variants */
|
||||
.opal-card[data-border="none"] {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.opal-card[data-border="dashed"] {
|
||||
@apply border border-dashed;
|
||||
}
|
||||
|
||||
.opal-card[data-border="solid"] {
|
||||
@apply border;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { EmptyMessageCard } from "@opal/components";
|
||||
import { SvgSparkle, SvgUsers } from "@opal/icons";
|
||||
|
||||
const SIZE_VARIANTS = ["lg", "md", "sm", "xs", "2xs", "fit"] as const;
|
||||
|
||||
const meta: Meta<typeof EmptyMessageCard> = {
|
||||
title: "opal/components/EmptyMessageCard",
|
||||
component: EmptyMessageCard,
|
||||
tags: ["autodocs"],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof EmptyMessageCard>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
title: "No items available.",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithCustomIcon: Story = {
|
||||
args: {
|
||||
icon: SvgSparkle,
|
||||
title: "No agents selected.",
|
||||
},
|
||||
};
|
||||
|
||||
export const SizeVariants: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4 w-96">
|
||||
{SIZE_VARIANTS.map((size) => (
|
||||
<EmptyMessageCard
|
||||
key={size}
|
||||
sizeVariant={size}
|
||||
title={`sizeVariant: ${size}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const Multiple: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4 w-96">
|
||||
<EmptyMessageCard title="No models available." />
|
||||
<EmptyMessageCard icon={SvgSparkle} title="No agents selected." />
|
||||
<EmptyMessageCard icon={SvgUsers} title="No groups added." />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
# EmptyMessageCard
|
||||
|
||||
**Import:** `import { EmptyMessageCard, type EmptyMessageCardProps } from "@opal/components";`
|
||||
|
||||
A pre-configured Card for empty states. Renders a transparent card with a dashed border containing a muted icon and message text using the `Content` layout.
|
||||
|
||||
## Props
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ------------- | -------------------------- | ---------- | ------------------------------------------------ |
|
||||
| `icon` | `IconFunctionComponent` | `SvgEmpty` | Icon displayed alongside the title |
|
||||
| `title` | `string` | — | Primary message text (required) |
|
||||
| `sizeVariant` | `SizeVariant` | `"lg"` | Size preset controlling padding and rounding |
|
||||
| `ref` | `React.Ref<HTMLDivElement>` | — | Ref forwarded to the root div |
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import { EmptyMessageCard } from "@opal/components";
|
||||
import { SvgSparkle, SvgFileText } from "@opal/icons";
|
||||
|
||||
// Default empty state
|
||||
<EmptyMessageCard title="No items yet." />
|
||||
|
||||
// With custom icon
|
||||
<EmptyMessageCard icon={SvgSparkle} title="No agents selected." />
|
||||
|
||||
// With custom size
|
||||
<EmptyMessageCard sizeVariant="sm" icon={SvgFileText} title="No documents available." />
|
||||
```
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Card } from "@opal/components/cards/card/components";
|
||||
import { Content } from "@opal/layouts";
|
||||
import { SvgEmpty } from "@opal/icons";
|
||||
import type { SizeVariant } from "@opal/shared";
|
||||
import type { IconFunctionComponent } from "@opal/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type EmptyMessageCardProps = {
|
||||
/** Icon displayed alongside the title. */
|
||||
icon?: IconFunctionComponent;
|
||||
|
||||
/** Primary message text. */
|
||||
title: string;
|
||||
|
||||
/** Size preset controlling padding and rounding of the card. */
|
||||
sizeVariant?: SizeVariant;
|
||||
|
||||
/** Ref forwarded to the root Card div. */
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EmptyMessageCard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function EmptyMessageCard({
|
||||
icon = SvgEmpty,
|
||||
title,
|
||||
sizeVariant = "lg",
|
||||
ref,
|
||||
}: EmptyMessageCardProps) {
|
||||
return (
|
||||
<Card
|
||||
ref={ref}
|
||||
backgroundVariant="none"
|
||||
borderVariant="dashed"
|
||||
sizeVariant={sizeVariant}
|
||||
>
|
||||
<Content
|
||||
icon={icon}
|
||||
title={title}
|
||||
sizePreset="secondary"
|
||||
variant="body"
|
||||
prominence="muted"
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export { EmptyMessageCard, type EmptyMessageCardProps };
|
||||
@@ -31,3 +31,17 @@ export {
|
||||
type TagProps,
|
||||
type TagColor,
|
||||
} from "@opal/components/tag/components";
|
||||
|
||||
/* Card */
|
||||
export {
|
||||
Card,
|
||||
type CardProps,
|
||||
type BackgroundVariant,
|
||||
type BorderVariant,
|
||||
} from "@opal/components/cards/card/components";
|
||||
|
||||
/* EmptyMessageCard */
|
||||
export {
|
||||
EmptyMessageCard,
|
||||
type EmptyMessageCardProps,
|
||||
} from "@opal/components/cards/empty-message-card/components";
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { BodyLayout } from "./BodyLayout";
|
||||
import { SvgSettings, SvgStar, SvgRefreshCw } from "@opal/icons";
|
||||
|
||||
const meta = {
|
||||
title: "Layouts/BodyLayout",
|
||||
component: BodyLayout,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
} satisfies Meta<typeof BodyLayout>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Size presets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const MainContent: Story = {
|
||||
args: {
|
||||
sizePreset: "main-content",
|
||||
title: "Last synced 2 minutes ago",
|
||||
},
|
||||
};
|
||||
|
||||
export const MainUi: Story = {
|
||||
args: {
|
||||
sizePreset: "main-ui",
|
||||
title: "Document count: 1,234",
|
||||
},
|
||||
};
|
||||
|
||||
export const Secondary: Story = {
|
||||
args: {
|
||||
sizePreset: "secondary",
|
||||
title: "Updated 5 min ago",
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// With icon
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const WithIcon: Story = {
|
||||
args: {
|
||||
sizePreset: "main-ui",
|
||||
title: "Settings",
|
||||
icon: SvgSettings,
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Orientations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const Vertical: Story = {
|
||||
args: {
|
||||
sizePreset: "main-ui",
|
||||
title: "Stacked layout",
|
||||
icon: SvgStar,
|
||||
orientation: "vertical",
|
||||
},
|
||||
};
|
||||
|
||||
export const Reverse: Story = {
|
||||
args: {
|
||||
sizePreset: "main-ui",
|
||||
title: "Reverse layout",
|
||||
icon: SvgRefreshCw,
|
||||
orientation: "reverse",
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prominence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const Muted: Story = {
|
||||
args: {
|
||||
sizePreset: "main-ui",
|
||||
title: "Muted body text",
|
||||
prominence: "muted",
|
||||
},
|
||||
};
|
||||
@@ -1,134 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { IconFunctionComponent } from "@opal/types";
|
||||
import { cn } from "@opal/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type BodySizePreset = "main-content" | "main-ui" | "secondary";
|
||||
type BodyOrientation = "vertical" | "inline" | "reverse";
|
||||
type BodyProminence = "default" | "muted";
|
||||
|
||||
interface BodyPresetConfig {
|
||||
/** Icon width/height (CSS value). */
|
||||
iconSize: string;
|
||||
/** Tailwind padding class for the icon container. */
|
||||
iconContainerPadding: string;
|
||||
/** Tailwind font class for the title. */
|
||||
titleFont: string;
|
||||
/** Title line-height — also used as icon container min-height (CSS value). */
|
||||
lineHeight: string;
|
||||
/** Gap between icon container and title (CSS value). */
|
||||
gap: string;
|
||||
}
|
||||
|
||||
/** Props for {@link BodyLayout}. Does not support editing or descriptions. */
|
||||
interface BodyLayoutProps {
|
||||
/** Optional icon component. */
|
||||
icon?: IconFunctionComponent;
|
||||
|
||||
/** Main title text (read-only — editing is not supported). */
|
||||
title: string;
|
||||
|
||||
/** Size preset. Default: `"main-ui"`. */
|
||||
sizePreset?: BodySizePreset;
|
||||
|
||||
/** Layout orientation. Default: `"inline"`. */
|
||||
orientation?: BodyOrientation;
|
||||
|
||||
/** Title prominence. Default: `"default"`. */
|
||||
prominence?: BodyProminence;
|
||||
|
||||
/** Ref forwarded to the root `<div>`. */
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Presets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BODY_PRESETS: Record<BodySizePreset, BodyPresetConfig> = {
|
||||
"main-content": {
|
||||
iconSize: "1rem",
|
||||
iconContainerPadding: "p-1",
|
||||
titleFont: "font-main-content-body",
|
||||
lineHeight: "1.5rem",
|
||||
gap: "0.125rem",
|
||||
},
|
||||
"main-ui": {
|
||||
iconSize: "1rem",
|
||||
iconContainerPadding: "p-0.5",
|
||||
titleFont: "font-main-ui-action",
|
||||
lineHeight: "1.25rem",
|
||||
gap: "0.25rem",
|
||||
},
|
||||
secondary: {
|
||||
iconSize: "0.75rem",
|
||||
iconContainerPadding: "p-0.5",
|
||||
titleFont: "font-secondary-action",
|
||||
lineHeight: "1rem",
|
||||
gap: "0.125rem",
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BodyLayout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function BodyLayout({
|
||||
icon: Icon,
|
||||
title,
|
||||
sizePreset = "main-ui",
|
||||
orientation = "inline",
|
||||
prominence = "default",
|
||||
ref,
|
||||
}: BodyLayoutProps) {
|
||||
const config = BODY_PRESETS[sizePreset];
|
||||
const titleColorClass =
|
||||
prominence === "muted" ? "text-text-03" : "text-text-04";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="opal-content-body"
|
||||
data-orientation={orientation}
|
||||
style={{ gap: config.gap }}
|
||||
>
|
||||
{Icon && (
|
||||
<div
|
||||
className={cn(
|
||||
"opal-content-body-icon-container shrink-0",
|
||||
config.iconContainerPadding
|
||||
)}
|
||||
style={{ minHeight: config.lineHeight }}
|
||||
>
|
||||
<Icon
|
||||
className="opal-content-body-icon text-text-03"
|
||||
style={{ width: config.iconSize, height: config.iconSize }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
"opal-content-body-title",
|
||||
config.titleFont,
|
||||
titleColorClass
|
||||
)}
|
||||
style={{ height: config.lineHeight }}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
BodyLayout,
|
||||
type BodyLayoutProps,
|
||||
type BodySizePreset,
|
||||
type BodyOrientation,
|
||||
type BodyProminence,
|
||||
};
|
||||
@@ -1,98 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { HeadingLayout } from "./HeadingLayout";
|
||||
import { SvgSettings, SvgStar } from "@opal/icons";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
|
||||
const meta = {
|
||||
title: "Layouts/HeadingLayout",
|
||||
component: HeadingLayout,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<TooltipPrimitive.Provider>
|
||||
<Story />
|
||||
</TooltipPrimitive.Provider>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof HeadingLayout>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Size presets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const Headline: Story = {
|
||||
args: {
|
||||
sizePreset: "headline",
|
||||
title: "Welcome to Onyx",
|
||||
description: "Your enterprise search and AI assistant platform.",
|
||||
},
|
||||
};
|
||||
|
||||
export const Section: Story = {
|
||||
args: {
|
||||
sizePreset: "section",
|
||||
title: "Configuration",
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// With icon
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const WithIcon: Story = {
|
||||
args: {
|
||||
sizePreset: "headline",
|
||||
title: "Settings",
|
||||
icon: SvgSettings,
|
||||
},
|
||||
};
|
||||
|
||||
export const SectionWithIcon: Story = {
|
||||
args: {
|
||||
sizePreset: "section",
|
||||
variant: "section",
|
||||
title: "Favorites",
|
||||
icon: SvgStar,
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Variants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const SectionVariant: Story = {
|
||||
args: {
|
||||
sizePreset: "headline",
|
||||
variant: "section",
|
||||
title: "Inline Icon Heading",
|
||||
icon: SvgSettings,
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Editable
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const Editable: Story = {
|
||||
args: {
|
||||
sizePreset: "headline",
|
||||
title: "Click to edit me",
|
||||
editable: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const EditableSection: Story = {
|
||||
args: {
|
||||
sizePreset: "section",
|
||||
title: "Editable Section Title",
|
||||
editable: true,
|
||||
description: "This title can be edited inline.",
|
||||
},
|
||||
};
|
||||
@@ -1,218 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@opal/components/buttons/button/components";
|
||||
import type { SizeVariant } from "@opal/shared";
|
||||
import SvgEdit from "@opal/icons/edit";
|
||||
import type { IconFunctionComponent } from "@opal/types";
|
||||
import { cn } from "@opal/utils";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type HeadingSizePreset = "headline" | "section";
|
||||
type HeadingVariant = "heading" | "section";
|
||||
|
||||
interface HeadingPresetConfig {
|
||||
/** Icon width/height (CSS value). */
|
||||
iconSize: string;
|
||||
/** Tailwind padding class for the icon container. */
|
||||
iconContainerPadding: string;
|
||||
/** Gap between icon container and content (CSS value). */
|
||||
gap: string;
|
||||
/** Tailwind font class for the title. */
|
||||
titleFont: string;
|
||||
/** Title line-height — also used as icon container min-height (CSS value). */
|
||||
lineHeight: string;
|
||||
/** Button `size` prop for the edit button. Uses the shared `SizeVariant` scale. */
|
||||
editButtonSize: SizeVariant;
|
||||
/** Tailwind padding class for the edit button container. */
|
||||
editButtonPadding: string;
|
||||
}
|
||||
|
||||
interface HeadingLayoutProps {
|
||||
/** Optional icon component. */
|
||||
icon?: IconFunctionComponent;
|
||||
|
||||
/** Main title text. */
|
||||
title: string;
|
||||
|
||||
/** Optional description below the title. */
|
||||
description?: string;
|
||||
|
||||
/** Enable inline editing of the title. */
|
||||
editable?: boolean;
|
||||
|
||||
/** Called when the user commits an edit. */
|
||||
onTitleChange?: (newTitle: string) => void;
|
||||
|
||||
/** Size preset. Default: `"headline"`. */
|
||||
sizePreset?: HeadingSizePreset;
|
||||
|
||||
/** Variant controls icon placement. `"heading"` = top, `"section"` = inline. Default: `"heading"`. */
|
||||
variant?: HeadingVariant;
|
||||
|
||||
/** Ref forwarded to the root `<div>`. */
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Presets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const HEADING_PRESETS: Record<HeadingSizePreset, HeadingPresetConfig> = {
|
||||
headline: {
|
||||
iconSize: "2rem",
|
||||
iconContainerPadding: "p-0.5",
|
||||
gap: "0.25rem",
|
||||
titleFont: "font-heading-h2",
|
||||
lineHeight: "2.25rem",
|
||||
editButtonSize: "md",
|
||||
editButtonPadding: "p-1",
|
||||
},
|
||||
section: {
|
||||
iconSize: "1.25rem",
|
||||
iconContainerPadding: "p-1",
|
||||
gap: "0rem",
|
||||
titleFont: "font-heading-h3",
|
||||
lineHeight: "1.75rem",
|
||||
editButtonSize: "sm",
|
||||
editButtonPadding: "p-0.5",
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HeadingLayout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function HeadingLayout({
|
||||
sizePreset = "headline",
|
||||
variant = "heading",
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
editable,
|
||||
onTitleChange,
|
||||
ref,
|
||||
}: HeadingLayoutProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(title);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const config = HEADING_PRESETS[sizePreset];
|
||||
const iconPlacement = variant === "heading" ? "top" : "left";
|
||||
|
||||
function startEditing() {
|
||||
setEditValue(title);
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
function commit() {
|
||||
const value = editValue.trim();
|
||||
if (value && value !== title) onTitleChange?.(value);
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="opal-content-heading"
|
||||
data-icon-placement={iconPlacement}
|
||||
style={{ gap: iconPlacement === "left" ? config.gap : undefined }}
|
||||
>
|
||||
{Icon && (
|
||||
<div
|
||||
className={cn(
|
||||
"opal-content-heading-icon-container shrink-0",
|
||||
config.iconContainerPadding
|
||||
)}
|
||||
style={{ minHeight: config.lineHeight }}
|
||||
>
|
||||
<Icon
|
||||
className="opal-content-heading-icon"
|
||||
style={{ width: config.iconSize, height: config.iconSize }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="opal-content-heading-body">
|
||||
<div className="opal-content-heading-title-row">
|
||||
{editing ? (
|
||||
<div className="opal-content-heading-input-sizer">
|
||||
<span
|
||||
className={cn(
|
||||
"opal-content-heading-input-mirror",
|
||||
config.titleFont
|
||||
)}
|
||||
>
|
||||
{editValue || "\u00A0"}
|
||||
</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className={cn(
|
||||
"opal-content-heading-input",
|
||||
config.titleFont,
|
||||
"text-text-04"
|
||||
)}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
size={1}
|
||||
autoFocus
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commit();
|
||||
if (e.key === "Escape") {
|
||||
setEditValue(title);
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
style={{ height: config.lineHeight }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
"opal-content-heading-title",
|
||||
config.titleFont,
|
||||
"text-text-04",
|
||||
editable && "cursor-pointer"
|
||||
)}
|
||||
onClick={editable ? startEditing : undefined}
|
||||
style={{ height: config.lineHeight }}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{editable && !editing && (
|
||||
<div
|
||||
className={cn(
|
||||
"opal-content-heading-edit-button",
|
||||
config.editButtonPadding
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
icon={SvgEdit}
|
||||
prominence="internal"
|
||||
size={config.editButtonSize}
|
||||
tooltip="Edit"
|
||||
tooltipSide="right"
|
||||
onClick={startEditing}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{description && (
|
||||
<div className="opal-content-heading-description font-secondary-body text-text-03">
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { HeadingLayout, type HeadingLayoutProps, type HeadingSizePreset };
|
||||
@@ -1,154 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { LabelLayout } from "./LabelLayout";
|
||||
import { SvgSettings, SvgStar } from "@opal/icons";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
|
||||
const meta = {
|
||||
title: "Layouts/LabelLayout",
|
||||
component: LabelLayout,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<TooltipPrimitive.Provider>
|
||||
<Story />
|
||||
</TooltipPrimitive.Provider>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof LabelLayout>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Size presets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const MainContent: Story = {
|
||||
args: {
|
||||
sizePreset: "main-content",
|
||||
title: "Display Name",
|
||||
},
|
||||
};
|
||||
|
||||
export const MainUi: Story = {
|
||||
args: {
|
||||
sizePreset: "main-ui",
|
||||
title: "Email Address",
|
||||
},
|
||||
};
|
||||
|
||||
export const SecondaryPreset: Story = {
|
||||
args: {
|
||||
sizePreset: "secondary",
|
||||
title: "API Key",
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// With description
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const WithDescription: Story = {
|
||||
args: {
|
||||
sizePreset: "main-content",
|
||||
title: "Workspace Name",
|
||||
description: "The name displayed across your organization.",
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// With icon
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const WithIcon: Story = {
|
||||
args: {
|
||||
sizePreset: "main-ui",
|
||||
title: "Settings",
|
||||
icon: SvgSettings,
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Optional
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const Optional: Story = {
|
||||
args: {
|
||||
sizePreset: "main-content",
|
||||
title: "Phone Number",
|
||||
optional: true,
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Aux icons
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const AuxInfoGray: Story = {
|
||||
args: {
|
||||
sizePreset: "main-content",
|
||||
title: "Connection Status",
|
||||
auxIcon: "info-gray",
|
||||
},
|
||||
};
|
||||
|
||||
export const AuxWarning: Story = {
|
||||
args: {
|
||||
sizePreset: "main-content",
|
||||
title: "Rate Limit",
|
||||
auxIcon: "warning",
|
||||
},
|
||||
};
|
||||
|
||||
export const AuxError: Story = {
|
||||
args: {
|
||||
sizePreset: "main-content",
|
||||
title: "API Key",
|
||||
auxIcon: "error",
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// With tag
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const WithTag: Story = {
|
||||
args: {
|
||||
sizePreset: "main-ui",
|
||||
title: "Knowledge Graph",
|
||||
tag: { title: "Beta", color: "blue" },
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Editable
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const Editable: Story = {
|
||||
args: {
|
||||
sizePreset: "main-ui",
|
||||
title: "Click to edit",
|
||||
editable: true,
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Combined
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const FullFeatured: Story = {
|
||||
args: {
|
||||
sizePreset: "main-content",
|
||||
title: "Custom Field",
|
||||
icon: SvgStar,
|
||||
description: "A custom field with all extras enabled.",
|
||||
optional: true,
|
||||
auxIcon: "info-blue",
|
||||
tag: { title: "New", color: "green" },
|
||||
editable: true,
|
||||
},
|
||||
};
|
||||
@@ -1,286 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@opal/components/buttons/button/components";
|
||||
import { Tag, type TagProps } from "@opal/components/tag/components";
|
||||
import type { SizeVariant } from "@opal/shared";
|
||||
import SvgAlertCircle from "@opal/icons/alert-circle";
|
||||
import SvgAlertTriangle from "@opal/icons/alert-triangle";
|
||||
import SvgEdit from "@opal/icons/edit";
|
||||
import SvgXOctagon from "@opal/icons/x-octagon";
|
||||
import type { IconFunctionComponent } from "@opal/types";
|
||||
import { cn } from "@opal/utils";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type LabelSizePreset = "main-content" | "main-ui" | "secondary";
|
||||
|
||||
type LabelAuxIcon = "info-gray" | "info-blue" | "warning" | "error";
|
||||
|
||||
interface LabelPresetConfig {
|
||||
iconSize: string;
|
||||
iconContainerPadding: string;
|
||||
iconColorClass: string;
|
||||
titleFont: string;
|
||||
lineHeight: string;
|
||||
gap: string;
|
||||
/** Button `size` prop for the edit button. Uses the shared `SizeVariant` scale. */
|
||||
editButtonSize: SizeVariant;
|
||||
editButtonPadding: string;
|
||||
optionalFont: string;
|
||||
/** Aux icon size = lineHeight − 2 × p-0.5. */
|
||||
auxIconSize: string;
|
||||
}
|
||||
|
||||
interface LabelLayoutProps {
|
||||
/** Optional icon component. */
|
||||
icon?: IconFunctionComponent;
|
||||
|
||||
/** Main title text. */
|
||||
title: string;
|
||||
|
||||
/** Optional description text below the title. */
|
||||
description?: string;
|
||||
|
||||
/** Enable inline editing of the title. */
|
||||
editable?: boolean;
|
||||
|
||||
/** Called when the user commits an edit. */
|
||||
onTitleChange?: (newTitle: string) => void;
|
||||
|
||||
/** When `true`, renders "(Optional)" beside the title. */
|
||||
optional?: boolean;
|
||||
|
||||
/** Auxiliary status icon rendered beside the title. */
|
||||
auxIcon?: LabelAuxIcon;
|
||||
|
||||
/** Tag rendered beside the title. */
|
||||
tag?: TagProps;
|
||||
|
||||
/** Size preset. Default: `"main-ui"`. */
|
||||
sizePreset?: LabelSizePreset;
|
||||
|
||||
/** Ref forwarded to the root `<div>`. */
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Presets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const LABEL_PRESETS: Record<LabelSizePreset, LabelPresetConfig> = {
|
||||
"main-content": {
|
||||
iconSize: "1rem",
|
||||
iconContainerPadding: "p-1",
|
||||
iconColorClass: "text-text-04",
|
||||
titleFont: "font-main-content-emphasis",
|
||||
lineHeight: "1.5rem",
|
||||
gap: "0.125rem",
|
||||
editButtonSize: "sm",
|
||||
editButtonPadding: "p-0",
|
||||
optionalFont: "font-main-content-muted",
|
||||
auxIconSize: "1.25rem",
|
||||
},
|
||||
"main-ui": {
|
||||
iconSize: "1rem",
|
||||
iconContainerPadding: "p-0.5",
|
||||
iconColorClass: "text-text-03",
|
||||
titleFont: "font-main-ui-action",
|
||||
lineHeight: "1.25rem",
|
||||
gap: "0.25rem",
|
||||
editButtonSize: "xs",
|
||||
editButtonPadding: "p-0",
|
||||
optionalFont: "font-main-ui-muted",
|
||||
auxIconSize: "1rem",
|
||||
},
|
||||
secondary: {
|
||||
iconSize: "0.75rem",
|
||||
iconContainerPadding: "p-0.5",
|
||||
iconColorClass: "text-text-04",
|
||||
titleFont: "font-secondary-action",
|
||||
lineHeight: "1rem",
|
||||
gap: "0.125rem",
|
||||
editButtonSize: "2xs",
|
||||
editButtonPadding: "p-0",
|
||||
optionalFont: "font-secondary-action",
|
||||
auxIconSize: "0.75rem",
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LabelLayout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const AUX_ICON_CONFIG: Record<
|
||||
LabelAuxIcon,
|
||||
{ icon: IconFunctionComponent; colorClass: string }
|
||||
> = {
|
||||
"info-gray": { icon: SvgAlertCircle, colorClass: "text-text-02" },
|
||||
"info-blue": { icon: SvgAlertCircle, colorClass: "text-status-info-05" },
|
||||
warning: { icon: SvgAlertTriangle, colorClass: "text-status-warning-05" },
|
||||
error: { icon: SvgXOctagon, colorClass: "text-status-error-05" },
|
||||
};
|
||||
|
||||
function LabelLayout({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
editable,
|
||||
onTitleChange,
|
||||
optional,
|
||||
auxIcon,
|
||||
tag,
|
||||
sizePreset = "main-ui",
|
||||
ref,
|
||||
}: LabelLayoutProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(title);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const config = LABEL_PRESETS[sizePreset];
|
||||
|
||||
function startEditing() {
|
||||
setEditValue(title);
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
function commit() {
|
||||
const value = editValue.trim();
|
||||
if (value && value !== title) onTitleChange?.(value);
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className="opal-content-label" style={{ gap: config.gap }}>
|
||||
{Icon && (
|
||||
<div
|
||||
className={cn(
|
||||
"opal-content-label-icon-container shrink-0",
|
||||
config.iconContainerPadding
|
||||
)}
|
||||
style={{ minHeight: config.lineHeight }}
|
||||
>
|
||||
<Icon
|
||||
className={cn("opal-content-label-icon", config.iconColorClass)}
|
||||
style={{ width: config.iconSize, height: config.iconSize }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="opal-content-label-body">
|
||||
<div className="opal-content-label-title-row">
|
||||
{editing ? (
|
||||
<div className="opal-content-label-input-sizer">
|
||||
<span
|
||||
className={cn(
|
||||
"opal-content-label-input-mirror",
|
||||
config.titleFont
|
||||
)}
|
||||
>
|
||||
{editValue || "\u00A0"}
|
||||
</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className={cn(
|
||||
"opal-content-label-input",
|
||||
config.titleFont,
|
||||
"text-text-04"
|
||||
)}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
size={1}
|
||||
autoFocus
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commit();
|
||||
if (e.key === "Escape") {
|
||||
setEditValue(title);
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
style={{ height: config.lineHeight }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
"opal-content-label-title",
|
||||
config.titleFont,
|
||||
"text-text-04",
|
||||
editable && "cursor-pointer"
|
||||
)}
|
||||
onClick={editable ? startEditing : undefined}
|
||||
style={{ height: config.lineHeight }}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{optional && (
|
||||
<span
|
||||
className={cn(config.optionalFont, "text-text-03 shrink-0")}
|
||||
style={{ height: config.lineHeight }}
|
||||
>
|
||||
(Optional)
|
||||
</span>
|
||||
)}
|
||||
|
||||
{auxIcon &&
|
||||
(() => {
|
||||
const { icon: AuxIcon, colorClass } = AUX_ICON_CONFIG[auxIcon];
|
||||
return (
|
||||
<div
|
||||
className="opal-content-label-aux-icon shrink-0 p-0.5"
|
||||
style={{ height: config.lineHeight }}
|
||||
>
|
||||
<AuxIcon
|
||||
className={colorClass}
|
||||
style={{
|
||||
width: config.auxIconSize,
|
||||
height: config.auxIconSize,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{tag && <Tag {...tag} />}
|
||||
|
||||
{editable && !editing && (
|
||||
<div
|
||||
className={cn(
|
||||
"opal-content-label-edit-button",
|
||||
config.editButtonPadding
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
icon={SvgEdit}
|
||||
prominence="internal"
|
||||
size={config.editButtonSize}
|
||||
tooltip="Edit"
|
||||
tooltipSide="right"
|
||||
onClick={startEditing}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{description && (
|
||||
<div className="opal-content-label-description font-secondary-body text-text-03">
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
LabelLayout,
|
||||
type LabelLayoutProps,
|
||||
type LabelSizePreset,
|
||||
type LabelAuxIcon,
|
||||
};
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
QwenIcon,
|
||||
OllamaIcon,
|
||||
LMStudioIcon,
|
||||
LiteLLMIcon,
|
||||
ZAIIcon,
|
||||
} from "@/components/icons/icons";
|
||||
import {
|
||||
@@ -21,12 +22,14 @@ import {
|
||||
OpenRouterModelResponse,
|
||||
BedrockModelResponse,
|
||||
LMStudioModelResponse,
|
||||
LiteLLMProxyModelResponse,
|
||||
ModelConfiguration,
|
||||
LLMProviderName,
|
||||
BedrockFetchParams,
|
||||
OllamaFetchParams,
|
||||
LMStudioFetchParams,
|
||||
OpenRouterFetchParams,
|
||||
LiteLLMProxyFetchParams,
|
||||
} from "@/interfaces/llm";
|
||||
import { SvgAws, SvgOpenrouter } from "@opal/icons";
|
||||
|
||||
@@ -37,6 +40,7 @@ export const AGGREGATOR_PROVIDERS = new Set([
|
||||
"openrouter",
|
||||
"ollama_chat",
|
||||
"lm_studio",
|
||||
"litellm_proxy",
|
||||
"vertex_ai",
|
||||
]);
|
||||
|
||||
@@ -73,6 +77,7 @@ export const getProviderIcon = (
|
||||
bedrock: SvgAws,
|
||||
bedrock_converse: SvgAws,
|
||||
openrouter: SvgOpenrouter,
|
||||
litellm_proxy: LiteLLMIcon,
|
||||
vertex_ai: GeminiIcon,
|
||||
};
|
||||
|
||||
@@ -338,6 +343,65 @@ export const fetchLMStudioModels = async (
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches LiteLLM Proxy models directly without any form state dependencies.
|
||||
* Uses snake_case params to match API structure.
|
||||
*/
|
||||
export const fetchLiteLLMProxyModels = async (
|
||||
params: LiteLLMProxyFetchParams
|
||||
): Promise<{ models: ModelConfiguration[]; error?: string }> => {
|
||||
const apiBase = params.api_base;
|
||||
const apiKey = params.api_key;
|
||||
if (!apiBase) {
|
||||
return { models: [], error: "API Base is required" };
|
||||
}
|
||||
if (!apiKey) {
|
||||
return { models: [], error: "API Key is required" };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/admin/llm/litellm/available-models", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
api_base: apiBase,
|
||||
api_key: apiKey,
|
||||
provider_name: params.provider_name,
|
||||
}),
|
||||
signal: params.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = "Failed to fetch models";
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
errorMessage = errorData.detail || errorData.message || errorMessage;
|
||||
} catch {
|
||||
// ignore JSON parsing errors
|
||||
}
|
||||
return { models: [], error: errorMessage };
|
||||
}
|
||||
|
||||
const data: LiteLLMProxyModelResponse[] = await response.json();
|
||||
const models: ModelConfiguration[] = data.map((modelData) => ({
|
||||
name: modelData.model_name,
|
||||
display_name: modelData.model_name,
|
||||
is_visible: true,
|
||||
max_input_tokens: null,
|
||||
supports_image_input: false,
|
||||
supports_reasoning: false,
|
||||
}));
|
||||
|
||||
return { models };
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
return { models: [], error: errorMessage };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches models for a provider. Accepts form values directly and maps them
|
||||
* to the expected fetch params format internally.
|
||||
@@ -385,6 +449,13 @@ export const fetchModels = async (
|
||||
api_key: formValues.api_key,
|
||||
provider_name: formValues.name,
|
||||
});
|
||||
case LLMProviderName.LITELLM_PROXY:
|
||||
return fetchLiteLLMProxyModels({
|
||||
api_base: formValues.api_base,
|
||||
api_key: formValues.api_key,
|
||||
provider_name: formValues.name,
|
||||
signal,
|
||||
});
|
||||
default:
|
||||
return { models: [], error: `Unknown provider: ${providerName}` };
|
||||
}
|
||||
@@ -397,6 +468,7 @@ export function canProviderFetchModels(providerName?: string) {
|
||||
case LLMProviderName.OLLAMA_CHAT:
|
||||
case LLMProviderName.LM_STUDIO:
|
||||
case LLMProviderName.OPENROUTER:
|
||||
case LLMProviderName.LITELLM_PROXY:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
|
||||
1
web/src/app/admin/users2/page.tsx
Normal file
1
web/src/app/admin/users2/page.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "@/refresh-pages/admin/UsersPage";
|
||||
@@ -249,6 +249,7 @@ export default function MessageToolbar({
|
||||
<SelectButton
|
||||
icon={SvgThumbsUp}
|
||||
onClick={() => handleFeedbackClick("like")}
|
||||
variant="select-light"
|
||||
state={isFeedbackTransient("like") ? "selected" : "empty"}
|
||||
tooltip={
|
||||
currentFeedback === "like" ? "Remove Like" : "Good Response"
|
||||
@@ -258,6 +259,7 @@ export default function MessageToolbar({
|
||||
<SelectButton
|
||||
icon={SvgThumbsDown}
|
||||
onClick={() => handleFeedbackClick("dislike")}
|
||||
variant="select-light"
|
||||
state={isFeedbackTransient("dislike") ? "selected" : "empty"}
|
||||
tooltip={
|
||||
currentFeedback === "dislike"
|
||||
@@ -283,7 +285,7 @@ export default function MessageToolbar({
|
||||
});
|
||||
regenerator(llmDescriptor);
|
||||
}}
|
||||
folded
|
||||
foldable
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -98,7 +98,7 @@ export default function ArtifactsTab({
|
||||
const handleWebappDownload = () => {
|
||||
if (!sessionId) return;
|
||||
const link = document.createElement("a");
|
||||
link.href = `/api/build/sessions/${sessionId}/webapp/download`;
|
||||
link.href = `/api/build/sessions/${sessionId}/webapp-download`;
|
||||
link.download = "";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Button } from "@opal/components";
|
||||
import { SvgBubbleText, SvgSearchMenu, SvgSidebar } from "@opal/icons";
|
||||
import MinimalMarkdown from "@/components/chat/MinimalMarkdown";
|
||||
import { useSettingsContext } from "@/providers/SettingsProvider";
|
||||
import { AppMode, useAppMode } from "@/providers/AppModeProvider";
|
||||
import type { AppMode } from "@/providers/QueryControllerProvider";
|
||||
import useAppFocus from "@/hooks/useAppFocus";
|
||||
import { useQueryController } from "@/providers/QueryControllerProvider";
|
||||
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
|
||||
@@ -58,15 +58,15 @@ const footerMarkdownComponents = {
|
||||
*/
|
||||
export default function NRFChrome() {
|
||||
const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled();
|
||||
const { appMode, setAppMode } = useAppMode();
|
||||
const { state, setAppMode } = useQueryController();
|
||||
const settings = useSettingsContext();
|
||||
const { isMobile } = useScreenSize();
|
||||
const { setFolded } = useAppSidebarContext();
|
||||
const appFocus = useAppFocus();
|
||||
const { classification } = useQueryController();
|
||||
const [modePopoverOpen, setModePopoverOpen] = useState(false);
|
||||
|
||||
const effectiveMode: AppMode = appFocus.isNewSession() ? appMode : "chat";
|
||||
const effectiveMode: AppMode =
|
||||
appFocus.isNewSession() && state.phase === "idle" ? state.appMode : "chat";
|
||||
|
||||
const customFooterContent =
|
||||
settings?.enterpriseSettings?.custom_lower_disclaimer_content ||
|
||||
@@ -78,7 +78,7 @@ export default function NRFChrome() {
|
||||
isPaidEnterpriseFeaturesEnabled &&
|
||||
settings.isSearchModeAvailable &&
|
||||
appFocus.isNewSession() &&
|
||||
!classification;
|
||||
state.phase === "idle";
|
||||
|
||||
const showHeader = isMobile || showModeToggle;
|
||||
|
||||
|
||||
@@ -175,7 +175,7 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
|
||||
const isStreaming = currentChatState === "streaming";
|
||||
|
||||
// Query controller for search/chat classification (EE feature)
|
||||
const { submit: submitQuery, classification } = useQueryController();
|
||||
const { submit: submitQuery, state } = useQueryController();
|
||||
|
||||
// Determine if retrieval (search) is enabled based on the agent
|
||||
const retrievalEnabled = useMemo(() => {
|
||||
@@ -186,7 +186,8 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
|
||||
}, [liveAgent]);
|
||||
|
||||
// Check if we're in search mode
|
||||
const isSearch = classification === "search";
|
||||
const isSearch =
|
||||
state.phase === "searching" || state.phase === "search-results";
|
||||
|
||||
// Anchor for scroll positioning (matches ChatPage pattern)
|
||||
const anchorMessage = messageHistory.at(-2) ?? messageHistory[0];
|
||||
@@ -317,7 +318,7 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
|
||||
};
|
||||
|
||||
// Use submitQuery which will classify the query and either:
|
||||
// - Route to search (sets classification to "search" and shows SearchUI)
|
||||
// - Route to search (sets phase to "searching"/"search-results" and shows SearchUI)
|
||||
// - Route to chat (calls onChat callback)
|
||||
await submitQuery(submittedMessage, onChat);
|
||||
},
|
||||
|
||||
@@ -31,6 +31,7 @@ const SETTINGS_LAYOUT_PREFIXES = [
|
||||
ADMIN_PATHS.LLM_MODELS,
|
||||
ADMIN_PATHS.AGENTS,
|
||||
ADMIN_PATHS.USERS,
|
||||
ADMIN_PATHS.USERS_V2,
|
||||
ADMIN_PATHS.TOKEN_RATE_LIMITS,
|
||||
ADMIN_PATHS.SEARCH_SETTINGS,
|
||||
ADMIN_PATHS.DOCUMENT_PROCESSING,
|
||||
|
||||
@@ -11,13 +11,14 @@ import rehypeHighlight from "rehype-highlight";
|
||||
import remarkMath from "remark-math";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
import "katex/dist/katex.min.css";
|
||||
import { cn, transformLinkUri } from "@/lib/utils";
|
||||
import { transformLinkUri } from "@/lib/utils";
|
||||
|
||||
type MinimalMarkdownComponentOverrides = Partial<Components>;
|
||||
|
||||
interface MinimalMarkdownProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
showHeader?: boolean;
|
||||
/**
|
||||
* Override specific markdown renderers.
|
||||
@@ -29,6 +30,7 @@ interface MinimalMarkdownProps {
|
||||
export default function MinimalMarkdown({
|
||||
content,
|
||||
className = "",
|
||||
style,
|
||||
showHeader = true,
|
||||
components,
|
||||
}: MinimalMarkdownProps) {
|
||||
@@ -61,17 +63,19 @@ export default function MinimalMarkdown({
|
||||
}, [content, components, showHeader]);
|
||||
|
||||
return (
|
||||
<ReactMarkdown
|
||||
className={cn(
|
||||
"prose dark:prose-invert max-w-full text-sm break-words",
|
||||
className
|
||||
)}
|
||||
components={markdownComponents}
|
||||
rehypePlugins={[rehypeHighlight, rehypeKatex]}
|
||||
remarkPlugins={[remarkGfm, [remarkMath, { singleDollarTextMath: false }]]}
|
||||
urlTransform={transformLinkUri}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
<div style={style || {}} className={`${className}`}>
|
||||
<ReactMarkdown
|
||||
className="prose dark:prose-invert max-w-full text-sm break-words"
|
||||
components={markdownComponents}
|
||||
rehypePlugins={[rehypeHighlight, rehypeKatex]}
|
||||
remarkPlugins={[
|
||||
remarkGfm,
|
||||
[remarkMath, { singleDollarTextMath: false }],
|
||||
]}
|
||||
urlTransform={transformLinkUri}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
|
||||
import { AppModeContext, AppMode } from "@/providers/AppModeProvider";
|
||||
import { useUser } from "@/providers/UserProvider";
|
||||
import { useSettingsContext } from "@/providers/SettingsProvider";
|
||||
|
||||
export interface AppModeProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider for application mode (Search/Chat).
|
||||
*
|
||||
* This controls how user queries are handled:
|
||||
* - **search**: Forces search mode - quick document lookup
|
||||
* - **chat**: Forces chat mode - conversation with follow-up questions
|
||||
*
|
||||
* The initial mode is read from the user's persisted `default_app_mode` preference.
|
||||
* When search mode is unavailable (admin setting or no connectors), the mode is locked to "chat".
|
||||
*/
|
||||
export function AppModeProvider({ children }: AppModeProviderProps) {
|
||||
const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled();
|
||||
const { user } = useUser();
|
||||
const { isSearchModeAvailable } = useSettingsContext();
|
||||
|
||||
const persistedMode = user?.preferences?.default_app_mode;
|
||||
const [appMode, setAppModeState] = useState<AppMode>("chat");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPaidEnterpriseFeaturesEnabled || !isSearchModeAvailable) {
|
||||
setAppModeState("chat");
|
||||
return;
|
||||
}
|
||||
|
||||
if (persistedMode) {
|
||||
setAppModeState(persistedMode.toLowerCase() as AppMode);
|
||||
}
|
||||
}, [isPaidEnterpriseFeaturesEnabled, isSearchModeAvailable, persistedMode]);
|
||||
|
||||
const setAppMode = useCallback(
|
||||
(mode: AppMode) => {
|
||||
if (!isPaidEnterpriseFeaturesEnabled || !isSearchModeAvailable) return;
|
||||
setAppModeState(mode);
|
||||
},
|
||||
[isPaidEnterpriseFeaturesEnabled, isSearchModeAvailable]
|
||||
);
|
||||
|
||||
return (
|
||||
<AppModeContext.Provider value={{ appMode, setAppMode }}>
|
||||
{children}
|
||||
</AppModeContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -8,14 +8,15 @@ import {
|
||||
SearchFullResponse,
|
||||
} from "@/lib/search/interfaces";
|
||||
import { classifyQuery, searchDocuments } from "@/ee/lib/search/svc";
|
||||
import { useAppMode } from "@/providers/AppModeProvider";
|
||||
import useAppFocus from "@/hooks/useAppFocus";
|
||||
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
|
||||
import { useSettingsContext } from "@/providers/SettingsProvider";
|
||||
import { useUser } from "@/providers/UserProvider";
|
||||
import {
|
||||
QueryControllerContext,
|
||||
QueryClassification,
|
||||
QueryControllerValue,
|
||||
QueryState,
|
||||
AppMode,
|
||||
} from "@/providers/QueryControllerProvider";
|
||||
|
||||
interface QueryControllerProviderProps {
|
||||
@@ -25,19 +26,53 @@ interface QueryControllerProviderProps {
|
||||
export function QueryControllerProvider({
|
||||
children,
|
||||
}: QueryControllerProviderProps) {
|
||||
const { appMode, setAppMode } = useAppMode();
|
||||
const appFocus = useAppFocus();
|
||||
const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled();
|
||||
const settings = useSettingsContext();
|
||||
const { isSearchModeAvailable: searchUiEnabled } = settings;
|
||||
const { user } = useUser();
|
||||
|
||||
// Query state
|
||||
// ── Merged query state (discriminated union) ──────────────────────────
|
||||
const [state, setState] = useState<QueryState>({
|
||||
phase: "idle",
|
||||
appMode: "chat",
|
||||
});
|
||||
|
||||
// Persistent app-mode preference — survives phase transitions and is
|
||||
// used to restore the correct mode when resetting back to idle.
|
||||
const appModeRef = useRef<AppMode>("chat");
|
||||
|
||||
// ── App mode sync from user preferences ───────────────────────────────
|
||||
const persistedMode = user?.preferences?.default_app_mode;
|
||||
|
||||
useEffect(() => {
|
||||
let mode: AppMode = "chat";
|
||||
if (isPaidEnterpriseFeaturesEnabled && searchUiEnabled && persistedMode) {
|
||||
const lower = persistedMode.toLowerCase();
|
||||
mode = (["auto", "search", "chat"] as const).includes(lower as AppMode)
|
||||
? (lower as AppMode)
|
||||
: "chat";
|
||||
}
|
||||
appModeRef.current = mode;
|
||||
setState((prev) =>
|
||||
prev.phase === "idle" ? { phase: "idle", appMode: mode } : prev
|
||||
);
|
||||
}, [isPaidEnterpriseFeaturesEnabled, searchUiEnabled, persistedMode]);
|
||||
|
||||
const setAppMode = useCallback(
|
||||
(mode: AppMode) => {
|
||||
if (!isPaidEnterpriseFeaturesEnabled || !searchUiEnabled) return;
|
||||
setState((prev) => {
|
||||
if (prev.phase !== "idle") return prev;
|
||||
appModeRef.current = mode;
|
||||
return { phase: "idle", appMode: mode };
|
||||
});
|
||||
},
|
||||
[isPaidEnterpriseFeaturesEnabled, searchUiEnabled]
|
||||
);
|
||||
|
||||
// ── Ancillary state ───────────────────────────────────────────────────
|
||||
const [query, setQuery] = useState<string | null>(null);
|
||||
const [classification, setClassification] =
|
||||
useState<QueryClassification>(null);
|
||||
const [isClassifying, setIsClassifying] = useState(false);
|
||||
|
||||
// Search state
|
||||
const [searchResults, setSearchResults] = useState<SearchDocWithContent[]>(
|
||||
[]
|
||||
);
|
||||
@@ -51,7 +86,7 @@ export function QueryControllerProvider({
|
||||
const searchAbortRef = useRef<AbortController | null>(null);
|
||||
|
||||
/**
|
||||
* Perform document search
|
||||
* Perform document search (pure data-fetching, no phase side effects)
|
||||
*/
|
||||
const performSearch = useCallback(
|
||||
async (searchQuery: string, filters?: BaseFilters): Promise<void> => {
|
||||
@@ -85,19 +120,15 @@ export function QueryControllerProvider({
|
||||
setLlmSelectedDocIds(response.llm_selected_doc_ids ?? null);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
return;
|
||||
throw err;
|
||||
}
|
||||
|
||||
setError("Document search failed. Please try again.");
|
||||
setSearchResults([]);
|
||||
setLlmSelectedDocIds(null);
|
||||
} finally {
|
||||
// After we've performed a search, we automatically switch to "search" mode.
|
||||
// This is a "sticky" implementation; on purpose.
|
||||
setAppMode("search");
|
||||
}
|
||||
},
|
||||
[setAppMode]
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -112,8 +143,6 @@ export function QueryControllerProvider({
|
||||
const controller = new AbortController();
|
||||
classifyAbortRef.current = controller;
|
||||
|
||||
setIsClassifying(true);
|
||||
|
||||
try {
|
||||
const response: SearchFlowClassificationResponse = await classifyQuery(
|
||||
classifyQueryText,
|
||||
@@ -129,8 +158,6 @@ export function QueryControllerProvider({
|
||||
|
||||
setError("Query classification failed. Falling back to chat.");
|
||||
return "chat";
|
||||
} finally {
|
||||
setIsClassifying(false);
|
||||
}
|
||||
},
|
||||
[]
|
||||
@@ -148,62 +175,51 @@ export function QueryControllerProvider({
|
||||
setQuery(submitQuery);
|
||||
setError(null);
|
||||
|
||||
// 1.
|
||||
// We always route through chat if we're not Enterprise Enabled.
|
||||
//
|
||||
// 2.
|
||||
// We always route through chat if the admin has disabled the Search UI.
|
||||
//
|
||||
// 3.
|
||||
// We only go down the classification route if we're in the "New Session" tab.
|
||||
// Everywhere else, we always use the chat-flow.
|
||||
//
|
||||
// 4.
|
||||
// If we're in the "New Session" tab and the app-mode is "Chat", we continue with the chat-flow anyways.
|
||||
const currentAppMode = appModeRef.current;
|
||||
|
||||
// Always route through chat if:
|
||||
// 1. Not Enterprise Enabled
|
||||
// 2. Admin has disabled the Search UI
|
||||
// 3. Not in the "New Session" tab
|
||||
// 4. In "New Session" tab but app-mode is "Chat"
|
||||
if (
|
||||
!isPaidEnterpriseFeaturesEnabled ||
|
||||
!searchUiEnabled ||
|
||||
!appFocus.isNewSession() ||
|
||||
appMode === "chat"
|
||||
currentAppMode === "chat"
|
||||
) {
|
||||
setClassification("chat");
|
||||
setState({ phase: "chat" });
|
||||
setSearchResults([]);
|
||||
setLlmSelectedDocIds(null);
|
||||
onChat(submitQuery);
|
||||
return;
|
||||
}
|
||||
|
||||
if (appMode === "search") {
|
||||
await performSearch(submitQuery, filters);
|
||||
setClassification("search");
|
||||
// Search mode: immediately show SearchUI with loading state
|
||||
if (currentAppMode === "search") {
|
||||
setState({ phase: "searching" });
|
||||
try {
|
||||
await performSearch(submitQuery, filters);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") return;
|
||||
throw err;
|
||||
}
|
||||
setState({ phase: "search-results" });
|
||||
return;
|
||||
}
|
||||
|
||||
// # Note (@raunakab)
|
||||
//
|
||||
// Interestingly enough, for search, we do:
|
||||
// 1. setClassification("search")
|
||||
// 2. performSearch
|
||||
//
|
||||
// But for chat, we do:
|
||||
// 1. performChat
|
||||
// 2. setClassification("chat")
|
||||
//
|
||||
// The ChatUI has a nice loading UI, so it's fine for us to prematurely set the
|
||||
// classification-state before the chat has finished loading.
|
||||
//
|
||||
// However, the SearchUI does not. Prematurely setting the classification-state
|
||||
// will lead to a slightly ugly UI.
|
||||
|
||||
// Auto mode: classify first, then route
|
||||
setState({ phase: "classifying" });
|
||||
try {
|
||||
const result = await performClassification(submitQuery);
|
||||
|
||||
if (result === "search") {
|
||||
setState({ phase: "searching" });
|
||||
await performSearch(submitQuery, filters);
|
||||
setClassification("search");
|
||||
setState({ phase: "search-results" });
|
||||
appModeRef.current = "search";
|
||||
} else {
|
||||
setClassification("chat");
|
||||
setState({ phase: "chat" });
|
||||
setSearchResults([]);
|
||||
setLlmSelectedDocIds(null);
|
||||
onChat(submitQuery);
|
||||
@@ -213,14 +229,13 @@ export function QueryControllerProvider({
|
||||
return;
|
||||
}
|
||||
|
||||
setClassification("chat");
|
||||
setState({ phase: "chat" });
|
||||
setSearchResults([]);
|
||||
setLlmSelectedDocIds(null);
|
||||
onChat(submitQuery);
|
||||
}
|
||||
},
|
||||
[
|
||||
appMode,
|
||||
appFocus,
|
||||
performClassification,
|
||||
performSearch,
|
||||
@@ -235,7 +250,14 @@ export function QueryControllerProvider({
|
||||
const refineSearch = useCallback(
|
||||
async (filters: BaseFilters): Promise<void> => {
|
||||
if (!query) return;
|
||||
await performSearch(query, filters);
|
||||
setState({ phase: "searching" });
|
||||
try {
|
||||
await performSearch(query, filters);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") return;
|
||||
throw err;
|
||||
}
|
||||
setState({ phase: "search-results" });
|
||||
},
|
||||
[query, performSearch]
|
||||
);
|
||||
@@ -254,7 +276,7 @@ export function QueryControllerProvider({
|
||||
}
|
||||
|
||||
setQuery(null);
|
||||
setClassification(null);
|
||||
setState({ phase: "idle", appMode: appModeRef.current });
|
||||
setSearchResults([]);
|
||||
setLlmSelectedDocIds(null);
|
||||
setError(null);
|
||||
@@ -262,8 +284,8 @@ export function QueryControllerProvider({
|
||||
|
||||
const value: QueryControllerValue = useMemo(
|
||||
() => ({
|
||||
classification,
|
||||
isClassifying,
|
||||
state,
|
||||
setAppMode,
|
||||
searchResults,
|
||||
llmSelectedDocIds,
|
||||
error,
|
||||
@@ -272,8 +294,8 @@ export function QueryControllerProvider({
|
||||
reset,
|
||||
}),
|
||||
[
|
||||
classification,
|
||||
isClassifying,
|
||||
state,
|
||||
setAppMode,
|
||||
searchResults,
|
||||
llmSelectedDocIds,
|
||||
error,
|
||||
@@ -283,7 +305,7 @@ export function QueryControllerProvider({
|
||||
]
|
||||
);
|
||||
|
||||
// Sync classification state with navigation context
|
||||
// Sync state with navigation context
|
||||
useEffect(reset, [appFocus, reset]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -56,7 +56,7 @@ export default function SearchCard({
|
||||
|
||||
return (
|
||||
<Interactive.Stateless onClick={handleClick} prominence="secondary">
|
||||
<Interactive.Container heightVariant="fit">
|
||||
<Interactive.Container heightVariant="fit" widthVariant="full">
|
||||
<Section alignItems="start" gap={0} padding={0.25}>
|
||||
{/* Title Row */}
|
||||
<Section
|
||||
|
||||
@@ -18,16 +18,17 @@ import { getTimeFilterDate, TimeFilter } from "@/lib/time";
|
||||
import useTags from "@/hooks/useTags";
|
||||
import { SourceIcon } from "@/components/SourceIcon";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import LineItem from "@/refresh-components/buttons/LineItem";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import Popover, { PopoverMenu } from "@/refresh-components/Popover";
|
||||
import { SvgCheck, SvgClock, SvgTag } from "@opal/icons";
|
||||
import FilterButton from "@/refresh-components/buttons/FilterButton";
|
||||
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
|
||||
import useFilter from "@/hooks/useFilter";
|
||||
import { LineItemButton } from "@opal/components";
|
||||
import { useQueryController } from "@/providers/QueryControllerProvider";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
@@ -51,22 +52,17 @@ const TIME_FILTER_OPTIONS: { value: TimeFilter; label: string }[] = [
|
||||
{ value: "year", label: "Past year" },
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// SearchResults Component (default export)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Component for displaying search results with source filter sidebar.
|
||||
*/
|
||||
export default function SearchUI({ onDocumentClick }: SearchResultsProps) {
|
||||
// Available tags from backend
|
||||
const { tags: availableTags } = useTags();
|
||||
const {
|
||||
state,
|
||||
searchResults: results,
|
||||
llmSelectedDocIds,
|
||||
error,
|
||||
refineSearch: onRefineSearch,
|
||||
} = useQueryController();
|
||||
|
||||
const prevErrorRef = useRef<string | null>(null);
|
||||
|
||||
// Show a toast notification when a new error occurs
|
||||
@@ -197,6 +193,15 @@ export default function SearchUI({ onDocumentClick }: SearchResultsProps) {
|
||||
|
||||
const showEmpty = !error && results.length === 0;
|
||||
|
||||
// Show a centered spinner while search is in-flight (after all hooks)
|
||||
if (state.phase === "searching") {
|
||||
return (
|
||||
<div className="flex-1 min-h-0 w-full flex items-center justify-center">
|
||||
<SimpleLoader />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 w-full flex flex-col gap-3">
|
||||
{/* ── Top row: Filters + Result count ── */}
|
||||
@@ -226,18 +231,19 @@ export default function SearchUI({ onDocumentClick }: SearchResultsProps) {
|
||||
<Popover.Content align="start" width="md">
|
||||
<PopoverMenu>
|
||||
{TIME_FILTER_OPTIONS.map((opt) => (
|
||||
<LineItem
|
||||
<LineItemButton
|
||||
key={opt.value}
|
||||
onClick={() => {
|
||||
setTimeFilter(opt.value);
|
||||
setTimeFilterOpen(false);
|
||||
onRefineSearch(buildFilters({ time: opt.value }));
|
||||
}}
|
||||
selected={timeFilter === opt.value}
|
||||
state={timeFilter === opt.value ? "selected" : "empty"}
|
||||
icon={timeFilter === opt.value ? SvgCheck : SvgClock}
|
||||
>
|
||||
{opt.label}
|
||||
</LineItem>
|
||||
title={opt.label}
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
/>
|
||||
))}
|
||||
</PopoverMenu>
|
||||
</Popover.Content>
|
||||
@@ -278,7 +284,7 @@ export default function SearchUI({ onDocumentClick }: SearchResultsProps) {
|
||||
t.tag_value === tag.tag_value
|
||||
);
|
||||
return (
|
||||
<LineItem
|
||||
<LineItemButton
|
||||
key={`${tag.tag_key}=${tag.tag_value}`}
|
||||
onClick={() => {
|
||||
const next = isSelected
|
||||
@@ -291,11 +297,12 @@ export default function SearchUI({ onDocumentClick }: SearchResultsProps) {
|
||||
setSelectedTags(next);
|
||||
onRefineSearch(buildFilters({ tags: next }));
|
||||
}}
|
||||
selected={isSelected}
|
||||
state={isSelected ? "selected" : "empty"}
|
||||
icon={isSelected ? SvgCheck : SvgTag}
|
||||
>
|
||||
{tag.tag_value}
|
||||
</LineItem>
|
||||
title={tag.tag_value}
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</PopoverMenu>
|
||||
@@ -357,7 +364,7 @@ export default function SearchUI({ onDocumentClick }: SearchResultsProps) {
|
||||
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col gap-4 px-1">
|
||||
<Section gap={0.25} height="fit">
|
||||
{sourcesWithMeta.map(({ source, meta, count }) => (
|
||||
<LineItem
|
||||
<LineItemButton
|
||||
key={source}
|
||||
icon={(props) => (
|
||||
<SourceIcon
|
||||
@@ -367,12 +374,15 @@ export default function SearchUI({ onDocumentClick }: SearchResultsProps) {
|
||||
/>
|
||||
)}
|
||||
onClick={() => handleSourceToggle(source)}
|
||||
selected={selectedSources.includes(source)}
|
||||
emphasized
|
||||
state={
|
||||
selectedSources.includes(source) ? "selected" : "empty"
|
||||
}
|
||||
title={meta.displayName}
|
||||
selectVariant="select-heavy"
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
rightChildren={<Text text03>{count}</Text>}
|
||||
>
|
||||
{meta.displayName}
|
||||
</LineItem>
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//
|
||||
// This is useful in determining what `SidebarTab` should be active, for example.
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { SEARCH_PARAM_NAMES } from "@/app/app/services/searchParams";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
|
||||
@@ -66,31 +67,25 @@ export default function useAppFocus(): AppFocus {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Check if we're viewing a shared chat
|
||||
if (pathname.startsWith("/app/shared/")) {
|
||||
return new AppFocus("shared-chat");
|
||||
}
|
||||
|
||||
// Check if we're on the user settings page
|
||||
if (pathname.startsWith("/app/settings")) {
|
||||
return new AppFocus("user-settings");
|
||||
}
|
||||
|
||||
// Check if we're on the agents page
|
||||
if (pathname.startsWith("/app/agents")) {
|
||||
return new AppFocus("more-agents");
|
||||
}
|
||||
|
||||
// Check search params for chat, agent, or project
|
||||
const chatId = searchParams.get(SEARCH_PARAM_NAMES.CHAT_ID);
|
||||
if (chatId) return new AppFocus({ type: "chat", id: chatId });
|
||||
|
||||
const agentId = searchParams.get(SEARCH_PARAM_NAMES.PERSONA_ID);
|
||||
if (agentId) return new AppFocus({ type: "agent", id: agentId });
|
||||
|
||||
const projectId = searchParams.get(SEARCH_PARAM_NAMES.PROJECT_ID);
|
||||
if (projectId) return new AppFocus({ type: "project", id: projectId });
|
||||
|
||||
// No search params means we're on a new session
|
||||
return new AppFocus("new-session");
|
||||
// Memoize on the values that determine which AppFocus is constructed.
|
||||
// AppFocus is immutable, so same inputs → same instance.
|
||||
return useMemo(() => {
|
||||
if (pathname.startsWith("/app/shared/")) {
|
||||
return new AppFocus("shared-chat");
|
||||
}
|
||||
if (pathname.startsWith("/app/settings")) {
|
||||
return new AppFocus("user-settings");
|
||||
}
|
||||
if (pathname.startsWith("/app/agents")) {
|
||||
return new AppFocus("more-agents");
|
||||
}
|
||||
if (chatId) return new AppFocus({ type: "chat", id: chatId });
|
||||
if (agentId) return new AppFocus({ type: "agent", id: agentId });
|
||||
if (projectId) return new AppFocus({ type: "project", id: projectId });
|
||||
return new AppFocus("new-session");
|
||||
}, [pathname, chatId, agentId, projectId]);
|
||||
}
|
||||
|
||||
40
web/src/hooks/useUserCounts.ts
Normal file
40
web/src/hooks/useUserCounts.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import useSWR from "swr";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import type { InvitedUserSnapshot } from "@/lib/types";
|
||||
import { NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
|
||||
|
||||
type PaginatedCountResponse = {
|
||||
total_items: number;
|
||||
};
|
||||
|
||||
type UserCounts = {
|
||||
activeCount: number | null;
|
||||
invitedCount: number | null;
|
||||
pendingCount: number | null;
|
||||
};
|
||||
|
||||
export default function useUserCounts(): UserCounts {
|
||||
// Active user count — lightweight fetch (page_size=1 to minimize payload)
|
||||
const { data: activeData } = useSWR<PaginatedCountResponse>(
|
||||
"/api/manage/users/accepted?page_num=0&page_size=1",
|
||||
errorHandlingFetcher
|
||||
);
|
||||
|
||||
const { data: invitedUsers } = useSWR<InvitedUserSnapshot[]>(
|
||||
"/api/manage/users/invited",
|
||||
errorHandlingFetcher
|
||||
);
|
||||
|
||||
const { data: pendingUsers } = useSWR<InvitedUserSnapshot[]>(
|
||||
NEXT_PUBLIC_CLOUD_ENABLED ? "/api/tenants/users/pending" : null,
|
||||
errorHandlingFetcher
|
||||
);
|
||||
|
||||
return {
|
||||
activeCount: activeData?.total_items ?? null,
|
||||
invitedCount: invitedUsers?.length ?? null,
|
||||
pendingCount: pendingUsers?.length ?? null,
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export enum LLMProviderName {
|
||||
OPENROUTER = "openrouter",
|
||||
VERTEX_AI = "vertex_ai",
|
||||
BEDROCK = "bedrock",
|
||||
LITELLM_PROXY = "litellm_proxy",
|
||||
CUSTOM = "custom",
|
||||
}
|
||||
|
||||
@@ -144,6 +145,18 @@ export interface OpenRouterFetchParams {
|
||||
provider_name?: string;
|
||||
}
|
||||
|
||||
export interface LiteLLMProxyFetchParams {
|
||||
api_base?: string;
|
||||
api_key?: string;
|
||||
provider_name?: string;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface LiteLLMProxyModelResponse {
|
||||
provider_name: string;
|
||||
model_name: string;
|
||||
}
|
||||
|
||||
export interface VertexAIFetchParams {
|
||||
model_configurations?: ModelConfiguration[];
|
||||
}
|
||||
@@ -153,4 +166,5 @@ export type FetchModelsParams =
|
||||
| OllamaFetchParams
|
||||
| LMStudioFetchParams
|
||||
| OpenRouterFetchParams
|
||||
| LiteLLMProxyFetchParams
|
||||
| VertexAIFetchParams;
|
||||
|
||||
@@ -60,7 +60,7 @@ import {
|
||||
} from "@opal/icons";
|
||||
import MinimalMarkdown from "@/components/chat/MinimalMarkdown";
|
||||
import { useSettingsContext } from "@/providers/SettingsProvider";
|
||||
import { AppMode, useAppMode } from "@/providers/AppModeProvider";
|
||||
import type { AppMode } from "@/providers/QueryControllerProvider";
|
||||
import useAppFocus from "@/hooks/useAppFocus";
|
||||
import { useQueryController } from "@/providers/QueryControllerProvider";
|
||||
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
|
||||
@@ -82,7 +82,7 @@ import useBrowserInfo from "@/hooks/useBrowserInfo";
|
||||
*/
|
||||
function Header() {
|
||||
const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled();
|
||||
const { appMode, setAppMode } = useAppMode();
|
||||
const { state, setAppMode } = useQueryController();
|
||||
const settings = useSettingsContext();
|
||||
const { isMobile } = useScreenSize();
|
||||
const { setFolded } = useAppSidebarContext();
|
||||
@@ -108,7 +108,6 @@ function Header() {
|
||||
useChatSessions();
|
||||
const router = useRouter();
|
||||
const appFocus = useAppFocus();
|
||||
const { classification } = useQueryController();
|
||||
|
||||
const customHeaderContent =
|
||||
settings?.enterpriseSettings?.custom_header_content;
|
||||
@@ -117,7 +116,8 @@ function Header() {
|
||||
// without this content still use.
|
||||
const pageWithHeaderContent = appFocus.isChat() || appFocus.isNewSession();
|
||||
|
||||
const effectiveMode: AppMode = appFocus.isNewSession() ? appMode : "chat";
|
||||
const effectiveMode: AppMode =
|
||||
appFocus.isNewSession() && state.phase === "idle" ? state.appMode : "chat";
|
||||
|
||||
const availableProjects = useMemo(() => {
|
||||
if (!projects) return [];
|
||||
@@ -323,7 +323,7 @@ function Header() {
|
||||
{isPaidEnterpriseFeaturesEnabled &&
|
||||
settings.isSearchModeAvailable &&
|
||||
appFocus.isNewSession() &&
|
||||
!classification && (
|
||||
state.phase === "idle" && (
|
||||
<Popover open={modePopoverOpen} onOpenChange={setModePopoverOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
<OpenButton
|
||||
|
||||
@@ -230,7 +230,7 @@ function SettingsHeader({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Spacer vertical rem={1} />
|
||||
<Spacer vertical rem={2.5} />
|
||||
|
||||
<div className="flex flex-col gap-6 px-4">
|
||||
<div className="flex w-full justify-between">
|
||||
|
||||
@@ -58,6 +58,7 @@ export const ADMIN_PATHS = {
|
||||
DOCUMENT_PROCESSING: "/admin/configuration/document-processing",
|
||||
KNOWLEDGE_GRAPH: "/admin/kg",
|
||||
USERS: "/admin/users",
|
||||
USERS_V2: "/admin/users2",
|
||||
API_KEYS: "/admin/api-key",
|
||||
TOKEN_RATE_LIMITS: "/admin/token-rate-limits",
|
||||
USAGE: "/admin/performance/usage",
|
||||
@@ -190,6 +191,11 @@ export const ADMIN_ROUTE_CONFIG: Record<string, AdminRouteConfig> = {
|
||||
title: "Manage Users",
|
||||
sidebarLabel: "Users",
|
||||
},
|
||||
[ADMIN_PATHS.USERS_V2]: {
|
||||
icon: SvgUser,
|
||||
title: "Users & Requests",
|
||||
sidebarLabel: "Users v2",
|
||||
},
|
||||
[ADMIN_PATHS.API_KEYS]: {
|
||||
icon: SvgKey,
|
||||
title: "API Keys",
|
||||
|
||||
@@ -22,6 +22,7 @@ const PROVIDER_ICONS: Record<string, IconFunctionComponent> = {
|
||||
[LLMProviderName.BEDROCK]: SvgAws,
|
||||
[LLMProviderName.AZURE]: SvgAzure,
|
||||
litellm: SvgLitellm,
|
||||
[LLMProviderName.LITELLM_PROXY]: SvgLitellm,
|
||||
[LLMProviderName.OLLAMA_CHAT]: SvgOllama,
|
||||
[LLMProviderName.OPENROUTER]: SvgOpenrouter,
|
||||
[LLMProviderName.LM_STUDIO]: SvgLmStudio,
|
||||
@@ -37,6 +38,7 @@ const PROVIDER_PRODUCT_NAMES: Record<string, string> = {
|
||||
[LLMProviderName.BEDROCK]: "Amazon Bedrock",
|
||||
[LLMProviderName.AZURE]: "Azure OpenAI",
|
||||
litellm: "LiteLLM",
|
||||
[LLMProviderName.LITELLM_PROXY]: "LiteLLM Proxy",
|
||||
[LLMProviderName.OLLAMA_CHAT]: "Ollama",
|
||||
[LLMProviderName.OPENROUTER]: "OpenRouter",
|
||||
[LLMProviderName.LM_STUDIO]: "LM Studio",
|
||||
@@ -52,6 +54,7 @@ const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
|
||||
[LLMProviderName.BEDROCK]: "AWS",
|
||||
[LLMProviderName.AZURE]: "Microsoft Azure",
|
||||
litellm: "LiteLLM",
|
||||
[LLMProviderName.LITELLM_PROXY]: "LiteLLM Proxy",
|
||||
[LLMProviderName.OLLAMA_CHAT]: "Ollama",
|
||||
[LLMProviderName.OPENROUTER]: "OpenRouter",
|
||||
[LLMProviderName.LM_STUDIO]: "LM Studio",
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
import { eeGated } from "@/ce";
|
||||
import { AppModeProvider as EEAppModeProvider } from "@/ee/providers/AppModeProvider";
|
||||
|
||||
export type AppMode = "auto" | "search" | "chat";
|
||||
|
||||
interface AppModeContextValue {
|
||||
appMode: AppMode;
|
||||
setAppMode: (mode: AppMode) => void;
|
||||
}
|
||||
|
||||
export const AppModeContext = createContext<AppModeContextValue>({
|
||||
appMode: "chat",
|
||||
setAppMode: () => undefined,
|
||||
});
|
||||
|
||||
export function useAppMode(): AppModeContextValue {
|
||||
return useContext(AppModeContext);
|
||||
}
|
||||
|
||||
export const AppModeProvider = eeGated(EEAppModeProvider);
|
||||
@@ -24,7 +24,7 @@
|
||||
* 4. **ProviderContextProvider** - LLM provider configuration
|
||||
* 5. **ModalProvider** - Global modal state management
|
||||
* 6. **AppSidebarProvider** - Sidebar open/closed state
|
||||
* 7. **AppModeProvider** - Search/Chat mode selection
|
||||
* 7. **QueryControllerProvider** - Search/Chat mode + query lifecycle
|
||||
*
|
||||
* ## Usage
|
||||
*
|
||||
@@ -40,7 +40,7 @@
|
||||
* - `useSettingsContext()` - from SettingsProvider
|
||||
* - `useUser()` - from UserProvider
|
||||
* - `useAppBackground()` - from AppBackgroundProvider
|
||||
* - `useAppMode()` - from AppModeProvider
|
||||
* - `useQueryController()` - from QueryControllerProvider (includes appMode)
|
||||
* - etc.
|
||||
*
|
||||
* @TODO(@raunakab): The providers wrapped by this component are currently
|
||||
@@ -65,7 +65,6 @@ import { User } from "@/lib/types";
|
||||
import { ModalProvider } from "@/components/context/ModalContext";
|
||||
import { AuthTypeMetadata } from "@/lib/userSS";
|
||||
import { AppSidebarProvider } from "@/providers/AppSidebarProvider";
|
||||
import { AppModeProvider } from "@/providers/AppModeProvider";
|
||||
import { AppBackgroundProvider } from "@/providers/AppBackgroundProvider";
|
||||
import { QueryControllerProvider } from "@/providers/QueryControllerProvider";
|
||||
import ToastProvider from "@/providers/ToastProvider";
|
||||
@@ -96,11 +95,9 @@ export default function AppProvider({
|
||||
<ProviderContextProvider>
|
||||
<ModalProvider user={user}>
|
||||
<AppSidebarProvider folded={!!folded}>
|
||||
<AppModeProvider>
|
||||
<QueryControllerProvider>
|
||||
<ToastProvider>{children}</ToastProvider>
|
||||
</QueryControllerProvider>
|
||||
</AppModeProvider>
|
||||
<QueryControllerProvider>
|
||||
<ToastProvider>{children}</ToastProvider>
|
||||
</QueryControllerProvider>
|
||||
</AppSidebarProvider>
|
||||
</ModalProvider>
|
||||
</ProviderContextProvider>
|
||||
|
||||
@@ -5,13 +5,20 @@ import { eeGated } from "@/ce";
|
||||
import { QueryControllerProvider as EEQueryControllerProvider } from "@/ee/providers/QueryControllerProvider";
|
||||
import { SearchDocWithContent, BaseFilters } from "@/lib/search/interfaces";
|
||||
|
||||
export type QueryClassification = "search" | "chat" | null;
|
||||
export type AppMode = "auto" | "search" | "chat";
|
||||
|
||||
export type QueryState =
|
||||
| { phase: "idle"; appMode: AppMode }
|
||||
| { phase: "classifying" }
|
||||
| { phase: "searching" }
|
||||
| { phase: "search-results" }
|
||||
| { phase: "chat" };
|
||||
|
||||
export interface QueryControllerValue {
|
||||
/** Classification state: null (idle), "search", or "chat" */
|
||||
classification: QueryClassification;
|
||||
/** Whether or not the currently submitted query is being actively classified by the backend */
|
||||
isClassifying: boolean;
|
||||
/** Single state variable encoding both the query lifecycle phase and (when idle) the user's mode selection. */
|
||||
state: QueryState;
|
||||
/** Update the app mode. Only takes effect when idle. No-op in CE or when search is unavailable. */
|
||||
setAppMode: (mode: AppMode) => void;
|
||||
/** Search results (empty if chat or not yet searched) */
|
||||
searchResults: SearchDocWithContent[];
|
||||
/** Document IDs selected by the LLM as most relevant */
|
||||
@@ -31,8 +38,8 @@ export interface QueryControllerValue {
|
||||
}
|
||||
|
||||
export const QueryControllerContext = createContext<QueryControllerValue>({
|
||||
classification: null,
|
||||
isClassifying: false,
|
||||
state: { phase: "idle", appMode: "chat" },
|
||||
setAppMode: () => undefined,
|
||||
searchResults: [],
|
||||
llmSelectedDocIds: null,
|
||||
error: null,
|
||||
|
||||
@@ -42,12 +42,6 @@ export default function ScrollIndicatorDiv({
|
||||
if (!container) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||
console.log(
|
||||
"scrollHeight: ",
|
||||
scrollHeight,
|
||||
" clientHeight: ",
|
||||
clientHeight
|
||||
);
|
||||
const isScrollable = scrollHeight > clientHeight;
|
||||
|
||||
// Show top indicator if scrolled down from top
|
||||
|
||||
@@ -118,6 +118,21 @@ describe("InputComboBox", () => {
|
||||
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows all options on focus when a value is already selected", () => {
|
||||
render(
|
||||
<InputComboBox
|
||||
placeholder="Select"
|
||||
value="apple"
|
||||
options={mockOptions}
|
||||
/>
|
||||
);
|
||||
const input = screen.getByDisplayValue("Apple");
|
||||
fireEvent.focus(input);
|
||||
|
||||
const options = screen.getAllByRole("option");
|
||||
expect(options.length).toBe(3);
|
||||
});
|
||||
|
||||
test("closes dropdown on tab", async () => {
|
||||
const user = setupUser();
|
||||
render(
|
||||
|
||||
@@ -322,24 +322,32 @@ const InputComboBox = ({
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
if (hasOptions) {
|
||||
setInputValue("");
|
||||
setIsOpen(true);
|
||||
setHighlightedIndex(-1); // Start with no highlight on focus
|
||||
setIsKeyboardNav(false); // Start with mouse mode
|
||||
setHighlightedIndex(-1);
|
||||
setIsKeyboardNav(false);
|
||||
}
|
||||
}, [hasOptions, setIsOpen, setHighlightedIndex, setIsKeyboardNav]);
|
||||
}, [
|
||||
hasOptions,
|
||||
setInputValue,
|
||||
setIsOpen,
|
||||
setHighlightedIndex,
|
||||
setIsKeyboardNav,
|
||||
]);
|
||||
|
||||
const toggleDropdown = useCallback(() => {
|
||||
if (!disabled && hasOptions) {
|
||||
setIsOpen((prev) => {
|
||||
const newOpen = !prev;
|
||||
if (newOpen) {
|
||||
setHighlightedIndex(-1); // Reset highlight when opening
|
||||
setInputValue("");
|
||||
setHighlightedIndex(-1);
|
||||
}
|
||||
return newOpen;
|
||||
});
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [disabled, hasOptions, setIsOpen, setHighlightedIndex]);
|
||||
}, [disabled, hasOptions, setIsOpen, setInputValue, setHighlightedIndex]);
|
||||
|
||||
const autoId = useId();
|
||||
const fieldId = fieldContext?.baseId || name || `combo-box-${autoId}`;
|
||||
|
||||
@@ -20,21 +20,26 @@ export function useComboBoxState({ value, options }: UseComboBoxStateProps) {
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||
const [isKeyboardNav, setIsKeyboardNav] = useState(false);
|
||||
|
||||
// State synchronization logic
|
||||
// Only sync when the dropdown is closed or when value changes significantly
|
||||
// Sync inputValue with the external value prop.
|
||||
// When the dropdown is closed, always reflect the controlled value.
|
||||
// When the dropdown is open, only sync if the *value prop itself* changes
|
||||
// (e.g. parent programmatically updates it), not when inputValue changes
|
||||
// (e.g. user clears the field on focus to browse all options).
|
||||
useEffect(() => {
|
||||
// If dropdown is closed, always sync with prop value
|
||||
if (!isOpen) {
|
||||
setInputValue(value);
|
||||
} else {
|
||||
// If dropdown is open, only sync if the new value is an exact match with an option
|
||||
// This prevents interference when user is typing
|
||||
}
|
||||
}, [value, isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const isExactOptionMatch = options.some((opt) => opt.value === value);
|
||||
if (isExactOptionMatch && inputValue !== value) {
|
||||
if (isExactOptionMatch) {
|
||||
setInputValue(value);
|
||||
}
|
||||
}
|
||||
}, [value, isOpen, options, inputValue]);
|
||||
// Only react to value prop changes while open, not inputValue changes
|
||||
}, [value]);
|
||||
|
||||
// Reset highlight and keyboard nav when closing dropdown
|
||||
useEffect(() => {
|
||||
|
||||
@@ -35,7 +35,7 @@ import { LLMOption, LLMOptionGroup } from "./interfaces";
|
||||
export interface LLMPopoverProps {
|
||||
llmManager: LlmManager;
|
||||
requiresImageInput?: boolean;
|
||||
folded?: boolean;
|
||||
foldable?: boolean;
|
||||
onSelect?: (value: string) => void;
|
||||
currentModelName?: string;
|
||||
disabled?: boolean;
|
||||
@@ -142,7 +142,7 @@ export function groupLlmOptions(
|
||||
export default function LLMPopover({
|
||||
llmManager,
|
||||
requiresImageInput,
|
||||
folded,
|
||||
foldable,
|
||||
onSelect,
|
||||
currentModelName,
|
||||
disabled = false,
|
||||
@@ -359,13 +359,14 @@ export default function LLMPopover({
|
||||
<Disabled disabled={disabled}>
|
||||
<OpenButton
|
||||
icon={
|
||||
folded
|
||||
foldable
|
||||
? SvgRefreshCw
|
||||
: getProviderIcon(
|
||||
llmManager.currentLlm.provider,
|
||||
llmManager.currentLlm.modelName
|
||||
)
|
||||
}
|
||||
foldable={foldable}
|
||||
>
|
||||
{currentLlmDisplayName}
|
||||
</OpenButton>
|
||||
|
||||
@@ -72,7 +72,6 @@ import { eeGated } from "@/ce";
|
||||
import EESearchUI from "@/ee/sections/SearchUI";
|
||||
const SearchUI = eeGated(EESearchUI);
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { useAppMode } from "@/providers/AppModeProvider";
|
||||
|
||||
interface FadeProps {
|
||||
show: boolean;
|
||||
@@ -129,7 +128,6 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
|
||||
type: "success",
|
||||
},
|
||||
});
|
||||
const { setAppMode } = useAppMode();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Use SWR hooks for data fetching
|
||||
@@ -485,7 +483,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
|
||||
finishOnboarding,
|
||||
]
|
||||
);
|
||||
const { submit: submitQuery, classification } = useQueryController();
|
||||
const { submit: submitQuery, state, setAppMode } = useQueryController();
|
||||
|
||||
const defaultAppMode =
|
||||
(user?.preferences?.default_app_mode?.toLowerCase() as "chat" | "search") ??
|
||||
@@ -493,12 +491,15 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
|
||||
|
||||
const isNewSession = appFocus.isNewSession();
|
||||
|
||||
const isSearch =
|
||||
state.phase === "searching" || state.phase === "search-results";
|
||||
|
||||
// 1. Reset the app-mode back to the user's default when navigating back to the "New Sessions" tab.
|
||||
// 2. If we're navigating away from the "New Session" tab after performing a search, we reset the app-input-bar.
|
||||
useEffect(() => {
|
||||
if (isNewSession) setAppMode(defaultAppMode);
|
||||
if (!isNewSession && classification === "search") resetInputBar();
|
||||
}, [isNewSession, defaultAppMode, classification, resetInputBar, setAppMode]);
|
||||
if (!isNewSession && isSearch) resetInputBar();
|
||||
}, [isNewSession, defaultAppMode, isSearch, resetInputBar, setAppMode]);
|
||||
|
||||
const handleSearchDocumentClick = useCallback(
|
||||
(doc: MinimalOnyxDocument) => setPresentingDocument(doc),
|
||||
@@ -607,7 +608,6 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
|
||||
|
||||
const hasStarterMessages = (liveAgent?.starter_messages?.length ?? 0) > 0;
|
||||
|
||||
const isSearch = classification === "search";
|
||||
const gridStyle = {
|
||||
gridTemplateColumns: "1fr",
|
||||
gridTemplateRows: isSearch
|
||||
@@ -735,7 +735,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
|
||||
<Fade
|
||||
show={
|
||||
(appFocus.isNewSession() || appFocus.isAgent()) &&
|
||||
!classification
|
||||
(state.phase === "idle" || state.phase === "classifying")
|
||||
}
|
||||
className="w-full flex-1 flex flex-col items-center justify-end"
|
||||
>
|
||||
@@ -764,7 +764,8 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
|
||||
|
||||
{/* OnboardingUI */}
|
||||
{(appFocus.isNewSession() || appFocus.isAgent()) &&
|
||||
!classification &&
|
||||
(state.phase === "idle" ||
|
||||
state.phase === "classifying") &&
|
||||
(showOnboarding || !user?.personalization?.name) &&
|
||||
!onboardingDismissed && (
|
||||
<OnboardingFlow
|
||||
@@ -799,7 +800,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
|
||||
<div
|
||||
className={cn(
|
||||
"transition-all duration-150 ease-in-out overflow-hidden",
|
||||
classification === "search" ? "h-[14px]" : "h-0"
|
||||
isSearch ? "h-[14px]" : "h-0"
|
||||
)}
|
||||
/>
|
||||
<AppInputBar
|
||||
|
||||
@@ -44,6 +44,7 @@ import { VertexAIModal } from "@/sections/modals/llmConfig/VertexAIModal";
|
||||
import { OpenRouterModal } from "@/sections/modals/llmConfig/OpenRouterModal";
|
||||
import { CustomModal } from "@/sections/modals/llmConfig/CustomModal";
|
||||
import { LMStudioForm } from "@/sections/modals/llmConfig/LMStudioForm";
|
||||
import { LiteLLMProxyModal } from "@/sections/modals/llmConfig/LiteLLMProxyModal";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
|
||||
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.LLM_MODELS]!;
|
||||
@@ -116,6 +117,13 @@ const PROVIDER_MODAL_MAP: Record<
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
),
|
||||
litellm_proxy: (d, open, onOpenChange) => (
|
||||
<LiteLLMProxyModal
|
||||
shouldMarkAsDefault={d}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
|
||||
58
web/src/refresh-pages/admin/UsersPage.tsx
Normal file
58
web/src/refresh-pages/admin/UsersPage.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { SvgUser, SvgUserPlus } from "@opal/icons";
|
||||
import { Button } from "@opal/components";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { useScimToken } from "@/hooks/useScimToken";
|
||||
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
|
||||
import useUserCounts from "@/hooks/useUserCounts";
|
||||
|
||||
import UsersSummary from "./UsersPage/UsersSummary";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Users page content
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function UsersContent() {
|
||||
const isEe = usePaidEnterpriseFeaturesEnabled();
|
||||
|
||||
const { data: scimToken } = useScimToken();
|
||||
const showScim = isEe && !!scimToken;
|
||||
|
||||
const { activeCount, invitedCount, pendingCount } = useUserCounts();
|
||||
|
||||
return (
|
||||
<>
|
||||
<UsersSummary
|
||||
activeUsers={activeCount}
|
||||
pendingInvites={invitedCount}
|
||||
requests={pendingCount}
|
||||
showScim={showScim}
|
||||
/>
|
||||
|
||||
{/* Table and filters will be added in subsequent PRs */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function UsersPage() {
|
||||
return (
|
||||
<SettingsLayouts.Root width="lg">
|
||||
<SettingsLayouts.Header
|
||||
title="Users & Requests"
|
||||
icon={SvgUser}
|
||||
rightChildren={
|
||||
// TODO (ENG-3806): Wire up invite modal
|
||||
<Button icon={SvgUserPlus}>Invite Users</Button>
|
||||
}
|
||||
/>
|
||||
<SettingsLayouts.Body>
|
||||
<UsersContent />
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
117
web/src/refresh-pages/admin/UsersPage/UsersSummary.tsx
Normal file
117
web/src/refresh-pages/admin/UsersPage/UsersSummary.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { SvgArrowUpRight, SvgUserSync } from "@opal/icons";
|
||||
import { ContentAction } from "@opal/layouts";
|
||||
import { Button } from "@opal/components";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import Card from "@/refresh-components/cards/Card";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import Link from "next/link";
|
||||
import { ADMIN_PATHS } from "@/lib/admin-routes";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stats cell — number + label
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type StatCellProps = {
|
||||
value: number | null;
|
||||
label: string;
|
||||
};
|
||||
|
||||
function StatCell({ value, label }: StatCellProps) {
|
||||
const display = value === null ? "\u2014" : value.toLocaleString();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-0.5 w-full p-2">
|
||||
<Text as="span" mainUiAction text04>
|
||||
{display}
|
||||
</Text>
|
||||
<Text as="span" secondaryBody text03>
|
||||
{label}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SCIM card
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ScimCard() {
|
||||
return (
|
||||
<Card gap={0.5} padding={0.75}>
|
||||
<ContentAction
|
||||
icon={SvgUserSync}
|
||||
title="SCIM Sync"
|
||||
description="Users are synced from your identity provider."
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
paddingVariant="fit"
|
||||
rightChildren={
|
||||
<Link href={ADMIN_PATHS.SCIM}>
|
||||
<Button prominence="tertiary" rightIcon={SvgArrowUpRight} size="sm">
|
||||
Manage
|
||||
</Button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stats bar — layout varies by SCIM status
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type UsersSummaryProps = {
|
||||
activeUsers: number | null;
|
||||
pendingInvites: number | null;
|
||||
requests: number | null;
|
||||
showScim: boolean;
|
||||
};
|
||||
|
||||
export default function UsersSummary({
|
||||
activeUsers,
|
||||
pendingInvites,
|
||||
requests,
|
||||
showScim,
|
||||
}: UsersSummaryProps) {
|
||||
const showRequests = requests !== null && requests > 0;
|
||||
|
||||
if (showScim) {
|
||||
return (
|
||||
<Section
|
||||
flexDirection="row"
|
||||
justifyContent="start"
|
||||
alignItems="stretch"
|
||||
gap={0.5}
|
||||
>
|
||||
<Card padding={0.5}>
|
||||
<Section flexDirection="row" gap={0}>
|
||||
<StatCell value={activeUsers} label="active users" />
|
||||
<StatCell value={pendingInvites} label="pending invites" />
|
||||
{showRequests && (
|
||||
<StatCell value={requests} label="requests to join" />
|
||||
)}
|
||||
</Section>
|
||||
</Card>
|
||||
<ScimCard />
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
// No SCIM — each stat gets its own card
|
||||
return (
|
||||
<Section flexDirection="row" gap={0.5}>
|
||||
<Card padding={0.5}>
|
||||
<StatCell value={activeUsers} label="active users" />
|
||||
</Card>
|
||||
<Card padding={0.5}>
|
||||
<StatCell value={pendingInvites} label="pending invites" />
|
||||
</Card>
|
||||
{showRequests && (
|
||||
<Card padding={0.5}>
|
||||
<StatCell value={requests} label="requests to join" />
|
||||
</Card>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,6 @@ import useCCPairs from "@/hooks/useCCPairs";
|
||||
import { MinimalOnyxDocument } from "@/lib/search/interfaces";
|
||||
import { ChatState } from "@/app/app/interfaces";
|
||||
import { useForcedTools } from "@/lib/hooks/useForcedTools";
|
||||
import { useAppMode } from "@/providers/AppModeProvider";
|
||||
import useAppFocus from "@/hooks/useAppFocus";
|
||||
import { cn, isImageFile } from "@/lib/utils";
|
||||
import { Disabled } from "@opal/core";
|
||||
@@ -120,7 +119,10 @@ const AppInputBar = React.memo(
|
||||
const filesContentRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { user } = useUser();
|
||||
const { isClassifying, classification } = useQueryController();
|
||||
const { state } = useQueryController();
|
||||
const isClassifying = state.phase === "classifying";
|
||||
const isSearchActive =
|
||||
state.phase === "searching" || state.phase === "search-results";
|
||||
|
||||
// Expose reset and focus methods to parent via ref
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
@@ -140,12 +142,10 @@ const AppInputBar = React.memo(
|
||||
setMessage(initialMessage);
|
||||
}
|
||||
}, [initialMessage]);
|
||||
|
||||
const { appMode } = useAppMode();
|
||||
const appFocus = useAppFocus();
|
||||
const appMode = state.phase === "idle" ? state.appMode : undefined;
|
||||
const isSearchMode =
|
||||
(appFocus.isNewSession() && appMode === "search") ||
|
||||
classification === "search";
|
||||
(appFocus.isNewSession() && appMode === "search") || isSearchActive;
|
||||
|
||||
const { forcedToolIds, setForcedToolIds } = useForcedTools();
|
||||
const { currentMessageFiles, setCurrentMessageFiles, currentProjectId } =
|
||||
|
||||
@@ -167,7 +167,7 @@ export default function PreviewModal({
|
||||
/>
|
||||
|
||||
{/* Body + floating footer wrapper */}
|
||||
<Modal.Body padding={0} gap={0} height="full">
|
||||
<Modal.Body padding={0} gap={0}>
|
||||
<Section padding={0} gap={0}>
|
||||
{isLoading ? (
|
||||
<Section>
|
||||
|
||||
@@ -1,36 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import MinimalMarkdown from "@/components/chat/MinimalMarkdown";
|
||||
import Spacer from "@/refresh-components/Spacer";
|
||||
import ScrollIndicatorDiv from "@/refresh-components/ScrollIndicatorDiv";
|
||||
import "@/app/app/message/custom-code-styles.css";
|
||||
|
||||
interface CodePreviewProps {
|
||||
content: string;
|
||||
language?: string | null;
|
||||
normalize?: boolean;
|
||||
}
|
||||
|
||||
export function CodePreview({
|
||||
content,
|
||||
language,
|
||||
normalize,
|
||||
}: CodePreviewProps) {
|
||||
const markdownContent = normalize
|
||||
? `~~~${language || ""}\n${content.replace(/~~~/g, "\\~\\~\\~")}\n~~~`
|
||||
: content;
|
||||
export function CodePreview({ content, language }: CodePreviewProps) {
|
||||
const normalizedContent = content.replace(/~~~/g, "\\~\\~\\~");
|
||||
const fenceHeader = language ? `~~~${language}` : "~~~";
|
||||
|
||||
return (
|
||||
<ScrollIndicatorDiv
|
||||
className="h-full bg-background-code-01"
|
||||
backgroundColor="var(--background-code-01)"
|
||||
variant="shadow"
|
||||
>
|
||||
<MinimalMarkdown
|
||||
content={markdownContent}
|
||||
className="w-full h-full p-4 pb-8"
|
||||
showHeader={false}
|
||||
/>
|
||||
</ScrollIndicatorDiv>
|
||||
<MinimalMarkdown
|
||||
content={`${fenceHeader}\n${normalizedContent}\n\n~~~`}
|
||||
className="w-full h-full"
|
||||
showHeader={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export const codeVariant: PreviewVariant = {
|
||||
: "",
|
||||
|
||||
renderContent: (ctx) => (
|
||||
<CodePreview normalize content={ctx.fileContent} language={ctx.language} />
|
||||
<CodePreview content={ctx.fileContent} language={ctx.language} />
|
||||
),
|
||||
|
||||
renderFooterLeft: (ctx) => (
|
||||
|
||||
@@ -36,9 +36,7 @@ export const dataVariant: PreviewVariant = {
|
||||
|
||||
renderContent: (ctx) => {
|
||||
const formatted = formatContent(ctx.language, ctx.fileContent);
|
||||
return (
|
||||
<CodePreview normalize content={formatted} language={ctx.language} />
|
||||
);
|
||||
return <CodePreview content={formatted} language={ctx.language} />;
|
||||
},
|
||||
|
||||
renderFooterLeft: (ctx) => (
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import MinimalMarkdown from "@/components/chat/MinimalMarkdown";
|
||||
import ScrollIndicatorDiv from "@/refresh-components/ScrollIndicatorDiv";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import { isMarkdownFile } from "@/lib/languages";
|
||||
import { PreviewVariant } from "@/sections/modals/PreviewModal/interfaces";
|
||||
import { CodePreview } from "@/sections/modals/PreviewModal/variants/CodePreview";
|
||||
import {
|
||||
CopyButton,
|
||||
DownloadButton,
|
||||
@@ -25,7 +26,12 @@ export const markdownVariant: PreviewVariant = {
|
||||
headerDescription: () => "",
|
||||
|
||||
renderContent: (ctx) => (
|
||||
<CodePreview content={ctx.fileContent} language={ctx.language} />
|
||||
<ScrollIndicatorDiv className="flex-1 min-h-0 p-4" variant="shadow">
|
||||
<MinimalMarkdown
|
||||
content={ctx.fileContent}
|
||||
className="w-full pb-4 text-lg break-words"
|
||||
/>
|
||||
</ScrollIndicatorDiv>
|
||||
),
|
||||
|
||||
renderFooterLeft: () => null,
|
||||
|
||||
@@ -36,7 +36,7 @@ export const textVariant: PreviewVariant = {
|
||||
: "",
|
||||
|
||||
renderContent: (ctx) => (
|
||||
<CodePreview normalize content={ctx.fileContent} language={ctx.language} />
|
||||
<CodePreview content={ctx.fileContent} language={ctx.language} />
|
||||
),
|
||||
|
||||
renderFooterLeft: (ctx) => (
|
||||
|
||||
180
web/src/sections/modals/llmConfig/LiteLLMProxyModal.tsx
Normal file
180
web/src/sections/modals/llmConfig/LiteLLMProxyModal.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import Separator from "@/refresh-components/Separator";
|
||||
import { Form, Formik } from "formik";
|
||||
import { TextFormField } from "@/components/Field";
|
||||
import {
|
||||
LLMProviderFormProps,
|
||||
LLMProviderName,
|
||||
ModelConfiguration,
|
||||
} from "@/interfaces/llm";
|
||||
import { fetchLiteLLMProxyModels } from "@/app/admin/configuration/llm/utils";
|
||||
import * as Yup from "yup";
|
||||
import {
|
||||
ProviderFormEntrypointWrapper,
|
||||
ProviderFormContext,
|
||||
} from "./components/FormWrapper";
|
||||
import { DisplayNameField } from "./components/DisplayNameField";
|
||||
import PasswordInputTypeInField from "@/refresh-components/form/PasswordInputTypeInField";
|
||||
import { FormActionButtons } from "./components/FormActionButtons";
|
||||
import {
|
||||
buildDefaultInitialValues,
|
||||
buildDefaultValidationSchema,
|
||||
buildAvailableModelConfigurations,
|
||||
submitLLMProvider,
|
||||
BaseLLMFormValues,
|
||||
LLM_FORM_CLASS_NAME,
|
||||
} from "./formUtils";
|
||||
import { AdvancedOptions } from "./components/AdvancedOptions";
|
||||
import { DisplayModels } from "./components/DisplayModels";
|
||||
import { FetchModelsButton } from "./components/FetchModelsButton";
|
||||
import { useState } from "react";
|
||||
|
||||
const LITELLM_PROXY_DISPLAY_NAME = "LiteLLM Proxy";
|
||||
const DEFAULT_API_BASE = "http://localhost:4000";
|
||||
|
||||
interface LiteLLMProxyModalValues extends BaseLLMFormValues {
|
||||
api_key: string;
|
||||
api_base: string;
|
||||
}
|
||||
|
||||
export function LiteLLMProxyModal({
|
||||
existingLlmProvider,
|
||||
shouldMarkAsDefault,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: LLMProviderFormProps) {
|
||||
const [fetchedModels, setFetchedModels] = useState<ModelConfiguration[]>([]);
|
||||
|
||||
return (
|
||||
<ProviderFormEntrypointWrapper
|
||||
providerName={LITELLM_PROXY_DISPLAY_NAME}
|
||||
providerEndpoint={LLMProviderName.LITELLM_PROXY}
|
||||
existingLlmProvider={existingLlmProvider}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
{({
|
||||
onClose,
|
||||
mutate,
|
||||
isTesting,
|
||||
setIsTesting,
|
||||
testError,
|
||||
setTestError,
|
||||
wellKnownLLMProvider,
|
||||
}: ProviderFormContext) => {
|
||||
const modelConfigurations = buildAvailableModelConfigurations(
|
||||
existingLlmProvider,
|
||||
wellKnownLLMProvider
|
||||
);
|
||||
const initialValues: LiteLLMProxyModalValues = {
|
||||
...buildDefaultInitialValues(
|
||||
existingLlmProvider,
|
||||
modelConfigurations
|
||||
),
|
||||
api_key: existingLlmProvider?.api_key ?? "",
|
||||
api_base: existingLlmProvider?.api_base ?? DEFAULT_API_BASE,
|
||||
};
|
||||
|
||||
const validationSchema = buildDefaultValidationSchema().shape({
|
||||
api_key: Yup.string().required("API Key is required"),
|
||||
api_base: Yup.string().required("API Base URL is required"),
|
||||
});
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
validationSchema={validationSchema}
|
||||
validateOnMount={true}
|
||||
onSubmit={async (values, { setSubmitting }) => {
|
||||
await submitLLMProvider({
|
||||
providerName: LLMProviderName.LITELLM_PROXY,
|
||||
values,
|
||||
initialValues,
|
||||
modelConfigurations:
|
||||
fetchedModels.length > 0
|
||||
? fetchedModels
|
||||
: modelConfigurations,
|
||||
existingLlmProvider,
|
||||
shouldMarkAsDefault,
|
||||
setIsTesting,
|
||||
setTestError,
|
||||
mutate,
|
||||
onClose,
|
||||
setSubmitting,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{(formikProps) => {
|
||||
const currentModels =
|
||||
fetchedModels.length > 0
|
||||
? fetchedModels
|
||||
: existingLlmProvider?.model_configurations ||
|
||||
modelConfigurations;
|
||||
|
||||
const isFetchDisabled =
|
||||
!formikProps.values.api_base || !formikProps.values.api_key;
|
||||
|
||||
return (
|
||||
<Form className={LLM_FORM_CLASS_NAME}>
|
||||
<DisplayNameField disabled={!!existingLlmProvider} />
|
||||
|
||||
<TextFormField
|
||||
name="api_base"
|
||||
label="API Base URL"
|
||||
subtext="The base URL for your LiteLLM Proxy server (e.g., http://localhost:4000)"
|
||||
placeholder={DEFAULT_API_BASE}
|
||||
/>
|
||||
|
||||
<PasswordInputTypeInField name="api_key" label="API Key" />
|
||||
|
||||
<FetchModelsButton
|
||||
onFetch={() =>
|
||||
fetchLiteLLMProxyModels({
|
||||
api_base: formikProps.values.api_base,
|
||||
api_key: formikProps.values.api_key,
|
||||
provider_name: existingLlmProvider?.name,
|
||||
})
|
||||
}
|
||||
isDisabled={isFetchDisabled}
|
||||
disabledHint={
|
||||
!formikProps.values.api_base
|
||||
? "Enter the API base URL first."
|
||||
: !formikProps.values.api_key
|
||||
? "Enter your API key first."
|
||||
: undefined
|
||||
}
|
||||
onModelsFetched={setFetchedModels}
|
||||
autoFetchOnInitialLoad={!!existingLlmProvider}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<DisplayModels
|
||||
modelConfigurations={currentModels}
|
||||
formikProps={formikProps}
|
||||
noModelConfigurationsMessage={
|
||||
"Fetch available models first, then you'll be able to select " +
|
||||
"the models you want to make available in Onyx."
|
||||
}
|
||||
recommendedDefaultModel={null}
|
||||
shouldShowAutoUpdateToggle={false}
|
||||
/>
|
||||
|
||||
<AdvancedOptions formikProps={formikProps} />
|
||||
|
||||
<FormActionButtons
|
||||
isTesting={isTesting}
|
||||
testError={testError}
|
||||
existingLlmProvider={existingLlmProvider}
|
||||
mutate={mutate}
|
||||
onClose={onClose}
|
||||
isFormValid={formikProps.isValid}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
);
|
||||
}}
|
||||
</ProviderFormEntrypointWrapper>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { OpenRouterModal } from "./OpenRouterModal";
|
||||
import { CustomModal } from "./CustomModal";
|
||||
import { BedrockModal } from "./BedrockModal";
|
||||
import { LMStudioForm } from "./LMStudioForm";
|
||||
import { LiteLLMProxyModal } from "./LiteLLMProxyModal";
|
||||
|
||||
export function detectIfRealOpenAIProvider(provider: LLMProviderView) {
|
||||
return (
|
||||
@@ -47,6 +48,8 @@ export function getModalForExistingProvider(
|
||||
return <OpenRouterModal {...props} />;
|
||||
case LLMProviderName.LM_STUDIO:
|
||||
return <LMStudioForm {...props} />;
|
||||
case LLMProviderName.LITELLM_PROXY:
|
||||
return <LiteLLMProxyModal {...props} />;
|
||||
default:
|
||||
return <CustomModal {...props} />;
|
||||
}
|
||||
|
||||
264
web/src/sections/onboarding/forms/LiteLLMProxyOnboardingForm.tsx
Normal file
264
web/src/sections/onboarding/forms/LiteLLMProxyOnboardingForm.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import * as Yup from "yup";
|
||||
import { FormikField } from "@/refresh-components/form/FormikField";
|
||||
import { FormField } from "@/refresh-components/form/FormField";
|
||||
import PasswordInputTypeIn from "@/refresh-components/inputs/PasswordInputTypeIn";
|
||||
import InputComboBox from "@/refresh-components/inputs/InputComboBox";
|
||||
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
|
||||
import Separator from "@/refresh-components/Separator";
|
||||
import { Button } from "@opal/components";
|
||||
import { Disabled } from "@opal/core";
|
||||
import { cn, noProp } from "@/lib/utils";
|
||||
import { SvgRefreshCw } from "@opal/icons";
|
||||
import {
|
||||
WellKnownLLMProviderDescriptor,
|
||||
ModelConfiguration,
|
||||
} from "@/interfaces/llm";
|
||||
import {
|
||||
OnboardingFormWrapper,
|
||||
OnboardingFormChildProps,
|
||||
} from "./OnboardingFormWrapper";
|
||||
import { OnboardingActions, OnboardingState } from "@/interfaces/onboarding";
|
||||
import { buildInitialValues } from "../components/llmConnectionHelpers";
|
||||
import ConnectionProviderIcon from "@/refresh-components/ConnectionProviderIcon";
|
||||
import { ProviderIcon } from "@/app/admin/configuration/llm/ProviderIcon";
|
||||
|
||||
const FIELD_API_KEY = "api_key";
|
||||
const FIELD_API_BASE = "api_base";
|
||||
const FIELD_DEFAULT_MODEL_NAME = "default_model_name";
|
||||
|
||||
interface LiteLLMProxyOnboardingFormProps {
|
||||
llmDescriptor: WellKnownLLMProviderDescriptor;
|
||||
onboardingState: OnboardingState;
|
||||
onboardingActions: OnboardingActions;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
interface LiteLLMProxyFormValues {
|
||||
name: string;
|
||||
provider: string;
|
||||
api_key: string;
|
||||
api_base: string;
|
||||
api_key_changed: boolean;
|
||||
default_model_name: string;
|
||||
model_configurations: ModelConfiguration[];
|
||||
groups: number[];
|
||||
is_public: boolean;
|
||||
}
|
||||
|
||||
function LiteLLMProxyFormFields(
|
||||
props: OnboardingFormChildProps<LiteLLMProxyFormValues>
|
||||
) {
|
||||
const {
|
||||
formikProps,
|
||||
apiStatus,
|
||||
showApiMessage,
|
||||
errorMessage,
|
||||
modelOptions,
|
||||
isFetchingModels,
|
||||
handleFetchModels,
|
||||
modelsApiStatus,
|
||||
modelsErrorMessage,
|
||||
showModelsApiErrorMessage,
|
||||
disabled,
|
||||
} = props;
|
||||
|
||||
const handleApiKeyInteraction = () => {
|
||||
if (formikProps.values.api_key && formikProps.values.api_base) {
|
||||
handleFetchModels();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormikField<string>
|
||||
name={FIELD_API_BASE}
|
||||
render={(field, _helper, meta, state) => (
|
||||
<FormField name={FIELD_API_BASE} state={state} className="w-full">
|
||||
<FormField.Label>API Base URL</FormField.Label>
|
||||
<FormField.Control>
|
||||
<InputTypeIn
|
||||
{...field}
|
||||
placeholder="http://localhost:4000"
|
||||
variant={disabled ? "disabled" : undefined}
|
||||
onBlur={(e: React.FocusEvent<HTMLInputElement>) => {
|
||||
field.onBlur(e);
|
||||
handleApiKeyInteraction();
|
||||
}}
|
||||
/>
|
||||
</FormField.Control>
|
||||
<FormField.Message
|
||||
messages={{
|
||||
idle: "The base URL for your LiteLLM Proxy server.",
|
||||
error: meta.error,
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormikField<string>
|
||||
name={FIELD_API_KEY}
|
||||
render={(field, _helper, meta, state) => (
|
||||
<FormField name={FIELD_API_KEY} state={state} className="w-full">
|
||||
<FormField.Label>API Key</FormField.Label>
|
||||
<FormField.Control>
|
||||
<PasswordInputTypeIn
|
||||
{...field}
|
||||
placeholder=""
|
||||
error={apiStatus === "error"}
|
||||
showClearButton={false}
|
||||
disabled={disabled}
|
||||
onBlur={(e) => {
|
||||
field.onBlur(e);
|
||||
handleApiKeyInteraction();
|
||||
}}
|
||||
/>
|
||||
</FormField.Control>
|
||||
{!showApiMessage && (
|
||||
<FormField.Message
|
||||
messages={{
|
||||
idle: "Enter the API key for your LiteLLM Proxy.",
|
||||
error: meta.error,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showApiMessage && (
|
||||
<FormField.APIMessage
|
||||
state={apiStatus}
|
||||
messages={{
|
||||
loading: "Checking API key with LiteLLM Proxy...",
|
||||
success: "API key valid. Your available models updated.",
|
||||
error: errorMessage || "Invalid API key",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Separator className="py-0" />
|
||||
|
||||
<FormikField<string>
|
||||
name={FIELD_DEFAULT_MODEL_NAME}
|
||||
render={(field, helper, meta, state) => (
|
||||
<FormField
|
||||
name={FIELD_DEFAULT_MODEL_NAME}
|
||||
state={state}
|
||||
className="w-full"
|
||||
>
|
||||
<FormField.Label>Default Model</FormField.Label>
|
||||
<FormField.Control>
|
||||
<InputComboBox
|
||||
value={field.value}
|
||||
onValueChange={(value) => helper.setValue(value)}
|
||||
onChange={(e) => helper.setValue(e.target.value)}
|
||||
options={modelOptions}
|
||||
disabled={
|
||||
disabled || isFetchingModels || modelOptions.length === 0
|
||||
}
|
||||
rightSection={
|
||||
<Disabled disabled={disabled || isFetchingModels}>
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
size="sm"
|
||||
icon={({ className }) => (
|
||||
<SvgRefreshCw
|
||||
className={cn(
|
||||
className,
|
||||
isFetchingModels && "animate-spin"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
onClick={noProp((e) => {
|
||||
e.preventDefault();
|
||||
handleFetchModels();
|
||||
})}
|
||||
tooltip="Fetch available models"
|
||||
/>
|
||||
</Disabled>
|
||||
}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="Select a model"
|
||||
/>
|
||||
</FormField.Control>
|
||||
{!showModelsApiErrorMessage && (
|
||||
<FormField.Message
|
||||
messages={{
|
||||
idle: "This model will be used by Onyx by default.",
|
||||
error: meta.error,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showModelsApiErrorMessage && (
|
||||
<FormField.APIMessage
|
||||
state={modelsApiStatus}
|
||||
messages={{
|
||||
loading: "Fetching models...",
|
||||
success: "Models fetched successfully.",
|
||||
error: modelsErrorMessage || "Failed to fetch models",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function LiteLLMProxyOnboardingForm({
|
||||
llmDescriptor,
|
||||
onboardingState,
|
||||
onboardingActions,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: LiteLLMProxyOnboardingFormProps) {
|
||||
const initialValues = useMemo(
|
||||
(): LiteLLMProxyFormValues => ({
|
||||
...buildInitialValues(),
|
||||
name: llmDescriptor.name,
|
||||
provider: llmDescriptor.name,
|
||||
api_base: "http://localhost:4000",
|
||||
}),
|
||||
[llmDescriptor.name]
|
||||
);
|
||||
|
||||
const validationSchema = useMemo(
|
||||
() =>
|
||||
Yup.object().shape({
|
||||
[FIELD_API_KEY]: Yup.string().required("API Key is required"),
|
||||
[FIELD_API_BASE]: Yup.string().required("API Base URL is required"),
|
||||
[FIELD_DEFAULT_MODEL_NAME]: Yup.string().required(
|
||||
"Model name is required"
|
||||
),
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const icon = () => (
|
||||
<ConnectionProviderIcon
|
||||
icon={<ProviderIcon provider={llmDescriptor.name} size={24} />}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<OnboardingFormWrapper<LiteLLMProxyFormValues>
|
||||
icon={icon}
|
||||
title="Set up LiteLLM Proxy"
|
||||
description="Connect to your LiteLLM Proxy server and set up your models."
|
||||
llmDescriptor={llmDescriptor}
|
||||
onboardingState={onboardingState}
|
||||
onboardingActions={onboardingActions}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
initialValues={initialValues}
|
||||
validationSchema={validationSchema}
|
||||
>
|
||||
{(props) => <LiteLLMProxyFormFields {...props} />}
|
||||
</OnboardingFormWrapper>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { AzureOnboardingForm } from "./AzureOnboardingForm";
|
||||
import { BedrockOnboardingForm } from "./BedrockOnboardingForm";
|
||||
import { VertexAIOnboardingForm } from "./VertexAIOnboardingForm";
|
||||
import { OpenRouterOnboardingForm } from "./OpenRouterOnboardingForm";
|
||||
import { LiteLLMProxyOnboardingForm } from "./LiteLLMProxyOnboardingForm";
|
||||
import { CustomOnboardingForm } from "./CustomOnboardingForm";
|
||||
|
||||
// Display info for LLM provider cards - title is the product name, displayName is the company/platform
|
||||
@@ -42,6 +43,10 @@ const PROVIDER_DISPLAY_INFO: Record<
|
||||
title: "LM Studio",
|
||||
displayName: "LM Studio",
|
||||
},
|
||||
[LLMProviderName.LITELLM_PROXY]: {
|
||||
title: "LiteLLM Proxy",
|
||||
displayName: "LiteLLM Proxy",
|
||||
},
|
||||
};
|
||||
|
||||
export function getProviderDisplayInfo(providerName: string): {
|
||||
@@ -175,6 +180,17 @@ export function getOnboardingForm({
|
||||
/>
|
||||
);
|
||||
|
||||
case LLMProviderName.LITELLM_PROXY:
|
||||
return (
|
||||
<LiteLLMProxyOnboardingForm
|
||||
llmDescriptor={llmDescriptor}
|
||||
onboardingState={onboardingState}
|
||||
onboardingActions={onboardingActions}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
// Fallback to custom form for unknown providers
|
||||
return (
|
||||
|
||||
@@ -127,14 +127,14 @@ const collections = (
|
||||
sidebarItem(ADMIN_PATHS.TOKEN_RATE_LIMITS),
|
||||
],
|
||||
},
|
||||
...(enableEnterprise
|
||||
? [
|
||||
{
|
||||
name: "Permissions",
|
||||
items: [sidebarItem(ADMIN_PATHS.SCIM)],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: "Permissions",
|
||||
items: [
|
||||
// TODO (nikolas): Uncommented in switchover PR once Users v2 is ready
|
||||
// sidebarItem(ADMIN_PATHS.USERS_V2),
|
||||
...(enableEnterprise ? [sidebarItem(ADMIN_PATHS.SCIM)] : []),
|
||||
],
|
||||
},
|
||||
...(enableEnterprise
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -77,7 +77,6 @@ import { Notification, NotificationType } from "@/interfaces/settings";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import UserAvatarPopover from "@/sections/sidebar/UserAvatarPopover";
|
||||
import ChatSearchCommandMenu from "@/sections/sidebar/ChatSearchCommandMenu";
|
||||
import { useAppMode } from "@/providers/AppModeProvider";
|
||||
import { useQueryController } from "@/providers/QueryControllerProvider";
|
||||
|
||||
// Visible-agents = pinned-agents + current-agent (if current-agent not in pinned-agents)
|
||||
@@ -206,8 +205,7 @@ const MemoizedAppSidebarInner = memo(
|
||||
const combinedSettings = useSettingsContext();
|
||||
const posthog = usePostHog();
|
||||
const { newTenantInfo, invitationInfo } = useModalContext();
|
||||
const { setAppMode } = useAppMode();
|
||||
const { reset } = useQueryController();
|
||||
const { setAppMode, reset } = useQueryController();
|
||||
|
||||
// Use SWR hooks for data fetching
|
||||
const {
|
||||
|
||||
@@ -260,7 +260,6 @@ module.exports = {
|
||||
"code-string": "var(--code-string)",
|
||||
"code-number": "var(--code-number)",
|
||||
"code-definition": "var(--code-definition)",
|
||||
"background-code-01": "var(--background-code-01)",
|
||||
|
||||
// Shimmer colors for loading animations
|
||||
"shimmer-base": "var(--shimmer-base)",
|
||||
|
||||
Reference in New Issue
Block a user