Compare commits

..

1 Commits

Author SHA1 Message Date
Bo-Onyx
39a3ee1a0a feat(hook) frontend ee 2026-03-31 18:54:14 -07:00
53 changed files with 163 additions and 546 deletions

View File

@@ -1,54 +0,0 @@
"""csv to tabular chat file type
Revision ID: 8188861f4e92
Revises: d8cdfee5df80
Create Date: 2026-03-31 19:23:05.753184
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = "8188861f4e92"
down_revision = "d8cdfee5df80"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
"""
UPDATE chat_message
SET files = (
SELECT jsonb_agg(
CASE
WHEN elem->>'type' = 'csv'
THEN jsonb_set(elem, '{type}', '"tabular"')
ELSE elem
END
)
FROM jsonb_array_elements(files) AS elem
)
WHERE files::text LIKE '%"type": "csv"%'
"""
)
def downgrade() -> None:
op.execute(
"""
UPDATE chat_message
SET files = (
SELECT jsonb_agg(
CASE
WHEN elem->>'type' = 'tabular'
THEN jsonb_set(elem, '{type}', '"csv"')
ELSE elem
END
)
FROM jsonb_array_elements(files) AS elem
)
WHERE files::text LIKE '%"type": "tabular"%'
"""
)

View File

@@ -1,55 +0,0 @@
"""add skipped to userfilestatus
Revision ID: d8cdfee5df80
Revises: 1d78c0ca7853
Create Date: 2026-04-01 10:47:12.593950
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "d8cdfee5df80"
down_revision = "1d78c0ca7853"
branch_labels = None
depends_on = None
TABLE = "user_file"
COLUMN = "status"
CONSTRAINT_NAME = "ck_user_file_status"
OLD_VALUES = ("PROCESSING", "INDEXING", "COMPLETED", "FAILED", "CANCELED", "DELETING")
NEW_VALUES = (
"PROCESSING",
"INDEXING",
"COMPLETED",
"SKIPPED",
"FAILED",
"CANCELED",
"DELETING",
)
def _drop_status_check_constraint() -> None:
inspector = sa.inspect(op.get_bind())
for constraint in inspector.get_check_constraints(TABLE):
if COLUMN in constraint.get("sqltext", ""):
constraint_name = constraint["name"]
if constraint_name is not None:
op.drop_constraint(constraint_name, TABLE, type_="check")
def upgrade() -> None:
_drop_status_check_constraint()
in_clause = ", ".join(f"'{v}'" for v in NEW_VALUES)
op.create_check_constraint(CONSTRAINT_NAME, TABLE, f"{COLUMN} IN ({in_clause})")
def downgrade() -> None:
op.execute(f"UPDATE {TABLE} SET {COLUMN} = 'COMPLETED' WHERE {COLUMN} = 'SKIPPED'")
_drop_status_check_constraint()
in_clause = ", ".join(f"'{v}'" for v in OLD_VALUES)
op.create_check_constraint(CONSTRAINT_NAME, TABLE, f"{COLUMN} IN ({in_clause})")

View File

@@ -215,7 +215,6 @@ class UserFileStatus(str, PyEnum):
PROCESSING = "PROCESSING"
INDEXING = "INDEXING"
COMPLETED = "COMPLETED"
SKIPPED = "SKIPPED"
FAILED = "FAILED"
CANCELED = "CANCELED"
DELETING = "DELETING"

View File

@@ -7,7 +7,6 @@ from fastapi import HTTPException
from fastapi import UploadFile
from pydantic import BaseModel
from pydantic import ConfigDict
from pydantic import Field
from sqlalchemy import func
from sqlalchemy.orm import Session
from starlette.background import BackgroundTasks
@@ -18,7 +17,6 @@ from onyx.configs.constants import FileOrigin
from onyx.configs.constants import OnyxCeleryPriority
from onyx.configs.constants import OnyxCeleryQueues
from onyx.configs.constants import OnyxCeleryTask
from onyx.db.enums import UserFileStatus
from onyx.db.models import Project__UserFile
from onyx.db.models import User
from onyx.db.models import UserFile
@@ -36,19 +34,9 @@ class CategorizedFilesResult(BaseModel):
user_files: list[UserFile]
rejected_files: list[RejectedFile]
id_to_temp_id: dict[str, str]
# Filenames that should be stored but not indexed.
skip_indexing_filenames: set[str] = Field(default_factory=set)
# Allow SQLAlchemy ORM models inside this result container
model_config = ConfigDict(arbitrary_types_allowed=True)
@property
def indexable_files(self) -> list[UserFile]:
return [
uf
for uf in self.user_files
if (uf.name or "") not in self.skip_indexing_filenames
]
def build_hashed_file_key(file: UploadFile) -> str:
name_prefix = (file.filename or "")[:50]
@@ -82,7 +70,6 @@ def create_user_files(
)
if new_temp_id is not None:
id_to_temp_id[str(new_id)] = new_temp_id
should_skip = (file.filename or "") in categorized_files.skip_indexing
new_file = UserFile(
id=new_id,
user_id=user.id,
@@ -94,7 +81,6 @@ def create_user_files(
link_url=link_url,
content_type=file.content_type,
file_type=file.content_type,
status=UserFileStatus.SKIPPED if should_skip else UserFileStatus.PROCESSING,
last_accessed_at=datetime.datetime.now(datetime.timezone.utc),
)
# Persist the UserFile first to satisfy FK constraints for association table
@@ -112,7 +98,6 @@ def create_user_files(
user_files=user_files,
rejected_files=rejected_files,
id_to_temp_id=id_to_temp_id,
skip_indexing_filenames=categorized_files.skip_indexing,
)
@@ -138,7 +123,6 @@ def upload_files_to_user_files_with_indexing(
user_files = categorized_files_result.user_files
rejected_files = categorized_files_result.rejected_files
id_to_temp_id = categorized_files_result.id_to_temp_id
indexable_files = categorized_files_result.indexable_files
# Trigger per-file processing immediately for the current tenant
tenant_id = get_current_tenant_id()
for rejected_file in rejected_files:
@@ -150,12 +134,12 @@ def upload_files_to_user_files_with_indexing(
from onyx.background.task_utils import drain_processing_loop
background_tasks.add_task(drain_processing_loop, tenant_id)
for user_file in indexable_files:
for user_file in user_files:
logger.info(f"Queued in-process processing for user_file_id={user_file.id}")
else:
from onyx.background.celery.versioned_apps.client import app as client_app
for user_file in indexable_files:
for user_file in user_files:
task = client_app.send_task(
OnyxCeleryTask.PROCESS_SINGLE_USER_FILE,
kwargs={"user_file_id": user_file.id, "tenant_id": tenant_id},
@@ -171,7 +155,6 @@ def upload_files_to_user_files_with_indexing(
user_files=user_files,
rejected_files=rejected_files,
id_to_temp_id=id_to_temp_id,
skip_indexing_filenames=categorized_files_result.skip_indexing_filenames,
)

View File

@@ -15,7 +15,6 @@ PLAIN_TEXT_MIME_TYPE = "text/plain"
class OnyxMimeTypes:
IMAGE_MIME_TYPES = {"image/jpg", "image/jpeg", "image/png", "image/webp"}
CSV_MIME_TYPES = {"text/csv"}
TABULAR_MIME_TYPES = CSV_MIME_TYPES | {SPREADSHEET_MIME_TYPE}
TEXT_MIME_TYPES = {
PLAIN_TEXT_MIME_TYPE,
"text/markdown",
@@ -35,12 +34,13 @@ class OnyxMimeTypes:
PDF_MIME_TYPE,
WORD_PROCESSING_MIME_TYPE,
PRESENTATION_MIME_TYPE,
SPREADSHEET_MIME_TYPE,
"message/rfc822",
"application/epub+zip",
}
ALLOWED_MIME_TYPES = IMAGE_MIME_TYPES.union(
TEXT_MIME_TYPES, DOCUMENT_MIME_TYPES, TABULAR_MIME_TYPES
TEXT_MIME_TYPES, DOCUMENT_MIME_TYPES, CSV_MIME_TYPES
)
EXCLUDED_IMAGE_TYPES = {
@@ -53,11 +53,6 @@ class OnyxMimeTypes:
class OnyxFileExtensions:
TABULAR_EXTENSIONS = {
".csv",
".tsv",
".xlsx",
}
PLAIN_TEXT_EXTENSIONS = {
".txt",
".md",

View File

@@ -13,14 +13,13 @@ class ChatFileType(str, Enum):
DOC = "document"
# Plain text only contain the text
PLAIN_TEXT = "plain_text"
# Tabular data files (CSV, XLSX)
TABULAR = "tabular"
CSV = "csv"
def is_text_file(self) -> bool:
return self in (
ChatFileType.PLAIN_TEXT,
ChatFileType.DOC,
ChatFileType.TABULAR,
ChatFileType.CSV,
)

View File

@@ -76,18 +76,11 @@ class CategorizedFiles(BaseModel):
acceptable: list[UploadFile] = Field(default_factory=list)
rejected: list[RejectedFile] = Field(default_factory=list)
acceptable_file_to_token_count: dict[str, int] = Field(default_factory=dict)
# Filenames within `acceptable` that should be stored but not indexed.
skip_indexing: set[str] = Field(default_factory=set)
# Allow FastAPI UploadFile instances
model_config = ConfigDict(arbitrary_types_allowed=True)
def _skip_token_threshold(extension: str) -> bool:
"""Return True if this file extension should bypass the token limit."""
return extension.lower() in OnyxFileExtensions.TABULAR_EXTENSIONS
def _apply_long_side_cap(width: int, height: int, cap: int) -> tuple[int, int]:
if max(width, height) <= cap:
return width, height
@@ -271,17 +264,7 @@ def categorize_uploaded_files(
token_count = count_tokens(
text_content, tokenizer, token_limit=token_threshold
)
exceeds_threshold = (
token_threshold is not None and token_count > token_threshold
)
if exceeds_threshold and _skip_token_threshold(extension):
# Exempt extensions (e.g. spreadsheets) are accepted
# but flagged to skip indexing — only metadata is
# injected into the LLM context.
results.acceptable.append(upload)
results.acceptable_file_to_token_count[filename] = token_count
results.skip_indexing.add(filename)
elif exceeds_threshold:
if token_threshold is not None and token_count > token_threshold:
results.rejected.append(
RejectedFile(
filename=filename,

View File

@@ -9,8 +9,8 @@ def mime_type_to_chat_file_type(mime_type: str | None) -> ChatFileType:
if mime_type in OnyxMimeTypes.IMAGE_MIME_TYPES:
return ChatFileType.IMAGE
if mime_type in OnyxMimeTypes.TABULAR_MIME_TYPES:
return ChatFileType.TABULAR
if mime_type in OnyxMimeTypes.CSV_MIME_TYPES:
return ChatFileType.CSV
if mime_type in OnyxMimeTypes.DOCUMENT_MIME_TYPES:
return ChatFileType.DOC

View File

@@ -1,4 +1,3 @@
import io
import json
from typing import Any
from typing import cast
@@ -10,7 +9,6 @@ from typing_extensions import override
from onyx.chat.emitter import Emitter
from onyx.configs.app_configs import DISABLE_VECTOR_DB
from onyx.db.engine.sql_engine import get_session_with_current_tenant
from onyx.file_processing.extract_file_text import extract_file_text
from onyx.file_store.models import ChatFileType
from onyx.file_store.models import InMemoryChatFile
from onyx.file_store.utils import load_chat_file_by_id
@@ -171,13 +169,10 @@ class FileReaderTool(Tool[FileReaderToolOverrideKwargs]):
chat_file = self._load_file(file_id)
# Only PLAIN_TEXT and TABULAR are guaranteed to contain actual text bytes.
# Only PLAIN_TEXT and CSV are guaranteed to contain actual text bytes.
# DOC type in a loaded file means plaintext extraction failed and the
# content is the original binary (e.g. raw PDF/DOCX bytes).
if chat_file.file_type not in (
ChatFileType.PLAIN_TEXT,
ChatFileType.TABULAR,
):
if chat_file.file_type not in (ChatFileType.PLAIN_TEXT, ChatFileType.CSV):
raise ToolCallException(
message=f"File {file_id} is not a text file (type={chat_file.file_type})",
llm_facing_message=(
@@ -186,19 +181,7 @@ class FileReaderTool(Tool[FileReaderToolOverrideKwargs]):
)
try:
if chat_file.file_type == ChatFileType.PLAIN_TEXT:
full_text = chat_file.content.decode("utf-8", errors="replace")
else:
full_text = (
extract_file_text(
file=io.BytesIO(chat_file.content),
file_name=chat_file.filename or "",
break_on_unprocessable=False,
)
or ""
)
except ToolCallException:
raise
full_text = chat_file.content.decode("utf-8", errors="replace")
except Exception:
raise ToolCallException(
message=f"Failed to decode file {file_id}",

View File

@@ -1175,7 +1175,7 @@ def test_code_interpreter_receives_chat_files(
file_descriptor: FileDescriptor = {
"id": user_file.file_id,
"type": ChatFileType.TABULAR,
"type": ChatFileType.CSV,
"name": "data.csv",
"user_file_id": str(user_file.id),
}

View File

@@ -1,9 +1,3 @@
import mimetypes
from typing import Any
import requests
from tests.integration.common_utils.constants import API_SERVER_URL
from tests.integration.common_utils.managers.chat import ChatSessionManager
from tests.integration.common_utils.managers.file import FileManager
from tests.integration.common_utils.managers.llm_provider import LLMProviderManager
@@ -85,90 +79,3 @@ def test_send_message_with_text_file_attachment(admin_user: DATestUser) -> None:
assert (
"third line" in response.full_message.lower()
), "Chat response should contain the contents of the file"
def _set_token_threshold(admin_user: DATestUser, threshold_k: int) -> None:
"""Set the file token count threshold via admin settings API."""
response = requests.put(
f"{API_SERVER_URL}/admin/settings",
json={"file_token_count_threshold_k": threshold_k},
headers=admin_user.headers,
)
response.raise_for_status()
def _upload_raw(
filename: str,
content: bytes,
user: DATestUser,
) -> dict[str, Any]:
"""Upload a file and return the full JSON response (user_files + rejected_files)."""
mime_type, _ = mimetypes.guess_type(filename)
headers = user.headers.copy()
headers.pop("Content-Type", None)
response = requests.post(
f"{API_SERVER_URL}/user/projects/file/upload",
files=[("files", (filename, content, mime_type or "application/octet-stream"))],
headers=headers,
)
response.raise_for_status()
return response.json()
def test_csv_over_token_threshold_uploaded_not_indexed(
admin_user: DATestUser,
) -> None:
"""CSV exceeding token threshold is uploaded (accepted) but skips indexing."""
_set_token_threshold(admin_user, threshold_k=1)
try:
# ~2000 tokens with default tokenizer, well over 1K threshold
content = ("x " * 100 + "\n") * 20
result = _upload_raw("large.csv", content.encode(), admin_user)
assert len(result["user_files"]) == 1, "CSV should be accepted"
assert len(result["rejected_files"]) == 0, "CSV should not be rejected"
assert (
result["user_files"][0]["status"] == "SKIPPED"
), "CSV over threshold should be SKIPPED (uploaded but not indexed)"
assert (
result["user_files"][0]["chunk_count"] is None
), "Skipped file should have no chunks"
finally:
_set_token_threshold(admin_user, threshold_k=200)
def test_csv_under_token_threshold_uploaded_and_indexed(
admin_user: DATestUser,
) -> None:
"""CSV under token threshold is uploaded and queued for indexing."""
_set_token_threshold(admin_user, threshold_k=200)
try:
content = "col1,col2\na,b\n"
result = _upload_raw("small.csv", content.encode(), admin_user)
assert len(result["user_files"]) == 1, "CSV should be accepted"
assert len(result["rejected_files"]) == 0, "CSV should not be rejected"
assert (
result["user_files"][0]["status"] == "PROCESSING"
), "CSV under threshold should be PROCESSING (queued for indexing)"
finally:
_set_token_threshold(admin_user, threshold_k=200)
def test_txt_over_token_threshold_rejected(
admin_user: DATestUser,
) -> None:
"""Non-exempt file exceeding token threshold is rejected entirely."""
_set_token_threshold(admin_user, threshold_k=1)
try:
# ~2000 tokens, well over 1K threshold. Unlike CSV, .txt is not
# exempt from the threshold so the file should be rejected.
content = ("x " * 100 + "\n") * 20
result = _upload_raw("big.txt", content.encode(), admin_user)
assert len(result["user_files"]) == 0, "File should not be accepted"
assert len(result["rejected_files"]) == 1, "File should be rejected"
assert "token limit" in result["rejected_files"][0]["reason"].lower()
finally:
_set_token_threshold(admin_user, threshold_k=200)

View File

@@ -139,7 +139,7 @@ def test_csv_file_type() -> None:
result = _extract_referenced_file_descriptors([tool_call], message)
assert len(result) == 1
assert result[0]["type"] == ChatFileType.TABULAR
assert result[0]["type"] == ChatFileType.CSV
def test_unknown_extension_defaults_to_plain_text() -> None:

View File

@@ -40,8 +40,6 @@ def test_send_task_includes_expires(
user_files=user_files,
rejected_files=[],
id_to_temp_id={},
skip_indexing_filenames=set(),
indexable_files=user_files,
)
mock_user = MagicMock()

View File

@@ -417,57 +417,3 @@ def test_categorize_text_under_token_limit_accepted(
assert len(result.acceptable) == 1
assert result.acceptable_file_to_token_count["ok.txt"] == 500
# --- skip-indexing vs rejection by file type ---
def test_csv_over_token_threshold_accepted_skip_indexing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""CSV exceeding token threshold is uploaded but flagged to skip indexing."""
_patch_common_dependencies(monkeypatch, upload_size_mb=1000, token_threshold_k=1)
text = "x" * 2000 # 2000 tokens > 1000 threshold
monkeypatch.setattr(utils, "extract_file_text", lambda **_kwargs: text)
upload = _make_upload("large.csv", size=2000, content=text.encode())
result = utils.categorize_uploaded_files([upload], MagicMock())
assert len(result.acceptable) == 1
assert result.acceptable[0].filename == "large.csv"
assert "large.csv" in result.skip_indexing
assert len(result.rejected) == 0
def test_csv_under_token_threshold_accepted_and_indexed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""CSV under token threshold is uploaded and indexed normally."""
_patch_common_dependencies(monkeypatch, upload_size_mb=1000, token_threshold_k=1)
text = "x" * 500 # 500 tokens < 1000 threshold
monkeypatch.setattr(utils, "extract_file_text", lambda **_kwargs: text)
upload = _make_upload("small.csv", size=500, content=text.encode())
result = utils.categorize_uploaded_files([upload], MagicMock())
assert len(result.acceptable) == 1
assert result.acceptable[0].filename == "small.csv"
assert "small.csv" not in result.skip_indexing
assert len(result.rejected) == 0
def test_pdf_over_token_threshold_rejected(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""PDF exceeding token threshold is rejected entirely (not uploaded)."""
_patch_common_dependencies(monkeypatch, upload_size_mb=1000, token_threshold_k=1)
text = "x" * 2000 # 2000 tokens > 1000 threshold
monkeypatch.setattr(utils, "extract_file_text", lambda **_kwargs: text)
upload = _make_upload("big.pdf", size=2000, content=text.encode())
result = utils.categorize_uploaded_files([upload], MagicMock())
assert len(result.rejected) == 1
assert result.rejected[0].filename == "big.pdf"
assert "1K token limit" in result.rejected[0].reason
assert len(result.acceptable) == 0

View File

@@ -82,7 +82,7 @@ class TestChatFileConversion:
ChatLoadedFile(
file_id="file-2",
content=b"csv,data\n1,2",
file_type=ChatFileType.TABULAR,
file_type=ChatFileType.CSV,
filename="data.csv",
content_text="csv,data\n1,2",
token_count=5,

View File

@@ -100,7 +100,9 @@ function Button({
border={interactiveProps.prominence === "secondary"}
heightVariant={size}
widthVariant={width}
roundingVariant={isLarge ? "md" : size === "2xs" ? "xs" : "sm"}
roundingVariant={
isLarge ? "default" : size === "2xs" ? "mini" : "compact"
}
>
<div className="flex flex-row items-center gap-1">
{iconWrapper(Icon, size, !!children)}

View File

@@ -35,7 +35,7 @@ Interactive.Stateful <- selectVariant, state, interaction, onClick, href
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `roundingVariant` | `InteractiveContainerRoundingVariant` | `"md"` | Corner rounding preset (height is content-driven) |
| `roundingVariant` | `InteractiveContainerRoundingVariant` | `"default"` | Corner rounding preset (height is content-driven) |
| `width` | `WidthVariant` | `"full"` | Container width |
| `type` | `"submit" \| "button" \| "reset"` | `"button"` | HTML button type |
| `tooltip` | `string` | — | Tooltip text shown on hover |
@@ -63,7 +63,7 @@ import { LineItemButton } from "@opal/components";
<LineItemButton
selectVariant="select-heavy"
state={isSelected ? "selected" : "empty"}
roundingVariant="sm"
roundingVariant="compact"
onClick={handleClick}
title="gpt-4o"
sizePreset="main-ui"

View File

@@ -33,7 +33,7 @@ type LineItemButtonOwnProps = Pick<
/** Interactive select variant. @default "select-light" */
selectVariant?: "select-light" | "select-heavy";
/** Corner rounding preset (height is always content-driven). @default "md" */
/** Corner rounding preset (height is always content-driven). @default "default" */
roundingVariant?: InteractiveContainerRoundingVariant;
/** Container width. @default "full" */
@@ -65,7 +65,7 @@ function LineItemButton({
type = "button",
// Sizing
roundingVariant = "md",
roundingVariant = "default",
width = "full",
tooltip,
tooltipSide = "top",

View File

@@ -127,7 +127,7 @@ function OpenButton({
widthVariant={width}
roundingVariant={
roundingVariantOverride ??
(isLarge ? "md" : size === "2xs" ? "xs" : "sm")
(isLarge ? "default" : size === "2xs" ? "mini" : "compact")
}
>
<div

View File

@@ -101,7 +101,9 @@ function SelectButton({
type={type}
heightVariant={size}
widthVariant={width}
roundingVariant={isLarge ? "md" : size === "2xs" ? "xs" : "sm"}
roundingVariant={
isLarge ? "default" : size === "2xs" ? "mini" : "compact"
}
>
<div
className={cn(

View File

@@ -3,8 +3,7 @@ import { Card } from "@opal/components";
const BACKGROUND_VARIANTS = ["none", "light", "heavy"] as const;
const BORDER_VARIANTS = ["none", "dashed", "solid"] as const;
const PADDING_VARIANTS = ["fit", "2xs", "xs", "sm", "md", "lg"] as const;
const ROUNDING_VARIANTS = ["xs", "sm", "md", "lg"] as const;
const SIZE_VARIANTS = ["lg", "md", "sm", "xs", "2xs", "fit"] as const;
const meta: Meta<typeof Card> = {
title: "opal/components/Card",
@@ -18,9 +17,7 @@ type Story = StoryObj<typeof Card>;
export const Default: Story = {
render: () => (
<Card>
<p>
Default card with light background, no border, sm padding, md rounding.
</p>
<p>Default card with light background, no border, lg size.</p>
</Card>
),
};
@@ -49,24 +46,12 @@ export const BorderVariants: Story = {
),
};
export const PaddingVariants: Story = {
export const SizeVariants: Story = {
render: () => (
<div className="flex flex-col gap-4 w-96">
{PADDING_VARIANTS.map((padding) => (
<Card key={padding} paddingVariant={padding} borderVariant="solid">
<p>paddingVariant: {padding}</p>
</Card>
))}
</div>
),
};
export const RoundingVariants: Story = {
render: () => (
<div className="flex flex-col gap-4 w-96">
{ROUNDING_VARIANTS.map((rounding) => (
<Card key={rounding} roundingVariant={rounding} borderVariant="solid">
<p>roundingVariant: {rounding}</p>
{SIZE_VARIANTS.map((size) => (
<Card key={size} sizeVariant={size} borderVariant="solid">
<p>sizeVariant: {size}</p>
</Card>
))}
</div>
@@ -76,15 +61,15 @@ export const RoundingVariants: Story = {
export const AllCombinations: Story = {
render: () => (
<div className="flex flex-col gap-8">
{PADDING_VARIANTS.map((padding) => (
<div key={padding}>
<p className="font-bold pb-2">paddingVariant: {padding}</p>
{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={`${padding}-${bg}-${border}`}
paddingVariant={padding}
key={`${size}-${bg}-${border}`}
sizeVariant={size}
backgroundVariant={bg}
borderVariant={border}
>

View File

@@ -6,53 +6,52 @@ A plain container component with configurable background, border, padding, and r
## Architecture
Padding and rounding are controlled independently:
The `sizeVariant` controls both padding and border-radius, mirroring the same mapping used by `Button` and `Interactive.Container`:
| `paddingVariant` | Class |
|------------------|---------|
| `"lg"` | `p-6` |
| `"md"` | `p-4` |
| `"sm"` | `p-2` |
| `"xs"` | `p-1` |
| `"2xs"` | `p-0.5` |
| `"fit"` | `p-0` |
| `roundingVariant` | Class |
|-------------------|--------------|
| `"xs"` | `rounded-04` |
| `"sm"` | `rounded-08` |
| `"md"` | `rounded-12` |
| `"lg"` | `rounded-16` |
| 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 |
|------|------|---------|-------------|
| `paddingVariant` | `PaddingVariants` | `"sm"` | Padding preset |
| `roundingVariant` | `RoundingVariants` | `"md"` | Border-radius preset |
| `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, sm padding, md rounding)
// Default card (light background, no border, lg padding + rounding)
<Card>
<h2>Card Title</h2>
<p>Card content</p>
</Card>
// Large padding + rounding with solid border
<Card paddingVariant="lg" roundingVariant="lg" borderVariant="solid">
<p>Spacious card</p>
</Card>
// Compact card with solid border
<Card paddingVariant="xs" roundingVariant="sm" borderVariant="solid">
<Card borderVariant="solid" sizeVariant="sm">
<p>Compact card</p>
</Card>
@@ -60,4 +59,9 @@ import { Card } from "@opal/components";
<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>
```

View File

@@ -1,5 +1,6 @@
import "@opal/components/cards/card/styles.css";
import type { PaddingVariants, RoundingVariants } from "@opal/types";
import type { ContainerSizeVariants } from "@opal/types";
import { containerSizeVariants } from "@opal/shared";
import { cn } from "@opal/utils";
// ---------------------------------------------------------------------------
@@ -11,34 +12,21 @@ type BorderVariant = "none" | "dashed" | "solid";
type CardProps = {
/**
* Padding preset.
* Size preset — controls padding and border-radius.
*
* | Value | Class |
* |---------|---------|
* | `"lg"` | `p-6` |
* | `"md"` | `p-4` |
* | `"sm"` | `p-2` |
* | `"xs"` | `p-1` |
* | `"2xs"` | `p-0.5` |
* | `"fit"` | `p-0` |
* Padding comes from the shared size scale. Rounding follows the same
* mapping as `Button` / `Interactive.Container`:
*
* @default "sm"
* | Size | Rounding |
* |--------|------------|
* | `lg` | `default` |
* | `md``sm` | `compact` |
* | `xs``2xs` | `mini` |
* | `fit` | `default` |
*
* @default "lg"
*/
paddingVariant?: PaddingVariants;
/**
* Border-radius preset.
*
* | Value | Class |
* |--------|--------------|
* | `"xs"` | `rounded-04` |
* | `"sm"` | `rounded-08` |
* | `"md"` | `rounded-12` |
* | `"lg"` | `rounded-16` |
*
* @default "md"
*/
roundingVariant?: RoundingVariants;
sizeVariant?: ContainerSizeVariants;
/**
* Background fill intensity.
@@ -67,23 +55,17 @@ type CardProps = {
};
// ---------------------------------------------------------------------------
// Mappings
// Rounding
// ---------------------------------------------------------------------------
const paddingForVariant: Record<PaddingVariants, string> = {
lg: "p-6",
md: "p-4",
sm: "p-2",
xs: "p-1",
"2xs": "p-0.5",
fit: "p-0",
};
const roundingForVariant: Record<RoundingVariants, string> = {
lg: "rounded-16",
md: "rounded-12",
/** Maps a size variant to a rounding class, mirroring the Button pattern. */
const roundingForSize: Record<ContainerSizeVariants, string> = {
lg: "rounded-12",
md: "rounded-08",
sm: "rounded-08",
xs: "rounded-04",
"2xs": "rounded-04",
fit: "rounded-12",
};
// ---------------------------------------------------------------------------
@@ -91,15 +73,14 @@ const roundingForVariant: Record<RoundingVariants, string> = {
// ---------------------------------------------------------------------------
function Card({
paddingVariant = "sm",
roundingVariant = "md",
sizeVariant = "lg",
backgroundVariant = "light",
borderVariant = "none",
ref,
children,
}: CardProps) {
const padding = paddingForVariant[paddingVariant];
const rounding = roundingForVariant[roundingVariant];
const { padding } = containerSizeVariants[sizeVariant];
const rounding = roundingForSize[sizeVariant];
return (
<div

View File

@@ -6,12 +6,12 @@ A pre-configured Card for empty states. Renders a transparent card with a dashed
## Props
| Prop | Type | Default | Description |
| ----------------- | --------------------------- | ---------- | ------------------------------------------------ |
| `icon` | `IconFunctionComponent` | `SvgEmpty` | Icon displayed alongside the title |
| `title` | `string` | — | Primary message text (required) |
| `paddingVariant` | `PaddingVariants` | `"sm"` | Padding preset for the card |
| `ref` | `React.Ref<HTMLDivElement>` | — | Ref forwarded to the root div |
| 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
@@ -25,6 +25,6 @@ import { SvgSparkle, SvgFileText } from "@opal/icons";
// With custom icon
<EmptyMessageCard icon={SvgSparkle} title="No agents selected." />
// With custom padding
<EmptyMessageCard paddingVariant="xs" icon={SvgFileText} title="No documents available." />
// With custom size
<EmptyMessageCard sizeVariant="sm" icon={SvgFileText} title="No documents available." />
```

View File

@@ -1,7 +1,8 @@
import { Card } from "@opal/components/cards/card/components";
import { Content } from "@opal/layouts";
import { SvgEmpty } from "@opal/icons";
import type { IconFunctionComponent, PaddingVariants } from "@opal/types";
import type { ContainerSizeVariants } from "@opal/types";
import type { IconFunctionComponent } from "@opal/types";
// ---------------------------------------------------------------------------
// Types
@@ -14,8 +15,8 @@ type EmptyMessageCardProps = {
/** Primary message text. */
title: string;
/** Padding preset for the card. */
paddingVariant?: PaddingVariants;
/** Size preset controlling padding and rounding of the card. */
sizeVariant?: ContainerSizeVariants;
/** Ref forwarded to the root Card div. */
ref?: React.Ref<HTMLDivElement>;
@@ -28,7 +29,7 @@ type EmptyMessageCardProps = {
function EmptyMessageCard({
icon = SvgEmpty,
title,
paddingVariant = "sm",
sizeVariant = "lg",
ref,
}: EmptyMessageCardProps) {
return (
@@ -36,7 +37,7 @@ function EmptyMessageCard({
ref={ref}
backgroundVariant="none"
borderVariant="dashed"
paddingVariant={paddingVariant}
sizeVariant={sizeVariant}
>
<Content
icon={icon}

View File

@@ -9,7 +9,7 @@ Structural container shared by both `Interactive.Stateless` and `Interactive.Sta
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `heightVariant` | `SizeVariant` | `"lg"` | Height preset (`2xs``lg`, `fit`) |
| `roundingVariant` | `"md" \| "sm" \| "xs"` | `"md"` | Border-radius preset |
| `roundingVariant` | `"default" \| "compact" \| "mini"` | `"default"` | Border-radius preset |
| `widthVariant` | `WidthVariant` | — | Width preset (`"auto"`, `"fit"`, `"full"`) |
| `border` | `boolean` | `false` | Renders a 1px border |
| `type` | `"submit" \| "button" \| "reset"` | — | When set, renders a `<button>` element |
@@ -18,7 +18,7 @@ Structural container shared by both `Interactive.Stateless` and `Interactive.Sta
```tsx
<Interactive.Stateless variant="default" prominence="primary">
<Interactive.Container heightVariant="sm" roundingVariant="sm" border>
<Interactive.Container heightVariant="sm" roundingVariant="compact" border>
<span>Content</span>
</Interactive.Container>
</Interactive.Stateless>

View File

@@ -3,7 +3,7 @@ import type { Route } from "next";
import "@opal/core/interactive/shared.css";
import React from "react";
import { cn } from "@opal/utils";
import type { ButtonType, RoundingVariants, WithoutStyles } from "@opal/types";
import type { ButtonType, WithoutStyles } from "@opal/types";
import {
containerSizeVariants,
type ContainerSizeVariants,
@@ -16,17 +16,19 @@ import { useDisabled } from "@opal/core/disabled/components";
// Types
// ---------------------------------------------------------------------------
type InteractiveContainerRoundingVariant = Extract<
RoundingVariants,
"md" | "sm" | "xs"
>;
const interactiveContainerRoundingVariants: Record<
InteractiveContainerRoundingVariant,
string
> = {
md: "rounded-12",
sm: "rounded-08",
xs: "rounded-04",
/**
* Border-radius presets for `Interactive.Container`.
*
* - `"default"` — Default radius of 0.75rem (12px), matching card rounding
* - `"compact"` — Smaller radius of 0.5rem (8px), for tighter/inline elements
* - `"mini"` — Smallest radius of 0.25rem (4px)
*/
type InteractiveContainerRoundingVariant =
keyof typeof interactiveContainerRoundingVariants;
const interactiveContainerRoundingVariants = {
default: "rounded-12",
compact: "rounded-08",
mini: "rounded-04",
} as const;
/**
@@ -97,7 +99,7 @@ function InteractiveContainer({
ref,
type,
border,
roundingVariant = "md",
roundingVariant = "default",
heightVariant = "lg",
widthVariant = "fit",
...props

View File

@@ -37,35 +37,6 @@ export type SizeVariants = "fit" | "full" | "lg" | "md" | "sm" | "xs" | "2xs";
*/
export type ContainerSizeVariants = Exclude<SizeVariants, "full">;
/**
* Padding size variants.
*
* | Variant | Class |
* |---------|---------|
* | `lg` | `p-6` |
* | `md` | `p-4` |
* | `sm` | `p-2` |
* | `xs` | `p-1` |
* | `2xs` | `p-0.5` |
* | `fit` | `p-0` |
*/
export type PaddingVariants = Extract<
SizeVariants,
"fit" | "lg" | "md" | "sm" | "xs" | "2xs"
>;
/**
* Rounding size variants.
*
* | Variant | Class |
* |---------|--------------|
* | `lg` | `rounded-16` |
* | `md` | `rounded-12` |
* | `sm` | `rounded-08` |
* | `xs` | `rounded-04` |
*/
export type RoundingVariants = Extract<SizeVariants, "lg" | "md" | "sm" | "xs">;
/**
* Extreme size variants ("fit" and "full" only).
*

View File

@@ -1 +0,0 @@
export { default } from "@/refresh-pages/admin/HooksPage";

View File

@@ -75,14 +75,14 @@ export enum ChatFileType {
IMAGE = "image",
DOCUMENT = "document",
PLAIN_TEXT = "plain_text",
TABULAR = "tabular",
CSV = "csv",
USER_KNOWLEDGE = "user_knowledge",
}
export const isTextFile = (fileType: ChatFileType) =>
[
ChatFileType.PLAIN_TEXT,
ChatFileType.TABULAR,
ChatFileType.CSV,
ChatFileType.USER_KNOWLEDGE,
ChatFileType.DOCUMENT,
].includes(fileType);

View File

@@ -42,12 +42,7 @@ export default function FileDisplay({ files }: FileDisplayProps) {
file.type === ChatFileType.DOCUMENT
);
const imageFiles = files.filter((file) => file.type === ChatFileType.IMAGE);
// TODO(danelegend): XLSX files are binary (OOXML) and will fail to parse in CsvContent.
// The backend should convert XLSX to CSV text before serving via /api/chat/file,
// or XLSX should be split into a separate ChatFileType and rendered as an Attachment.
const tabularFiles = files.filter(
(file) => file.type === ChatFileType.TABULAR
);
const csvFiles = files.filter((file) => file.type === ChatFileType.CSV);
const presentingDocument: MinimalOnyxDocument = {
document_id: previewingFile?.id ?? "",
@@ -83,9 +78,9 @@ export default function FileDisplay({ files }: FileDisplayProps) {
</FileContainer>
)}
{tabularFiles.length > 0 && (
{csvFiles.length > 0 && (
<FileContainer className="overflow-auto">
{tabularFiles.map((file) =>
{csvFiles.map((file) =>
close ? (
<ExpandableContentWrapper
key={file.id}

View File

@@ -51,7 +51,6 @@ export enum UserFileStatus {
UPLOADING = "UPLOADING", //UI only
PROCESSING = "PROCESSING",
COMPLETED = "COMPLETED",
SKIPPED = "SKIPPED",
FAILED = "FAILED",
CANCELED = "CANCELED",
DELETING = "DELETING",

View File

@@ -0,0 +1 @@
export { default } from "@/ee/refresh-pages/admin/HooksPage";

View File

@@ -1,6 +1,6 @@
import useSWR from "swr";
import { fetchExecutionLogs } from "@/refresh-pages/admin/HooksPage/svc";
import type { HookExecutionRecord } from "@/refresh-pages/admin/HooksPage/interfaces";
import { fetchExecutionLogs } from "@/ee/refresh-pages/admin/HooksPage/svc";
import type { HookExecutionRecord } from "@/ee/refresh-pages/admin/HooksPage/interfaces";
const ONE_HOUR_MS = 60 * 60 * 1000;
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;

View File

@@ -2,7 +2,7 @@
import useSWR from "swr";
import { errorHandlingFetcher } from "@/lib/fetcher";
import { HookPointMeta } from "@/refresh-pages/admin/HooksPage/interfaces";
import { HookPointMeta } from "@/ee/refresh-pages/admin/HooksPage/interfaces";
export function useHookSpecs() {
const { data, isLoading, error } = useSWR<HookPointMeta[]>(

View File

@@ -2,7 +2,7 @@
import useSWR from "swr";
import { errorHandlingFetcher } from "@/lib/fetcher";
import { HookResponse } from "@/refresh-pages/admin/HooksPage/interfaces";
import { HookResponse } from "@/ee/refresh-pages/admin/HooksPage/interfaces";
export function useHooks() {
const { data, isLoading, error, mutate } = useSWR<HookResponse[]>(

View File

@@ -22,15 +22,15 @@ import Modal, { BasicModalFooter } from "@/refresh-components/Modal";
import type {
HookPointMeta,
HookResponse,
} from "@/refresh-pages/admin/HooksPage/interfaces";
} from "@/ee/refresh-pages/admin/HooksPage/interfaces";
import {
activateHook,
deactivateHook,
deleteHook,
validateHook,
} from "@/refresh-pages/admin/HooksPage/svc";
import { getHookPointIcon } from "@/refresh-pages/admin/HooksPage/hookPointIcons";
import HookStatusPopover from "@/refresh-pages/admin/HooksPage/HookStatusPopover";
} from "@/ee/refresh-pages/admin/HooksPage/svc";
import { getHookPointIcon } from "@/ee/refresh-pages/admin/HooksPage/hookPointIcons";
import HookStatusPopover from "@/ee/refresh-pages/admin/HooksPage/HookStatusPopover";
// ---------------------------------------------------------------------------
// Sub-component: disconnect confirmation modal

View File

@@ -23,14 +23,14 @@ import {
HookAuthError,
HookTimeoutError,
HookConnectError,
} from "@/refresh-pages/admin/HooksPage/svc";
} from "@/ee/refresh-pages/admin/HooksPage/svc";
import type {
HookFailStrategy,
HookFormState,
HookPointMeta,
HookResponse,
HookUpdateRequest,
} from "@/refresh-pages/admin/HooksPage/interfaces";
} from "@/ee/refresh-pages/admin/HooksPage/interfaces";
// ---------------------------------------------------------------------------
// Types

View File

@@ -5,7 +5,7 @@ import { SvgDownload, SvgTextLines } from "@opal/icons";
import Modal from "@/refresh-components/Modal";
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
import CopyIconButton from "@/refresh-components/buttons/CopyIconButton";
import { useHookExecutionLogs } from "@/hooks/useHookExecutionLogs";
import { useHookExecutionLogs } from "@/ee/hooks/useHookExecutionLogs";
import { formatDateTimeLog } from "@/lib/dateUtils";
import { downloadFile } from "@/lib/download";
import { Section } from "@/layouts/general-layouts";
@@ -13,7 +13,7 @@ import type {
HookExecutionRecord,
HookPointMeta,
HookResponse,
} from "@/refresh-pages/admin/HooksPage/interfaces";
} from "@/ee/refresh-pages/admin/HooksPage/interfaces";
interface HookLogsModalProps {
open: boolean;

View File

@@ -16,12 +16,12 @@ import {
SvgXOctagon,
} from "@opal/icons";
import CopyIconButton from "@/refresh-components/buttons/CopyIconButton";
import { useHookExecutionLogs } from "@/hooks/useHookExecutionLogs";
import HookLogsModal from "@/refresh-pages/admin/HooksPage/HookLogsModal";
import { useHookExecutionLogs } from "@/ee/hooks/useHookExecutionLogs";
import HookLogsModal from "@/ee/refresh-pages/admin/HooksPage/HookLogsModal";
import type {
HookPointMeta,
HookResponse,
} from "@/refresh-pages/admin/HooksPage/interfaces";
} from "@/ee/refresh-pages/admin/HooksPage/interfaces";
interface HookStatusPopoverProps {
hook: HookResponse;

View File

@@ -1,8 +1,8 @@
"use client";
import { useState } from "react";
import { useHookSpecs } from "@/hooks/useHookSpecs";
import { useHooks } from "@/hooks/useHooks";
import { useHookSpecs } from "@/ee/hooks/useHookSpecs";
import { useHooks } from "@/ee/hooks/useHooks";
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
import { Button } from "@opal/components";
import { Content } from "@opal/layouts";
@@ -10,13 +10,13 @@ import InputSearch from "@/refresh-components/inputs/InputSearch";
import Card from "@/refresh-components/cards/Card";
import Text from "@/refresh-components/texts/Text";
import { SvgArrowExchange, SvgExternalLink } from "@opal/icons";
import HookFormModal from "@/refresh-pages/admin/HooksPage/HookFormModal";
import ConnectedHookCard from "@/refresh-pages/admin/HooksPage/ConnectedHookCard";
import { getHookPointIcon } from "@/refresh-pages/admin/HooksPage/hookPointIcons";
import HookFormModal from "@/ee/refresh-pages/admin/HooksPage/HookFormModal";
import ConnectedHookCard from "@/ee/refresh-pages/admin/HooksPage/ConnectedHookCard";
import { getHookPointIcon } from "@/ee/refresh-pages/admin/HooksPage/hookPointIcons";
import type {
HookPointMeta,
HookResponse,
} from "@/refresh-pages/admin/HooksPage/interfaces";
} from "@/ee/refresh-pages/admin/HooksPage/interfaces";
import { markdown } from "@opal/utils";
// ---------------------------------------------------------------------------

View File

@@ -4,7 +4,7 @@ import {
HookResponse,
HookUpdateRequest,
HookValidateResponse,
} from "@/refresh-pages/admin/HooksPage/interfaces";
} from "@/ee/refresh-pages/admin/HooksPage/interfaces";
export class HookAuthError extends Error {}
export class HookTimeoutError extends Error {}

View File

@@ -229,7 +229,7 @@ export const ADMIN_ROUTES = {
sidebarLabel: "Document Index Migration",
},
HOOKS: {
path: "/admin/hooks",
path: "/ee/admin/hooks",
icon: SvgHookNodes,
title: "Hook Extensions",
sidebarLabel: "Hook Extensions",

View File

@@ -664,12 +664,12 @@ export function ProjectsProvider({ children }: ProjectsProviderProps) {
return changed ? Array.from(map.values()) : prev;
});
// Remove completed/skipped/failed from tracking
// Remove completed/failed from tracking
const remaining = new Set(trackedUploadIds);
const newlyFailed: ProjectFile[] = [];
for (const f of statuses) {
const s = String(f.status).toLowerCase();
if (s === "completed" || s === "skipped") {
if (s === "completed") {
remaining.delete(f.id);
} else if (s === "failed") {
remaining.delete(f.id);

View File

@@ -64,7 +64,7 @@ export default function SidebarTab({
group="group/SidebarTab"
>
<Interactive.Container
roundingVariant="sm"
roundingVariant="compact"
heightVariant="lg"
widthVariant="full"
type={type}

View File

@@ -108,7 +108,7 @@ export default function UserRoleCell({ user, onMutate }: UserRoleCellProps) {
variant="select-tinted"
width="full"
justifyContent="between"
roundingVariant="sm"
roundingVariant="compact"
>
{USER_ROLE_LABELS[user.role]}
</OpenButton>

View File

@@ -38,7 +38,7 @@ export default function Suggestions({ onSubmit }: SuggestionsProps) {
>
<Interactive.Container
widthVariant="full"
roundingVariant="sm"
roundingVariant="compact"
heightVariant="lg"
>
<Content

View File

@@ -225,11 +225,7 @@ function BedrockModalInternals({
</FieldWrapper>
{authMethod === AUTH_METHOD_ACCESS_KEY && (
<Card
backgroundVariant="light"
borderVariant="none"
paddingVariant="sm"
>
<Card backgroundVariant="light" borderVariant="none" sizeVariant="lg">
<Section gap={1}>
<InputLayouts.Vertical
name={FIELD_AWS_ACCESS_KEY_ID}
@@ -267,11 +263,7 @@ function BedrockModalInternals({
)}
{authMethod === AUTH_METHOD_LONG_TERM_API_KEY && (
<Card
backgroundVariant="light"
borderVariant="none"
paddingVariant="sm"
>
<Card backgroundVariant="light" borderVariant="none" sizeVariant="lg">
<Section gap={0.5}>
<InputLayouts.Vertical
name={FIELD_AWS_BEARER_TOKEN_BEDROCK}

View File

@@ -140,7 +140,7 @@ function OllamaModalInternals({
isTesting={isTesting}
isSubmitting={formikProps.isSubmitting}
>
<Card backgroundVariant="light" borderVariant="none" paddingVariant="sm">
<Card backgroundVariant="light" borderVariant="none" sizeVariant="lg">
<Tabs defaultValue={defaultTab}>
<Tabs.List>
<Tabs.Trigger value={TAB_SELF_HOSTED}>

View File

@@ -224,6 +224,9 @@ export function ModelsAccessField<T extends BaseLLMFormValues>({
);
}
const hasSelections =
selectedGroupIds.length > 0 || selectedAgentIds.length > 0;
return (
<div className="flex flex-col w-full">
<FieldWrapper>
@@ -250,11 +253,7 @@ export function ModelsAccessField<T extends BaseLLMFormValues>({
</FieldWrapper>
{!isPublic && (
<Card
backgroundVariant="light"
borderVariant="none"
paddingVariant="sm"
>
<Card backgroundVariant="light" borderVariant="none" sizeVariant="lg">
<Section gap={0.5}>
<InputComboBox
placeholder="Add groups and agents"
@@ -452,7 +451,7 @@ export function ModelsField<T extends BaseLLMFormValues>({
const visibleModels = modelConfigurations.filter((m) => m.is_visible);
return (
<Card backgroundVariant="light" borderVariant="none" paddingVariant="sm">
<Card backgroundVariant="light" borderVariant="none" sizeVariant="lg">
<Section gap={0.5}>
<InputLayouts.Horizontal
title="Models"