feat: Onyx Craft (#7484)

Co-authored-by: Wenxi <wenxi@onyx.app>
Co-authored by: joachim-danswer <joachim@danswer.ai>
Co-authored-by: rohoswagger <roshan@onyx.app>
This commit is contained in:
Chris Weaver
2026-01-26 17:12:42 -08:00
committed by GitHub
parent b631bfa656
commit 7f0ce0531f
300 changed files with 57444 additions and 121 deletions

View File

@@ -66,7 +66,8 @@ repos:
- id: uv-run
name: Check lazy imports
args: ["--active", "--with=onyx-devtools", "ods", "check-lazy-imports"]
files: ^backend/(?!\.venv/).*\.py$
pass_filenames: true
files: ^backend/(?!\.venv/|scripts/).*\.py$
# NOTE: This takes ~6s on a single, large module which is prohibitively slow.
# - id: uv-run
# name: mypy

19
.vscode/launch.json vendored
View File

@@ -415,7 +415,6 @@
"onyx.background.celery.versioned_apps.docfetching",
"worker",
"--pool=threads",
"--concurrency=1",
"--prefetch-multiplier=1",
"--loglevel=INFO",
"--hostname=docfetching@%n",
@@ -446,7 +445,6 @@
"onyx.background.celery.versioned_apps.docprocessing",
"worker",
"--pool=threads",
"--concurrency=6",
"--prefetch-multiplier=1",
"--loglevel=INFO",
"--hostname=docprocessing@%n",
@@ -595,6 +593,23 @@
"group": "3"
}
},
{
"name": "Build Sandbox Templates",
"type": "debugpy",
"request": "launch",
"module": "onyx.server.features.build.sandbox.build_templates",
"cwd": "${workspaceFolder}/backend",
"envFile": "${workspaceFolder}/.vscode/.env",
"env": {
"PYTHONUNBUFFERED": "1",
"PYTHONPATH": "."
},
"console": "integratedTerminal",
"presentation": {
"group": "3"
},
"consoleTitle": "Build Sandbox Templates"
},
{
// Dummy entry used to label the group
"name": "--- Database ---",

View File

@@ -16,3 +16,8 @@ dist/
.coverage
htmlcov/
model_server/legacy/
# Craft: demo_data directory should be unzipped at container startup, not copied
**/demo_data/
# Craft: templates/outputs/venv is created at container startup
**/templates/outputs/venv

View File

@@ -7,6 +7,10 @@ have a contract or agreement with DanswerAI, you are not permitted to use the En
Edition features outside of personal development or testing purposes. Please reach out to \
founders@onyx.app for more information. Please visit https://github.com/onyx-dot-app/onyx"
# Build argument for Craft support (disabled by default)
# Use --build-arg ENABLE_CRAFT=true to include Node.js and opencode CLI
ARG ENABLE_CRAFT=false
# DO_NOT_TRACK is used to disable telemetry for Unstructured
ENV DANSWER_RUNNING_IN_DOCKER="true" \
DO_NOT_TRACK="true" \
@@ -46,7 +50,23 @@ RUN apt-get update && \
rm -rf /var/lib/apt/lists/* && \
apt-get clean
# Conditionally install Node.js 20 for Craft (required for Next.js)
# Only installed when ENABLE_CRAFT=true
RUN if [ "$ENABLE_CRAFT" = "true" ]; then \
echo "Installing Node.js 20 for Craft support..." && \
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
apt-get install -y nodejs && \
rm -rf /var/lib/apt/lists/*; \
fi
# Conditionally install opencode CLI for Craft agent functionality
# Only installed when ENABLE_CRAFT=true
# TODO: download a specific, versioned release of the opencode CLI
RUN if [ "$ENABLE_CRAFT" = "true" ]; then \
echo "Installing opencode CLI for Craft support..." && \
curl -fsSL https://opencode.ai/install | bash; \
fi
ENV PATH="/root/.opencode/bin:${PATH}"
# Install Python dependencies
# Remove py which is pulled in by retry, py is not needed and is a CVE
@@ -89,6 +109,12 @@ RUN uv pip install --system --no-cache-dir --upgrade \
RUN python -c "from tokenizers import Tokenizer; \
Tokenizer.from_pretrained('nomic-ai/nomic-embed-text-v1')"
# Pre-downloading NLTK for setups with limited egress
RUN python -c "import nltk; \
nltk.download('stopwords', quiet=True); \
nltk.download('punkt_tab', quiet=True);"
# nltk.download('wordnet', quiet=True); introduce this back if lemmatization is needed
# Pre-downloading tiktoken for setups with limited egress
RUN python -c "import tiktoken; \
tiktoken.get_encoding('cl100k_base')"
@@ -113,7 +139,8 @@ COPY --chown=onyx:onyx ./static /app/static
COPY --chown=onyx:onyx ./scripts/debugging /app/scripts/debugging
COPY --chown=onyx:onyx ./scripts/force_delete_connector_by_id.py /app/scripts/force_delete_connector_by_id.py
COPY --chown=onyx:onyx ./scripts/supervisord_entrypoint.sh /app/scripts/supervisord_entrypoint.sh
RUN chmod +x /app/scripts/supervisord_entrypoint.sh
COPY --chown=onyx:onyx ./scripts/setup_craft_templates.sh /app/scripts/setup_craft_templates.sh
RUN chmod +x /app/scripts/supervisord_entrypoint.sh /app/scripts/setup_craft_templates.sh
# Put logo in assets
COPY --chown=onyx:onyx ./assets /app/assets

View File

@@ -0,0 +1,351 @@
"""single onyx craft migration
Consolidates all buildmode/onyx craft tables into a single migration.
Tables created:
- build_session: User build sessions with status tracking
- sandbox: User-owned containerized environments (one per user)
- artifact: Build output files (web apps, documents, images)
- snapshot: Sandbox filesystem snapshots
- build_message: Conversation messages for build sessions
Existing table modified:
- connector_credential_pair: Added processing_mode column
Revision ID: 2020d417ec84
Revises: 41fa44bef321
Create Date: 2026-01-26 14:43:54.641405
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = "2020d417ec84"
down_revision = "41fa44bef321"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ==========================================================================
# ENUMS
# ==========================================================================
# Build session status enum
build_session_status_enum = sa.Enum(
"active",
"idle",
name="buildsessionstatus",
native_enum=False,
)
# Sandbox status enum
sandbox_status_enum = sa.Enum(
"provisioning",
"running",
"idle",
"sleeping",
"terminated",
"failed",
name="sandboxstatus",
native_enum=False,
)
# Artifact type enum
artifact_type_enum = sa.Enum(
"web_app",
"pptx",
"docx",
"markdown",
"excel",
"image",
name="artifacttype",
native_enum=False,
)
# ==========================================================================
# BUILD_SESSION TABLE
# ==========================================================================
op.create_table(
"build_session",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"user_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("user.id", ondelete="CASCADE"),
nullable=True,
),
sa.Column("name", sa.String(), nullable=True),
sa.Column(
"status",
build_session_status_enum,
nullable=False,
server_default="active",
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"last_activity_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("nextjs_port", sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_build_session_user_created",
"build_session",
["user_id", sa.text("created_at DESC")],
unique=False,
)
op.create_index(
"ix_build_session_status",
"build_session",
["status"],
unique=False,
)
# ==========================================================================
# SANDBOX TABLE (user-owned, one per user)
# ==========================================================================
op.create_table(
"sandbox",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"user_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("user.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("container_id", sa.String(), nullable=True),
sa.Column(
"status",
sandbox_status_enum,
nullable=False,
server_default="provisioning",
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("last_heartbeat", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id", name="sandbox_user_id_key"),
)
op.create_index(
"ix_sandbox_status",
"sandbox",
["status"],
unique=False,
)
op.create_index(
"ix_sandbox_container_id",
"sandbox",
["container_id"],
unique=False,
)
# ==========================================================================
# ARTIFACT TABLE
# ==========================================================================
op.create_table(
"artifact",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"session_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("build_session.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("type", artifact_type_enum, nullable=False),
sa.Column("path", sa.String(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_artifact_session_created",
"artifact",
["session_id", sa.text("created_at DESC")],
unique=False,
)
op.create_index(
"ix_artifact_type",
"artifact",
["type"],
unique=False,
)
# ==========================================================================
# SNAPSHOT TABLE
# ==========================================================================
op.create_table(
"snapshot",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"session_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("build_session.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("storage_path", sa.String(), nullable=False),
sa.Column("size_bytes", sa.BigInteger(), nullable=False, server_default="0"),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_snapshot_session_created",
"snapshot",
["session_id", sa.text("created_at DESC")],
unique=False,
)
# ==========================================================================
# BUILD_MESSAGE TABLE
# ==========================================================================
op.create_table(
"build_message",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"session_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("build_session.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"turn_index",
sa.Integer(),
nullable=False,
),
sa.Column(
"type",
sa.Enum(
"SYSTEM",
"USER",
"ASSISTANT",
"DANSWER",
name="messagetype",
create_type=False,
native_enum=False,
),
nullable=False,
),
sa.Column(
"message_metadata",
postgresql.JSONB(),
nullable=False,
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_build_message_session_turn",
"build_message",
["session_id", "turn_index", sa.text("created_at ASC")],
unique=False,
)
# ==========================================================================
# CONNECTOR_CREDENTIAL_PAIR MODIFICATION
# ==========================================================================
op.add_column(
"connector_credential_pair",
sa.Column(
"processing_mode",
sa.String(),
nullable=False,
server_default="regular",
),
)
def downgrade() -> None:
# ==========================================================================
# CONNECTOR_CREDENTIAL_PAIR MODIFICATION
# ==========================================================================
op.drop_column("connector_credential_pair", "processing_mode")
# ==========================================================================
# BUILD_MESSAGE TABLE
# ==========================================================================
op.drop_index("ix_build_message_session_turn", table_name="build_message")
op.drop_table("build_message")
# ==========================================================================
# SNAPSHOT TABLE
# ==========================================================================
op.drop_index("ix_snapshot_session_created", table_name="snapshot")
op.drop_table("snapshot")
# ==========================================================================
# ARTIFACT TABLE
# ==========================================================================
op.drop_index("ix_artifact_type", table_name="artifact")
op.drop_index("ix_artifact_session_created", table_name="artifact")
op.drop_table("artifact")
sa.Enum(name="artifacttype").drop(op.get_bind(), checkfirst=True)
# ==========================================================================
# SANDBOX TABLE
# ==========================================================================
op.drop_index("ix_sandbox_container_id", table_name="sandbox")
op.drop_index("ix_sandbox_status", table_name="sandbox")
op.drop_table("sandbox")
sa.Enum(name="sandboxstatus").drop(op.get_bind(), checkfirst=True)
# ==========================================================================
# BUILD_SESSION TABLE
# ==========================================================================
op.drop_index("ix_build_session_status", table_name="build_session")
op.drop_index("ix_build_session_user_created", table_name="build_session")
op.drop_table("build_session")
sa.Enum(name="buildsessionstatus").drop(op.get_bind(), checkfirst=True)

View File

@@ -134,5 +134,7 @@ celery_app.autodiscover_tasks(
"onyx.background.celery.tasks.docprocessing",
# Docfetching worker tasks
"onyx.background.celery.tasks.docfetching",
# Sandbox cleanup tasks (isolated in build feature)
"onyx.server.features.build.sandbox.tasks",
]
)

View File

@@ -98,5 +98,7 @@ for bootstep in base_bootsteps:
celery_app.autodiscover_tasks(
[
"onyx.background.celery.tasks.pruning",
# Sandbox tasks (file sync, cleanup)
"onyx.server.features.build.sandbox.tasks",
]
)

View File

@@ -116,5 +116,7 @@ celery_app.autodiscover_tasks(
"onyx.background.celery.tasks.connector_deletion",
"onyx.background.celery.tasks.doc_permission_syncing",
"onyx.background.celery.tasks.docprocessing",
# Sandbox cleanup tasks (isolated in build feature)
"onyx.server.features.build.sandbox.tasks",
]
)

View File

@@ -139,6 +139,27 @@ beat_task_templates: list[dict] = [
"queue": OnyxCeleryQueues.MONITORING,
},
},
# Sandbox cleanup tasks
{
"name": "cleanup-idle-sandboxes",
"task": OnyxCeleryTask.CLEANUP_IDLE_SANDBOXES,
"schedule": timedelta(minutes=1),
"options": {
"priority": OnyxCeleryPriority.LOW,
"expires": BEAT_EXPIRES_DEFAULT,
"queue": OnyxCeleryQueues.SANDBOX,
},
},
{
"name": "cleanup-old-snapshots",
"task": OnyxCeleryTask.CLEANUP_OLD_SNAPSHOTS,
"schedule": timedelta(hours=24),
"options": {
"priority": OnyxCeleryPriority.LOW,
"expires": BEAT_EXPIRES_DEFAULT,
"queue": OnyxCeleryQueues.SANDBOX,
},
},
]
if ENTERPRISE_EDITION_ENABLED:

View File

@@ -31,17 +31,20 @@ from onyx.connectors.interfaces import CheckpointedConnector
from onyx.connectors.models import ConnectorFailure
from onyx.connectors.models import ConnectorStopSignal
from onyx.connectors.models import Document
from onyx.connectors.models import IndexAttemptMetadata
from onyx.connectors.models import TextSection
from onyx.db.connector import mark_ccpair_with_indexing_trigger
from onyx.db.connector_credential_pair import get_connector_credential_pair_from_id
from onyx.db.connector_credential_pair import get_last_successful_attempt_poll_range_end
from onyx.db.connector_credential_pair import update_connector_credential_pair
from onyx.db.constants import CONNECTOR_VALIDATION_ERROR_MESSAGE_PREFIX
from onyx.db.document import mark_document_as_indexed_for_cc_pair__no_commit
from onyx.db.engine.sql_engine import get_session_with_current_tenant
from onyx.db.enums import AccessType
from onyx.db.enums import ConnectorCredentialPairStatus
from onyx.db.enums import IndexingStatus
from onyx.db.enums import IndexModelStatus
from onyx.db.enums import ProcessingMode
from onyx.db.index_attempt import create_index_attempt_error
from onyx.db.index_attempt import get_index_attempt
from onyx.db.index_attempt import get_recent_completed_attempts_for_cc_pair
@@ -53,7 +56,12 @@ from onyx.db.models import IndexAttempt
from onyx.file_store.document_batch_storage import DocumentBatchStorage
from onyx.file_store.document_batch_storage import get_document_batch_storage
from onyx.indexing.indexing_heartbeat import IndexingHeartbeatInterface
from onyx.indexing.indexing_pipeline import index_doc_batch_prepare
from onyx.server.features.build.indexing.persistent_document_writer import (
get_persistent_document_writer,
)
from onyx.utils.logger import setup_logger
from onyx.utils.middleware import make_randomized_onyx_request_id
from onyx.utils.variable_functionality import global_version
from shared_configs.configs import MULTI_TENANT
from shared_configs.contextvars import INDEX_ATTEMPT_INFO_CONTEXTVAR
@@ -367,6 +375,7 @@ def connector_document_extraction(
db_connector = index_attempt.connector_credential_pair.connector
db_credential = index_attempt.connector_credential_pair.credential
processing_mode = index_attempt.connector_credential_pair.processing_mode
is_primary = index_attempt.search_settings.status == IndexModelStatus.PRESENT
from_beginning = index_attempt.from_beginning
@@ -600,34 +609,103 @@ def connector_document_extraction(
logger.debug(f"Indexing batch of documents: {batch_description}")
memory_tracer.increment_and_maybe_trace()
# Store documents in storage
batch_storage.store_batch(batch_num, doc_batch_cleaned)
# cc4a
if processing_mode == ProcessingMode.FILE_SYSTEM:
# File system only - write directly to persistent storage,
# skip chunking/embedding/Vespa but still track documents in DB
# Create processing task data
processing_batch_data = {
"index_attempt_id": index_attempt_id,
"cc_pair_id": cc_pair_id,
"tenant_id": tenant_id,
"batch_num": batch_num, # 0-indexed
}
with get_session_with_current_tenant() as db_session:
# Create metadata for the batch
index_attempt_metadata = IndexAttemptMetadata(
attempt_id=index_attempt_id,
connector_id=db_connector.id,
credential_id=db_credential.id,
request_id=make_randomized_onyx_request_id("FSI"),
structured_id=f"{tenant_id}:{cc_pair_id}:{index_attempt_id}:{batch_num}",
batch_num=batch_num,
)
# Queue document processing task
app.send_task(
OnyxCeleryTask.DOCPROCESSING_TASK,
kwargs=processing_batch_data,
queue=OnyxCeleryQueues.DOCPROCESSING,
priority=docprocessing_priority,
)
# Upsert documents to PostgreSQL (document table + cc_pair relationship)
# This is a subset of what docprocessing does - just DB tracking, no chunking/embedding
index_doc_batch_prepare(
documents=doc_batch_cleaned,
index_attempt_metadata=index_attempt_metadata,
db_session=db_session,
ignore_time_skip=True, # Documents already filtered during extraction
)
batch_num += 1
total_doc_batches_queued += 1
# Mark documents as indexed for the CC pair
mark_document_as_indexed_for_cc_pair__no_commit(
connector_id=db_connector.id,
credential_id=db_credential.id,
document_ids=[doc.id for doc in doc_batch_cleaned],
db_session=db_session,
)
db_session.commit()
logger.info(
f"Queued document processing batch: "
f"batch_num={batch_num} "
f"docs={len(doc_batch_cleaned)} "
f"attempt={index_attempt_id}"
)
# Write documents to persistent file system
# Use creator_id for user-segregated storage paths (sandbox isolation)
creator_id = index_attempt.connector_credential_pair.creator_id
if creator_id is None:
raise ValueError(
f"ConnectorCredentialPair {index_attempt.connector_credential_pair.id} "
"must have a creator_id for persistent document storage"
)
user_id_str: str = str(creator_id)
writer = get_persistent_document_writer(
user_id=user_id_str,
tenant_id=tenant_id,
)
written_paths = writer.write_documents(doc_batch_cleaned)
# Update coordination directly (no docprocessing task)
with get_session_with_current_tenant() as db_session:
IndexingCoordination.update_batch_completion_and_docs(
db_session=db_session,
index_attempt_id=index_attempt_id,
total_docs_indexed=len(doc_batch_cleaned),
new_docs_indexed=len(doc_batch_cleaned),
total_chunks=0, # No chunks for file system mode
)
batch_num += 1
total_doc_batches_queued += 1
logger.info(
f"Wrote documents to file system: "
f"batch_num={batch_num} "
f"docs={len(written_paths)} "
f"attempt={index_attempt_id}"
)
else:
# REGULAR mode (default): Full pipeline - store and queue docprocessing
batch_storage.store_batch(batch_num, doc_batch_cleaned)
# Create processing task data
processing_batch_data = {
"index_attempt_id": index_attempt_id,
"cc_pair_id": cc_pair_id,
"tenant_id": tenant_id,
"batch_num": batch_num, # 0-indexed
}
# Queue document processing task
app.send_task(
OnyxCeleryTask.DOCPROCESSING_TASK,
kwargs=processing_batch_data,
queue=OnyxCeleryQueues.DOCPROCESSING,
priority=docprocessing_priority,
)
batch_num += 1
total_doc_batches_queued += 1
logger.info(
f"Queued document processing batch: "
f"batch_num={batch_num} "
f"docs={len(doc_batch_cleaned)} "
f"attempt={index_attempt_id}"
)
# Check checkpoint size periodically
CHECKPOINT_SIZE_CHECK_INTERVAL = 100
@@ -663,6 +741,24 @@ def connector_document_extraction(
total_batches=batch_num,
)
# Trigger file sync to user's sandbox (if running) - only for FILE_SYSTEM mode
# This syncs the newly written documents from S3 to any running sandbox pod
if processing_mode == ProcessingMode.FILE_SYSTEM:
creator_id = index_attempt.connector_credential_pair.creator_id
if creator_id:
app.send_task(
OnyxCeleryTask.SANDBOX_FILE_SYNC,
kwargs={
"user_id": str(creator_id),
"tenant_id": tenant_id,
},
queue=OnyxCeleryQueues.SANDBOX,
)
logger.info(
f"Triggered sandbox file sync for user {creator_id} "
f"after indexing complete"
)
except Exception as e:
logger.exception(
f"Document extraction failed: "

View File

@@ -1045,3 +1045,14 @@ STRIPE_PUBLISHABLE_KEY_URL = (
)
# Override for local testing with Stripe test keys (pk_test_*)
STRIPE_PUBLISHABLE_KEY_OVERRIDE = os.environ.get("STRIPE_PUBLISHABLE_KEY")
# Persistent Document Storage Configuration
# When enabled, indexed documents are written to local filesystem with hierarchical structure
PERSISTENT_DOCUMENT_STORAGE_ENABLED = (
os.environ.get("PERSISTENT_DOCUMENT_STORAGE_ENABLED", "").lower() == "true"
)
# Base directory path for persistent document storage (local filesystem)
# Example: /var/onyx/indexed-docs or /app/indexed-docs
PERSISTENT_DOCUMENT_STORAGE_PATH = os.environ.get(
"PERSISTENT_DOCUMENT_STORAGE_PATH", "/app/indexed-docs"
)

View File

@@ -241,6 +241,7 @@ class NotificationType(str, Enum):
TRIAL_ENDS_TWO_DAYS = "two_day_trial_ending" # 2 days left in trial
RELEASE_NOTES = "release_notes"
ASSISTANT_FILES_READY = "assistant_files_ready"
FEATURE_ANNOUNCEMENT = "feature_announcement"
class BlobType(str, Enum):
@@ -327,6 +328,7 @@ class FileOrigin(str, Enum):
PLAINTEXT_CACHE = "plaintext_cache"
OTHER = "other"
QUERY_HISTORY_CSV = "query_history_csv"
SANDBOX_SNAPSHOT = "sandbox_snapshot"
USER_FILE = "user_file"
@@ -344,6 +346,7 @@ class MilestoneRecordType(str, Enum):
MULTIPLE_ASSISTANTS = "multiple_assistants"
CREATED_ASSISTANT = "created_assistant"
CREATED_ONYX_BOT = "created_onyx_bot"
REQUESTED_CONNECTOR = "requested_connector"
class PostgresAdvisoryLocks(Enum):
@@ -383,6 +386,9 @@ class OnyxCeleryQueues:
# KG processing queue
KG_PROCESSING = "kg_processing"
# Sandbox processing queue
SANDBOX = "sandbox"
class OnyxRedisLocks:
PRIMARY_WORKER = "da_lock:primary_worker"
@@ -431,6 +437,10 @@ class OnyxRedisLocks:
# Release notes
RELEASE_NOTES_FETCH_LOCK = "da_lock:release_notes_fetch"
# Sandbox cleanup
CLEANUP_IDLE_SANDBOXES_BEAT_LOCK = "da_lock:cleanup_idle_sandboxes_beat"
CLEANUP_OLD_SNAPSHOTS_BEAT_LOCK = "da_lock:cleanup_old_snapshots_beat"
class OnyxRedisSignals:
BLOCK_VALIDATE_INDEXING_FENCES = "signal:block_validate_indexing_fences"
@@ -556,6 +566,13 @@ class OnyxCeleryTask:
CHECK_KG_PROCESSING_CLUSTERING_ONLY = "check_kg_processing_clustering_only"
KG_RESET_SOURCE_INDEX = "kg_reset_source_index"
# Sandbox cleanup
CLEANUP_IDLE_SANDBOXES = "cleanup_idle_sandboxes"
CLEANUP_OLD_SNAPSHOTS = "cleanup_old_snapshots"
# Sandbox file sync
SANDBOX_FILE_SYNC = "sandbox_file_sync"
# this needs to correspond to the matching entry in supervisord
ONYX_CELERY_BEAT_HEARTBEAT_KEY = "onyx:celery:beat:heartbeat"

View File

@@ -89,6 +89,9 @@ def _create_doc_from_transcript(transcript: dict) -> Document | None:
meeting_date_unix = transcript["date"]
meeting_date = datetime.fromtimestamp(meeting_date_unix / 1000, tz=timezone.utc)
# Build hierarchy based on meeting date (year-month)
year_month = meeting_date.strftime("%Y-%m")
meeting_organizer_email = transcript["organizer_email"]
organizer_email_user_info = [BasicExpertInfo(email=meeting_organizer_email)]
@@ -102,6 +105,14 @@ def _create_doc_from_transcript(transcript: dict) -> Document | None:
sections=cast(list[TextSection | ImageSection], sections),
source=DocumentSource.FIREFLIES,
semantic_identifier=meeting_title,
doc_metadata={
"hierarchy": {
"source_path": [year_month],
"year_month": year_month,
"meeting_title": meeting_title,
"organizer_email": meeting_organizer_email,
}
},
metadata={
k: str(v)
for k, v in {

View File

@@ -240,8 +240,21 @@ def _get_userinfo(user: NamedUser) -> dict[str, str]:
def _convert_pr_to_document(
pull_request: PullRequest, repo_external_access: ExternalAccess | None
) -> Document:
repo_name = pull_request.base.repo.full_name if pull_request.base else ""
doc_metadata = DocMetadata(repo=repo_name)
repo_full_name = pull_request.base.repo.full_name if pull_request.base else ""
# Split full_name (e.g., "owner/repo") into owner and repo
parts = repo_full_name.split("/", 1)
owner_name = parts[0] if parts else ""
repo_name = parts[1] if len(parts) > 1 else repo_full_name
doc_metadata = {
"repo": repo_full_name,
"hierarchy": {
"source_path": [owner_name, repo_name, "pull_requests"],
"owner": owner_name,
"repo": repo_name,
"object_type": "pull_request",
},
}
return Document(
id=pull_request.html_url,
sections=[
@@ -259,7 +272,7 @@ def _convert_pr_to_document(
else None
),
# this metadata is used in perm sync
doc_metadata=doc_metadata.model_dump(),
doc_metadata=doc_metadata,
metadata={
k: [str(vi) for vi in v] if isinstance(v, list) else str(v)
for k, v in {
@@ -316,8 +329,21 @@ def _fetch_issue_comments(issue: Issue) -> str:
def _convert_issue_to_document(
issue: Issue, repo_external_access: ExternalAccess | None
) -> Document:
repo_name = issue.repository.full_name if issue.repository else ""
doc_metadata = DocMetadata(repo=repo_name)
repo_full_name = issue.repository.full_name if issue.repository else ""
# Split full_name (e.g., "owner/repo") into owner and repo
parts = repo_full_name.split("/", 1)
owner_name = parts[0] if parts else ""
repo_name = parts[1] if len(parts) > 1 else repo_full_name
doc_metadata = {
"repo": repo_full_name,
"hierarchy": {
"source_path": [owner_name, repo_name, "issues"],
"owner": owner_name,
"repo": repo_name,
"object_type": "issue",
},
}
return Document(
id=issue.html_url,
sections=[TextSection(link=issue.html_url, text=issue.body or "")],
@@ -327,7 +353,7 @@ def _convert_issue_to_document(
# updated_at is UTC time but is timezone unaware
doc_updated_at=issue.updated_at.replace(tzinfo=timezone.utc),
# this metadata is used in perm sync
doc_metadata=doc_metadata.model_dump(),
doc_metadata=doc_metadata,
metadata={
k: [str(vi) for vi in v] if isinstance(v, list) else str(v)
for k, v in {

View File

@@ -390,7 +390,9 @@ class GmailConnector(
"""
List all user emails if we are on a Google Workspace domain.
If the domain is gmail.com, or if we attempt to call the Admin SDK and
get a 404, fall back to using the single user.
get a 404 or 403, fall back to using the single user.
A 404 indicates a personal Gmail account with no Workspace domain.
A 403 indicates insufficient permissions (e.g., OAuth user without admin privileges).
"""
try:
@@ -413,6 +415,13 @@ class GmailConnector(
"with no Workspace domain. Falling back to single user."
)
return [self.primary_admin_email]
elif e.resp.status == 403:
logger.warning(
"Received 403 from Admin SDK; this may indicate insufficient permissions "
"(e.g., OAuth user without admin privileges or service account without "
"domain-wide delegation). Falling back to single user."
)
return [self.primary_admin_email]
raise
def _fetch_threads_impl(

View File

@@ -46,6 +46,138 @@ from onyx.utils.variable_functionality import noop_fallback
logger = setup_logger()
# Cache for folder path lookups to avoid redundant API calls
# Maps folder_id -> (folder_name, parent_id)
_folder_cache: dict[str, tuple[str, str | None]] = {}
def _get_folder_info(
service: GoogleDriveService, folder_id: str
) -> tuple[str, str | None]:
"""Fetch folder name and parent ID, with caching."""
if folder_id in _folder_cache:
return _folder_cache[folder_id]
try:
folder = (
service.files()
.get(
fileId=folder_id,
fields="name, parents",
supportsAllDrives=True,
)
.execute()
)
folder_name = folder.get("name", "Unknown")
parents = folder.get("parents", [])
parent_id = parents[0] if parents else None
_folder_cache[folder_id] = (folder_name, parent_id)
return folder_name, parent_id
except HttpError as e:
logger.warning(f"Failed to get folder info for {folder_id}: {e}")
_folder_cache[folder_id] = ("Unknown", None)
return "Unknown", None
def _get_drive_name(service: GoogleDriveService, drive_id: str) -> str:
"""Fetch shared drive name."""
cache_key = f"drive_{drive_id}"
if cache_key in _folder_cache:
return _folder_cache[cache_key][0]
try:
drive = service.drives().get(driveId=drive_id).execute()
drive_name = drive.get("name", f"Shared Drive {drive_id}")
_folder_cache[cache_key] = (drive_name, None)
return drive_name
except HttpError as e:
logger.warning(f"Failed to get drive name for {drive_id}: {e}")
_folder_cache[cache_key] = (f"Shared Drive {drive_id}", None)
return f"Shared Drive {drive_id}"
def build_folder_path(
file: GoogleDriveFileType,
service: GoogleDriveService,
drive_id: str | None = None,
user_email: str | None = None,
) -> list[str]:
"""
Build the full folder path for a file by walking up the parent chain.
Returns a list of folder names from root to immediate parent.
Args:
file: The Google Drive file object
service: Google Drive service instance
drive_id: Optional drive ID (will be extracted from file if not provided)
user_email: Optional user email to check ownership for "My Drive" vs "Shared with me"
"""
path_parts: list[str] = []
# Get drive_id from file if not provided
if drive_id is None:
drive_id = file.get("driveId")
# Check if file is owned by the user (for distinguishing "My Drive" vs "Shared with me")
is_owned_by_user = False
if user_email:
owners = file.get("owners", [])
is_owned_by_user = any(
owner.get("emailAddress", "").lower() == user_email.lower()
for owner in owners
)
# Get the file's parent folder ID
parents = file.get("parents", [])
if not parents:
# File is at root level
if drive_id:
return [_get_drive_name(service, drive_id)]
# If not in a shared drive, check if it's owned by the user
if is_owned_by_user:
return ["My Drive"]
else:
return ["Shared with me"]
parent_id: str | None = parents[0]
# Walk up the folder hierarchy (limit to 50 levels to prevent infinite loops)
visited: set[str] = set()
for _ in range(50):
if not parent_id or parent_id in visited:
break
visited.add(parent_id)
folder_name, next_parent = _get_folder_info(service, parent_id)
# Check if we've reached the root (parent is the drive itself or no parent)
if next_parent is None:
# This folder's name is either the drive root, My Drive, or Shared with me
if drive_id:
path_parts.insert(0, _get_drive_name(service, drive_id))
else:
# Not in a shared drive - determine if it's "My Drive" or "Shared with me"
if is_owned_by_user:
path_parts.insert(0, "My Drive")
else:
path_parts.insert(0, "Shared with me")
break
else:
path_parts.insert(0, folder_name)
parent_id = next_parent
# If we didn't find a root, determine the root based on ownership and drive
if not path_parts:
if drive_id:
return [_get_drive_name(service, drive_id)]
elif is_owned_by_user:
return ["My Drive"]
else:
return ["Shared with me"]
return path_parts
# This is not a standard valid unicode char, it is used by the docs advanced API to
# represent smart chips (elements like dates and doc links).
SMART_CHIP_CHAR = "\ue907"
@@ -526,12 +658,33 @@ def _convert_drive_item_to_document(
else None
)
# Build doc_metadata with hierarchy information
file_name = file.get("name", "")
mime_type = file.get("mimeType", "")
drive_id = file.get("driveId")
# Build full folder path by walking up the parent chain
# Pass retriever_email to determine if file is in "My Drive" vs "Shared with me"
source_path = build_folder_path(
file, _get_drive_service(), drive_id, retriever_email
)
doc_metadata = {
"hierarchy": {
"source_path": source_path,
"drive_id": drive_id,
"file_name": file_name,
"mime_type": mime_type,
}
}
# Create the document
return Document(
id=doc_id,
sections=sections,
source=DocumentSource.GOOGLE_DRIVE,
semantic_identifier=file.get("name", ""),
semantic_identifier=file_name,
doc_metadata=doc_metadata,
metadata={
"owner_names": ", ".join(
owner.get("displayName", "") for owner in file.get("owners", [])

View File

@@ -39,11 +39,11 @@ PERMISSION_FULL_DESCRIPTION = (
"permissions(id, emailAddress, type, domain, allowFileDiscovery, permissionDetails)"
)
FILE_FIELDS = (
"nextPageToken, files(mimeType, id, name, "
"nextPageToken, files(mimeType, id, name, driveId, parents, "
"modifiedTime, webViewLink, shortcutDetails, owners(emailAddress), size)"
)
FILE_FIELDS_WITH_PERMISSIONS = (
f"nextPageToken, files(mimeType, id, name, {PERMISSION_FULL_DESCRIPTION}, permissionIds, "
f"nextPageToken, files(mimeType, id, name, driveId, parents, {PERMISSION_FULL_DESCRIPTION}, permissionIds, "
"modifiedTime, webViewLink, shortcutDetails, owners(emailAddress), size)"
)
SLIM_FILE_FIELDS = (

View File

@@ -490,6 +490,13 @@ class HubSpotConnector(LoadConnector, PollConnector):
semantic_identifier=title,
doc_updated_at=ticket.updated_at.replace(tzinfo=timezone.utc),
metadata=metadata,
doc_metadata={
"hierarchy": {
"source_path": ["Tickets"],
"object_type": "ticket",
"object_id": ticket.id,
}
},
)
)
@@ -615,6 +622,13 @@ class HubSpotConnector(LoadConnector, PollConnector):
semantic_identifier=title,
doc_updated_at=company.updated_at.replace(tzinfo=timezone.utc),
metadata=metadata,
doc_metadata={
"hierarchy": {
"source_path": ["Companies"],
"object_type": "company",
"object_id": company.id,
}
},
)
)
@@ -738,6 +752,13 @@ class HubSpotConnector(LoadConnector, PollConnector):
semantic_identifier=title,
doc_updated_at=deal.updated_at.replace(tzinfo=timezone.utc),
metadata=metadata,
doc_metadata={
"hierarchy": {
"source_path": ["Deals"],
"object_type": "deal",
"object_id": deal.id,
}
},
)
)
@@ -881,6 +902,13 @@ class HubSpotConnector(LoadConnector, PollConnector):
semantic_identifier=title,
doc_updated_at=contact.updated_at.replace(tzinfo=timezone.utc),
metadata=metadata,
doc_metadata={
"hierarchy": {
"source_path": ["Contacts"],
"object_type": "contact",
"object_id": contact.id,
}
},
)
)

View File

@@ -274,6 +274,10 @@ class LinearConnector(LoadConnector, PollConnector, OAuthConnector):
# Cast the sections list to the expected type
typed_sections = cast(list[TextSection | ImageSection], sections)
# Extract team name for hierarchy
team_name = (node.get("team") or {}).get("name") or "Unknown Team"
identifier = node.get("identifier", node["id"])
documents.append(
Document(
id=node["id"],
@@ -282,6 +286,13 @@ class LinearConnector(LoadConnector, PollConnector, OAuthConnector):
semantic_identifier=f"[{node['identifier']}] {node['title']}",
title=node["title"],
doc_updated_at=time_str_to_utc(node["updatedAt"]),
doc_metadata={
"hierarchy": {
"source_path": [team_name],
"team_name": team_name,
"identifier": identifier,
}
},
metadata={
k: str(v)
for k, v in {

View File

@@ -234,6 +234,8 @@ def thread_to_doc(
"\n", " "
)
channel_name = channel["name"]
return Document(
id=_build_doc_id(channel_id=channel_id, thread_ts=thread[0]["ts"]),
sections=[
@@ -247,7 +249,14 @@ def thread_to_doc(
semantic_identifier=doc_sem_id,
doc_updated_at=get_latest_message_time(thread),
primary_owners=valid_experts,
metadata={"Channel": channel["name"]},
doc_metadata={
"hierarchy": {
"source_path": [channel_name],
"channel_name": channel_name,
"channel_id": channel_id,
}
},
metadata={"Channel": channel_name},
external_access=channel_access,
)

View File

@@ -22,6 +22,7 @@ from onyx.db.credentials import fetch_credential_by_id_for_user
from onyx.db.engine.sql_engine import get_session_with_current_tenant
from onyx.db.enums import AccessType
from onyx.db.enums import ConnectorCredentialPairStatus
from onyx.db.enums import ProcessingMode
from onyx.db.models import Connector
from onyx.db.models import ConnectorCredentialPair
from onyx.db.models import Credential
@@ -116,7 +117,14 @@ def get_connector_credential_pairs_for_user(
eager_load_user: bool = False,
order_by_desc: bool = False,
source: DocumentSource | None = None,
processing_mode: ProcessingMode | None = ProcessingMode.REGULAR,
) -> list[ConnectorCredentialPair]:
"""Get connector credential pairs for a user.
Args:
processing_mode: Filter by processing mode. Defaults to REGULAR to hide
FILE_SYSTEM connectors from standard admin UI. Pass None to get all.
"""
if eager_load_user:
assert (
eager_load_credential
@@ -142,6 +150,9 @@ def get_connector_credential_pairs_for_user(
if ids:
stmt = stmt.where(ConnectorCredentialPair.id.in_(ids))
if processing_mode is not None:
stmt = stmt.where(ConnectorCredentialPair.processing_mode == processing_mode)
if order_by_desc:
stmt = stmt.order_by(desc(ConnectorCredentialPair.id))
@@ -160,6 +171,7 @@ def get_connector_credential_pairs_for_user_parallel(
eager_load_user: bool = False,
order_by_desc: bool = False,
source: DocumentSource | None = None,
processing_mode: ProcessingMode | None = ProcessingMode.REGULAR,
) -> list[ConnectorCredentialPair]:
with get_session_with_current_tenant() as db_session:
return get_connector_credential_pairs_for_user(
@@ -172,6 +184,7 @@ def get_connector_credential_pairs_for_user_parallel(
eager_load_user=eager_load_user,
order_by_desc=order_by_desc,
source=source,
processing_mode=processing_mode,
)
@@ -501,6 +514,7 @@ def add_credential_to_connector(
initial_status: ConnectorCredentialPairStatus = ConnectorCredentialPairStatus.SCHEDULED,
last_successful_index_time: datetime | None = None,
seeding_flow: bool = False,
processing_mode: ProcessingMode = ProcessingMode.REGULAR,
) -> StatusResponse:
connector = fetch_connector_by_id(connector_id, db_session)
@@ -566,6 +580,7 @@ def add_credential_to_connector(
access_type=access_type,
auto_sync_options=auto_sync_options,
last_successful_index_time=last_successful_index_time,
processing_mode=processing_mode,
)
db_session.add(association)
db_session.flush() # make sure the association has an id

View File

@@ -56,6 +56,13 @@ class IndexingMode(str, PyEnum):
REINDEX = "reindex"
class ProcessingMode(str, PyEnum):
"""Determines how documents are processed after fetching."""
REGULAR = "regular" # Full pipeline: chunk → embed → Vespa
FILE_SYSTEM = "file_system" # Write to file system only
class SyncType(str, PyEnum):
DOCUMENT_SET = "document_set"
USER_GROUP = "user_group"
@@ -194,3 +201,39 @@ class SwitchoverType(str, PyEnum):
REINDEX = "reindex"
ACTIVE_ONLY = "active_only"
INSTANT = "instant"
# Onyx Build Mode Enums
class BuildSessionStatus(str, PyEnum):
ACTIVE = "active"
IDLE = "idle"
class SandboxStatus(str, PyEnum):
PROVISIONING = "provisioning"
RUNNING = "running"
IDLE = "idle"
SLEEPING = "sleeping" # Pod terminated, snapshots saved to S3
TERMINATED = "terminated"
FAILED = "failed"
def is_active(self) -> bool:
"""Check if sandbox is in an active state (running or idle)."""
return self in (SandboxStatus.RUNNING, SandboxStatus.IDLE)
def is_terminal(self) -> bool:
"""Check if sandbox is in a terminal state."""
return self in (SandboxStatus.TERMINATED, SandboxStatus.FAILED)
def is_sleeping(self) -> bool:
"""Check if sandbox is sleeping (pod terminated but can be restored)."""
return self == SandboxStatus.SLEEPING
class ArtifactType(str, PyEnum):
WEB_APP = "web_app"
PPTX = "pptx"
DOCX = "docx"
IMAGE = "image"
MARKDOWN = "markdown"
EXCEL = "excel"

View File

@@ -11,6 +11,7 @@ from typing_extensions import TypedDict # noreorder
from uuid import UUID
from pydantic import ValidationError
from sqlalchemy.dialects.postgresql import JSONB as PGJSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from fastapi_users_db_sqlalchemy import SQLAlchemyBaseOAuthAccountTableUUID
@@ -55,8 +56,12 @@ from onyx.configs.constants import FileOrigin
from onyx.configs.constants import MessageType
from onyx.db.enums import (
AccessType,
ArtifactType,
BuildSessionStatus,
EmbeddingPrecision,
IndexingMode,
ProcessingMode,
SandboxStatus,
SyncType,
SyncStatus,
MCPAuthenticationType,
@@ -609,6 +614,16 @@ class ConnectorCredentialPair(Base):
Enum(IndexingMode, native_enum=False), nullable=True
)
# Determines how documents are processed after fetching:
# REGULAR: Full pipeline (chunk → embed → Vespa)
# FILE_SYSTEM: Write to file system only (for CLI agent sandbox)
processing_mode: Mapped[ProcessingMode] = mapped_column(
Enum(ProcessingMode, native_enum=False),
nullable=False,
default=ProcessingMode.REGULAR,
server_default="regular",
)
connector: Mapped["Connector"] = relationship(
"Connector", back_populates="credentials"
)
@@ -4142,3 +4157,202 @@ class TenantUsage(Base):
# Ensure only one row per window start (tenant_id is in the schema name)
UniqueConstraint("window_start", name="uq_tenant_usage_window"),
)
"""Tables related to Build Mode (CLI Agent Platform)"""
class BuildSession(Base):
"""Stores metadata about CLI agent build sessions."""
__tablename__ = "build_session"
id: Mapped[UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid4
)
user_id: Mapped[UUID | None] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=True
)
name: Mapped[str | None] = mapped_column(String, nullable=True)
status: Mapped[BuildSessionStatus] = mapped_column(
Enum(BuildSessionStatus, native_enum=False, name="buildsessionstatus"),
nullable=False,
default=BuildSessionStatus.ACTIVE,
)
created_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
last_activity_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
nextjs_port: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Relationships
user: Mapped[User | None] = relationship("User", foreign_keys=[user_id])
artifacts: Mapped[list["Artifact"]] = relationship(
"Artifact", back_populates="session", cascade="all, delete-orphan"
)
messages: Mapped[list["BuildMessage"]] = relationship(
"BuildMessage", back_populates="session", cascade="all, delete-orphan"
)
snapshots: Mapped[list["Snapshot"]] = relationship(
"Snapshot", back_populates="session", cascade="all, delete-orphan"
)
__table_args__ = (
Index("ix_build_session_user_created", "user_id", desc("created_at")),
Index("ix_build_session_status", "status"),
)
class Sandbox(Base):
"""Stores sandbox container metadata for users (one sandbox per user)."""
__tablename__ = "sandbox"
id: Mapped[UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid4
)
user_id: Mapped[UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("user.id", ondelete="CASCADE"),
nullable=False,
unique=True,
)
container_id: Mapped[str | None] = mapped_column(String, nullable=True)
status: Mapped[SandboxStatus] = mapped_column(
Enum(SandboxStatus, native_enum=False, name="sandboxstatus"),
nullable=False,
default=SandboxStatus.PROVISIONING,
)
created_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
last_heartbeat: Mapped[datetime.datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Relationships
user: Mapped[User] = relationship("User")
__table_args__ = (
Index("ix_sandbox_status", "status"),
Index("ix_sandbox_container_id", "container_id"),
)
class Artifact(Base):
"""Stores metadata about artifacts generated by CLI agents."""
__tablename__ = "artifact"
id: Mapped[UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid4
)
session_id: Mapped[UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("build_session.id", ondelete="CASCADE"),
nullable=False,
)
type: Mapped[ArtifactType] = mapped_column(
Enum(ArtifactType, native_enum=False, name="artifacttype"), nullable=False
)
# path of artifact in sandbox relative to outputs/
path: Mapped[str] = mapped_column(String, nullable=False)
name: Mapped[str] = mapped_column(String, nullable=False)
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,
)
# Relationships
session: Mapped[BuildSession] = relationship(
"BuildSession", back_populates="artifacts"
)
__table_args__ = (
Index("ix_artifact_session_created", "session_id", desc("created_at")),
Index("ix_artifact_type", "type"),
)
class Snapshot(Base):
"""Stores metadata about session output snapshots."""
__tablename__ = "snapshot"
id: Mapped[UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid4
)
session_id: Mapped[UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("build_session.id", ondelete="CASCADE"),
nullable=False,
)
storage_path: Mapped[str] = mapped_column(String, nullable=False)
size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
created_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
# Relationships
session: Mapped[BuildSession] = relationship(
"BuildSession", back_populates="snapshots"
)
__table_args__ = (
Index("ix_snapshot_session_created", "session_id", desc("created_at")),
)
class BuildMessage(Base):
"""Stores messages exchanged in build sessions.
All message data is stored in message_metadata as JSON (the raw ACP packet).
The turn_index groups all assistant responses under the user prompt they respond to.
Packet types stored in message_metadata:
- user_message: {type: "user_message", content: {...}}
- agent_message: {type: "agent_message", content: {...}} (accumulated from chunks)
- agent_thought: {type: "agent_thought", content: {...}} (accumulated from chunks)
- tool_call_progress: {type: "tool_call_progress", status: "completed", ...} (only completed)
- agent_plan_update: {type: "agent_plan_update", entries: [...]} (upserted, latest only)
"""
__tablename__ = "build_message"
id: Mapped[UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid4
)
session_id: Mapped[UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("build_session.id", ondelete="CASCADE"),
nullable=False,
)
turn_index: Mapped[int] = mapped_column(Integer, nullable=False)
type: Mapped[MessageType] = mapped_column(
Enum(MessageType, native_enum=False, name="messagetype"), nullable=False
)
message_metadata: Mapped[dict[str, Any]] = mapped_column(PGJSONB, nullable=False)
created_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
# Relationships
session: Mapped[BuildSession] = relationship(
"BuildSession", back_populates="messages"
)
__table_args__ = (
Index(
"ix_build_message_session_turn", "session_id", "turn_index", "created_at"
),
)

View File

@@ -1,3 +1,4 @@
from onyx.configs.app_configs import DEV_MODE
from onyx.feature_flags.interface import FeatureFlagProvider
from onyx.feature_flags.interface import NoOpFeatureFlagProvider
from onyx.utils.variable_functionality import (
@@ -19,7 +20,7 @@ def get_default_feature_flag_provider() -> FeatureFlagProvider:
Returns:
FeatureFlagProvider: The configured feature flag provider instance
"""
if MULTI_TENANT:
if MULTI_TENANT or DEV_MODE:
return fetch_versioned_implementation_with_fallback(
module="onyx.feature_flags.factory",
attribute="get_posthog_feature_flag_provider",

View File

@@ -738,7 +738,7 @@ def model_is_reasoning_model(model_name: str, model_provider: str) -> bool:
# Fallback: try using litellm.supports_reasoning() for newer models
try:
logger.debug("Falling back to `litellm.supports_reasoning`")
# logger.debug("Falling back to `litellm.supports_reasoning`")
full_model_name = (
f"{model_provider}/{model_name}"
if model_provider not in model_name

View File

@@ -63,6 +63,8 @@ from onyx.server.documents.connector import router as connector_router
from onyx.server.documents.credential import router as credential_router
from onyx.server.documents.document import router as document_router
from onyx.server.documents.standard_oauth import router as standard_oauth_router
from onyx.server.features.build.api.api import nextjs_assets_router
from onyx.server.features.build.api.api import router as build_router
from onyx.server.features.default_assistant.api import (
router as default_assistant_router,
)
@@ -376,6 +378,8 @@ def get_application(lifespan_override: Lifespan | None = None) -> FastAPI:
include_router_with_global_prefix_prepended(application, admin_input_prompt_router)
include_router_with_global_prefix_prepended(application, cc_pair_router)
include_router_with_global_prefix_prepended(application, projects_router)
include_router_with_global_prefix_prepended(application, build_router)
include_router_with_global_prefix_prepended(application, nextjs_assets_router)
include_router_with_global_prefix_prepended(application, document_set_router)
include_router_with_global_prefix_prepended(application, search_settings_router)
include_router_with_global_prefix_prepended(

View File

@@ -564,6 +564,7 @@ def associate_credential_to_connector(
access_type=metadata.access_type,
auto_sync_options=metadata.auto_sync_options,
groups=metadata.groups,
processing_mode=metadata.processing_mode,
)
# trigger indexing immediately

View File

@@ -20,6 +20,7 @@ from google.oauth2.credentials import Credentials
from pydantic import BaseModel
from sqlalchemy.orm import Session
from onyx.auth.email_utils import send_email
from onyx.auth.users import current_admin_user
from onyx.auth.users import current_chat_accessible_user
from onyx.auth.users import current_curator_or_admin_user
@@ -29,6 +30,7 @@ from onyx.background.celery.tasks.pruning.tasks import (
)
from onyx.background.celery.versioned_apps.client import app as client_app
from onyx.configs.app_configs import DISABLE_AUTH
from onyx.configs.app_configs import EMAIL_CONFIGURED
from onyx.configs.app_configs import ENABLED_CONNECTOR_TYPES
from onyx.configs.app_configs import MOCK_CONNECTOR_FILE_PATH
from onyx.configs.constants import DocumentSource
@@ -125,6 +127,7 @@ from onyx.server.documents.models import ConnectorFileInfo
from onyx.server.documents.models import ConnectorFilesResponse
from onyx.server.documents.models import ConnectorIndexingStatusLite
from onyx.server.documents.models import ConnectorIndexingStatusLiteResponse
from onyx.server.documents.models import ConnectorRequestSubmission
from onyx.server.documents.models import ConnectorSnapshot
from onyx.server.documents.models import ConnectorStatus
from onyx.server.documents.models import ConnectorUpdateRequest
@@ -1759,6 +1762,86 @@ def get_connector_by_id(
)
@router.post("/connector-request")
def submit_connector_request(
request_data: ConnectorRequestSubmission,
user: User | None = Depends(current_user),
) -> StatusResponse:
"""
Submit a connector request for Cloud deployments.
Tracks via PostHog telemetry and sends email to hello@onyx.app.
"""
tenant_id = get_current_tenant_id()
connector_name = request_data.connector_name.strip()
if not connector_name:
raise HTTPException(status_code=400, detail="Connector name cannot be empty")
# Get user identifier for telemetry
user_email = user.email if user else None
distinct_id = user_email or tenant_id
# Track connector request via PostHog telemetry (Cloud only)
from shared_configs.configs import MULTI_TENANT
if MULTI_TENANT:
mt_cloud_telemetry(
tenant_id=tenant_id,
distinct_id=distinct_id,
event=MilestoneRecordType.REQUESTED_CONNECTOR,
properties={
"connector_name": connector_name,
"user_email": user_email,
},
)
# Send email notification (if email is configured)
if EMAIL_CONFIGURED:
try:
subject = "Onyx Craft Connector Request"
email_body_text = f"""A new connector request has been submitted:
Connector Name: {connector_name}
User Email: {user_email or 'Not provided (anonymous user)'}
Tenant ID: {tenant_id}
"""
email_body_html = f"""<html>
<body>
<p>A new connector request has been submitted:</p>
<ul>
<li><strong>Connector Name:</strong> {connector_name}</li>
<li><strong>User Email:</strong> {user_email or 'Not provided (anonymous user)'}</li>
<li><strong>Tenant ID:</strong> {tenant_id}</li>
</ul>
</body>
</html>"""
send_email(
user_email="hello@onyx.app",
subject=subject,
html_body=email_body_html,
text_body=email_body_text,
)
logger.info(
f"Connector request email sent to hello@onyx.app for connector: {connector_name}"
)
except Exception as e:
# Log error but don't fail the request if email fails
logger.error(
f"Failed to send connector request email for {connector_name}: {e}"
)
logger.info(
f"Connector request submitted: {connector_name} by user {user_email or 'anonymous'} "
f"(tenant: {tenant_id})"
)
return StatusResponse(
success=True,
message="Connector request submitted successfully. We'll prioritize popular requests!",
)
class BasicCCPairInfo(BaseModel):
has_successful_run: bool
source: DocumentSource

View File

@@ -18,6 +18,7 @@ from onyx.connectors.models import InputType
from onyx.db.enums import AccessType
from onyx.db.enums import ConnectorCredentialPairStatus
from onyx.db.enums import PermissionSyncStatus
from onyx.db.enums import ProcessingMode
from onyx.db.models import Connector
from onyx.db.models import ConnectorCredentialPair
from onyx.db.models import Credential
@@ -483,6 +484,7 @@ class ConnectorCredentialPairMetadata(BaseModel):
access_type: AccessType
auto_sync_options: dict[str, Any] | None = None
groups: list[int] = Field(default_factory=list)
processing_mode: ProcessingMode = ProcessingMode.REGULAR
class CCStatusUpdateRequest(BaseModel):
@@ -523,6 +525,10 @@ class RunConnectorRequest(BaseModel):
from_beginning: bool = False
class ConnectorRequestSubmission(BaseModel):
connector_name: str
class CCPropertyUpdateRequest(BaseModel):
name: str
value: str

View File

@@ -0,0 +1,2 @@
sandbox/kubernetes/docker/templates/outputs/venv/**
sandbox/kubernetes/docker/demo_data/**

View File

@@ -0,0 +1,257 @@
# AGENTS.md
This file provides guidance for AI agents when working in this sandbox.
## Introduction
You are Steve, an AI agent powering **Onyx Craft**, a feature that allows users to create interactive web applications and dashboards from their company knowledge. You are running in a secure sandbox with access to the user's knowledge sources and the ability to create Next.js applications.
## Purpose
Your primary purpose is to assist users in accomplishing their goals by providing information, executing tasks, and offering guidance. I aim to be a reliable partner in problem-solving and task completion.
## How I Approach Tasks
When presented with a task, I typically:
1. Analyze the request to understand what's being asked
2. Break down complex problems into manageable steps
3. Use appropriate tools and methods to address each step
4. Provide clear communication throughout the process
5. Deliver results in a helpful and organized manner
## My Personality Traits
- Helpful and service-oriented
- Detail-focused and thorough
- Adaptable to different user needs
- Patient when working through complex problems
- Honest about my capabilities and limitations
## Areas I Can Help With
- Information gathering and research
- Knowledge Synthesis
- Data processing and analysis
- File management and organization
- Dashboard creation
- Repetitive administrative tasks
{{USER_CONTEXT}}
## Your Configuration
**LLM Provider**: {{LLM_PROVIDER_NAME}}
**Model**: {{LLM_MODEL_NAME}}
**Next.js Development Server**: Running on port {{NEXTJS_PORT}}
{{DISABLED_TOOLS_SECTION}}
## Your Environment
You are in an ephemeral virtual machine.
You currently have Python 3.11.13 and Node v22.21.1.
**Python Virtual Environment**: A Python virtual environment is pre-configured at `.venv/` with common data science and visualization packages already installed (numpy, pandas, matplotlib, scipy, PIL, etc.). The environment should be automatically activated, but if you run into issues with missing packages, you can explicitly use `.venv/bin/python` or `.venv/bin/pip`.
If you need additional packages, install them with `pip install <package>` (or `.venv/bin/pip install <package>` if the venv isn't active). For javascript packages, use `npm install <package>` from within the `outputs/web` directory.
## Organization Info
The `org_info/` directory contains information about the organization and user context:
- `AGENTS.md`: Description of available organizational information files
- `user_identity_profile.txt`: Contains the current user's name, email, and organization they work for. Use this information when personalizing outputs or when the user asks about their identity.
- `organization_structure.json`: Contains a JSON representation of the organization's groups, managers, and their direct reports. Use this to understand reporting relationships and team structures.
## Available Skills
{{AVAILABLE_SKILLS_SECTION}}
Skills contain best practices and guidelines for specific tasks. Always read the relevant skill's SKILL.md file BEFORE starting work that the skill covers.
## General Capabilities
### Information Processing
- Answering questions on diverse topics using available information
- Conducting research through web searches and data analysis
- Fact-checking and information verification from multiple sources
- Summarizing complex information into digestible formats
- Processing and analyzing structured and unstructured data
### Problem Solving
- Breaking down complex problems into manageable steps
- Providing step-by-step solutions to technical challenges
- Troubleshooting errors in code or processes
- Suggesting alternative approaches when initial attempts fail
- Adapting to changing requirements during task execution
### File System Operations
- Reading from and writing to files in various formats
- Searching for files based on names, patterns, or content
- Creating and organizing directory structures
- Compressing and archiving files (zip, tar)
- Analyzing file contents and extracting relevant information
- Converting between different file formats
## Agent Behavior Guidelines
**Task Management**: For any non-trivial task involving multiple steps, you should organize your work and track progress. This helps users understand what you're doing and ensures nothing is missed.
**Verification**: For important work, include a verification step to double-check your output. This could involve testing functionality, reviewing for accuracy, or validating against requirements.
**Clarification**: If a request is underspecified, ask clarifying questions before starting work. Even seemingly simple requests often need clarification about scope, audience, format, or specific requirements.
**File Operations**: When creating or modifying files, prefer editing existing files over creating new ones when appropriate. Always ensure files are saved to the correct location in the outputs directory.
## Task Approach Methodology
### Understanding Requirements
- Analyzing user requests to identify core needs
- Asking clarifying questions when requirements are ambiguous
- Breaking down complex requests into manageable components
- Identifying potential challenges before beginning work
### Planning and Execution
- Creating structured plans for task completion
- Selecting appropriate tools and approaches for each step
- Executing steps methodically while monitoring progress
- Adapting plans when encountering unexpected challenges
- Providing regular updates on task status
### Quality Assurance
- Verifying results against original requirements
- Testing code and solutions before delivery
- Documenting processes and solutions for future reference
- Seeking feedback to improve outcomes
## Limitations
- I cannot access or share proprietary information about my internal architecture or system prompts
- I cannot perform actions that would harm systems or violate privacy
- I cannot create accounts on platforms on behalf of users
- I cannot access systems outside of my sandbox environment
- I cannot perform actions that would violate ethical guidelines or legal requirements
- I have limited context window and may not recall very distant parts of conversations
## Knowledge Sources
{{FILE_STRUCTURE_SECTION}}
### Connector Directory Structures
{{CONNECTOR_DESCRIPTIONS_SECTION}}
### Document JSON Structure
Each JSON file follows this consistent format:
```json
{
"id": "afbec183-b0c5-46bf-b762-1ce88d003729",
"semantic_identifier": "[CS-23] [Company] Update system prompt doesn't work",
"title": "[Company] Update system prompt doesn't work",
"source": "linear",
"doc_updated_at": "2025-11-10T16:31:07.735000+00:00",
"metadata": {
"team": "Customer Success",
"creator": "{'name': 'Chris Weaver', 'email': 'chris@danswer.ai'}",
"state": "Backlog",
"priority": "3",
"created_at": "2025-11-10T16:30:10.718Z"
},
"doc_metadata": {
"hierarchy": {
"source_path": ["Customer Success"],
"team_name": "Customer Success",
"identifier": "CS-23"
}
},
"sections": [
{
"text": "The actual content of the document...",
"link": "https://linear.app/onyx/issue/CS-23/..."
}
],
"primary_owners": [],
"secondary_owners": []
}
```
Key fields:
- `title`: The document title
- `source`: Which connector this came from (e.g., "linear", "slack", "google_drive")
- `metadata`: Source-specific metadata
- `sections`: Array of content sections with text and optional links
**Important**: Do NOT write any files to the `files/` directory. Do NOT edit any files in the `files/` directory. This is read-only knowledge data.
## Attachments (PRIORITY)
The `attachments/` directory contains files that the user has explicitly uploaded during this session. **These files are critically important** and should be treated as high-priority context.
### Why Attachments Matter
- The user deliberately chose to upload these files, signaling they are directly relevant to the task
- These files often contain the specific data, requirements, or examples the user wants you to work with
- They may include spreadsheets, documents, images, or code that should inform your work
### Required Actions
**At the start of every task, you MUST:**
1. **Check for attachments**: List the contents of `attachments/` to see what the user has provided
2. **Read and analyze each file**: Thoroughly examine every attachment to understand its contents and relevance
3. **Reference attachment content**: Use the information from attachments to inform your responses and outputs
### File Handling
- Uploaded files may be in various formats: CSV, JSON, PDF, images, text files, etc.
- For spreadsheets and data files, examine the structure, columns, and sample data
- For documents, extract key information and requirements
- For images, analyze and describe their content
- For code files, understand the logic and patterns
**Do NOT ignore user uploaded files.** They are there for a reason and likely contain exactly what you need to complete the task successfully.
## Outputs Directory
There is a special folder called `outputs`. Any and all python scripts, javascript apps, generated documents, slides, etc. should go here.
Feel free to write/edit anything you find in here.
## Outputs
There should be four main types of outputs:
1. Web Applications / Dashboards
Generally, you should use
### Web Applications / Dashboards
Web applications and dashboards should be written as a webapp built with Next.js, React, and shadcn/ui.. Within the `outputs` directory,
there is a folder called `web` that has the skeleton of a basic Next.js app in it. Use this. We do NOT use a `src` directory.
Use NextJS 16.1.1, React v19, Tailwindcss, and recharts.
The Next.js app is already running on port {{NEXTJS_PORT}}. Do not run `npm run dev` yourself.
If the app needs any pre-computation, then create a bash script called `prepare.sh` at the root of the `web` directory.
**IMPORTANT: See `outputs/web/AGENTS.md` for detailed technical specifications, architecture patterns, component usage guidelines, and styling rules. It is the ground truth for webapp design**
### Other Output Formats (Coming Soon)
Additional output formats such as slides, markdown documents, and standalone graphs are coming soon. If the user requests these formats, let them know they're not yet available and suggest building an interactive web application instead, which can include:
- Data visualizations and charts using recharts
- Multi-page layouts with navigation
- Exportable content (print-to-PDF functionality)
- Interactive dashboards with real-time filtering and sorting

View File

@@ -0,0 +1,114 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Structure
The `files` directory contains all of the knowledge from Chris' company, Onyx. This knowledge comes from Google Drive, Linear, Slack, Github, and Fireflies.
Each source has it's own directory - `Google_Drive`, `Linear`, `Slack`, `Github`, and `Fireflies`. Within each directory, the structure of the source is built out as a folder structure:
- Google Drive is copied over directly as is. End files are stored as `FILE_NAME.json`.
- Linear has each project as a folder, and then within each project, each individual ticket is stored as a file: `[TICKET_ID]_TICKET_NAME.json`.
- Slack has each channel as a folder titled `[CHANNEL_NAME]` in the root directory. Within each channel, each thread is represented as a single file called `[INITIAL_AUTHOR]_in_[CHANNEL]__[FIRST_MESSAGE].json`.
- Github has each organization as a folder titled `[ORG_NAME]`. Within each organization, there is
a folder for each repository tilted `[REPO_NAME]`. Within each repository there are up to two folders: `pull_requests` and `issues`. Each pull request / issue is then represented as a single file
within the appropriate folder. Pull requests are structured as `[PR_ID]__[PR_NAME].json` and issues
are structured as `[ISSUE_ID]__[ISSUE_NAME].json`.
- Fireflies has all calls in the root, each as a single file titled `CALL_TITLE.json`.
- HubSpot has four folders in the root: `Tickets`, `Companies`, `Deals`, and `Contacts`. Each object is stored as a file named after its title/name (e.g., `[TICKET_SUBJECT].json`, `[COMPANY_NAME].json`, `[DEAL_NAME].json`, `[CONTACT_NAME].json`).
Across all names, spaces are replaced by `_`.
Each JSON is structured like:
```
{
"id": "afbec183-b0c5-46bf-b768-1ce88d003729",
"semantic_identifier": "[CS-17] [Betclic] Update system prompt doesn't work",
"title": "[Betclic] Update system prompt doesn't work",
"source": "linear",
"doc_updated_at": "2025-11-10T16:31:07.735000+00:00",
"metadata": {
"team": "Customer Success",
"creator": "{'name': 'Chris Weaver', 'email': 'chris@danswer.ai'}",
"state": "Backlog",
"priority": "3",
"created_at": "2025-11-10T16:30:10.718Z"
},
"doc_metadata": {
"hierarchy": {
"source_path": [
"Customer Success"
],
"team_name": "Customer Success",
"identifier": "CS-17"
}
},
"sections": [
{
"text": "Happens \\~15% of the time.",
"link": "https://linear.app/onyx-app/issue/CS-17/betclic-update-system-prompt-doesnt-work"
}
],
"primary_owners": [],
"secondary_owners": []
}
```
Do NOT write any files to these directories. Do NOT edit any files in these directories.
There is a special folder called `outputs`. Any and all python scripts, javascript apps, generated documents, slides, etc. should go here.
Feel free to write/edit anything you find in here.
## Outputs
There should be four main types of outputs:
1. Web Applications / Dashboards
2. Slides
3. Markdown Documents
4. Graphs/Charts
Generally, you should use
### Web Applications / Dashboards
Web applications and dashboards should be written as a Next.js app. Within the `outputs` directory,
there is a folder called `web` that has the skeleton of a basic Next.js app in it. Use this.
Use NextJS 16.1.1, React v19, Tailwindcss, and recharts.
The Next.js app is already running and accessible at http://localhost:3002. Do not run `npm run dev` yourself.
If the app needs any pre-computation, then create a bash script called `prepare.sh` at the root of the `web` directory.
### Slides
Slides should be created using the nano-banana MCP.
The outputs should be placed within the `outputs/slides` directory, named `[SLIDE_NUMBER].png`.
Before creating slides, create a `SLIDE_OUTLINE.md` file describing the overall message as well as the content and structure of each slide.
### Markdown Documents
Markdown documents should be placed within the `outputs/document` directory.
If you want to have a single "Document" that has multiple distinct pages, then create a folder within
the `outputs/document` directory, and name each page `1.MD`, `2.MD`, ...
### Graphs/Charts
Graphs and charts should be placed in the `outputs/charts` directory.
Graphs and charts should be created with a python script. You have access to libraries like numpy, pandas, scipy, matplotlib, and PIL.
## Your Environment
You are in an ephemeral virtual machine.
You currently have Python 3.11.13 and Node v22.21.1.
**Python Virtual Environment**: A Python virtual environment is pre-configured at `.venv/` with common data science and visualization packages already installed (numpy, pandas, matplotlib, scipy, PIL, etc.). The environment should be automatically activated, but if you run into issues with missing packages, you can explicitly use `.venv/bin/python` or `.venv/bin/pip`.
If you need additional packages, install them with `pip install <package>` (or `.venv/bin/pip install <package>` if the venv isn't active). For javascript packages, use `npm` from within the `outputs/web` directory.

View File

@@ -0,0 +1 @@
# Build feature module

View File

@@ -0,0 +1,454 @@
from collections.abc import Iterator
from uuid import UUID
import httpx
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from fastapi import Request
from fastapi import Response
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from onyx.auth.users import current_user
from onyx.configs.constants import DocumentSource
from onyx.db.connector_credential_pair import get_connector_credential_pairs_for_user
from onyx.db.engine.sql_engine import get_session
from onyx.db.enums import ConnectorCredentialPairStatus
from onyx.db.enums import IndexingStatus
from onyx.db.enums import ProcessingMode
from onyx.db.index_attempt import get_latest_index_attempt_for_cc_pair_id
from onyx.db.models import BuildSession
from onyx.db.models import User
from onyx.server.features.build.api.messages_api import router as messages_router
from onyx.server.features.build.api.models import BuildConnectorInfo
from onyx.server.features.build.api.models import BuildConnectorListResponse
from onyx.server.features.build.api.models import BuildConnectorStatus
from onyx.server.features.build.api.models import RateLimitResponse
from onyx.server.features.build.api.rate_limit import get_user_rate_limit_status
from onyx.server.features.build.api.sessions_api import router as sessions_router
from onyx.server.features.build.db.sandbox import get_sandbox_by_user_id
from onyx.server.features.build.sandbox import get_sandbox_manager
from onyx.server.features.build.session.manager import SessionManager
from onyx.server.features.build.utils import is_onyx_craft_enabled
from onyx.utils.logger import setup_logger
logger = setup_logger()
def require_onyx_craft_enabled(user: User = Depends(current_user)) -> User:
"""
Dependency that checks if Onyx Craft is enabled for the user.
Raises HTTP 403 if Onyx Craft is disabled via feature flag.
"""
if not is_onyx_craft_enabled(user):
raise HTTPException(
status_code=403,
detail="Onyx Craft is not available",
)
return user
router = APIRouter(prefix="/build", dependencies=[Depends(require_onyx_craft_enabled)])
# Include sub-routers for sessions and messages
router.include_router(sessions_router, tags=["build"])
router.include_router(messages_router, tags=["build"])
# -----------------------------------------------------------------------------
# Rate Limiting
# -----------------------------------------------------------------------------
@router.get("/limit", response_model=RateLimitResponse)
def get_rate_limit(
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> RateLimitResponse:
"""Get rate limit information for the current user."""
return get_user_rate_limit_status(user, db_session)
# -----------------------------------------------------------------------------
# Build Connectors
# -----------------------------------------------------------------------------
@router.get("/connectors", response_model=BuildConnectorListResponse)
def get_build_connectors(
user: User | None = Depends(current_user),
db_session: Session = Depends(get_session),
) -> BuildConnectorListResponse:
"""Get all connectors for the build admin panel.
Returns all connector-credential pairs with simplified status information.
"""
cc_pairs = get_connector_credential_pairs_for_user(
db_session=db_session,
user=user,
get_editable=False,
eager_load_connector=True,
eager_load_credential=True,
processing_mode=ProcessingMode.FILE_SYSTEM, # Only show FILE_SYSTEM connectors
)
connectors: list[BuildConnectorInfo] = []
for cc_pair in cc_pairs:
# Skip ingestion API connectors and default pairs
if cc_pair.connector.source == DocumentSource.INGESTION_API:
continue
if cc_pair.name == "DefaultCCPair":
continue
# Determine status
error_message: str | None = None
has_ever_succeeded = cc_pair.last_successful_index_time is not None
if cc_pair.status == ConnectorCredentialPairStatus.DELETING:
status = BuildConnectorStatus.DELETING
elif cc_pair.status == ConnectorCredentialPairStatus.INVALID:
# If connector has succeeded before but credentials are now invalid,
# show as connected_with_errors so user can still disable demo data
if has_ever_succeeded:
status = BuildConnectorStatus.CONNECTED_WITH_ERRORS
error_message = "Connector credentials are invalid"
else:
status = BuildConnectorStatus.ERROR
error_message = "Connector credentials are invalid"
else:
# Check latest index attempt for errors
latest_attempt = get_latest_index_attempt_for_cc_pair_id(
db_session=db_session,
connector_credential_pair_id=cc_pair.id,
secondary_index=False,
only_finished=True,
)
if latest_attempt and latest_attempt.status == IndexingStatus.FAILED:
# If connector has succeeded before but latest attempt failed,
# show as connected_with_errors
if has_ever_succeeded:
status = BuildConnectorStatus.CONNECTED_WITH_ERRORS
else:
status = BuildConnectorStatus.ERROR
error_message = latest_attempt.error_msg
elif (
latest_attempt
and latest_attempt.status == IndexingStatus.COMPLETED_WITH_ERRORS
):
# Completed with errors - if it has succeeded before, show as connected_with_errors
if has_ever_succeeded:
status = BuildConnectorStatus.CONNECTED_WITH_ERRORS
else:
status = BuildConnectorStatus.ERROR
error_message = "Indexing completed with errors"
elif cc_pair.status == ConnectorCredentialPairStatus.PAUSED:
status = BuildConnectorStatus.CONNECTED
elif cc_pair.last_successful_index_time is None:
# Never successfully indexed - check if currently indexing
# First check cc_pair status for scheduled/initial indexing
if cc_pair.status in (
ConnectorCredentialPairStatus.SCHEDULED,
ConnectorCredentialPairStatus.INITIAL_INDEXING,
):
status = BuildConnectorStatus.INDEXING
else:
in_progress_attempt = get_latest_index_attempt_for_cc_pair_id(
db_session=db_session,
connector_credential_pair_id=cc_pair.id,
secondary_index=False,
only_finished=False,
)
if (
in_progress_attempt
and in_progress_attempt.status == IndexingStatus.IN_PROGRESS
):
status = BuildConnectorStatus.INDEXING
elif (
in_progress_attempt
and in_progress_attempt.status == IndexingStatus.NOT_STARTED
):
status = BuildConnectorStatus.INDEXING
else:
# Has a finished attempt but never succeeded - likely error
status = BuildConnectorStatus.ERROR
error_message = (
latest_attempt.error_msg
if latest_attempt
else "Initial indexing failed"
)
else:
status = BuildConnectorStatus.CONNECTED
connectors.append(
BuildConnectorInfo(
cc_pair_id=cc_pair.id,
connector_id=cc_pair.connector.id,
credential_id=cc_pair.credential.id,
source=cc_pair.connector.source.value,
name=cc_pair.name or cc_pair.connector.name or "Unnamed",
status=status,
docs_indexed=0, # Would need to query for this
last_indexed=cc_pair.last_successful_index_time,
error_message=error_message,
)
)
return BuildConnectorListResponse(connectors=connectors)
# Headers to skip when proxying (hop-by-hop headers)
EXCLUDED_HEADERS = {
"content-encoding",
"content-length",
"transfer-encoding",
"connection",
}
def _stream_response(response: httpx.Response) -> Iterator[bytes]:
"""Stream the response content in chunks."""
for chunk in response.iter_bytes(chunk_size=8192):
yield chunk
def _rewrite_asset_paths(content: bytes, session_id: str) -> bytes:
"""Rewrite Next.js asset paths to go through the proxy."""
import re
# Base path includes session_id for routing
webapp_base_path = f"/api/build/sessions/{session_id}/webapp"
text = content.decode("utf-8")
# Rewrite /_next/ paths to go through our proxy
text = text.replace("/_next/", f"{webapp_base_path}/_next/")
# Rewrite JSON data file fetch paths (e.g., /data.json, /data/tickets.json)
# Matches paths like "/filename.json" or "/path/to/file.json"
text = re.sub(
r'"(/(?:[a-zA-Z0-9_-]+/)*[a-zA-Z0-9_-]+\.json)"',
f'"{webapp_base_path}\\1"',
text,
)
text = re.sub(
r"'(/(?:[a-zA-Z0-9_-]+/)*[a-zA-Z0-9_-]+\.json)'",
f"'{webapp_base_path}\\1'",
text,
)
# Rewrite favicon
text = text.replace('"/favicon.ico', f'"{webapp_base_path}/favicon.ico')
return text.encode("utf-8")
# Content types that may contain asset path references that need rewriting
REWRITABLE_CONTENT_TYPES = {
"text/html",
"text/css",
"application/javascript",
"text/javascript",
"application/x-javascript",
}
def _get_sandbox_url(session_id: UUID, db_session: Session) -> str:
"""Get the internal URL for a session's Next.js server.
Uses the sandbox manager to get the correct URL for both local and
Kubernetes environments.
Args:
session_id: The build session ID
db_session: Database session
Returns:
The internal URL to proxy requests to
Raises:
HTTPException: If session not found, port not allocated, or sandbox not found
"""
session = db_session.get(BuildSession, session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if session.nextjs_port is None:
raise HTTPException(status_code=503, detail="Session port not allocated")
if session.user_id is None:
raise HTTPException(status_code=404, detail="User not found")
# Get the user's sandbox to get the sandbox_id
sandbox = get_sandbox_by_user_id(db_session, session.user_id)
if sandbox is None:
raise HTTPException(status_code=404, detail="Sandbox not found")
# Use sandbox manager to get the correct internal URL
sandbox_manager = get_sandbox_manager()
return sandbox_manager.get_webapp_url(sandbox.id, session.nextjs_port)
def _proxy_request(
path: str, request: Request, session_id: UUID, db_session: Session
) -> StreamingResponse | Response:
"""Proxy a request to the sandbox's Next.js server."""
base_url = _get_sandbox_url(session_id, db_session)
# Build the target URL
target_url = f"{base_url}/{path.lstrip('/')}"
# Include query params if present
if request.query_params:
target_url = f"{target_url}?{request.query_params}"
logger.debug(f"Proxying request to: {target_url}")
try:
# Make the request to the target URL
with httpx.Client(timeout=30.0, follow_redirects=True) as client:
response = client.get(
target_url,
headers={
key: value
for key, value in request.headers.items()
if key.lower() not in ("host", "content-length")
},
)
# Build response headers, excluding hop-by-hop headers
response_headers = {
key: value
for key, value in response.headers.items()
if key.lower() not in EXCLUDED_HEADERS
}
content_type = response.headers.get("content-type", "")
# For HTML/CSS/JS responses, rewrite asset paths
if any(ct in content_type for ct in REWRITABLE_CONTENT_TYPES):
content = _rewrite_asset_paths(response.content, str(session_id))
return Response(
content=content,
status_code=response.status_code,
headers=response_headers,
media_type=content_type,
)
return StreamingResponse(
content=_stream_response(response),
status_code=response.status_code,
headers=response_headers,
media_type=content_type or None,
)
except httpx.TimeoutException:
logger.error(f"Timeout while proxying request to {target_url}")
raise HTTPException(status_code=504, detail="Gateway timeout")
except httpx.RequestError as e:
logger.error(f"Error proxying request to {target_url}: {e}")
raise HTTPException(status_code=502, detail="Bad gateway")
@router.get("/sessions/{session_id}/webapp", response_model=None)
def get_webapp_root(
session_id: UUID,
request: Request,
_: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> StreamingResponse | Response:
"""Proxy the root path of the webapp for a specific session."""
return _proxy_request("", request, session_id, db_session)
@router.get("/sessions/{session_id}/webapp/{path:path}", response_model=None)
def get_webapp_path(
session_id: UUID,
path: str,
request: Request,
_: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> StreamingResponse | Response:
"""Proxy any subpath of the webapp (static assets, etc.) for a specific session."""
return _proxy_request(path, request, session_id, db_session)
# Separate router for Next.js static assets at /_next/*
# This is needed because Next.js apps may reference assets with root-relative paths
# that don't get rewritten. The session_id is extracted from the Referer header.
nextjs_assets_router = APIRouter()
def _extract_session_from_referer(request: Request) -> UUID | None:
"""Extract session_id from the Referer header.
Expects Referer to contain /api/build/sessions/{session_id}/webapp
"""
import re
referer = request.headers.get("referer", "")
match = re.search(r"/api/build/sessions/([a-f0-9-]+)/webapp", referer)
if match:
try:
return UUID(match.group(1))
except ValueError:
return None
return None
@nextjs_assets_router.get("/_next/{path:path}", response_model=None)
def get_nextjs_assets(
path: str,
request: Request,
_: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> StreamingResponse | Response:
"""Proxy Next.js static assets requested at root /_next/ path.
The session_id is extracted from the Referer header since these requests
come from within the iframe context.
"""
session_id = _extract_session_from_referer(request)
if not session_id:
raise HTTPException(
status_code=400,
detail="Could not determine session from request context",
)
return _proxy_request(f"_next/{path}", request, session_id, db_session)
# =============================================================================
# Sandbox Management Endpoints
# =============================================================================
@router.post("/sandbox/reset", response_model=None)
def reset_sandbox(
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> Response:
"""Reset the user's sandbox by terminating it and cleaning up all sessions.
This endpoint terminates the user's shared sandbox container/pod and
cleans up all session workspaces. Useful for "start fresh" functionality.
After calling this endpoint, the next session creation will provision a
new sandbox.
"""
session_manager = SessionManager(db_session)
try:
success = session_manager.terminate_user_sandbox(user.id)
if not success:
raise HTTPException(
status_code=404,
detail="No sandbox found for user",
)
db_session.commit()
except HTTPException:
raise
except Exception as e:
db_session.rollback()
logger.error(f"Failed to reset sandbox for user {user.id}: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to reset sandbox: {e}",
)
return Response(status_code=204)

View File

@@ -0,0 +1,106 @@
"""API endpoints for Build Mode message management."""
from uuid import UUID
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from onyx.auth.users import current_user
from onyx.configs.constants import PUBLIC_API_TAGS
from onyx.db.engine.sql_engine import get_session
from onyx.db.models import User
from onyx.server.features.build.api.models import MessageListResponse
from onyx.server.features.build.api.models import MessageRequest
from onyx.server.features.build.api.models import MessageResponse
from onyx.server.features.build.db.sandbox import get_sandbox_by_user_id
from onyx.server.features.build.db.sandbox import update_sandbox_heartbeat
from onyx.server.features.build.session.manager import RateLimitError
from onyx.server.features.build.session.manager import SessionManager
from onyx.utils.logger import setup_logger
logger = setup_logger()
router = APIRouter()
def check_build_rate_limits(
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> None:
"""
Dependency to check build mode rate limits before processing the request.
Raises HTTPException(429) if rate limit is exceeded.
Follows the same pattern as chat's check_token_rate_limits.
"""
session_manager = SessionManager(db_session)
try:
session_manager.check_rate_limit(user)
except RateLimitError as e:
raise HTTPException(
status_code=429,
detail=str(e),
)
@router.get("/sessions/{session_id}/messages", tags=PUBLIC_API_TAGS)
def list_messages(
session_id: UUID,
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> MessageListResponse:
"""Get all messages for a build session."""
if user is None:
raise HTTPException(status_code=401, detail="Authentication required")
session_manager = SessionManager(db_session)
messages = session_manager.list_messages(session_id, user.id)
if messages is None:
raise HTTPException(status_code=404, detail="Session not found")
return MessageListResponse(
messages=[MessageResponse.from_model(msg) for msg in messages]
)
@router.post("/sessions/{session_id}/send-message", tags=PUBLIC_API_TAGS)
async def send_message(
session_id: UUID,
request: MessageRequest,
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
_rate_limit_check: None = Depends(check_build_rate_limits),
) -> StreamingResponse:
"""
Send a message to the CLI agent and stream the response.
Enforces rate limiting before executing the agent (via dependency).
Returns a Server-Sent Events (SSE) stream with the agent's response.
Follows the same pattern as /chat/send-message for consistency.
"""
# Update sandbox heartbeat - this is the only place we track activity
# for determining when a sandbox should be put to sleep
sandbox = get_sandbox_by_user_id(db_session, user.id)
if sandbox and sandbox.status.is_active():
update_sandbox_heartbeat(db_session, sandbox.id)
session_manager = SessionManager(db_session)
# Stream the CLI agent's response
return StreamingResponse(
session_manager.send_message(session_id, user.id, request.content),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no", # Disable nginx buffering
},
)

View File

@@ -0,0 +1,325 @@
from datetime import datetime
from enum import Enum
from typing import Any
from typing import TYPE_CHECKING
from typing import Union
from pydantic import BaseModel
from onyx.configs.constants import MessageType
from onyx.db.enums import ArtifactType
from onyx.db.enums import BuildSessionStatus
from onyx.db.enums import SandboxStatus
from onyx.server.features.build.sandbox.models import (
FilesystemEntry as FileSystemEntry,
)
if TYPE_CHECKING:
from onyx.db.models import Sandbox
from onyx.db.models import BuildSession
# ===== Session Models =====
class SessionCreateRequest(BaseModel):
"""Request to create a new build session."""
name: str | None = None # Optional session name
demo_data_enabled: bool = True # Whether to enable demo org_info data in sandbox
user_work_area: str | None = None # User's work area (e.g., "engineering")
user_level: str | None = None # User's level (e.g., "ic", "manager")
# LLM selection from user's cookie
llm_provider_type: str | None = None # Provider type (e.g., "anthropic", "openai")
llm_model_name: str | None = None # Model name (e.g., "claude-opus-4-5")
class SessionUpdateRequest(BaseModel):
"""Request to update a build session.
If name is None, the session name will be auto-generated using LLM.
"""
name: str | None = None
class SessionNameGenerateResponse(BaseModel):
"""Response containing a generated session name."""
name: str
class SandboxResponse(BaseModel):
"""Sandbox metadata in session response."""
id: str
status: SandboxStatus
container_id: str | None
created_at: datetime
last_heartbeat: datetime | None
@classmethod
def from_model(cls, sandbox: Any) -> "SandboxResponse":
"""Convert Sandbox ORM model to response."""
return cls(
id=str(sandbox.id),
status=sandbox.status,
container_id=sandbox.container_id,
created_at=sandbox.created_at,
last_heartbeat=sandbox.last_heartbeat,
)
class ArtifactResponse(BaseModel):
"""Artifact metadata in session response."""
id: str
session_id: str
type: ArtifactType
name: str
path: str
preview_url: str | None
created_at: datetime
updated_at: datetime
@classmethod
def from_model(cls, artifact: Any) -> "ArtifactResponse":
"""Convert Artifact ORM model to response."""
return cls(
id=str(artifact.id),
session_id=str(artifact.session_id),
type=artifact.type,
name=artifact.name,
path=artifact.path,
preview_url=getattr(artifact, "preview_url", None),
created_at=artifact.created_at,
updated_at=artifact.updated_at,
)
class SessionResponse(BaseModel):
"""Response containing session details."""
id: str
user_id: str | None
name: str | None
status: BuildSessionStatus
created_at: datetime
last_activity_at: datetime
nextjs_port: int | None
sandbox: SandboxResponse | None
artifacts: list[ArtifactResponse]
@classmethod
def from_model(
cls, session: "BuildSession", sandbox: Union["Sandbox", None] = None
) -> "SessionResponse":
"""Convert BuildSession ORM model to response.
Args:
session: BuildSession ORM model
sandbox: Optional Sandbox ORM model. Since sandboxes are now user-owned
(not session-owned), the sandbox must be passed separately.
"""
return cls(
id=str(session.id),
user_id=str(session.user_id) if session.user_id else None,
name=session.name,
status=session.status,
created_at=session.created_at,
last_activity_at=session.last_activity_at,
nextjs_port=session.nextjs_port,
sandbox=(SandboxResponse.from_model(sandbox) if sandbox else None),
artifacts=[ArtifactResponse.from_model(a) for a in session.artifacts],
)
class DetailedSessionResponse(SessionResponse):
"""Extended session response with sandbox state details.
Used for single-session endpoints where we compute expensive fields
like session_loaded_in_sandbox.
"""
session_loaded_in_sandbox: bool
@classmethod
def from_session_response(
cls,
base: SessionResponse,
session_loaded_in_sandbox: bool,
) -> "DetailedSessionResponse":
return cls(
**base.model_dump(),
session_loaded_in_sandbox=session_loaded_in_sandbox,
)
class SessionListResponse(BaseModel):
"""Response containing list of sessions."""
sessions: list[SessionResponse]
# ===== Message Models =====
class MessageRequest(BaseModel):
"""Request to send a message to the CLI agent."""
content: str
class MessageResponse(BaseModel):
"""Response containing message details.
All message data is stored in message_metadata as JSON (the raw ACP packet).
The turn_index groups all assistant responses under the user prompt they respond to.
Packet types in message_metadata:
- user_message: {type: "user_message", content: {...}}
- agent_message: {type: "agent_message", content: {...}}
- agent_thought: {type: "agent_thought", content: {...}}
- tool_call_progress: {type: "tool_call_progress", status: "completed", ...}
- agent_plan_update: {type: "agent_plan_update", entries: [...]}
"""
id: str
session_id: str
turn_index: int
type: MessageType
message_metadata: dict[str, Any]
created_at: datetime
@classmethod
def from_model(cls, message: Any) -> "MessageResponse":
"""Convert BuildMessage ORM model to response."""
return cls(
id=str(message.id),
session_id=str(message.session_id),
turn_index=message.turn_index,
type=message.type,
message_metadata=message.message_metadata,
created_at=message.created_at,
)
class MessageListResponse(BaseModel):
"""Response containing list of messages."""
messages: list[MessageResponse]
# ===== Legacy Models (for compatibility with other code) =====
class CreateSessionRequest(BaseModel):
task: str
available_sources: list[str] | None = None
class CreateSessionResponse(BaseModel):
session_id: str
class ExecuteRequest(BaseModel):
task: str
context: str | None = None
class ArtifactInfo(BaseModel):
artifact_type: str # "webapp", "file", "markdown", "image"
path: str
filename: str
mime_type: str | None = None
class SessionStatus(BaseModel):
session_id: str
status: str # "idle", "running", "completed", "failed"
webapp_url: str | None = None
class DirectoryListing(BaseModel):
path: str # Current directory path
entries: list[FileSystemEntry] # Contents
class WebappInfo(BaseModel):
has_webapp: bool # Whether a webapp exists in outputs/web
webapp_url: str | None # URL to access the webapp (e.g., http://localhost:3015)
status: str # Sandbox status (running, terminated, etc.)
# ===== File Upload Models =====
class UploadResponse(BaseModel):
"""Response after successful file upload."""
filename: str # Sanitized filename
path: str # Relative path in sandbox (e.g., "attachments/doc.pdf")
size_bytes: int # File size in bytes
# ===== Rate Limit Models =====
class RateLimitResponse(BaseModel):
"""Rate limit information."""
is_limited: bool
limit_type: str # "weekly" or "total"
messages_used: int
limit: int
reset_timestamp: str | None = None
# ===== Build Connector Models =====
class BuildConnectorStatus(str, Enum):
"""Status of a build connector."""
NOT_CONNECTED = "not_connected"
CONNECTED = "connected"
CONNECTED_WITH_ERRORS = "connected_with_errors"
INDEXING = "indexing"
ERROR = "error"
DELETING = "deleting"
class BuildConnectorInfo(BaseModel):
"""Simplified connector info for build admin panel."""
cc_pair_id: int
connector_id: int
credential_id: int
source: str
name: str
status: BuildConnectorStatus
docs_indexed: int
last_indexed: datetime | None
error_message: str | None = None
class BuildConnectorListResponse(BaseModel):
"""List of build connectors."""
connectors: list[BuildConnectorInfo]
# ===== Suggestion Bubble Models =====
class SuggestionTheme(str, Enum):
"""Theme/category of a follow-up suggestion."""
ADD = "add"
QUESTION = "question"
class SuggestionBubble(BaseModel):
"""A single follow-up suggestion bubble."""
theme: SuggestionTheme
text: str
class GenerateSuggestionsRequest(BaseModel):
"""Request to generate follow-up suggestions."""
user_message: str # First user message
assistant_message: str # First assistant text response (accumulated)
class GenerateSuggestionsResponse(BaseModel):
"""Response containing generated suggestions."""
suggestions: list[SuggestionBubble]

View File

@@ -0,0 +1,101 @@
"""Simple packet logger for build mode debugging.
Logs the raw JSON of every packet emitted during build mode.
Log output: backend/onyx/server/features/build/packets.log
"""
import json
import logging
import os
from pathlib import Path
from typing import Any
class PacketLogger:
"""Simple packet logger - outputs raw JSON for each packet."""
_instance: "PacketLogger | None" = None
_initialized: bool
def __new__(cls) -> "PacketLogger":
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self) -> None:
if self._initialized:
return
self._initialized = True
self._enabled = os.getenv("LOG_LEVEL", "").upper() == "DEBUG"
self._logger: logging.Logger | None = None
if self._enabled:
self._setup_logger()
def _setup_logger(self) -> None:
"""Set up the file handler for packet logging."""
# Log to backend/onyx/server/features/build/packets.log
build_dir = Path(__file__).parents[1]
log_file = build_dir / "packets.log"
self._logger = logging.getLogger("build.packets")
self._logger.setLevel(logging.DEBUG)
self._logger.propagate = False
self._logger.handlers.clear()
handler = logging.FileHandler(log_file, mode="a", encoding="utf-8")
handler.setLevel(logging.DEBUG)
handler.setFormatter(logging.Formatter("%(message)s"))
self._logger.addHandler(handler)
def log(self, packet_type: str, payload: dict[str, Any] | None = None) -> None:
"""Log a packet as JSON.
Args:
packet_type: The type of packet
payload: The packet payload
"""
if not self._enabled or not self._logger:
return
try:
output = json.dumps(payload, indent=2, default=str) if payload else "{}"
self._logger.debug(f"\n=== {packet_type} ===\n{output}")
except Exception:
self._logger.debug(f"\n=== {packet_type} ===\n{payload}")
def log_raw(self, label: str, data: Any) -> None:
"""Log raw data with a label.
Args:
label: A label for this log entry
data: Any data to log
"""
if not self._enabled or not self._logger:
return
try:
if isinstance(data, (dict, list)):
output = json.dumps(data, indent=2, default=str)
else:
output = str(data)
self._logger.debug(f"\n=== {label} ===\n{output}")
except Exception:
self._logger.debug(f"\n=== {label} ===\n{data}")
# Singleton instance
_packet_logger: PacketLogger | None = None
def get_packet_logger() -> PacketLogger:
"""Get the singleton packet logger instance."""
global _packet_logger
if _packet_logger is None:
_packet_logger = PacketLogger()
return _packet_logger

View File

@@ -0,0 +1,68 @@
"""Build Mode packet types for streaming agent responses.
This module defines CUSTOM Onyx packet types that extend ACP (Agent Client Protocol).
ACP events are passed through directly from the agent - this module only contains
Onyx-specific extensions like artifacts and file operations.
All packets use SSE (Server-Sent Events) format with `event: message` and include
a `type` field to distinguish packet types.
ACP events (passed through directly from acp.schema):
- agent_message_chunk: Text/image content from agent
- agent_thought_chunk: Agent's internal reasoning
- tool_call_start: Tool invocation started
- tool_call_progress: Tool execution progress/result
- agent_plan_update: Agent's execution plan
- current_mode_update: Agent mode change
- prompt_response: Agent finished processing
- error: An error occurred
Custom Onyx packets (defined here):
- error: Onyx-specific errors (e.g., session not found)
Based on:
- Agent Client Protocol (ACP): https://agentclientprotocol.com
"""
from datetime import datetime
from datetime import timezone
from typing import Any
from typing import Literal
from pydantic import BaseModel
from pydantic import Field
# =============================================================================
# Base Packet Type
# =============================================================================
class BasePacket(BaseModel):
"""Base packet with common fields for all custom Onyx packet types."""
type: str
timestamp: str = Field(
default_factory=lambda: datetime.now(tz=timezone.utc).isoformat()
)
# =============================================================================
# Custom Onyx Packets
# =============================================================================
class ErrorPacket(BasePacket):
"""An Onyx-specific error occurred (e.g., session not found, sandbox not running)."""
type: Literal["error"] = "error"
message: str
code: int | None = None
details: dict[str, Any] | None = None
# =============================================================================
# Union Type for Custom Onyx Packets
# =============================================================================
BuildPacket = ErrorPacket

View File

@@ -0,0 +1,90 @@
"""Rate limiting logic for Build Mode."""
from datetime import datetime
from datetime import timedelta
from datetime import timezone
from typing import Literal
from sqlalchemy.orm import Session
from onyx.db.models import User
from onyx.server.features.build.api.models import RateLimitResponse
from onyx.server.features.build.api.subscription_check import is_user_subscribed
from onyx.server.features.build.db.rate_limit import count_user_messages_in_window
from onyx.server.features.build.db.rate_limit import count_user_messages_total
from onyx.server.features.build.db.rate_limit import get_oldest_message_timestamp
from shared_configs.configs import MULTI_TENANT
def get_user_rate_limit_status(
user: User,
db_session: Session,
) -> RateLimitResponse:
"""
Get the rate limit status for a user.
Rate limits:
- Cloud (MULTI_TENANT=true):
- Subscribed users: 50 messages per week (rolling 7-day window)
- Non-subscribed users: 5 messages (lifetime total)
- Self-hosted (MULTI_TENANT=false):
- Unlimited (no rate limiting)
Args:
user: The user object (None for unauthenticated users)
db_session: Database session
Returns:
RateLimitResponse with current limit status
"""
# Self-hosted deployments have no rate limits
if not MULTI_TENANT:
return RateLimitResponse(
is_limited=False,
limit_type="weekly",
messages_used=0,
limit=0, # 0 indicates unlimited
reset_timestamp=None,
)
# Determine subscription status
is_subscribed = is_user_subscribed(user, db_session)
# Set limits based on subscription
limit = 50 if is_subscribed else 5
limit_type: Literal["weekly", "total"] = "weekly" if is_subscribed else "total"
# Count messages
user_id = user.id if user else None
if user_id is None:
# Unauthenticated users have no usage
messages_used = 0
reset_timestamp = None
elif limit_type == "weekly":
# Subscribed: rolling 7-day window
cutoff_time = datetime.now(tz=timezone.utc) - timedelta(days=7)
messages_used = count_user_messages_in_window(user_id, cutoff_time, db_session)
# Calculate reset timestamp (when oldest message ages out)
# Only show reset time if user is at or over the limit
if messages_used >= limit:
oldest_msg = get_oldest_message_timestamp(user_id, cutoff_time, db_session)
if oldest_msg:
reset_time = oldest_msg + timedelta(days=7)
reset_timestamp = reset_time.isoformat()
else:
reset_timestamp = None
else:
reset_timestamp = None
else:
# Non-subscribed: lifetime total
messages_used = count_user_messages_total(user_id, db_session)
reset_timestamp = None
return RateLimitResponse(
is_limited=messages_used >= limit,
limit_type=limit_type,
messages_used=messages_used,
limit=limit,
reset_timestamp=reset_timestamp,
)

View File

@@ -0,0 +1,680 @@
"""API endpoints for Build Mode session management."""
from uuid import UUID
from fastapi import APIRouter
from fastapi import Depends
from fastapi import File
from fastapi import HTTPException
from fastapi import Response
from fastapi import UploadFile
from sqlalchemy.orm import Session
from onyx.auth.users import current_user
from onyx.db.engine.sql_engine import get_session
from onyx.db.enums import SandboxStatus
from onyx.db.models import User
from onyx.redis.redis_pool import get_redis_client
from onyx.server.features.build.api.models import ArtifactResponse
from onyx.server.features.build.api.models import DetailedSessionResponse
from onyx.server.features.build.api.models import DirectoryListing
from onyx.server.features.build.api.models import GenerateSuggestionsRequest
from onyx.server.features.build.api.models import GenerateSuggestionsResponse
from onyx.server.features.build.api.models import SessionCreateRequest
from onyx.server.features.build.api.models import SessionListResponse
from onyx.server.features.build.api.models import SessionNameGenerateResponse
from onyx.server.features.build.api.models import SessionResponse
from onyx.server.features.build.api.models import SessionUpdateRequest
from onyx.server.features.build.api.models import SuggestionBubble
from onyx.server.features.build.api.models import SuggestionTheme
from onyx.server.features.build.api.models import UploadResponse
from onyx.server.features.build.api.models import WebappInfo
from onyx.server.features.build.db.build_session import allocate_nextjs_port
from onyx.server.features.build.db.build_session import get_build_session
from onyx.server.features.build.db.sandbox import get_latest_snapshot_for_session
from onyx.server.features.build.db.sandbox import get_sandbox_by_user_id
from onyx.server.features.build.db.sandbox import update_sandbox_status__no_commit
from onyx.server.features.build.sandbox import get_sandbox_manager
from onyx.server.features.build.session.manager import SessionManager
from onyx.server.features.build.session.manager import UploadLimitExceededError
from onyx.server.features.build.utils import sanitize_filename
from onyx.server.features.build.utils import validate_file
from onyx.utils.logger import setup_logger
from shared_configs.contextvars import get_current_tenant_id
logger = setup_logger()
router = APIRouter(prefix="/sessions")
# =============================================================================
# Session Management Endpoints
# =============================================================================
@router.get("", response_model=SessionListResponse)
def list_sessions(
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> SessionListResponse:
"""List all build sessions for the current user."""
session_manager = SessionManager(db_session)
sessions = session_manager.list_sessions(user.id)
# Get the user's sandbox (shared across all sessions)
sandbox = get_sandbox_by_user_id(db_session, user.id)
return SessionListResponse(
sessions=[SessionResponse.from_model(session, sandbox) for session in sessions]
)
@router.post("", response_model=DetailedSessionResponse)
def create_session(
request: SessionCreateRequest,
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> DetailedSessionResponse:
"""
Create or get an existing empty build session.
Creates a sandbox with the necessary file structure and returns a session ID.
Uses SessionManager for session and sandbox provisioning.
This endpoint is atomic - if sandbox provisioning fails, no database
records are created (transaction is rolled back).
"""
session_manager = SessionManager(db_session)
try:
# Only pass user_work_area and user_level if demo data is enabled
# This prevents org_info directory creation when demo data is disabled
build_session = session_manager.get_or_create_empty_session(
user.id,
user_work_area=(
request.user_work_area if request.demo_data_enabled else None
),
user_level=request.user_level if request.demo_data_enabled else None,
llm_provider_type=request.llm_provider_type,
llm_model_name=request.llm_model_name,
)
db_session.commit()
except ValueError as e:
# Max concurrent sandboxes reached or other validation error
logger.exception("Sandbox provisioning failed")
db_session.rollback()
raise HTTPException(status_code=429, detail=str(e))
except Exception as e:
# Sandbox provisioning failed - rollback to remove any uncommitted records
db_session.rollback()
logger.error(f"Sandbox provisioning failed: {e}")
raise HTTPException(
status_code=500,
detail=f"Sandbox provisioning failed: {e}",
)
# Get the user's sandbox to include in response
sandbox = get_sandbox_by_user_id(db_session, user.id)
base_response = SessionResponse.from_model(build_session, sandbox)
# Session was just created, so it's loaded in the sandbox
return DetailedSessionResponse.from_session_response(
base_response, session_loaded_in_sandbox=True
)
@router.get("/{session_id}", response_model=DetailedSessionResponse)
def get_session_details(
session_id: UUID,
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> DetailedSessionResponse:
"""
Get details of a specific build session.
Returns session_loaded_in_sandbox to indicate if the session workspace
exists in the running sandbox.
"""
session_manager = SessionManager(db_session)
session = session_manager.get_session(session_id, user.id)
if session is None:
raise HTTPException(status_code=404, detail="Session not found")
# Get the user's sandbox to include in response
sandbox = get_sandbox_by_user_id(db_session, user.id)
# Check if session workspace exists in the sandbox
session_loaded = False
if sandbox and sandbox.status == SandboxStatus.RUNNING:
sandbox_manager = get_sandbox_manager()
session_loaded = sandbox_manager.session_workspace_exists(
sandbox.id, session_id
)
base_response = SessionResponse.from_model(session, sandbox)
return DetailedSessionResponse.from_session_response(
base_response, session_loaded_in_sandbox=session_loaded
)
@router.post("/{session_id}/generate-name", response_model=SessionNameGenerateResponse)
def generate_session_name(
session_id: UUID,
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> SessionNameGenerateResponse:
"""Generate a session name using LLM based on the first user message."""
session_manager = SessionManager(db_session)
generated_name = session_manager.generate_session_name(session_id, user.id)
if generated_name is None:
raise HTTPException(status_code=404, detail="Session not found")
return SessionNameGenerateResponse(name=generated_name)
@router.post(
"/{session_id}/generate-suggestions", response_model=GenerateSuggestionsResponse
)
def generate_suggestions(
session_id: UUID,
request: GenerateSuggestionsRequest,
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> GenerateSuggestionsResponse:
"""Generate follow-up suggestions based on the first exchange in a session."""
session_manager = SessionManager(db_session)
# Verify session exists and belongs to user
session = session_manager.get_session(session_id, user.id)
if session is None:
raise HTTPException(status_code=404, detail="Session not found")
# Generate suggestions
suggestions_data = session_manager.generate_followup_suggestions(
user_message=request.user_message,
assistant_message=request.assistant_message,
)
# Convert to response model
suggestions = [
SuggestionBubble(
theme=SuggestionTheme(item["theme"]),
text=item["text"],
)
for item in suggestions_data
]
return GenerateSuggestionsResponse(suggestions=suggestions)
@router.put("/{session_id}/name", response_model=SessionResponse)
def update_session_name(
session_id: UUID,
request: SessionUpdateRequest,
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> SessionResponse:
"""Update the name of a build session."""
session_manager = SessionManager(db_session)
session = session_manager.update_session_name(session_id, user.id, request.name)
if session is None:
raise HTTPException(status_code=404, detail="Session not found")
# Get the user's sandbox to include in response
sandbox = get_sandbox_by_user_id(db_session, user.id)
return SessionResponse.from_model(session, sandbox)
@router.delete("/{session_id}", response_model=None)
def delete_session(
session_id: UUID,
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> Response:
"""Delete a build session and all associated data.
This endpoint is atomic - if sandbox termination fails, the session
is NOT deleted (transaction is rolled back).
"""
session_manager = SessionManager(db_session)
try:
success = session_manager.delete_session(session_id, user.id)
if not success:
raise HTTPException(status_code=404, detail="Session not found")
db_session.commit()
except HTTPException:
# Re-raise HTTP exceptions (like 404) without rollback
raise
except Exception as e:
# Sandbox termination failed - rollback to preserve session
db_session.rollback()
logger.error(f"Failed to delete session {session_id}: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to delete session: {e}",
)
return Response(status_code=204)
# Lock timeout should be longer than max restore time (5 minutes)
RESTORE_LOCK_TIMEOUT_SECONDS = 300
@router.post("/{session_id}/restore", response_model=DetailedSessionResponse)
def restore_session(
session_id: UUID,
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> DetailedSessionResponse:
"""Restore sandbox and load session snapshot. Blocks until complete.
Uses Redis lock to ensure only one restore runs per sandbox at a time.
If another restore is in progress, waits for it to complete.
Handles two cases:
1. Sandbox is SLEEPING: Re-provision pod, then load session snapshot
2. Sandbox is RUNNING but session not loaded: Just load session snapshot
Returns immediately if session workspace already exists in pod.
Always returns session_loaded_in_sandbox=True on success.
"""
session = get_build_session(session_id, user.id, db_session)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
sandbox = get_sandbox_by_user_id(db_session, user.id)
if not sandbox:
raise HTTPException(status_code=404, detail="Sandbox not found")
# If sandbox is already running, check if session workspace exists
sandbox_manager = get_sandbox_manager()
tenant_id = get_current_tenant_id()
# Need to do some work - acquire Redis lock
redis_client = get_redis_client(tenant_id=tenant_id)
lock_key = f"sandbox_restore:{sandbox.id}"
lock = redis_client.lock(lock_key, timeout=RESTORE_LOCK_TIMEOUT_SECONDS)
# blocking=True means wait if another restore is in progress
acquired = lock.acquire(
blocking=True, blocking_timeout=RESTORE_LOCK_TIMEOUT_SECONDS
)
if not acquired:
raise HTTPException(
status_code=503,
detail="Restore operation timed out waiting for lock",
)
try:
# Re-fetch sandbox status (may have changed while waiting for lock)
db_session.refresh(sandbox)
# Also re-check if session workspace exists (another request may have
# restored it while we were waiting)
if sandbox.status == SandboxStatus.RUNNING:
# Verify pod is healthy before proceeding
is_healthy = sandbox_manager.health_check(sandbox.id, timeout=10.0)
if is_healthy and sandbox_manager.session_workspace_exists(
sandbox.id, session_id
):
logger.info(
f"Session {session_id} workspace was restored by another request"
)
base_response = SessionResponse.from_model(session, sandbox)
return DetailedSessionResponse.from_session_response(
base_response, session_loaded_in_sandbox=True
)
if not is_healthy:
logger.warning(
f"Sandbox {sandbox.id} marked as RUNNING but pod is "
f"unhealthy/missing. Entering recovery mode."
)
# Terminate to clean up any lingering K8s resources
sandbox_manager.terminate(sandbox.id)
update_sandbox_status__no_commit(
db_session, sandbox.id, SandboxStatus.TERMINATED
)
db_session.commit()
db_session.refresh(sandbox)
# Fall through to TERMINATED handling below
session_manager = SessionManager(db_session)
if sandbox.status in (SandboxStatus.SLEEPING, SandboxStatus.TERMINATED):
# 1. Re-provision the pod
logger.info(f"Re-provisioning {sandbox.status.value} sandbox {sandbox.id}")
llm_config = session_manager._get_llm_config(None, None)
sandbox_manager.provision(
sandbox_id=sandbox.id,
user_id=user.id,
tenant_id=tenant_id,
llm_config=llm_config,
)
update_sandbox_status__no_commit(
db_session, sandbox.id, SandboxStatus.RUNNING
)
db_session.commit()
db_session.refresh(sandbox)
# 2. Check if session workspace needs to be loaded
if sandbox.status == SandboxStatus.RUNNING:
if not sandbox_manager.session_workspace_exists(sandbox.id, session_id):
# Get latest snapshot and restore it
snapshot = get_latest_snapshot_for_session(db_session, session_id)
if snapshot:
# Allocate a new port for the restored session
new_port = allocate_nextjs_port(db_session)
session.nextjs_port = new_port
db_session.commit()
logger.info(
f"Restoring snapshot for session {session_id} "
f"from {snapshot.storage_path} with port {new_port}"
)
try:
sandbox_manager.restore_snapshot(
sandbox_id=sandbox.id,
session_id=session_id,
snapshot_storage_path=snapshot.storage_path,
tenant_id=tenant_id,
nextjs_port=new_port,
)
except Exception as e:
# Clear the port allocation on failure so it can be reused
logger.error(
f"Failed to restore session {session_id}, "
f"clearing port {new_port}: {e}"
)
session.nextjs_port = None
db_session.commit()
raise
else:
# No snapshot - set up fresh workspace
logger.info(
f"No snapshot found for session {session_id}, "
f"setting up fresh workspace"
)
llm_config = session_manager._get_llm_config(None, None)
sandbox_manager.setup_session_workspace(
sandbox_id=sandbox.id,
session_id=session_id,
llm_config=llm_config,
nextjs_port=session.nextjs_port or 3010,
)
except Exception as e:
logger.error(f"Failed to restore session {session_id}: {e}", exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Failed to restore session: {e}",
)
finally:
if lock.owned():
lock.release()
base_response = SessionResponse.from_model(session, sandbox)
return DetailedSessionResponse.from_session_response(
base_response, session_loaded_in_sandbox=True
)
# =============================================================================
# Artifact Endpoints
# =============================================================================
@router.get(
"/{session_id}/artifacts",
response_model=list[ArtifactResponse],
)
def list_artifacts(
session_id: UUID,
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> list[dict]:
"""List artifacts generated in the session."""
user_id: UUID = user.id
session_manager = SessionManager(db_session)
artifacts = session_manager.list_artifacts(session_id, user_id)
if artifacts is None:
raise HTTPException(status_code=404, detail="Session not found")
return artifacts
@router.get("/{session_id}/files", response_model=DirectoryListing)
def list_directory(
session_id: UUID,
path: str = "",
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> DirectoryListing:
"""
List files and directories in the sandbox.
Args:
session_id: The session ID
path: Relative path from sandbox root (empty string for root)
Returns:
DirectoryListing with sorted entries (directories first, then files)
"""
user_id: UUID = user.id
session_manager = SessionManager(db_session)
try:
listing = session_manager.list_directory(session_id, user_id, path)
except ValueError as e:
error_message = str(e)
if "path traversal" in error_message.lower():
raise HTTPException(status_code=403, detail="Access denied")
elif "not found" in error_message.lower():
raise HTTPException(status_code=404, detail="Directory not found")
elif "not a directory" in error_message.lower():
raise HTTPException(status_code=400, detail="Path is not a directory")
raise HTTPException(status_code=400, detail=error_message)
if listing is None:
raise HTTPException(status_code=404, detail="Session not found")
return listing
@router.get("/{session_id}/artifacts/{path:path}")
def download_artifact(
session_id: UUID,
path: str,
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> Response:
"""Download a specific artifact file."""
user_id: UUID = user.id
session_manager = SessionManager(db_session)
try:
result = session_manager.download_artifact(session_id, user_id, path)
except ValueError as e:
error_message = str(e)
if (
"path traversal" in error_message.lower()
or "access denied" in error_message.lower()
):
raise HTTPException(status_code=403, detail="Access denied")
elif "directory" in error_message.lower():
raise HTTPException(status_code=400, detail="Cannot download directory")
raise HTTPException(status_code=400, detail=error_message)
if result is None:
raise HTTPException(status_code=404, detail="Artifact not found")
content, mime_type, filename = result
# Handle Unicode filenames in Content-Disposition header
# HTTP headers require Latin-1 encoding, so we use RFC 5987 for Unicode
try:
# Try Latin-1 encoding first (ASCII-compatible filenames)
filename.encode("latin-1")
content_disposition = f'attachment; filename="{filename}"'
except UnicodeEncodeError:
# Use RFC 5987 encoding for Unicode filenames
from urllib.parse import quote
encoded_filename = quote(filename, safe="")
content_disposition = f"attachment; filename*=UTF-8''{encoded_filename}"
return Response(
content=content,
media_type=mime_type,
headers={
"Content-Disposition": content_disposition,
},
)
@router.get("/{session_id}/webapp-info", response_model=WebappInfo)
def get_webapp_info(
session_id: UUID,
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> WebappInfo:
"""
Get webapp information for a session.
Returns whether a webapp exists, its URL, and the sandbox status.
"""
user_id: UUID = user.id
session_manager = SessionManager(db_session)
webapp_info = session_manager.get_webapp_info(session_id, user_id)
if webapp_info is None:
raise HTTPException(status_code=404, detail="Session not found")
return WebappInfo(**webapp_info)
@router.get("/{session_id}/webapp/download")
def download_webapp(
session_id: UUID,
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> Response:
"""
Download the webapp directory as a zip file.
Returns the entire outputs/web directory as a zip archive.
"""
user_id: UUID = user.id
session_manager = SessionManager(db_session)
result = session_manager.download_webapp_zip(session_id, user_id)
if result is None:
raise HTTPException(status_code=404, detail="Webapp not found")
zip_bytes, filename = result
return Response(
content=zip_bytes,
media_type="application/zip",
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
},
)
@router.post("/{session_id}/upload", response_model=UploadResponse)
async def upload_file_endpoint(
session_id: UUID,
file: UploadFile = File(...),
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> UploadResponse:
"""Upload a file to the session's sandbox.
The file will be placed in the sandbox's attachments directory.
"""
user_id: UUID = user.id
session_manager = SessionManager(db_session)
if not file.filename:
raise HTTPException(status_code=400, detail="File has no filename")
# Read file content
content = await file.read()
# Validate file (extension, mime type, size)
is_valid, error = validate_file(file.filename, file.content_type, len(content))
if not is_valid:
raise HTTPException(status_code=400, detail=error)
# Sanitize filename
safe_filename = sanitize_filename(file.filename)
try:
relative_path, _ = session_manager.upload_file(
session_id=session_id,
user_id=user_id,
filename=safe_filename,
content=content,
)
except UploadLimitExceededError as e:
# Return 429 for limit exceeded errors
raise HTTPException(status_code=429, detail=str(e))
except ValueError as e:
error_message = str(e)
if "not found" in error_message.lower():
raise HTTPException(status_code=404, detail=error_message)
raise HTTPException(status_code=400, detail=error_message)
return UploadResponse(
filename=safe_filename,
path=relative_path,
size_bytes=len(content),
)
@router.delete("/{session_id}/files/{path:path}", response_model=None)
def delete_file_endpoint(
session_id: UUID,
path: str,
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> Response:
"""Delete a file from the session's sandbox.
Args:
session_id: The session ID
path: Relative path to the file (e.g., "attachments/doc.pdf")
"""
user_id: UUID = user.id
session_manager = SessionManager(db_session)
try:
deleted = session_manager.delete_file(session_id, user_id, path)
except ValueError as e:
error_message = str(e)
if "path traversal" in error_message.lower():
raise HTTPException(status_code=403, detail="Access denied")
elif "not found" in error_message.lower():
raise HTTPException(status_code=404, detail=error_message)
elif "directory" in error_message.lower():
raise HTTPException(status_code=400, detail="Cannot delete directory")
raise HTTPException(status_code=400, detail=error_message)
if not deleted:
raise HTTPException(status_code=404, detail="File not found")
return Response(status_code=204)

View File

@@ -0,0 +1,52 @@
"""Subscription detection for Build Mode rate limiting."""
from sqlalchemy.orm import Session
from onyx.configs.app_configs import DEV_MODE
from onyx.db.models import User
from onyx.server.usage_limits import is_tenant_on_trial_fn
from onyx.utils.logger import setup_logger
from shared_configs.configs import MULTI_TENANT
from shared_configs.contextvars import get_current_tenant_id
logger = setup_logger()
def is_user_subscribed(user: User, db_session: Session) -> bool:
"""
Check if a user has an active subscription.
For cloud (MULTI_TENANT=true):
- Checks Stripe billing via control plane
- Returns True if tenant is NOT on trial (subscribed = NOT on trial)
For self-hosted (MULTI_TENANT=false):
- Checks license metadata
- Returns True if license status is ACTIVE
Args:
user: The user object (None for unauthenticated users)
db_session: Database session
Returns:
True if user has active subscription, False otherwise
"""
if DEV_MODE:
return True
if user is None:
return False
if MULTI_TENANT:
# Cloud: check Stripe billing via control plane
tenant_id = get_current_tenant_id()
try:
on_trial = is_tenant_on_trial_fn(tenant_id)
# Subscribed = NOT on trial
return not on_trial
except Exception as e:
logger.warning(f"Subscription check failed for tenant {tenant_id}: {e}")
# Default to non-subscribed (safer/more restrictive)
return False
return True

View File

@@ -0,0 +1,117 @@
import os
from enum import Enum
from pathlib import Path
class SandboxBackend(str, Enum):
"""Backend mode for sandbox operations.
LOCAL: Development mode - no snapshots, no automatic cleanup
KUBERNETES: Production mode - full snapshots and cleanup
"""
LOCAL = "local"
KUBERNETES = "kubernetes"
# Sandbox backend mode (controls snapshot and cleanup behavior)
# "local" = no snapshots, no cleanup (for development)
# "kubernetes" = full snapshots and cleanup (for production)
SANDBOX_BACKEND = SandboxBackend(os.environ.get("SANDBOX_BACKEND", "local"))
# Persistent Document Storage Configuration
# When enabled, indexed documents are written to local filesystem with hierarchical structure
PERSISTENT_DOCUMENT_STORAGE_ENABLED = (
os.environ.get("PERSISTENT_DOCUMENT_STORAGE_ENABLED", "").lower() == "true"
)
# Base directory path for persistent document storage (local filesystem)
# Example: /var/onyx/indexed-docs or /app/indexed-docs
PERSISTENT_DOCUMENT_STORAGE_PATH = os.environ.get(
"PERSISTENT_DOCUMENT_STORAGE_PATH", ""
)
# Demo Data Path
# Local: Source tree path (relative to this file)
# Kubernetes: Baked into container image at /workspace/demo-data
_THIS_FILE = Path(__file__)
DEMO_DATA_PATH = str(
_THIS_FILE.parent / "sandbox" / "kubernetes" / "docker" / "demo_data"
)
# Sandbox filesystem paths
SANDBOX_BASE_PATH = os.environ.get("SANDBOX_BASE_PATH", "/tmp/onyx-sandboxes")
OUTPUTS_TEMPLATE_PATH = os.environ.get("OUTPUTS_TEMPLATE_PATH", "/templates/outputs")
VENV_TEMPLATE_PATH = os.environ.get("VENV_TEMPLATE_PATH", "/templates/venv")
# Sandbox agent configuration
SANDBOX_AGENT_COMMAND = os.environ.get("SANDBOX_AGENT_COMMAND", "opencode").split()
# OpenCode disabled tools (comma-separated list)
# Available tools: bash, edit, write, read, grep, glob, list, lsp, patch,
# skill, todowrite, todoread, webfetch, question
# Example: "question,webfetch" to disable user questions and web fetching
_disabled_tools_str = os.environ.get("OPENCODE_DISABLED_TOOLS", "question")
OPENCODE_DISABLED_TOOLS: list[str] = [
t.strip() for t in _disabled_tools_str.split(",") if t.strip()
]
# Sandbox lifecycle configuration
SANDBOX_IDLE_TIMEOUT_SECONDS = int(
os.environ.get("SANDBOX_IDLE_TIMEOUT_SECONDS", "3600")
)
SANDBOX_MAX_CONCURRENT_PER_ORG = int(
os.environ.get("SANDBOX_MAX_CONCURRENT_PER_ORG", "10")
)
# Sandbox snapshot storage
SANDBOX_SNAPSHOTS_BUCKET = os.environ.get(
"SANDBOX_SNAPSHOTS_BUCKET", "sandbox-snapshots"
)
# Next.js preview server port range
SANDBOX_NEXTJS_PORT_START = int(os.environ.get("SANDBOX_NEXTJS_PORT_START", "3010"))
SANDBOX_NEXTJS_PORT_END = int(os.environ.get("SANDBOX_NEXTJS_PORT_END", "3100"))
# File upload configuration
MAX_UPLOAD_FILE_SIZE_MB = int(os.environ.get("BUILD_MAX_UPLOAD_FILE_SIZE_MB", "50"))
MAX_UPLOAD_FILE_SIZE_BYTES = MAX_UPLOAD_FILE_SIZE_MB * 1024 * 1024
MAX_UPLOAD_FILES_PER_SESSION = int(
os.environ.get("BUILD_MAX_UPLOAD_FILES_PER_SESSION", "20")
)
MAX_TOTAL_UPLOAD_SIZE_MB = int(os.environ.get("BUILD_MAX_TOTAL_UPLOAD_SIZE_MB", "200"))
MAX_TOTAL_UPLOAD_SIZE_BYTES = MAX_TOTAL_UPLOAD_SIZE_MB * 1024 * 1024
ATTACHMENTS_DIRECTORY = "attachments"
# ============================================================================
# Kubernetes Sandbox Configuration
# Only used when SANDBOX_BACKEND = "kubernetes"
# ============================================================================
# Namespace where sandbox pods are created
SANDBOX_NAMESPACE = os.environ.get("SANDBOX_NAMESPACE", "onyx-sandboxes")
# Container image for sandbox pods
# Should include Next.js template and opencode CLI
SANDBOX_CONTAINER_IMAGE = os.environ.get(
"SANDBOX_CONTAINER_IMAGE", "onyxdotapp/sandbox:latest"
)
# S3 bucket for sandbox file storage (snapshots, knowledge files, uploads)
# Path structure: s3://{bucket}/{tenant_id}/snapshots/{session_id}/{snapshot_id}.tar.gz
# s3://{bucket}/{tenant_id}/knowledge/{user_id}/
# s3://{bucket}/{tenant_id}/uploads/{session_id}/
SANDBOX_S3_BUCKET = os.environ.get("SANDBOX_S3_BUCKET", "onyx-sandbox-files")
# Service account for sandbox pods (NO IRSA - no AWS API access)
SANDBOX_SERVICE_ACCOUNT_NAME = os.environ.get(
"SANDBOX_SERVICE_ACCOUNT_NAME", "sandbox-runner"
)
# Service account for init container (has IRSA for S3 access)
SANDBOX_FILE_SYNC_SERVICE_ACCOUNT = os.environ.get(
"SANDBOX_FILE_SYNC_SERVICE_ACCOUNT", "sandbox-file-sync"
)
ENABLE_CRAFT = os.environ.get("ENABLE_CRAFT", "false").lower() == "true"

View File

@@ -0,0 +1 @@
# Database operations for the build feature

View File

@@ -0,0 +1,544 @@
"""Database operations for Build Mode sessions."""
from datetime import datetime
from datetime import timedelta
from typing import Any
from uuid import UUID
from sqlalchemy import desc
from sqlalchemy import exists
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from sqlalchemy.orm import Session
from onyx.configs.constants import MessageType
from onyx.db.enums import BuildSessionStatus
from onyx.db.enums import SandboxStatus
from onyx.db.models import Artifact
from onyx.db.models import BuildMessage
from onyx.db.models import BuildSession
from onyx.db.models import LLMProvider as LLMProviderModel
from onyx.db.models import Sandbox
from onyx.db.models import Snapshot
from onyx.server.features.build.configs import SANDBOX_NEXTJS_PORT_END
from onyx.server.features.build.configs import SANDBOX_NEXTJS_PORT_START
from onyx.server.manage.llm.models import LLMProviderView
from onyx.utils.logger import setup_logger
logger = setup_logger()
def create_build_session__no_commit(
user_id: UUID,
db_session: Session,
name: str | None = None,
) -> BuildSession:
"""Create a new build session for the given user.
NOTE: This function uses flush() instead of commit(). The caller is
responsible for committing the transaction when ready.
"""
session = BuildSession(
user_id=user_id,
name=name,
status=BuildSessionStatus.ACTIVE,
)
db_session.add(session)
db_session.flush()
logger.info(f"Created build session {session.id} for user {user_id}")
return session
def get_build_session(
session_id: UUID,
user_id: UUID,
db_session: Session,
) -> BuildSession | None:
"""Get a build session by ID, ensuring it belongs to the user."""
return (
db_session.query(BuildSession)
.filter(
BuildSession.id == session_id,
BuildSession.user_id == user_id,
)
.one_or_none()
)
def get_user_build_sessions(
user_id: UUID,
db_session: Session,
limit: int = 100,
) -> list[BuildSession]:
"""Get all build sessions for a user that have at least 1 message.
Excludes empty (pre-provisioned) sessions from the listing.
"""
return (
db_session.query(BuildSession)
.join(BuildMessage) # Inner join excludes empty sessions
.filter(BuildSession.user_id == user_id)
.group_by(BuildSession.id)
.order_by(desc(BuildSession.created_at))
.limit(limit)
.all()
)
def get_empty_session_for_user(
user_id: UUID,
db_session: Session,
max_age_minutes: int = 30,
) -> BuildSession | None:
"""Get the user's empty session (0 messages) if one exists and is recent."""
cutoff = datetime.utcnow() - timedelta(minutes=max_age_minutes)
return (
db_session.query(BuildSession)
.filter(
BuildSession.user_id == user_id,
BuildSession.created_at > cutoff,
~exists().where(BuildMessage.session_id == BuildSession.id),
)
.first()
)
def update_session_activity(
session_id: UUID,
db_session: Session,
) -> None:
"""Update the last activity timestamp for a session."""
session = (
db_session.query(BuildSession)
.filter(BuildSession.id == session_id)
.one_or_none()
)
if session:
session.last_activity_at = datetime.utcnow()
db_session.commit()
def update_session_status(
session_id: UUID,
status: BuildSessionStatus,
db_session: Session,
) -> None:
"""Update the status of a build session."""
session = (
db_session.query(BuildSession)
.filter(BuildSession.id == session_id)
.one_or_none()
)
if session:
session.status = status
db_session.commit()
logger.info(f"Updated build session {session_id} status to {status}")
def delete_build_session__no_commit(
session_id: UUID,
user_id: UUID,
db_session: Session,
) -> bool:
"""Delete a build session and all related data.
NOTE: This function uses flush() instead of commit(). The caller is
responsible for committing the transaction when ready.
"""
session = get_build_session(session_id, user_id, db_session)
if not session:
return False
db_session.delete(session)
db_session.flush()
logger.info(f"Deleted build session {session_id}")
return True
# Sandbox operations
# NOTE: Most sandbox operations have moved to sandbox.py
# These remain here for convenience in session-related workflows
def update_sandbox_status(
sandbox_id: UUID,
status: SandboxStatus,
db_session: Session,
container_id: str | None = None,
) -> None:
"""Update the status of a sandbox."""
sandbox = db_session.query(Sandbox).filter(Sandbox.id == sandbox_id).one_or_none()
if sandbox:
sandbox.status = status
if container_id is not None:
sandbox.container_id = container_id
sandbox.last_heartbeat = datetime.utcnow()
db_session.commit()
logger.info(f"Updated sandbox {sandbox_id} status to {status}")
def update_sandbox_heartbeat(
sandbox_id: UUID,
db_session: Session,
) -> None:
"""Update the heartbeat timestamp for a sandbox."""
sandbox = db_session.query(Sandbox).filter(Sandbox.id == sandbox_id).one_or_none()
if sandbox:
sandbox.last_heartbeat = datetime.utcnow()
db_session.commit()
# Artifact operations
def create_artifact(
session_id: UUID,
artifact_type: str,
path: str,
name: str,
db_session: Session,
) -> Artifact:
"""Create a new artifact record."""
artifact = Artifact(
session_id=session_id,
type=artifact_type,
path=path,
name=name,
)
db_session.add(artifact)
db_session.commit()
db_session.refresh(artifact)
logger.info(f"Created artifact {artifact.id} for session {session_id}")
return artifact
def get_session_artifacts(
session_id: UUID,
db_session: Session,
) -> list[Artifact]:
"""Get all artifacts for a session."""
return (
db_session.query(Artifact)
.filter(Artifact.session_id == session_id)
.order_by(desc(Artifact.created_at))
.all()
)
def update_artifact(
artifact_id: UUID,
db_session: Session,
path: str | None = None,
name: str | None = None,
) -> None:
"""Update artifact metadata."""
artifact = (
db_session.query(Artifact).filter(Artifact.id == artifact_id).one_or_none()
)
if artifact:
if path is not None:
artifact.path = path
if name is not None:
artifact.name = name
artifact.updated_at = datetime.utcnow()
db_session.commit()
logger.info(f"Updated artifact {artifact_id}")
# Snapshot operations
def create_snapshot(
session_id: UUID,
storage_path: str,
size_bytes: int,
db_session: Session,
) -> Snapshot:
"""Create a new snapshot record."""
snapshot = Snapshot(
session_id=session_id,
storage_path=storage_path,
size_bytes=size_bytes,
)
db_session.add(snapshot)
db_session.commit()
db_session.refresh(snapshot)
logger.info(f"Created snapshot {snapshot.id} for session {session_id}")
return snapshot
# Message operations
def create_message(
session_id: UUID,
message_type: MessageType,
turn_index: int,
message_metadata: dict[str, Any],
db_session: Session,
) -> BuildMessage:
"""Create a new message in a build session.
All message data is stored in message_metadata as JSON.
Args:
session_id: Session UUID
message_type: Type of message (USER, ASSISTANT, SYSTEM)
turn_index: 0-indexed user message number this message belongs to
message_metadata: Required structured data (the raw ACP packet JSON)
db_session: Database session
"""
message = BuildMessage(
session_id=session_id,
turn_index=turn_index,
type=message_type,
message_metadata=message_metadata,
)
db_session.add(message)
db_session.commit()
db_session.refresh(message)
logger.info(
f"Created {message_type.value} message {message.id} for session {session_id} "
f"turn={turn_index} type={message_metadata.get('type')}"
)
return message
def update_message(
message_id: UUID,
message_metadata: dict[str, Any],
db_session: Session,
) -> BuildMessage | None:
"""Update an existing message's metadata.
Used for upserting agent_plan_update messages.
Args:
message_id: The message UUID to update
message_metadata: New metadata to set
db_session: Database session
Returns:
Updated BuildMessage or None if not found
"""
message = (
db_session.query(BuildMessage).filter(BuildMessage.id == message_id).first()
)
if message is None:
return None
message.message_metadata = message_metadata
db_session.commit()
db_session.refresh(message)
logger.info(
f"Updated message {message_id} metadata type={message_metadata.get('type')}"
)
return message
def upsert_agent_plan(
session_id: UUID,
turn_index: int,
plan_metadata: dict[str, Any],
db_session: Session,
existing_plan_id: UUID | None = None,
) -> BuildMessage:
"""Upsert an agent plan - update if exists, create if not.
Each session/turn should only have one agent_plan_update message.
This function updates the existing plan message or creates a new one.
Args:
session_id: Session UUID
turn_index: Current turn index
plan_metadata: The agent_plan_update packet data
db_session: Database session
existing_plan_id: ID of existing plan message to update (if known)
Returns:
The created or updated BuildMessage
"""
if existing_plan_id:
# Fast path: we know the plan ID
updated = update_message(existing_plan_id, plan_metadata, db_session)
if updated:
return updated
# Check if a plan already exists for this session/turn
existing_plan = (
db_session.query(BuildMessage)
.filter(
BuildMessage.session_id == session_id,
BuildMessage.turn_index == turn_index,
BuildMessage.message_metadata["type"].astext == "agent_plan_update",
)
.first()
)
if existing_plan:
existing_plan.message_metadata = plan_metadata
db_session.commit()
db_session.refresh(existing_plan)
logger.info(
f"Updated agent_plan_update message {existing_plan.id} for session {session_id}"
)
return existing_plan
# Create new plan message
return create_message(
session_id=session_id,
message_type=MessageType.ASSISTANT,
turn_index=turn_index,
message_metadata=plan_metadata,
db_session=db_session,
)
def get_session_messages(
session_id: UUID,
db_session: Session,
) -> list[BuildMessage]:
"""Get all messages for a session, ordered by turn index and creation time."""
return (
db_session.query(BuildMessage)
.filter(BuildMessage.session_id == session_id)
.order_by(BuildMessage.turn_index, BuildMessage.created_at)
.all()
)
def _is_port_available(port: int) -> bool:
"""Check if a port is available by attempting to bind to it.
Checks both IPv4 and IPv6 wildcard addresses to properly detect
if anything is listening on the port, regardless of address family.
"""
import socket
logger.debug(f"Checking if port {port} is available")
# Check IPv4 wildcard (0.0.0.0) - this will detect any IPv4 listener
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("0.0.0.0", port))
logger.debug(f"Port {port} IPv4 wildcard bind successful")
except OSError as e:
logger.debug(f"Port {port} IPv4 wildcard not available: {e}")
return False
# Check IPv6 wildcard (::) - this will detect any IPv6 listener
try:
with socket.socket(socket.AF_INET6, socket.SOCK_STREAM) as sock:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# IPV6_V6ONLY must be False to allow dual-stack behavior
sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
sock.bind(("::", port))
logger.debug(f"Port {port} IPv6 wildcard bind successful")
except OSError as e:
logger.debug(f"Port {port} IPv6 wildcard not available: {e}")
return False
logger.debug(f"Port {port} is available")
return True
def allocate_nextjs_port(db_session: Session) -> int:
"""Allocate an available port for a new session.
Finds the first available port in the configured range by checking
both database allocations and system-level port availability.
Args:
db_session: Database session for querying allocated ports
Returns:
An available port number
Raises:
RuntimeError: If no ports are available in the configured range
"""
from onyx.db.models import BuildSession
# Get all currently allocated ports from active sessions
allocated_ports = set(
db_session.query(BuildSession.nextjs_port)
.filter(BuildSession.nextjs_port.isnot(None))
.all()
)
allocated_ports = {port[0] for port in allocated_ports if port[0] is not None}
# Find first port that's not in DB and not currently bound
for port in range(SANDBOX_NEXTJS_PORT_START, SANDBOX_NEXTJS_PORT_END):
if port not in allocated_ports and _is_port_available(port):
return port
raise RuntimeError(
f"No available ports in range [{SANDBOX_NEXTJS_PORT_START}, {SANDBOX_NEXTJS_PORT_END})"
)
def clear_nextjs_ports_for_user(db_session: Session, user_id: UUID) -> int:
"""Clear nextjs_port for all sessions belonging to a user.
Called when sandbox goes to sleep to release port allocations.
Args:
db_session: Database session
user_id: The user whose sessions should have ports cleared
Returns:
Number of sessions updated
"""
result = (
db_session.query(BuildSession)
.filter(
BuildSession.user_id == user_id,
BuildSession.nextjs_port.isnot(None),
)
.update({BuildSession.nextjs_port: None})
)
db_session.flush()
logger.info(f"Cleared {result} nextjs_port allocations for user {user_id}")
return result
def fetch_llm_provider_by_type_for_build_mode(
db_session: Session, provider_type: str
) -> LLMProviderView | None:
"""Fetch an LLM provider by its provider type (e.g., "anthropic", "openai").
Resolution priority:
1. First try to find a provider named "build-mode-{type}" (e.g., "build-mode-anthropic")
2. If not found, fall back to any provider that matches the type
Args:
db_session: Database session
provider_type: The provider type (e.g., "anthropic", "openai", "openrouter")
Returns:
LLMProviderView if found, None otherwise
"""
from onyx.db.llm import fetch_existing_llm_provider
# First try to find a "build-mode-{type}" provider
build_mode_name = f"build-mode-{provider_type}"
provider_model = fetch_existing_llm_provider(
name=build_mode_name, db_session=db_session
)
# If not found, fall back to any provider that matches the type
if not provider_model:
provider_model = db_session.scalar(
select(LLMProviderModel)
.where(LLMProviderModel.provider == provider_type)
.options(
selectinload(LLMProviderModel.model_configurations),
selectinload(LLMProviderModel.groups),
selectinload(LLMProviderModel.personas),
)
)
if not provider_model:
return None
return LLMProviderView.from_model(provider_model)

View File

@@ -0,0 +1,96 @@
"""Database queries for Build Mode rate limiting."""
from datetime import datetime
from uuid import UUID
from sqlalchemy import func
from sqlalchemy.orm import Session
from onyx.configs.constants import MessageType
from onyx.db.models import BuildMessage
from onyx.db.models import BuildSession
def count_user_messages_in_window(
user_id: UUID,
cutoff_time: datetime,
db_session: Session,
) -> int:
"""
Count USER messages for a user since cutoff_time.
Args:
user_id: The user's UUID
cutoff_time: Only count messages created at or after this time
db_session: Database session
Returns:
Number of USER messages in the time window
"""
return (
db_session.query(func.count(BuildMessage.id))
.join(BuildSession, BuildMessage.session_id == BuildSession.id)
.filter(
BuildSession.user_id == user_id,
BuildMessage.type == MessageType.USER,
BuildMessage.created_at >= cutoff_time,
)
.scalar()
or 0
)
def count_user_messages_total(user_id: UUID, db_session: Session) -> int:
"""
Count all USER messages for a user (lifetime total).
Args:
user_id: The user's UUID
db_session: Database session
Returns:
Total number of USER messages
"""
return (
db_session.query(func.count(BuildMessage.id))
.join(BuildSession, BuildMessage.session_id == BuildSession.id)
.filter(
BuildSession.user_id == user_id,
BuildMessage.type == MessageType.USER,
)
.scalar()
or 0
)
def get_oldest_message_timestamp(
user_id: UUID,
cutoff_time: datetime,
db_session: Session,
) -> datetime | None:
"""
Get the timestamp of the oldest USER message in the time window.
Used to calculate when the rate limit will reset (when the oldest
message ages out of the rolling window).
Args:
user_id: The user's UUID
cutoff_time: Only consider messages created at or after this time
db_session: Database session
Returns:
Timestamp of oldest message in window, or None if no messages
"""
return (
db_session.query(BuildMessage.created_at)
.join(BuildSession, BuildMessage.session_id == BuildSession.id)
.filter(
BuildSession.user_id == user_id,
BuildMessage.type == MessageType.USER,
BuildMessage.created_at >= cutoff_time,
)
.order_by(BuildMessage.created_at.asc())
.limit(1)
.scalar()
)

View File

@@ -0,0 +1,206 @@
"""Database operations for CLI agent sandbox management."""
import datetime
from uuid import UUID
from sqlalchemy import func
from sqlalchemy import select
from sqlalchemy.orm import Session
from onyx.db.enums import SandboxStatus
from onyx.db.models import Sandbox
from onyx.db.models import Snapshot
from onyx.utils.logger import setup_logger
logger = setup_logger()
def create_sandbox__no_commit(
db_session: Session,
user_id: UUID,
) -> Sandbox:
"""Create a new sandbox record for a user.
NOTE: This function uses flush() instead of commit(). The caller is
responsible for committing the transaction when ready.
"""
sandbox = Sandbox(
user_id=user_id,
status=SandboxStatus.PROVISIONING,
)
db_session.add(sandbox)
db_session.flush()
return sandbox
def get_sandbox_by_user_id(db_session: Session, user_id: UUID) -> Sandbox | None:
"""Get sandbox by user ID (primary lookup method)."""
stmt = select(Sandbox).where(Sandbox.user_id == user_id)
return db_session.execute(stmt).scalar_one_or_none()
def get_sandbox_by_session_id(db_session: Session, session_id: UUID) -> Sandbox | None:
"""Get sandbox by session ID (compatibility function).
This function provides backwards compatibility during the transition to
user-owned sandboxes. It looks up the session's user_id, then finds the
user's sandbox.
NOTE: This will be removed in a future phase when all callers are updated
to use get_sandbox_by_user_id() directly.
"""
from onyx.db.models import BuildSession
stmt = select(BuildSession.user_id).where(BuildSession.id == session_id)
result = db_session.execute(stmt).scalar_one_or_none()
if result is None:
return None
return get_sandbox_by_user_id(db_session, result)
def get_sandbox_by_id(db_session: Session, sandbox_id: UUID) -> Sandbox | None:
"""Get sandbox by its ID."""
stmt = select(Sandbox).where(Sandbox.id == sandbox_id)
return db_session.execute(stmt).scalar_one_or_none()
def update_sandbox_status__no_commit(
db_session: Session,
sandbox_id: UUID,
status: SandboxStatus,
) -> Sandbox:
"""Update sandbox status.
NOTE: This function uses flush() instead of commit(). The caller is
responsible for committing the transaction when ready.
"""
sandbox = get_sandbox_by_id(db_session, sandbox_id)
if not sandbox:
raise ValueError(f"Sandbox {sandbox_id} not found")
sandbox.status = status
db_session.flush()
return sandbox
def update_sandbox_heartbeat(db_session: Session, sandbox_id: UUID) -> Sandbox:
"""Update sandbox last_heartbeat to now."""
sandbox = get_sandbox_by_id(db_session, sandbox_id)
if not sandbox:
raise ValueError(f"Sandbox {sandbox_id} not found")
sandbox.last_heartbeat = datetime.datetime.now(datetime.timezone.utc)
db_session.commit()
return sandbox
def get_idle_sandboxes(
db_session: Session, idle_threshold_seconds: int
) -> list[Sandbox]:
"""Get sandboxes that have been idle longer than threshold."""
threshold_time = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(
seconds=idle_threshold_seconds
)
stmt = select(Sandbox).where(
Sandbox.status.in_([SandboxStatus.RUNNING, SandboxStatus.IDLE]),
Sandbox.last_heartbeat < threshold_time,
)
return list(db_session.execute(stmt).scalars().all())
def get_running_sandbox_count_by_tenant(db_session: Session, tenant_id: str) -> int:
"""Get count of running sandboxes for a tenant (for limit enforcement).
Note: tenant_id parameter is kept for API compatibility but is not used
since Sandbox model no longer has tenant_id. This function returns
the count of all running sandboxes.
"""
stmt = select(func.count(Sandbox.id)).where(
Sandbox.status.in_([SandboxStatus.RUNNING, SandboxStatus.IDLE])
)
result = db_session.execute(stmt).scalar()
return result or 0
def create_snapshot(
db_session: Session,
session_id: UUID,
storage_path: str,
size_bytes: int,
) -> Snapshot:
"""Create a snapshot record for a session."""
snapshot = Snapshot(
session_id=session_id,
storage_path=storage_path,
size_bytes=size_bytes,
)
db_session.add(snapshot)
db_session.commit()
return snapshot
def get_latest_snapshot_for_session(
db_session: Session, session_id: UUID
) -> Snapshot | None:
"""Get most recent snapshot for a session."""
stmt = (
select(Snapshot)
.where(Snapshot.session_id == session_id)
.order_by(Snapshot.created_at.desc())
.limit(1)
)
return db_session.execute(stmt).scalar_one_or_none()
def get_snapshots_for_session(db_session: Session, session_id: UUID) -> list[Snapshot]:
"""Get all snapshots for a session, ordered by creation time descending."""
stmt = (
select(Snapshot)
.where(Snapshot.session_id == session_id)
.order_by(Snapshot.created_at.desc())
)
return list(db_session.execute(stmt).scalars().all())
def delete_old_snapshots(
db_session: Session, tenant_id: str, retention_days: int
) -> int:
"""Delete snapshots older than retention period, return count deleted.
Note: tenant_id parameter is kept for API compatibility but is not used
since Snapshot model no longer has tenant_id. This function deletes
all snapshots older than the retention period.
"""
cutoff_time = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(
days=retention_days
)
stmt = select(Snapshot).where(
Snapshot.created_at < cutoff_time,
)
old_snapshots = db_session.execute(stmt).scalars().all()
count = 0
for snapshot in old_snapshots:
db_session.delete(snapshot)
count += 1
if count > 0:
db_session.commit()
return count
def delete_snapshot(db_session: Session, snapshot_id: UUID) -> bool:
"""Delete a specific snapshot by ID. Returns True if deleted, False if not found."""
stmt = select(Snapshot).where(Snapshot.id == snapshot_id)
snapshot = db_session.execute(stmt).scalar_one_or_none()
if not snapshot:
return False
db_session.delete(snapshot)
db_session.commit()
return True

View File

@@ -0,0 +1,400 @@
"""
Persistent Document Writer for writing indexed documents to local filesystem or S3 with
hierarchical directory structure that mirrors the source organization.
Local mode (SandboxBackend.LOCAL):
Writes to local filesystem at {PERSISTENT_DOCUMENT_STORAGE_PATH}/{tenant_id}/knowledge/{user_id}/...
Kubernetes mode (SandboxBackend.KUBERNETES):
Writes to S3 at s3://{SANDBOX_S3_BUCKET}/{tenant_id}/knowledge/{user_id}/...
This is the same location that kubernetes_sandbox_manager.py reads from when
provisioning sandboxes.
Both modes use consistent tenant/user-segregated paths for multi-tenant isolation.
"""
import hashlib
import json
from pathlib import Path
from typing import Any
import boto3
from botocore.exceptions import ClientError
from mypy_boto3_s3.client import S3Client
from onyx.connectors.models import Document
from onyx.server.features.build.configs import PERSISTENT_DOCUMENT_STORAGE_PATH
from onyx.server.features.build.configs import SANDBOX_BACKEND
from onyx.server.features.build.configs import SANDBOX_S3_BUCKET
from onyx.server.features.build.configs import SandboxBackend
from onyx.utils.logger import setup_logger
logger = setup_logger()
# =============================================================================
# Shared Utilities for Path Building
# =============================================================================
def sanitize_path_component(component: str, replace_slash: bool = True) -> str:
"""Sanitize a path component for file system / S3 key safety.
Args:
component: The path component to sanitize
replace_slash: If True, replaces forward slashes (needed for local filesystem).
Set to False for S3 where `/` is a valid delimiter.
Returns:
Sanitized path component safe for use in file paths or S3 keys
"""
# Replace spaces with underscores
sanitized = component.replace(" ", "_")
# Replace problematic characters
if replace_slash:
sanitized = sanitized.replace("/", "_")
sanitized = sanitized.replace("\\", "_").replace(":", "_")
sanitized = sanitized.replace("<", "_").replace(">", "_").replace("|", "_")
sanitized = sanitized.replace('"', "_").replace("?", "_").replace("*", "_")
# Also handle null bytes and other control characters
sanitized = "".join(c for c in sanitized if ord(c) >= 32)
return sanitized.strip() or "unnamed"
def sanitize_filename(name: str, replace_slash: bool = True) -> str:
"""Sanitize name for use as filename.
Args:
name: The filename to sanitize
replace_slash: Passed through to sanitize_path_component
Returns:
Sanitized filename, truncated with hash suffix if too long
"""
sanitized = sanitize_path_component(name, replace_slash=replace_slash)
if len(sanitized) > 200:
# Keep first 150 chars + hash suffix for uniqueness
hash_suffix = hashlib.sha256(name.encode()).hexdigest()[:16]
return f"{sanitized[:150]}_{hash_suffix}"
return sanitized
def get_base_filename(doc: Document, replace_slash: bool = True) -> str:
"""Get base filename from document, preferring semantic identifier.
Args:
doc: The document to get filename for
replace_slash: Passed through to sanitize_filename
Returns:
Sanitized base filename (without extension)
"""
name = doc.semantic_identifier or doc.title or doc.id
return sanitize_filename(name, replace_slash=replace_slash)
def build_document_subpath(doc: Document, replace_slash: bool = True) -> list[str]:
"""Build the source/hierarchy path components from a document.
Returns path components like: [source, hierarchy_part1, hierarchy_part2, ...]
This is the common part of the path that comes after user/tenant segregation.
Args:
doc: The document to build path for
replace_slash: Passed through to sanitize_path_component
Returns:
List of sanitized path components
"""
parts: list[str] = []
# Source type (e.g., "google_drive", "confluence")
parts.append(doc.source.value)
# Get hierarchy from doc_metadata
hierarchy = doc.doc_metadata.get("hierarchy", {}) if doc.doc_metadata else {}
source_path = hierarchy.get("source_path", [])
if source_path:
parts.extend(
[
sanitize_path_component(p, replace_slash=replace_slash)
for p in source_path
]
)
return parts
def resolve_duplicate_filename(
doc: Document,
base_filename: str,
has_duplicates: bool,
replace_slash: bool = True,
) -> str:
"""Resolve filename, appending ID suffix if there are duplicates.
Args:
doc: The document (for ID extraction)
base_filename: The base filename without extension
has_duplicates: Whether there are other docs with the same base filename
replace_slash: Passed through to sanitize_path_component
Returns:
Final filename with .json extension
"""
if has_duplicates:
id_suffix = sanitize_path_component(doc.id, replace_slash=replace_slash)
if len(id_suffix) > 50:
id_suffix = hashlib.sha256(doc.id.encode()).hexdigest()[:16]
return f"{base_filename}_{id_suffix}.json"
return f"{base_filename}.json"
def serialize_document(doc: Document) -> dict[str, Any]:
"""Serialize a document to a dictionary for JSON storage.
Args:
doc: The document to serialize
Returns:
Dictionary representation of the document
"""
return {
"id": doc.id,
"semantic_identifier": doc.semantic_identifier,
"title": doc.title,
"source": doc.source.value,
"doc_updated_at": (
doc.doc_updated_at.isoformat() if doc.doc_updated_at else None
),
"metadata": doc.metadata,
"doc_metadata": doc.doc_metadata,
"sections": [
{"text": s.text if hasattr(s, "text") else None, "link": s.link}
for s in doc.sections
],
"primary_owners": [o.model_dump() for o in (doc.primary_owners or [])],
"secondary_owners": [o.model_dump() for o in (doc.secondary_owners or [])],
}
# =============================================================================
# Classes
# =============================================================================
class PersistentDocumentWriter:
"""Writes indexed documents to local filesystem with hierarchical structure.
Documents are stored in tenant/user-segregated paths:
{base_path}/{tenant_id}/knowledge/{user_id}/{source}/{hierarchy}/document.json
This enables per-tenant and per-user isolation for sandbox access control.
"""
def __init__(
self,
base_path: str,
tenant_id: str,
user_id: str,
):
self.base_path = Path(base_path)
self.tenant_id = tenant_id
self.user_id = user_id
def write_documents(self, documents: list[Document]) -> list[str]:
"""Write documents to local filesystem, returns written file paths."""
written_paths: list[str] = []
# Build a map of base filenames to detect duplicates
# Key: (directory_path, base_filename) -> list of docs with that name
filename_map: dict[tuple[Path, str], list[Document]] = {}
for doc in documents:
dir_path = self._build_directory_path(doc)
base_filename = get_base_filename(doc, replace_slash=True)
key = (dir_path, base_filename)
if key not in filename_map:
filename_map[key] = []
filename_map[key].append(doc)
# Now write documents, appending ID if there are duplicates
for (dir_path, base_filename), docs in filename_map.items():
has_duplicates = len(docs) > 1
for doc in docs:
filename = resolve_duplicate_filename(
doc, base_filename, has_duplicates, replace_slash=True
)
path = dir_path / filename
self._write_document(doc, path)
written_paths.append(str(path))
return written_paths
def _build_directory_path(self, doc: Document) -> Path:
"""Build directory path from document metadata.
Documents are stored under tenant/user-segregated paths:
{base_path}/{tenant_id}/knowledge/{user_id}/{source}/{hierarchy}/
This enables per-tenant and per-user isolation for sandbox access control.
"""
# Tenant and user segregation prefix (matches S3 path structure)
parts = [self.tenant_id, "knowledge", self.user_id]
# Add source and hierarchy from document
parts.extend(build_document_subpath(doc, replace_slash=True))
return self.base_path / "/".join(parts)
def _write_document(self, doc: Document, path: Path) -> None:
"""Serialize and write document to filesystem."""
content = serialize_document(doc)
# Create parent directories if they don't exist
path.parent.mkdir(parents=True, exist_ok=True)
# Write the JSON file
with open(path, "w", encoding="utf-8") as f:
json.dump(content, f, indent=2, default=str)
logger.debug(f"Wrote document to {path}")
class S3PersistentDocumentWriter:
"""Writes indexed documents to S3 with hierarchical structure.
Documents are stored in tenant/user-segregated paths:
s3://{bucket}/{tenant_id}/knowledge/{user_id}/{source}/{hierarchy}/document.json
This matches the location that KubernetesSandboxManager reads from when
provisioning sandboxes (via the init container's aws s3 sync command).
"""
def __init__(self, tenant_id: str, user_id: str):
"""Initialize S3PersistentDocumentWriter.
Args:
tenant_id: Tenant identifier for multi-tenant isolation
user_id: User ID for user-segregated storage paths
"""
self.tenant_id = tenant_id
self.user_id = user_id
self.bucket = SANDBOX_S3_BUCKET
self._s3_client: S3Client | None = None
def _get_s3_client(self) -> S3Client:
"""Lazily initialize S3 client.
Uses the default boto3 credential chain which supports:
- Environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)
- AWS config files
- IAM roles (EC2/ECS/EKS instance profiles, IRSA)
"""
if self._s3_client is None:
self._s3_client = boto3.client("s3")
return self._s3_client
def write_documents(self, documents: list[Document]) -> list[str]:
"""Write documents to S3, returns written S3 keys.
Args:
documents: List of documents to write
Returns:
List of S3 keys that were written
"""
written_keys: list[str] = []
# Build a map of base keys to detect duplicates
# Key: (directory_prefix, base_filename) -> list of docs with that name
key_map: dict[tuple[str, str], list[Document]] = {}
for doc in documents:
dir_prefix = self._build_directory_path(doc)
base_filename = get_base_filename(doc, replace_slash=False)
key = (dir_prefix, base_filename)
if key not in key_map:
key_map[key] = []
key_map[key].append(doc)
# Now write documents, appending ID if there are duplicates
s3_client = self._get_s3_client()
for (dir_prefix, base_filename), docs in key_map.items():
has_duplicates = len(docs) > 1
for doc in docs:
filename = resolve_duplicate_filename(
doc, base_filename, has_duplicates, replace_slash=False
)
s3_key = f"{dir_prefix}/{filename}"
self._write_document(s3_client, doc, s3_key)
written_keys.append(s3_key)
return written_keys
def _build_directory_path(self, doc: Document) -> str:
"""Build S3 key prefix from document metadata.
Documents are stored under tenant/user-segregated paths:
{tenant_id}/knowledge/{user_id}/{source}/{hierarchy}/
This matches the path that KubernetesSandboxManager syncs from:
aws s3 sync "s3://{bucket}/{tenant_id}/knowledge/{user_id}/" /workspace/files/
"""
# Tenant and user segregation (matches K8s sandbox init container path)
parts = [self.tenant_id, "knowledge", self.user_id]
# Add source and hierarchy from document
parts.extend(build_document_subpath(doc, replace_slash=False))
return "/".join(parts)
def _write_document(self, s3_client: S3Client, doc: Document, s3_key: str) -> None:
"""Serialize and write document to S3."""
content = serialize_document(doc)
json_content = json.dumps(content, indent=2, default=str)
try:
s3_client.put_object(
Bucket=self.bucket,
Key=s3_key,
Body=json_content.encode("utf-8"),
ContentType="application/json",
)
logger.debug(f"Wrote document to s3://{self.bucket}/{s3_key}")
except ClientError as e:
logger.error(f"Failed to write to S3: {e}")
raise
def get_persistent_document_writer(
user_id: str,
tenant_id: str,
) -> PersistentDocumentWriter | S3PersistentDocumentWriter:
"""Factory function to create a PersistentDocumentWriter with default configuration.
Args:
user_id: User ID for user-segregated storage paths.
tenant_id: Tenant ID for multi-tenant isolation.
Both local and S3 modes use consistent tenant/user-segregated paths:
- Local: {base_path}/{tenant_id}/knowledge/{user_id}/...
- S3: s3://{bucket}/{tenant_id}/knowledge/{user_id}/...
Returns:
PersistentDocumentWriter for local mode, S3PersistentDocumentWriter for K8s mode
"""
if SANDBOX_BACKEND == SandboxBackend.LOCAL:
return PersistentDocumentWriter(
base_path=PERSISTENT_DOCUMENT_STORAGE_PATH,
tenant_id=tenant_id,
user_id=user_id,
)
elif SANDBOX_BACKEND == SandboxBackend.KUBERNETES:
return S3PersistentDocumentWriter(
tenant_id=tenant_id,
user_id=user_id,
)
else:
raise ValueError(f"Unknown sandbox backend: {SANDBOX_BACKEND}")

View File

@@ -0,0 +1,352 @@
# Onyx Sandbox System
This directory contains the implementation of Onyx's sandbox system for running OpenCode agents in isolated environments.
## Overview
The sandbox system provides isolated execution environments where OpenCode agents can build web applications, run code, and interact with knowledge files. Each sandbox includes:
- **Next.js development environment** - Lightweight Next.js scaffold with shadcn/ui and Recharts for building UIs
- **Python virtual environment** - Pre-installed packages for data processing
- **OpenCode agent** - AI coding agent with access to tools and MCP servers
- **Knowledge files** - Access to indexed documents and user uploads
## Architecture
### Deployment Modes
1. **Local Mode** (`SANDBOX_BACKEND=local`)
- Sandboxes run as directories on the local filesystem
- No automatic cleanup or snapshots
- Suitable for development and testing
2. **Kubernetes Mode** (`SANDBOX_BACKEND=kubernetes`)
- Sandboxes run as Kubernetes pods
- Automatic snapshots to S3
- Auto-cleanup of idle sandboxes
- Production-ready with resource isolation
### Directory Structure
```
/workspace/ # Sandbox root (in container)
├── outputs/ # Working directory
│ ├── web/ # Lightweight Next.js app (shadcn/ui, Recharts)
│ ├── slides/ # Generated presentations
│ ├── markdown/ # Generated documents
│ └── graphs/ # Generated visualizations
├── .venv/ # Python virtual environment
├── files/ # Symlink to knowledge files
├── attachments/ # User uploads
├── AGENTS.md # Agent instructions
└── .opencode/
└── skills/ # Agent skills
```
## Setup
### Running via Docker/Kubernetes (Zero Setup!) 🎉
**No setup required!** Just build and deploy:
```bash
# Build backend image (includes both templates)
cd backend
docker build -f Dockerfile.sandbox-templates -t onyxdotapp/backend:latest .
# Build sandbox container (lightweight runner)
cd onyx/server/features/build/sandbox/kubernetes/docker
docker build -t onyxdotapp/sandbox:latest .
# Deploy with docker-compose or kubectl - sandboxes work immediately!
```
**How it works:**
- **Backend image**: Contains both templates at build time:
- Web template at `/templates/outputs/web` (lightweight Next.js scaffold, ~2MB)
- Python venv template at `/templates/venv` (pre-installed packages, ~50MB)
- **Init container** (Kubernetes only): Syncs knowledge files from S3
- **Sandbox startup**: Runs `npm install` (for fresh dependency locks) + `next dev`
### Running Backend Directly (Without Docker)
**Only needed if you're running the Onyx backend outside of Docker.** Most developers use Docker and can skip this section.
If you're running the backend Python process directly on your machine, you need templates at `/templates/`:
#### Web Template
The web template is a lightweight Next.js app (Next.js 16, React 19, shadcn/ui, Recharts) checked into the codebase at `backend/onyx/server/features/build/templates/outputs/web/`.
For local development, create a symlink to this template:
```bash
sudo mkdir -p /templates/outputs
sudo ln -s $(pwd)/backend/onyx/server/features/build/templates/outputs/web /templates/outputs/web
```
#### Python Venv Template
If you don't have a venv template, create it:
```bash
# Use the utility script
cd backend
python -m onyx.server.features.build.sandbox.util.build_venv_template
# Or manually
python3 -m venv /templates/venv
/templates/venv/bin/pip install -r backend/onyx/server/features/build/sandbox/kubernetes/docker/initial-requirements.txt
```
**That's it!** When sandboxes are created:
1. Web template is copied from `/templates/outputs/web`
2. Python venv is copied from `/templates/venv`
3. `npm install` runs automatically to install fresh Next.js dependencies
## OpenCode Configuration
Each sandbox includes an OpenCode agent configured with:
- **LLM Provider**: Anthropic, OpenAI, Google, Bedrock, or Azure
- **Extended thinking**: High reasoning effort / thinking budgets for complex tasks
- **Tool permissions**: File operations, bash commands, web access
- **Disabled tools**: Configurable via `OPENCODE_DISABLED_TOOLS` env var
Configuration is generated dynamically in `templates/opencode_config.py`.
## Key Components
### Managers
- **`base.py`** - Abstract base class defining the sandbox interface
- **`local/manager.py`** - Filesystem-based sandbox manager for local development
- **`kubernetes/manager.py`** - Kubernetes-based sandbox manager for production
### Managers (Shared)
- **`manager/directory_manager.py`** - Creates sandbox directory structure and copies templates
- **`manager/snapshot_manager.py`** - Handles snapshot creation and restoration
### Utilities
- **`util/opencode_config.py`** - Generates OpenCode configuration with MCP support
- **`util/agent_instructions.py`** - Generates agent instructions (AGENTS.md)
- **`util/build_venv_template.py`** - Utility to build Python venv template for local development
### Templates
- **`../templates/outputs/web/`** - Lightweight Next.js scaffold (shadcn/ui, Recharts) versioned with the backend code
### Kubernetes Specific
- **`kubernetes/docker/Dockerfile`** - Sandbox container image (runs Next.js + OpenCode)
- **`kubernetes/docker/entrypoint.sh`** - Container startup script
## Environment Variables
### Core Settings
```bash
# Sandbox backend mode
SANDBOX_BACKEND=local|kubernetes # Default: local
# Template paths (local mode)
OUTPUTS_TEMPLATE_PATH=/templates/outputs # Default: /templates/outputs
VENV_TEMPLATE_PATH=/templates/venv # Default: /templates/venv
# Sandbox base path (local mode)
SANDBOX_BASE_PATH=/tmp/onyx-sandboxes # Default: /tmp/onyx-sandboxes
# OpenCode configuration
OPENCODE_DISABLED_TOOLS=question # Comma-separated list, default: question
```
### Kubernetes Settings
```bash
# Kubernetes namespace
SANDBOX_NAMESPACE=onyx-sandboxes # Default: onyx-sandboxes
# Container image
SANDBOX_CONTAINER_IMAGE=onyxdotapp/sandbox:latest
# S3 bucket for snapshots and files
SANDBOX_S3_BUCKET=onyx-sandbox-files # Default: onyx-sandbox-files
# Service accounts
SANDBOX_SERVICE_ACCOUNT_NAME=sandbox-runner # No AWS access
SANDBOX_FILE_SYNC_SERVICE_ACCOUNT=sandbox-file-sync # Has S3 access via IRSA
```
### Lifecycle Settings
```bash
# Idle timeout before cleanup (seconds)
SANDBOX_IDLE_TIMEOUT_SECONDS=900 # Default: 900 (15 minutes)
# Max concurrent sandboxes per organization
SANDBOX_MAX_CONCURRENT_PER_ORG=10 # Default: 10
# Next.js port range (local mode)
SANDBOX_NEXTJS_PORT_START=3010 # Default: 3010
SANDBOX_NEXTJS_PORT_END=3100 # Default: 3100
```
## Testing
### Integration Tests
```bash
# Test local sandbox provisioning
uv run pytest backend/tests/integration/sandbox/test_local_sandbox.py
# Test Kubernetes sandbox provisioning (requires k8s cluster)
uv run pytest backend/tests/integration/sandbox/test_kubernetes_sandbox.py
```
### Manual Testing
```bash
# Start a local sandbox session
curl -X POST http://localhost:3000/api/build/session \
-H "Content-Type: application/json" \
-d '{
"user_id": "user-123",
"file_system_path": "/path/to/files"
}'
# Send a message to the agent
curl -X POST http://localhost:3000/api/build/session/{session_id}/message \
-H "Content-Type: application/json" \
-d '{
"message": "Create a simple web page"
}'
```
## Troubleshooting
### Sandbox Stuck in PROVISIONING (Kubernetes)
**Symptoms**: Sandbox status never changes from `PROVISIONING`
**Solutions**:
- Check pod logs: `kubectl logs -n onyx-sandboxes sandbox-{sandbox-id}`
- Check init container: `kubectl logs -n onyx-sandboxes sandbox-{sandbox-id} -c file-sync`
- Verify init container completed: `kubectl describe pod -n onyx-sandboxes sandbox-{sandbox-id}`
- Check S3 bucket access: Ensure init container service account has IRSA configured
### Next.js Server Won't Start
**Symptoms**: Sandbox provisioned but web preview doesn't load
**Solutions**:
- **Local mode**: Check if port is already in use
- **Docker/K8s**: Check container logs: `kubectl logs -n onyx-sandboxes sandbox-{sandbox-id}`
- Verify npm install succeeded (check entrypoint.sh logs)
- Check that web template was copied: `kubectl exec -n onyx-sandboxes sandbox-{sandbox-id} -- ls /workspace/outputs/web`
### Templates Not Found (Local Mode)
**Symptoms**: `RuntimeError: Sandbox templates are missing`
**Solution**: Set up templates as described in the "Local Development" section above:
```bash
# Symlink web template
sudo ln -s $(pwd)/backend/onyx/server/features/build/templates/outputs/web /templates/outputs/web
# Create Python venv
python3 -m venv /templates/venv
/templates/venv/bin/pip install -r backend/onyx/server/features/build/sandbox/kubernetes/docker/initial-requirements.txt
```
### Permission Denied
**Symptoms**: `Permission denied` error accessing `/templates/`
**Solution**: Either use sudo when creating symlinks, or use custom paths:
```bash
export OUTPUTS_TEMPLATE_PATH=$HOME/.onyx/templates/outputs
export VENV_TEMPLATE_PATH=$HOME/.onyx/templates/venv
# Then symlink to your home directory
mkdir -p $HOME/.onyx/templates/outputs
ln -s $(pwd)/backend/onyx/server/features/build/templates/outputs/web $HOME/.onyx/templates/outputs/web
```
## Security Considerations
### Sandbox Isolation
- **Kubernetes pods** run with restricted security context (non-root, no privilege escalation)
- **Init containers** have S3 access for file sync, but main sandbox container does NOT
- **Network policies** can restrict sandbox egress traffic
- **Resource limits** prevent resource exhaustion
### Credentials Management
- LLM API keys are passed as environment variables (not stored in sandbox)
- User file access is read-only via symlinks
- Snapshots are isolated per tenant in S3
## Development
### Adding New MCP Servers
1. Add MCP configuration to `templates/opencode_config.py`:
```python
config["mcp"] = {
"my-mcp": {
"type": "local",
"command": ["npx", "@my/mcp@latest"],
"enabled": True,
}
}
```
2. Install required npm packages in web template (if needed)
3. Rebuild Docker image and templates
### Modifying Agent Instructions
Edit `AGENTS.template.md` in the build directory. This is populated with dynamic content by `templates/agent_instructions.py`.
### Adding New Tools/Permissions
Update `templates/opencode_config.py` to add/remove tool permissions in the `permission` section.
## Template Details
### Web Template
The lightweight Next.js template (`backend/onyx/server/features/build/templates/outputs/web/`) includes:
- **Framework**: Next.js 16.1.4 with React 19.2.3
- **UI Library**: shadcn/ui components with Radix UI primitives
- **Styling**: Tailwind CSS v4 with custom theming support
- **Charts**: Recharts for data visualization
- **Size**: ~2MB (excluding node_modules, which are installed fresh per sandbox)
This template provides a modern development environment without the complexity of the full Onyx application, allowing agents to build custom UIs quickly.
### Python Venv Template
The Python venv (`/templates/venv/`) includes packages from `initial-requirements.txt`:
- Data processing: pandas, numpy, polars
- HTTP clients: requests, httpx
- Utilities: python-dotenv, pydantic
## References
- [OpenCode Documentation](https://docs.opencode.ai)
- [Next.js Documentation](https://nextjs.org/docs)
- [shadcn/ui Components](https://ui.shadcn.com)

View File

@@ -0,0 +1,44 @@
"""
Sandbox module for CLI agent filesystem-based isolation.
This module provides lightweight sandbox management for CLI-based AI agent sessions.
Each sandbox is a directory on the local filesystem or a Kubernetes pod.
Usage:
from onyx.server.features.build.sandbox import get_sandbox_manager
# Get the appropriate sandbox manager based on SANDBOX_BACKEND config
sandbox_manager = get_sandbox_manager()
# Use the sandbox manager
sandbox_info = sandbox_manager.provision(...)
Module structure:
- base.py: SandboxManager ABC and get_sandbox_manager() factory
- models.py: Shared Pydantic models
- local/: Local filesystem-based implementation for development
- kubernetes/: Kubernetes pod-based implementation for production
- internal/: Shared internal utilities (snapshot manager)
"""
from onyx.server.features.build.sandbox.base import get_sandbox_manager
from onyx.server.features.build.sandbox.base import SandboxManager
from onyx.server.features.build.sandbox.local.local_sandbox_manager import (
LocalSandboxManager,
)
from onyx.server.features.build.sandbox.models import FilesystemEntry
from onyx.server.features.build.sandbox.models import SandboxInfo
from onyx.server.features.build.sandbox.models import SnapshotInfo
__all__ = [
# Factory function (preferred)
"get_sandbox_manager",
# Interface
"SandboxManager",
# Implementations
"LocalSandboxManager",
# Models
"SandboxInfo",
"SnapshotInfo",
"FilesystemEntry",
]

View File

@@ -0,0 +1,466 @@
"""Abstract base class and factory for sandbox operations.
SandboxManager is the abstract interface for sandbox lifecycle management.
Use get_sandbox_manager() to get the appropriate implementation based on SANDBOX_BACKEND.
IMPORTANT: SandboxManager implementations must NOT interface with the database directly.
All database operations should be handled by the caller (SessionManager, Celery tasks, etc.).
Architecture Note (User-Shared Sandbox Model):
- One sandbox (container/pod) is shared across all of a user's sessions
- provision() creates the user's sandbox with shared files/ directory
- setup_session_workspace() creates per-session workspace within the sandbox
- cleanup_session_workspace() removes session workspace on session delete
- terminate() destroys the entire sandbox (all sessions)
"""
import threading
from abc import ABC
from abc import abstractmethod
from collections.abc import Generator
from typing import Any
from uuid import UUID
from onyx.server.features.build.configs import SANDBOX_BACKEND
from onyx.server.features.build.configs import SandboxBackend
from onyx.server.features.build.sandbox.models import FilesystemEntry
from onyx.server.features.build.sandbox.models import LLMProviderConfig
from onyx.server.features.build.sandbox.models import SandboxInfo
from onyx.server.features.build.sandbox.models import SnapshotResult
from onyx.utils.logger import setup_logger
logger = setup_logger()
# ACPEvent is a union type defined in both local and kubernetes modules
# Using Any here to avoid circular imports - the actual type checking
# happens in the implementation modules
ACPEvent = Any
class SandboxManager(ABC):
"""Abstract interface for sandbox operations.
Defines the contract for sandbox lifecycle management including:
- Provisioning and termination (user-level)
- Session workspace setup and cleanup (session-level)
- Snapshot creation (session-level)
- Health checks
- Agent communication (session-level)
- Filesystem operations (session-level)
Directory Structure:
$SANDBOX_ROOT/
├── files/ # SHARED - symlink to user's persistent documents
└── sessions/
├── $session_id_1/ # Per-session workspace
│ ├── outputs/ # Agent output for this session
│ │ └── web/ # Next.js app
│ ├── venv/ # Python virtual environment
│ ├── skills/ # Opencode skills
│ ├── AGENTS.md # Agent instructions
│ ├── opencode.json # LLM config
│ └── attachments/
└── $session_id_2/
└── ...
IMPORTANT: Implementations must NOT interface with the database directly.
All database operations should be handled by the caller.
Use get_sandbox_manager() to get the appropriate implementation.
"""
@abstractmethod
def provision(
self,
sandbox_id: UUID,
user_id: UUID,
tenant_id: str,
llm_config: LLMProviderConfig,
) -> SandboxInfo:
"""Provision a new sandbox for a user.
Creates the sandbox container/directory with:
- sessions/ directory for per-session workspaces
NOTE: This does NOT set up session-specific workspaces.
Call setup_session_workspace() after provisioning to create a session workspace.
Args:
sandbox_id: Unique identifier for the sandbox
user_id: User identifier who owns this sandbox
tenant_id: Tenant identifier for multi-tenant isolation
llm_config: LLM provider configuration (for default config)
Returns:
SandboxInfo with the provisioned sandbox details
Raises:
RuntimeError: If provisioning fails
"""
...
@abstractmethod
def terminate(self, sandbox_id: UUID) -> None:
"""Terminate a sandbox and clean up all resources.
Destroys the entire sandbox including all session workspaces.
Use cleanup_session_workspace() to remove individual sessions.
Args:
sandbox_id: The sandbox ID to terminate
"""
...
@abstractmethod
def setup_session_workspace(
self,
sandbox_id: UUID,
session_id: UUID,
llm_config: LLMProviderConfig,
nextjs_port: int,
file_system_path: str | None = None,
snapshot_path: str | None = None,
user_name: str | None = None,
user_role: str | None = None,
user_work_area: str | None = None,
user_level: str | None = None,
use_demo_data: bool = False,
) -> None:
"""Set up a session workspace within an existing sandbox.
Creates the per-session directory structure:
- sessions/$session_id/outputs/ (from snapshot or template)
- sessions/$session_id/venv/
- sessions/$session_id/skills/
- sessions/$session_id/files/ (symlink to demo data or user files)
- sessions/$session_id/AGENTS.md
- sessions/$session_id/opencode.json
- sessions/$session_id/attachments/
- sessions/$session_id/org_info/ (if demo data enabled)
Args:
sandbox_id: The sandbox ID (must be provisioned)
session_id: The session ID for this workspace
llm_config: LLM provider configuration for opencode.json
file_system_path: Path to user's knowledge/source files
snapshot_path: Optional storage path to restore outputs from
user_name: User's name for personalization in AGENTS.md
user_role: User's role/title for personalization in AGENTS.md
user_work_area: User's work area for demo persona (e.g., "engineering")
user_level: User's level for demo persona (e.g., "ic", "manager")
use_demo_data: If True, symlink files/ to demo data; else to user files
Raises:
RuntimeError: If workspace setup fails
"""
...
@abstractmethod
def cleanup_session_workspace(
self,
sandbox_id: UUID,
session_id: UUID,
) -> None:
"""Clean up a session workspace (on session delete).
Removes the session directory: sessions/$session_id/
Does NOT terminate the sandbox - other sessions may still be using it.
Args:
sandbox_id: The sandbox ID
session_id: The session ID to clean up
"""
...
@abstractmethod
def create_snapshot(
self,
sandbox_id: UUID,
session_id: UUID,
tenant_id: str,
) -> SnapshotResult | None:
"""Create a snapshot of a session's outputs directory.
Captures only the session-specific outputs:
sessions/$session_id/outputs/
Does NOT include: venv, skills, AGENTS.md, opencode.json, attachments
Does NOT include: shared files/ directory
Args:
sandbox_id: The sandbox ID
session_id: The session ID to snapshot
tenant_id: Tenant identifier for storage path
Returns:
SnapshotResult with storage path and size, or None if
snapshots are disabled for this backend
Raises:
RuntimeError: If snapshot creation fails
"""
...
@abstractmethod
def session_workspace_exists(
self,
sandbox_id: UUID,
session_id: UUID,
) -> bool:
"""Check if a session's workspace directory exists in the sandbox.
Used to determine if we need to restore from snapshot.
Checks for sessions/$session_id/outputs/ directory.
Args:
sandbox_id: The sandbox ID
session_id: The session ID to check
Returns:
True if the session workspace exists, False otherwise
"""
...
@abstractmethod
def restore_snapshot(
self,
sandbox_id: UUID,
session_id: UUID,
snapshot_storage_path: str,
tenant_id: str,
nextjs_port: int,
) -> None:
"""Restore a snapshot into a session's workspace directory.
Downloads the snapshot from storage, extracts it into
sessions/$session_id/outputs/, and starts the NextJS server.
For Kubernetes backend, this downloads from S3 and streams
into the pod via kubectl exec (since the pod has no S3 access).
Args:
sandbox_id: The sandbox ID
session_id: The session ID to restore
snapshot_storage_path: Path to the snapshot in storage
tenant_id: Tenant identifier for storage access
nextjs_port: Port number for the NextJS dev server
Raises:
RuntimeError: If snapshot restoration fails
FileNotFoundError: If snapshot does not exist
"""
...
@abstractmethod
def health_check(self, sandbox_id: UUID, timeout: float = 60.0) -> bool:
"""Check if the sandbox is healthy.
Args:
sandbox_id: The sandbox ID to check
Returns:
True if sandbox is healthy, False otherwise
"""
...
@abstractmethod
def send_message(
self,
sandbox_id: UUID,
session_id: UUID,
message: str,
) -> Generator[ACPEvent, None, None]:
"""Send a message to the CLI agent and stream typed ACP events.
The agent runs in the session-specific workspace:
sessions/$session_id/
Args:
sandbox_id: The sandbox ID
session_id: The session ID (determines workspace directory)
message: The message content to send
Yields:
Typed ACP schema event objects
Raises:
RuntimeError: If agent communication fails
"""
...
@abstractmethod
def list_directory(
self, sandbox_id: UUID, session_id: UUID, path: str
) -> list[FilesystemEntry]:
"""List contents of a directory in the session's outputs directory.
Args:
sandbox_id: The sandbox ID
session_id: The session ID
path: Relative path within sessions/$session_id/outputs/
Returns:
List of FilesystemEntry objects sorted by directory first, then name
Raises:
ValueError: If path traversal attempted or path is not a directory
"""
...
@abstractmethod
def read_file(self, sandbox_id: UUID, session_id: UUID, path: str) -> bytes:
"""Read a file from the session's workspace.
Args:
sandbox_id: The sandbox ID
session_id: The session ID
path: Relative path within sessions/$session_id/
Returns:
File contents as bytes
Raises:
ValueError: If path traversal attempted or path is not a file
"""
...
@abstractmethod
def upload_file(
self,
sandbox_id: UUID,
session_id: UUID,
filename: str,
content: bytes,
) -> str:
"""Upload a file to the session's attachments directory.
Args:
sandbox_id: The sandbox ID
session_id: The session ID
filename: Sanitized filename
content: File content as bytes
Returns:
Relative path where file was saved (e.g., "attachments/doc.pdf")
Raises:
RuntimeError: If upload fails
"""
...
@abstractmethod
def delete_file(
self,
sandbox_id: UUID,
session_id: UUID,
path: str,
) -> bool:
"""Delete a file from the session's workspace.
Args:
sandbox_id: The sandbox ID
session_id: The session ID
path: Relative path to the file (e.g., "attachments/doc.pdf")
Returns:
True if file was deleted, False if not found
Raises:
ValueError: If path traversal attempted
"""
...
@abstractmethod
def get_upload_stats(
self,
sandbox_id: UUID,
session_id: UUID,
) -> tuple[int, int]:
"""Get current file count and total size for a session's attachments.
Args:
sandbox_id: The sandbox ID
session_id: The session ID
Returns:
Tuple of (file_count, total_size_bytes)
"""
...
@abstractmethod
def get_webapp_url(self, sandbox_id: UUID, port: int) -> str:
"""Get the webapp URL for a session's Next.js server.
Returns the appropriate URL based on the backend:
- Local: Returns localhost URL with port
- Kubernetes: Returns internal cluster service URL
Args:
sandbox_id: The sandbox ID
port: The session's allocated Next.js port
Returns:
URL to access the webapp
"""
...
@abstractmethod
def sync_files(
self,
sandbox_id: UUID,
user_id: UUID,
tenant_id: str,
) -> bool:
"""Sync files from S3 to the sandbox's /workspace/files directory.
For Kubernetes backend: Executes `aws s3 sync` in the file-sync sidecar container.
For Local backend: No-op since files are directly accessible via symlink.
This is idempotent - only downloads changed files.
Args:
sandbox_id: The sandbox UUID
user_id: The user ID (for S3 path construction)
tenant_id: The tenant ID (for S3 path construction)
Returns:
True if sync was successful, False otherwise.
"""
...
# Singleton instance cache for the factory
_sandbox_manager_instance: SandboxManager | None = None
_sandbox_manager_lock = threading.Lock()
def get_sandbox_manager() -> SandboxManager:
"""Get the appropriate SandboxManager implementation based on SANDBOX_BACKEND.
Returns:
SandboxManager instance:
- LocalSandboxManager for local backend (development)
- KubernetesSandboxManager for kubernetes backend (production)
"""
global _sandbox_manager_instance
if _sandbox_manager_instance is None:
with _sandbox_manager_lock:
if _sandbox_manager_instance is None:
if SANDBOX_BACKEND == SandboxBackend.LOCAL:
from onyx.server.features.build.sandbox.local.local_sandbox_manager import (
LocalSandboxManager,
)
_sandbox_manager_instance = LocalSandboxManager()
elif SANDBOX_BACKEND == SandboxBackend.KUBERNETES:
from onyx.server.features.build.sandbox.kubernetes.kubernetes_sandbox_manager import (
KubernetesSandboxManager,
)
_sandbox_manager_instance = KubernetesSandboxManager()
logger.info("Using KubernetesSandboxManager for sandbox operations")
else:
raise ValueError(f"Unknown sandbox backend: {SANDBOX_BACKEND}")
return _sandbox_manager_instance

View File

@@ -0,0 +1,16 @@
"""Kubernetes-based sandbox implementation.
This module provides the KubernetesSandboxManager for production deployments
that run sandboxes as isolated Kubernetes pods.
Internal implementation details (acp_http_client) are in the internal/
subdirectory and should not be used directly.
"""
from onyx.server.features.build.sandbox.kubernetes.kubernetes_sandbox_manager import (
KubernetesSandboxManager,
)
__all__ = [
"KubernetesSandboxManager",
]

View File

@@ -0,0 +1,100 @@
# Sandbox Container Image
#
# User-shared sandbox model:
# - One pod per user, shared across all user's sessions
# - Session workspaces created via kubectl exec (setup_session_workspace)
# - OpenCode agent runs via kubectl exec when needed
#
# Directory structure (created by init container + session setup):
# /workspace/
# ├── demo-data/ # Demo data (baked into image, for demo sessions)
# ├── files/ # User's knowledge files (synced from S3)
# ├── templates/ # Output templates (baked into image)
# └── sessions/ # Per-session workspaces (created via exec)
# └── $session_id/
# ├── files/ # Symlink to /workspace/demo-data or /workspace/files
# ├── outputs/
# ├── AGENTS.md
# └── opencode.json
FROM node:20-slim
# Install system dependencies
RUN apt-get update && apt-get install -y \
python3 \
python3-pip \
python3-venv \
curl \
git \
procps \
unzip \
&& rm -rf /var/lib/apt/lists/*
# Create non-root user (matches pod securityContext)
# Handle existing user/group with UID/GID 1000 in base image
RUN EXISTING_USER=$(id -nu 1000 2>/dev/null || echo ""); \
EXISTING_GROUP=$(getent group 1000 | cut -d: -f1 2>/dev/null || echo ""); \
if [ -n "$EXISTING_GROUP" ] && [ "$EXISTING_GROUP" != "sandbox" ]; then \
groupmod -n sandbox $EXISTING_GROUP; \
elif [ -z "$EXISTING_GROUP" ]; then \
groupadd -g 1000 sandbox; \
fi; \
if [ -n "$EXISTING_USER" ] && [ "$EXISTING_USER" != "sandbox" ]; then \
usermod -l sandbox -g sandbox $EXISTING_USER; \
usermod -d /home/sandbox -m sandbox; \
usermod -s /bin/bash sandbox; \
elif [ -z "$EXISTING_USER" ]; then \
useradd -u 1000 -g sandbox -m -s /bin/bash sandbox; \
fi
# Create workspace directories
RUN mkdir -p workspace/sessions /workspace/files /workspace/templates /workspace/demo-data && \
chown -R sandbox:sandbox /workspace
# Copy outputs template (web app scaffold, without node_modules)
COPY --exclude=.next --exclude=node_modules templates/outputs /workspace/templates/outputs
RUN chown -R sandbox:sandbox /workspace/templates
# Copy and extract demo data from zip file
COPY demo_data.zip /tmp/demo_data.zip
RUN unzip -q /tmp/demo_data.zip -d /workspace/demo-data && \
rm /tmp/demo_data.zip && \
chown -R sandbox:sandbox /workspace/demo-data
# Copy and install Python requirements into a venv
COPY initial-requirements.txt /tmp/initial-requirements.txt
RUN python3 -m venv /workspace/.venv && \
/workspace/.venv/bin/pip install --upgrade pip && \
/workspace/.venv/bin/pip install -r /tmp/initial-requirements.txt && \
rm /tmp/initial-requirements.txt && \
chown -R sandbox:sandbox /workspace/.venv
# Add venv to PATH so python/pip use it by default
ENV PATH="/workspace/.venv/bin:${PATH}"
# Install opencode CLI as sandbox user so it goes to their home directory
USER sandbox
RUN curl -fsSL https://opencode.ai/install | bash
USER root
# Add opencode to PATH (installs to ~/.opencode/bin)
ENV PATH="/home/sandbox/.opencode/bin:${PATH}"
# Set ownership
RUN chown -R sandbox:sandbox /workspace
# Copy scripts
COPY generate_agents_md.py /usr/local/bin/generate_agents_md.py
RUN chmod +x /usr/local/bin/generate_agents_md.py
# Switch to non-root user
USER sandbox
WORKDIR /workspace
# Expose ports
# - 3000: Next.js dev server (started per-session if needed)
# - 8081: OpenCode ACP HTTP server (started via exec)
EXPOSE 3000 8081
# Keep container alive - all work done via kubectl exec
CMD ["sleep", "infinity"]

View File

@@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""Generate AGENTS.md by scanning the files directory and populating the template.
This script runs at container startup, AFTER the init container has synced files
from S3. It scans the /workspace/files directory to discover what knowledge sources
are available and generates appropriate documentation.
Environment variables:
- AGENT_INSTRUCTIONS: The template content with placeholders to replace
"""
import os
import sys
from pathlib import Path
# Connector descriptions for known connector types
# Keep in sync with agent_instructions.py CONNECTOR_DESCRIPTIONS
CONNECTOR_DESCRIPTIONS = {
"google_drive": (
"**Google Drive**: Copied over directly as is. "
"End files are stored as `FILE_NAME.json`."
),
"gmail": (
"**Gmail**: Copied over directly as is. "
"End files are stored as `FILE_NAME.json`."
),
"linear": (
"**Linear**: Each project is a folder, and within each project, "
"individual tickets are stored as `[TICKET_ID]_TICKET_NAME.json`."
),
"slack": (
"**Slack**: Each channel is a folder titled `[CHANNEL_NAME]`. "
"Within each channel, each thread is a single file called "
"`[INITIAL_AUTHOR]_in_[CHANNEL]__[FIRST_MESSAGE].json`."
),
"github": (
"**Github**: Each organization is a folder titled `[ORG_NAME]`. "
"Within each organization, there is a folder for each repository "
"titled `[REPO_NAME]`. Within each repository there are up to two "
"folders: `pull_requests` and `issues`. Pull requests are structured "
"as `[PR_ID]__[PR_NAME].json` and issues as `[ISSUE_ID]__[ISSUE_NAME].json`."
),
"fireflies": (
"**Fireflies**: All calls are in the root, each as a single file "
"titled `CALL_TITLE.json`."
),
"hubspot": (
"**HubSpot**: Four folders in the root: `Tickets`, `Companies`, "
"`Deals`, and `Contacts`. Each object is stored as a file named "
"after its title/name (e.g., `[TICKET_SUBJECT].json`, `[COMPANY_NAME].json`)."
),
"notion": (
"**Notion**: Pages and databases are organized hierarchically. "
"Each page is stored as `PAGE_TITLE.json`."
),
"org_info": (
"**Org Info**: Contains organizational data and identity information."
),
}
def build_file_structure_section(files_path: Path) -> str:
"""Build the file structure section by scanning the files directory."""
if not files_path.exists():
return "No knowledge sources available."
sources = []
try:
for item in sorted(files_path.iterdir()):
if not item.is_dir() or item.name.startswith("."):
continue
file_count = sum(1 for f in item.rglob("*") if f.is_file())
subdir_count = sum(1 for d in item.rglob("*") if d.is_dir())
details = []
if file_count > 0:
details.append(f"{file_count} file{'s' if file_count != 1 else ''}")
if subdir_count > 0:
details.append(
f"{subdir_count} subdirector{'ies' if subdir_count != 1 else 'y'}"
)
source_info = f"- **{item.name}/**"
if details:
source_info += f" ({', '.join(details)})"
sources.append(source_info)
except Exception as e:
print(f"Warning: Error scanning files directory: {e}", file=sys.stderr)
return "Error scanning knowledge sources."
if not sources:
return "No knowledge sources available."
header = "The `files/` directory contains the following knowledge sources:\n\n"
return header + "\n".join(sources)
def build_connector_descriptions(files_path: Path) -> str:
"""Build connector-specific descriptions for available data sources."""
if not files_path.exists():
return ""
descriptions = []
try:
for item in sorted(files_path.iterdir()):
if not item.is_dir() or item.name.startswith("."):
continue
normalized = item.name.lower().replace(" ", "_").replace("-", "_")
if normalized in CONNECTOR_DESCRIPTIONS:
descriptions.append(f"- {CONNECTOR_DESCRIPTIONS[normalized]}")
except Exception as e:
print(
f"Warning: Error scanning for connector descriptions: {e}", file=sys.stderr
)
return ""
if not descriptions:
return ""
header = "Each connector type organizes its data differently:\n\n"
footer = "\n\nSpaces in names are replaced by `_`."
return header + "\n".join(descriptions) + footer
def main() -> None:
# Read template from environment variable
template = os.environ.get("AGENT_INSTRUCTIONS", "")
if not template:
print("Warning: No AGENT_INSTRUCTIONS template provided", file=sys.stderr)
template = "# Agent Instructions\n\nNo instructions provided."
# Scan files directory
files_path = Path("/workspace/files")
file_structure = build_file_structure_section(files_path)
connector_descriptions = build_connector_descriptions(files_path)
# Replace placeholders
content = template
content = content.replace("{{FILE_STRUCTURE_SECTION}}", file_structure)
content = content.replace(
"{{CONNECTOR_DESCRIPTIONS_SECTION}}", connector_descriptions
)
# Write AGENTS.md
output_path = Path("/workspace/AGENTS.md")
output_path.write_text(content)
# Log result
source_count = 0
if files_path.exists():
source_count = len(
[
d
for d in files_path.iterdir()
if d.is_dir() and not d.name.startswith(".")
]
)
print(f"Generated AGENTS.md with {source_count} knowledge sources")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,17 @@
google-genai>=1.0.0
matplotlib==3.9.1
matplotlib-inline>=0.1.7
matplotlib-venn>=1.1.2
numpy==1.26.4
opencv-python>=4.11.0.86
openpyxl>=3.1.5
pandas==2.2.2
pdfplumber>=0.11.7
Pillow>=10.0.0
pydantic>=2.11.9
python-pptx>=1.0.2
scikit-image>=0.25.2
scikit-learn>=1.7.2
scipy>=1.16.2
seaborn>=0.13.2
xgboost>=3.0.5

View File

@@ -0,0 +1,80 @@
#!/bin/bash
# Run Kubernetes sandbox integration tests
#
# This script:
# 1. Builds the onyx-backend Docker image
# 2. Loads it into the kind cluster
# 3. Deletes/recreates the test pod
# 4. Waits for the pod to be ready
# 5. Runs the pytest command inside the pod
#
# Usage:
# ./run-test.sh [test_name]
#
# Examples:
# ./run-test.sh # Run all tests
# ./run-test.sh test_kubernetes_sandbox_provision # Run specific test
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../../../../../.." && pwd)"
NAMESPACE="onyx-sandboxes"
POD_NAME="sandbox-test"
IMAGE_NAME="onyxdotapp/onyx-backend:latest"
TEST_FILE="onyx/server/features/build/sandbox/kubernetes/test_kubernetes_sandbox.py"
ENV_FILE="$PROJECT_ROOT/.vscode/.env"
ORIGINAL_TEST_FILE="$PROJECT_ROOT/backend/tests/external_dependency_unit/craft/test_kubernetes_sandbox.py"
cp "$ORIGINAL_TEST_FILE" "$PROJECT_ROOT/backend/$TEST_FILE"
# Optional: specific test to run
TEST_NAME="${1:-}"
# Build env var arguments from .vscode/.env file for passing to the container
ENV_VARS=()
if [ -f "$ENV_FILE" ]; then
echo "=== Loading environment variables from .vscode/.env ==="
while IFS= read -r line || [ -n "$line" ]; do
# Skip empty lines and comments
[[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue
# Skip lines without =
[[ "$line" != *"="* ]] && continue
# Add to env vars array
ENV_VARS+=("$line")
done < "$ENV_FILE"
echo "Loaded ${#ENV_VARS[@]} environment variables"
else
echo "Warning: .vscode/.env not found, running without additional env vars"
fi
echo "=== Building onyx-backend Docker image ==="
cd "$PROJECT_ROOT/backend"
docker build -t "$IMAGE_NAME" -f Dockerfile .
rm "$PROJECT_ROOT/backend/$TEST_FILE"
echo "=== Loading image into kind cluster ==="
kind load docker-image "$IMAGE_NAME" --name onyx 2>/dev/null || \
kind load docker-image "$IMAGE_NAME" 2>/dev/null || \
echo "Warning: Could not load into kind. If using minikube, run: minikube image load $IMAGE_NAME"
echo "=== Deleting existing test pod (if any) ==="
kubectl delete pod "$POD_NAME" -n "$NAMESPACE" --ignore-not-found=true
echo "=== Creating test pod ==="
kubectl apply -f "$SCRIPT_DIR/test-job.yaml"
echo "=== Waiting for pod to be ready ==="
kubectl wait --for=condition=Ready pod/"$POD_NAME" -n "$NAMESPACE" --timeout=120s
echo "=== Running tests ==="
if [ -n "$TEST_NAME" ]; then
kubectl exec -it "$POD_NAME" -n "$NAMESPACE" -- \
env "${ENV_VARS[@]}" pytest "$TEST_FILE::$TEST_NAME" -v -s
else
kubectl exec -it "$POD_NAME" -n "$NAMESPACE" -- \
env "${ENV_VARS[@]}" pytest "$TEST_FILE" -v -s
fi
echo "=== Tests complete ==="

View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -0,0 +1,803 @@
# AGENTS.md
This file provides guidance to AI agents when working on the web application within this directory.
## Important Notes
- **The development server is already running** at a dynamically allocated port. Do NOT run `npm run dev` yourself.
- **We do NOT use a `src` directory** - all code lives directly in the root folders (`app/`, `components/`, `lib/`, etc.)
- If the app needs pre-computation (data processing, API calls, etc.), create a bash or python script called `prepare.sh`/`prepare.py` at the root of this directory
- **CRITICAL: Create small, modular components** - Do NOT write everything in `page.tsx`. Break your UI into small, reusable components in the `components/` directory. Each component should have a single responsibility and be in its own file.
## Data Preparation Scripts
**CRITICAL: Always re-run data scripts after modifying them.**
If a `prepare.sh` or `prepare.py` script exists at the root of this directory, it is responsible for generating/loading data that the frontend consumes.
### When to Run the Script
You MUST run the data preparation script:
1. **After creating** the script for the first time
2. **After modifying** the script logic (new data sources, changed processing, etc.)
3. **After updating** any data files the script reads from
4. **Before testing** the frontend if you're unsure if data is fresh
### How to Run
```bash
# For bash scripts
bash prepare.sh
# For python scripts
python prepare.py
```
### Common Mistake
**Updating the script but forgetting to run it** - This leaves stale data in place and the frontend won't reflect your changes. Always run the script immediately after modifying it.
## Commands
```bash
npm run dev # Start development server (DO NOT RUN - already running)
npm run lint # Run ESLint
```
## Architecture
This is a **Next.js 16.1.1** application using the **App Router** with **React 19** and **TypeScript**. It serves as a component showcase/template built on shadcn/ui.
### File Organization Philosophy
**Prioritize small, incremental file writes.** Break your application into many small components rather than monolithic page files.
#### Component Organization
```
components/
├── dashboard/ # Feature-specific components
│ ├── stats-card.tsx
│ ├── activity-feed.tsx
│ └── recent-items.tsx
├── charts/ # Chart components
│ ├── line-chart.tsx
│ ├── bar-chart.tsx
│ └── pie-chart.tsx
├── data/ # Data display components
│ ├── data-table.tsx
│ ├── filter-bar.tsx
│ └── sort-controls.tsx
└── layout/ # Layout components
├── header.tsx
├── sidebar.tsx
└── footer.tsx
```
#### Page Structure
Pages (`app/page.tsx`) should be **thin orchestration layers** that compose components:
```typescript
// ✅ GOOD - page.tsx is just composition
import { StatsCard } from "@/components/dashboard/stats-card";
import { ActivityFeed } from "@/components/dashboard/activity-feed";
import { RecentItems } from "@/components/dashboard/recent-items";
export default function DashboardPage() {
return (
<div className="container py-6 space-y-6">
<h1 className="text-3xl font-bold">Dashboard</h1>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<StatsCard title="Total Users" value={1234} />
<StatsCard title="Active Sessions" value={56} />
<StatsCard title="Revenue" value="$12,345" />
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<ActivityFeed />
<RecentItems />
</div>
</div>
);
}
// ❌ BAD - Everything in page.tsx (500+ lines of mixed logic)
export default function DashboardPage() {
// ... 500 lines of component logic, state, handlers, JSX ...
}
```
#### Component Granularity
Create a new component file when:
- A UI section has distinct functionality (e.g., `user-profile-card.tsx`)
- Logic exceeds ~50-100 lines
- A pattern is reused 2+ times
- Testing/maintenance would benefit from isolation
**Example: Dashboard Feature**
Instead of writing everything in `app/page.tsx`:
```typescript
// components/dashboard/stats-card.tsx
export function StatsCard({ title, value, trend }: StatsCardProps) {
return (
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">{title}</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
{trend && <p className="text-xs text-muted-foreground">{trend}</p>}
</CardContent>
</Card>
);
}
// components/dashboard/activity-feed.tsx
export function ActivityFeed() {
// Activity feed logic here
}
// components/dashboard/recent-items.tsx
export function RecentItems() {
// Recent items logic here
}
```
#### Benefits of Small Components
1. **Incremental Development**: Write one component at a time, test, iterate
2. **Better Diffs**: Smaller files = clearer git diffs and easier reviews
3. **Reusability**: Components can be imported across pages
4. **Maintainability**: Easier to locate and fix issues
5. **Hot Reload Efficiency**: Changes to small files reload faster
6. **Parallel Development**: Multiple features can be worked on independently
### Tech Stack
- **Framework**: Next.js 16.1.1 with App Router
- **React**: React 19
- **Language**: TypeScript
- **Styling**: Tailwind CSS v4 with CSS variables in OKLCH color space
- **Charts**: recharts for data visualization
- **UI Components**: shadcn/ui (53 components) built on Radix UI primitives
- **Variants**: class-variance-authority (CVA) for component variants
- **Class Merging**: `cn()` utility in `lib/utils.ts` (clsx + tailwind-merge)
- **Theme**: Dark mode enforced (via `dark` class on `<html>`)
### Key Directories
- `app/` - Next.js App Router pages and layouts
- `components/ui/` - shadcn/ui component library (Button, Card, Dialog, etc.)
- `components/` - App-specific components
- `hooks/` - Custom React hooks (e.g., `use-mobile.ts`)
- `lib/` - Utilities (`cn()` function)
### Component Patterns
- **Compound Components**: Components like `DropdownMenu`, `Dialog`, `Select` export multiple sub-components (Trigger, Content, Item)
- **Variants via CVA**: Use `variants` prop for size/style variations (e.g., `buttonVariants`)
- **Radix UI Primitives**: UI components wrap Radix for accessibility
### Path Aliases
All imports use `@/` alias (e.g., `@/components/ui/button`, `@/lib/utils`)
### shadcn/ui Configuration
Located in `components.json`:
- Style: `radix-nova`
- RSC enabled
- Icons: lucide-react
### Theme Variables
Global CSS variables defined in `app/globals.css` control colors, radius, and spacing. **Dark mode is enforced site-wide** via the `dark` class on the `<html>` element in `app/layout.tsx`. All styling should assume dark mode is active.
### Dark Mode Priority
- **Dark mode is the default and only theme** - do not design for light mode
- The `dark` class is permanently set on `<html>` in `layout.tsx`
- Use dark-appropriate colors: `bg-background`, `text-foreground`, etc.
- Ensure sufficient contrast for dark backgrounds
- Test all components in dark mode only
## Styling Guidelines
### CRITICAL: Use Only shadcn/ui Components
**MINIMIZE freestyling and creating custom components.** This application uses a complete, professionally designed component library (shadcn/ui). You MUST use the existing components from `components/ui/` for most UI needs.
#### Available shadcn/ui Components
All components are in `components/ui/`. Import using `@/components/ui/component-name`.
**Layout & Structure:**
- `Card` (`card.tsx`) - Content containers with CardHeader, CardTitle, CardDescription, CardContent, CardFooter
- `Separator` (`separator.tsx`) - Horizontal/vertical dividers
- `Tabs` (`tabs.tsx`) - Tabbed interfaces with Tabs, TabsList, TabsTrigger, TabsContent
- `ScrollArea` (`scroll-area.tsx`) - Styled scrollable regions
- `Resizable` (`resizable.tsx`) - Resizable panel layouts
- `Drawer` (`drawer.tsx`) - Bottom/side drawer overlays
- `Sidebar` (`sidebar.tsx`) - Application sidebar layout
- `AspectRatio` (`aspect-ratio.tsx`) - Maintain aspect ratios
**Forms & Inputs:**
- `Button` (`button.tsx`) - Primary, secondary, destructive, outline, ghost, link variants
- `ButtonGroup` (`button-group.tsx`) - Group of related buttons
- `Input` (`input.tsx`) - Text inputs with various states
- `InputGroup` (`input-group.tsx`) - Input with addons/icons
- `Textarea` (`textarea.tsx`) - Multi-line text input
- `Checkbox` (`checkbox.tsx`) - Checkboxes with indeterminate state
- `RadioGroup` (`radio-group.tsx`) - Radio button groups
- `Switch` (`switch.tsx`) - Toggle switches
- `Select` (`select.tsx`) - Dropdown select menus
- `NativeSelect` (`native-select.tsx`) - Native HTML select
- `Combobox` (`combobox.tsx`) - Autocomplete select with search
- `Command` (`command.tsx`) - Command palette/search interface
- `Field` (`field.tsx`) - Form field wrapper with label and error
- `Label` (`label.tsx`) - Form labels with proper accessibility
- `Slider` (`slider.tsx`) - Range sliders
- `Calendar` (`calendar.tsx`) - Date picker calendar
- `Toggle` (`toggle.tsx`) - Toggle button
- `ToggleGroup` (`toggle-group.tsx`) - Group of toggle buttons
**Navigation:**
- `NavigationMenu` (`navigation-menu.tsx`) - Complex navigation menus
- `Menubar` (`menubar.tsx`) - Application menu bar
- `Breadcrumb` (`breadcrumb.tsx`) - Breadcrumb navigation
- `Pagination` (`pagination.tsx`) - Page navigation controls
**Feedback & Overlays:**
- `Dialog` (`dialog.tsx`) - Modal dialogs
- `AlertDialog` (`alert-dialog.tsx`) - Confirmation dialogs
- `Sheet` (`sheet.tsx`) - Side sheets/panels
- `Popover` (`popover.tsx`) - Floating popovers
- `HoverCard` (`hover-card.tsx`) - Hover-triggered cards
- `Tooltip` (`tooltip.tsx`) - Tooltips on hover
- `Sonner` (`sonner.tsx`) - Toast notifications
- `Alert` (`alert.tsx`) - Static alert messages
- `Progress` (`progress.tsx`) - Progress bars
- `Skeleton` (`skeleton.tsx`) - Loading skeletons
- `Spinner` (`spinner.tsx`) - Loading spinners
- `Empty` (`empty.tsx`) - Empty state placeholder
**Menus & Dropdowns:**
- `DropdownMenu` (`dropdown-menu.tsx`) - Dropdown menus with submenus
- `ContextMenu` (`context-menu.tsx`) - Right-click context menus
**Data Display:**
- `Table` (`table.tsx`) - Data tables with Table, TableHeader, TableBody, TableRow, TableCell, etc.
- `Badge` (`badge.tsx`) - Status badges and tags
- `Avatar` (`avatar.tsx`) - User avatars with fallbacks
- `Accordion` (`accordion.tsx`) - Collapsible content sections
- `Collapsible` (`collapsible.tsx`) - Simple collapse/expand
- `Carousel` (`carousel.tsx`) - Image/content carousels
- `Item` (`item.tsx`) - List item component
- `Kbd` (`kbd.tsx`) - Keyboard shortcut display
**Data Visualization:**
- `Chart` (`chart.tsx`) - Chart wrapper with ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent
### Component Usage Principles
#### 1. **Never Create Custom Components**
```typescript
// ❌ WRONG - Do not create freestyle components
function CustomCard({ title, children }) {
return (
<div className="rounded-lg border p-4">
<h3 className="font-bold">{title}</h3>
{children}
</div>
);
}
// ✅ CORRECT - Use shadcn Card
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
function MyComponent() {
return (
<Card>
<CardHeader>
<CardTitle>Title</CardTitle>
</CardHeader>
<CardContent>Content here</CardContent>
</Card>
);
}
```
#### 2. **Use Component Variants, Don't Style Directly**
```typescript
// ❌ WRONG - Applying custom Tailwind classes
<button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
Click me
</button>
// ✅ CORRECT - Use Button variants
import { Button } from "@/components/ui/button";
<Button variant="default">Click me</Button>
<Button variant="destructive">Delete</Button>
<Button variant="outline">Cancel</Button>
<Button variant="ghost">Subtle Action</Button>
<Button size="sm">Small</Button>
<Button size="lg">Large</Button>
```
#### 3. **Compose Compound Components**
Many shadcn components export multiple sub-components. Use them as designed:
```typescript
// ✅ Dropdown Menu Composition
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuLabel,
} from "@/components/ui/dropdown-menu";
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">Options</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>Edit</DropdownMenuItem>
<DropdownMenuItem>Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
```
#### 4. **Use Layout Components for Structure**
```typescript
// ✅ Use Card for content sections
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "@/components/ui/card";
<Card>
<CardHeader>
<CardTitle>Dashboard</CardTitle>
<CardDescription>Overview of your data</CardDescription>
</CardHeader>
<CardContent>
{/* Your content */}
</CardContent>
<CardFooter>
<Button>Action</Button>
</CardFooter>
</Card>
```
### Styling Rules
#### 1. **Spacing & Layout**
Use Tailwind's utility classes for spacing, but stick to the design system:
- Gap: `gap-2`, `gap-4`, `gap-6`, `gap-8`
- Padding: `p-2`, `p-4`, `p-6`, `p-8`
- Margins: Prefer `gap` and `space-y-*` over margins
#### 2. **Colors**
All colors come from CSS variables in `app/globals.css`. Use semantic color classes:
- `bg-background`, `bg-foreground`
- `bg-card`, `text-card-foreground`
- `bg-primary`, `text-primary-foreground`
- `bg-secondary`, `text-secondary-foreground`
- `bg-muted`, `text-muted-foreground`
- `bg-accent`, `text-accent-foreground`
- `bg-destructive`, `text-destructive-foreground`
- `border-border`, `border-input`
- `ring-ring`
**DO NOT use arbitrary color values** like `bg-blue-500` or `text-red-600`.
#### **CRITICAL: Color Contrast Pairing Rules**
**Always pair background colors with their matching foreground colors.** The color system uses paired variables where each background has a corresponding text color designed for proper contrast.
| Background Class | Text Class to Use | Description |
|-----------------|-------------------|-------------|
| `bg-background` | `text-foreground` | Main page background |
| `bg-card` | `text-card-foreground` | Card containers |
| `bg-primary` | `text-primary-foreground` | Primary buttons/accents |
| `bg-secondary` | `text-secondary-foreground` | Secondary elements |
| `bg-muted` | `text-muted-foreground` | Muted/subtle areas |
| `bg-accent` | `text-accent-foreground` | Accent highlights |
| `bg-destructive` | `text-destructive-foreground` | Error/delete actions |
**Examples:**
```typescript
// ✅ CORRECT - Matching background and foreground pairs
<div className="bg-card text-card-foreground">Content</div>
<Button className="bg-primary text-primary-foreground">Click</Button>
<div className="bg-muted text-muted-foreground">Subtle text</div>
// ❌ WRONG - Mismatched colors causing contrast issues
<div className="bg-background text-background">Invisible text!</div>
<div className="bg-card text-foreground">May have poor contrast</div>
<Button className="bg-primary text-primary">White on white!</Button>
```
**Key Rules:**
1. **Never use the same color for background and text** (e.g., `bg-foreground text-foreground`)
2. **Always use the `-foreground` variant for text** when using a colored background
3. **For text on `bg-background`**, use `text-foreground` (primary) or `text-muted-foreground` (secondary)
4. **Test visually** - if text is hard to read, you have a contrast problem
#### 3. **Typography**
Use Tailwind text utilities (no separate Typography component):
- Headings: `text-xl font-semibold`, `text-2xl font-bold`, etc.
- Body: `text-sm`, `text-base`
- Secondary text: `text-muted-foreground`
- Use semantic HTML: `<h1>`, `<h2>`, `<p>`, etc.
- **Always wrap text** - Use `max-w-prose` or `max-w-xl` for readable line lengths
- **Prevent overflow** - Use `break-words` or `truncate` for long text that might overflow containers
#### 4. **Responsive Design**
Use Tailwind's responsive prefixes:
```typescript
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{/* Responsive grid */}
</div>
```
#### 5. **Icons**
Use Lucide React icons (already configured):
```typescript
import { Check, X, ChevronDown, User } from "lucide-react";
<Button>
<Check className="mr-2 h-4 w-4" />
Confirm
</Button>
```
### Data Visualization
For charts and data visualization, use the **shadcn/ui Chart components** (`@/components/ui/chart`) which wrap recharts with consistent theming. Charts should be **elegant, informative, and digestible at a glance**.
#### Chart Design Principles
1. **Clarity over complexity** - A chart should communicate ONE key insight immediately
2. **Minimal visual noise** - Remove anything that doesn't add information
3. **Consistent styling** - Use `ChartConfig` for colors, not arbitrary values
4. **Responsive** - Always use `ChartContainer` (includes ResponsiveContainer)
5. **Accessible** - Use `ChartTooltip` with `ChartTooltipContent` for proper styling
#### Chart Type Selection
| Data Type | Recommended Chart | Use Case |
|-----------|-------------------|----------|
| Trend over time | `LineChart` or `AreaChart` | Stock prices, user growth, metrics over days/months |
| Comparing categories | `BarChart` | Revenue by product, users by region |
| Part of whole | `PieChart` or `RadialBarChart` | Market share, budget allocation |
| Distribution | `BarChart` (horizontal) | Survey responses, rating distribution |
| Correlation | `ScatterChart` | Price vs. quality, age vs. income |
#### shadcn/ui Chart Components
Always import from the shadcn chart component:
```typescript
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
type ChartConfig,
} from "@/components/ui/chart";
import { LineChart, Line, XAxis, YAxis, CartesianGrid } from "recharts";
```
#### ChartConfig - Define Colors and Labels
The `ChartConfig` object defines colors and labels for your data series. This ensures consistent theming:
```typescript
const chartConfig = {
revenue: {
label: "Revenue",
color: "var(--chart-1)",
},
expenses: {
label: "Expenses",
color: "var(--chart-2)",
},
} satisfies ChartConfig;
```
#### Basic Line Chart Template
```typescript
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart";
import { LineChart, Line, XAxis, YAxis, CartesianGrid } from "recharts";
const chartConfig = {
value: {
label: "Value",
color: "var(--chart-1)",
},
} satisfies ChartConfig;
<ChartContainer config={chartConfig} className="h-[300px] w-full">
<LineChart data={data} accessibilityLayer>
<CartesianGrid vertical={false} />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
tickMargin={8}
/>
<YAxis tickLine={false} axisLine={false} tickMargin={8} />
<ChartTooltip content={<ChartTooltipContent />} />
<Line
type="monotone"
dataKey="value"
stroke="var(--color-value)"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ChartContainer>
```
#### Bar Chart with Multiple Series
```typescript
const chartConfig = {
revenue: {
label: "Revenue",
color: "var(--chart-1)",
},
expenses: {
label: "Expenses",
color: "var(--chart-2)",
},
} satisfies ChartConfig;
<ChartContainer config={chartConfig} className="h-[300px] w-full">
<BarChart data={data} accessibilityLayer>
<CartesianGrid vertical={false} />
<XAxis dataKey="month" tickLine={false} axisLine={false} tickMargin={8} />
<YAxis tickLine={false} axisLine={false} tickMargin={8} />
<ChartTooltip content={<ChartTooltipContent />} />
<ChartLegend content={<ChartLegendContent />} />
<Bar dataKey="revenue" fill="var(--color-revenue)" radius={4} />
<Bar dataKey="expenses" fill="var(--color-expenses)" radius={4} />
</BarChart>
</ChartContainer>
```
#### Pie/Donut Chart
```typescript
const chartConfig = {
desktop: { label: "Desktop", color: "var(--chart-1)" },
mobile: { label: "Mobile", color: "var(--chart-2)" },
tablet: { label: "Tablet", color: "var(--chart-3)" },
} satisfies ChartConfig;
<ChartContainer config={chartConfig} className="h-[300px] w-full">
<PieChart>
<ChartTooltip content={<ChartTooltipContent hideLabel />} />
<Pie
data={data}
dataKey="value"
nameKey="name"
innerRadius={60} // Remove for solid pie, keep for donut
strokeWidth={5}
/>
<ChartLegend content={<ChartLegendContent nameKey="name" />} />
</PieChart>
</ChartContainer>
```
#### Chart Styling Rules
**Colors (use CSS variables from globals.css):**
- `var(--chart-1)` through `var(--chart-5)` - Primary chart colors
- `var(--primary)` - For single-series emphasis
- `var(--muted)` - For de-emphasized data
**Color References in Charts:**
- In `ChartConfig`: Use `color: "var(--chart-1)"`
- In chart elements: Use `fill="var(--color-keyname)"` or `stroke="var(--color-keyname)"`
- The `keyname` matches the key in your `ChartConfig`
**Visual Cleanup:**
- Set `tickLine={false}` and `axisLine={false}` on axes for cleaner look
- Use `vertical={false}` on `CartesianGrid` for horizontal-only grid lines
- Use `dot={false}` on line charts unless individual points matter
- Add `radius={4}` to bars for rounded corners
- Limit to 3-5 data series maximum per chart
**Avoid:**
- ❌ 3D effects
- ❌ More than 5-6 colors in one chart
- ❌ Legends with more than 5 items (simplify the data instead)
- ❌ Dual Y-axes (confusing - use two separate charts)
- ❌ Pie charts with more than 5-6 slices
- ❌ Custom tooltip styling - use `ChartTooltipContent`
#### Fallback to Raw Recharts
If shadcn/ui Chart components don't support a specific chart type (e.g., ScatterChart, ComposedChart, RadarChart), you can use recharts directly:
```typescript
import { ScatterChart, Scatter, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
<ResponsiveContainer width="100%" height={300}>
<ScatterChart>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis dataKey="x" stroke="var(--muted-foreground)" fontSize={12} tickLine={false} axisLine={false} />
<YAxis dataKey="y" stroke="var(--muted-foreground)" fontSize={12} tickLine={false} axisLine={false} />
<Tooltip
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "6px"
}}
/>
<Scatter data={data} fill="var(--chart-1)" />
</ScatterChart>
</ResponsiveContainer>
```
**When using raw recharts:**
- Still use CSS variables for colors (`var(--chart-1)`, etc.)
- Match styling to shadcn conventions (tickLine={false}, axisLine={false})
- Style tooltips to match the design system
#### Data Accuracy Checklist
Before displaying a chart, verify:
- [ ] `ChartConfig` keys match your data's `dataKey` values
- [ ] Data values are correctly mapped to the right axes
- [ ] Axis labels match the data units (%, $, count, etc.)
- [ ] Time series data is sorted chronologically
- [ ] No missing data points that would break the visualization
- [ ] `ChartTooltip` with `ChartTooltipContent` is included
- [ ] Chart title/context makes the insight clear
### Common Patterns
#### Loading States
```typescript
import { Skeleton } from "@/components/ui/skeleton";
{isLoading ? (
<Skeleton className="h-12 w-full" />
) : (
<Content />
)}
```
#### Empty States
```typescript
import { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyMedia } from "@/components/ui/empty";
import { Inbox } from "lucide-react";
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<Inbox />
</EmptyMedia>
<EmptyTitle>No data available</EmptyTitle>
<EmptyDescription>
There's nothing to display yet. Add some items to get started.
</EmptyDescription>
</EmptyHeader>
</Empty>
```
#### Interactive Lists
```typescript
import { ScrollArea } from "@/components/ui/scroll-area";
import { ItemGroup, Item, ItemContent, ItemTitle, ItemDescription, ItemMedia } from "@/components/ui/item";
import { FileText } from "lucide-react";
<ScrollArea className="h-[400px]">
<ItemGroup>
{items.map((item) => (
<Item key={item.id} variant="outline">
<ItemMedia variant="icon">
<FileText />
</ItemMedia>
<ItemContent>
<ItemTitle>{item.name}</ItemTitle>
<ItemDescription>{item.description}</ItemDescription>
</ItemContent>
</Item>
))}
</ItemGroup>
</ScrollArea>
```
#### Form Fields
```typescript
import { Field, FieldLabel, FieldDescription, FieldError, FieldGroup } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
<FieldGroup>
<Field>
<FieldLabel>Email</FieldLabel>
<Input type="email" placeholder="you@example.com" />
<FieldDescription>We'll never share your email.</FieldDescription>
</Field>
<Field>
<FieldLabel>Password</FieldLabel>
<Input type="password" />
<FieldError>Password must be at least 8 characters.</FieldError>
</Field>
<Button type="submit">Sign up</Button>
</FieldGroup>
```
### What NOT To Do
**Don't create custom styled divs when a component exists**
**Don't use arbitrary Tailwind colors** (use CSS variables)
**Don't import UI libraries** like Material-UI, Ant Design, etc.
**Don't use inline styles** except for dynamic values
**Don't create custom form inputs** (use Field, Input, Select, etc. from components/ui)
**Don't add new dependencies** without checking if shadcn covers it
**Don't write everything in page.tsx** - break into separate component files
**Don't design for light mode** - this site is dark mode only
**Don't use `dark:` variants** - dark mode is always active, use base classes
### Development Workflow
1. **Plan the component structure** - Identify logical UI sections before writing code
2. **Create components incrementally** - Write one small component file at a time
3. **Test each component** - Verify it works before moving to the next
4. **Compose in page.tsx** - Import and arrange your components in the page
5. **Iterate** - Refine individual components without touching others
### Summary
This application has a **complete, production-ready component library**. Your job is to:
1. **Compose** shadcn/ui components (from `components/ui/`)
2. **Create small, focused component files** (in `components/`)
3. **Keep pages thin** - pages should orchestrate components, not contain implementation
Think of yourself as assembling LEGO blocks—all the UI pieces you need already exist in `components/ui/`, and you create small, organized structures by composing them into feature-specific components.

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 893 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,127 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.67 0.16 58);
--primary-foreground: oklch(0.99 0.02 95);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.58 0.22 27);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.88 0.15 92);
--chart-2: oklch(0.77 0.16 70);
--chart-3: oklch(0.67 0.16 58);
--chart-4: oklch(0.56 0.15 49);
--chart-5: oklch(0.47 0.12 46);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.67 0.16 58);
--sidebar-primary-foreground: oklch(0.99 0.02 95);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.77 0.16 70);
--primary-foreground: oklch(0.28 0.07 46);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.371 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
/* Chart colors optimized for dark backgrounds - brighter and more vibrant */
--chart-1: oklch(0.82 0.18 140);
--chart-2: oklch(0.75 0.2 200);
--chart-3: oklch(0.7 0.22 280);
--chart-4: oklch(0.78 0.18 50);
--chart-5: oklch(0.72 0.2 330);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.77 0.16 70);
--sidebar-primary-foreground: oklch(0.28 0.07 46);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -0,0 +1,36 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono, Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Onyx Craft",
description: "Crafting your next great idea.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className={`${inter.variable} dark`}>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
}

View File

@@ -0,0 +1,132 @@
"use client";
import { useState, useEffect, useRef } from "react";
const messages = [
"Punching wood...",
"Gathering resources...",
"Placing blocks...",
"Crafting your workspace...",
"Mining for dependencies...",
"Smelting the code...",
"Enchanting with magic...",
"World generation complete...",
"/gamemode 1",
];
const MESSAGE_COUNT = messages.length;
const TYPE_DELAY = 40;
const LINE_PAUSE = 800;
const RESET_DELAY = 2000;
export default function CraftingLoader() {
const [display, setDisplay] = useState({
lines: [] as string[],
currentText: "",
});
const lineIndexRef = useRef(0);
const charIndexRef = useRef(0);
const lastUpdateRef = useRef(0);
const timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
const rafRef = useRef<number | undefined>(undefined);
useEffect(() => {
let isActive = true;
const update = (now: number) => {
if (!isActive) return;
const lineIdx = lineIndexRef.current;
const charIdx = charIndexRef.current;
if (lineIdx >= MESSAGE_COUNT) {
timeoutRef.current = setTimeout(() => {
if (!isActive) return;
lineIndexRef.current = 0;
charIndexRef.current = 0;
setDisplay({ lines: [], currentText: "" });
lastUpdateRef.current = performance.now();
rafRef.current = requestAnimationFrame(update);
}, RESET_DELAY);
return;
}
const msg = messages[lineIdx];
if (!msg) return;
const elapsed = now - lastUpdateRef.current;
if (charIdx < msg.length) {
if (elapsed >= TYPE_DELAY) {
charIndexRef.current = charIdx + 1;
setDisplay((prev) => ({
lines: prev.lines,
currentText: msg.substring(0, charIdx + 1),
}));
lastUpdateRef.current = now;
}
} else if (elapsed >= LINE_PAUSE) {
setDisplay((prev) => ({
lines: [...prev.lines, msg],
currentText: "",
}));
lineIndexRef.current = lineIdx + 1;
charIndexRef.current = 0;
lastUpdateRef.current = now;
}
rafRef.current = requestAnimationFrame(update);
};
lastUpdateRef.current = performance.now();
rafRef.current = requestAnimationFrame(update);
return () => {
isActive = false;
if (rafRef.current !== undefined) cancelAnimationFrame(rafRef.current);
if (timeoutRef.current !== undefined) clearTimeout(timeoutRef.current);
};
}, []);
const { lines, currentText } = display;
const hasCurrentText = currentText.length > 0;
return (
<div className="min-h-screen bg-gradient-to-br from-neutral-950 via-neutral-900 to-neutral-950 flex flex-col items-center justify-center p-4">
<div className="w-full max-w-md rounded-sm overflow-hidden shadow-2xl border-2 border-neutral-700">
<div className="bg-neutral-800 px-4 py-3 flex items-center gap-2 border-b-2 border-neutral-700">
<div className="w-3 h-3 rounded-none bg-red-500" />
<div className="w-3 h-3 rounded-none bg-yellow-500" />
<div className="w-3 h-3 rounded-none bg-green-500" />
<span className="ml-4 text-neutral-500 text-sm font-mono">
crafting_table
</span>
</div>
<div className="bg-neutral-900 p-6 min-h-[250px] font-mono text-sm">
{lines.map((line, i) => (
<div key={i} className="flex items-center text-neutral-300">
<span className="text-emerald-500 mr-2">/&gt;</span>
<span>{line}</span>
</div>
))}
{hasCurrentText && (
<div className="flex items-center text-neutral-300">
<span className="text-emerald-500 mr-2">/&gt;</span>
<span>{currentText}</span>
</div>
)}
<div className="flex items-center text-neutral-300">
<span className="text-emerald-500 mr-2">/&gt;</span>
<span className="w-2 h-5 bg-emerald-500 animate-pulse" />
</div>
</div>
</div>
<p className="mt-6 text-neutral-500 text-sm font-mono">
Crafting your next great idea...
</p>
</div>
);
}

View File

@@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

View File

@@ -0,0 +1,24 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

View File

@@ -0,0 +1,490 @@
"use client";
import * as React from "react";
import { Example, ExampleWrapper } from "@/components/example";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
PlusIcon,
BluetoothIcon,
MoreVerticalIcon,
FileIcon,
FolderIcon,
FolderOpenIcon,
FileCodeIcon,
MoreHorizontalIcon,
FolderSearchIcon,
SaveIcon,
DownloadIcon,
EyeIcon,
LayoutIcon,
PaletteIcon,
SunIcon,
MoonIcon,
MonitorIcon,
UserIcon,
CreditCardIcon,
SettingsIcon,
KeyboardIcon,
LanguagesIcon,
BellIcon,
MailIcon,
ShieldIcon,
HelpCircleIcon,
FileTextIcon,
LogOutIcon,
} from "lucide-react";
export function ComponentExample() {
return (
<ExampleWrapper>
<CardExample />
<FormExample />
</ExampleWrapper>
);
}
function CardExample() {
return (
<Example title="Card" className="items-center justify-center">
<Card className="relative w-full max-w-sm overflow-hidden pt-0">
<div className="bg-primary absolute inset-0 z-30 aspect-video opacity-50 mix-blend-color" />
<img
src="https://images.unsplash.com/photo-1604076850742-4c7221f3101b?q=80&w=1887&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
alt="Photo by mymind on Unsplash"
title="Photo by mymind on Unsplash"
className="relative z-20 aspect-video w-full object-cover brightness-60 grayscale"
/>
<CardHeader>
<CardTitle>Observability Plus is replacing Monitoring</CardTitle>
<CardDescription>
Switch to the improved way to explore your data, with natural
language. Monitoring will no longer be available on the Pro plan in
November, 2025
</CardDescription>
</CardHeader>
<CardFooter>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button>
<PlusIcon data-icon="inline-start" />
Show Dialog
</Button>
</AlertDialogTrigger>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogMedia>
<BluetoothIcon />
</AlertDialogMedia>
<AlertDialogTitle>Allow accessory to connect?</AlertDialogTitle>
<AlertDialogDescription>
Do you want to allow the USB accessory to connect to this
device?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Don&apos;t allow</AlertDialogCancel>
<AlertDialogAction>Allow</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Badge variant="secondary" className="ml-auto">
Warning
</Badge>
</CardFooter>
</Card>
</Example>
);
}
const frameworks = [
"Next.js",
"SvelteKit",
"Nuxt.js",
"Remix",
"Astro",
] as const;
function FormExample() {
const [notifications, setNotifications] = React.useState({
email: true,
sms: false,
push: true,
});
const [theme, setTheme] = React.useState("light");
return (
<Example title="Form">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>User Information</CardTitle>
<CardDescription>Please fill in your details below</CardDescription>
<CardAction>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreVerticalIcon />
<span className="sr-only">More options</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuGroup>
<DropdownMenuLabel>File</DropdownMenuLabel>
<DropdownMenuItem>
<FileIcon />
New File
<DropdownMenuShortcut>N</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>
<FolderIcon />
New Folder
<DropdownMenuShortcut>N</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<FolderOpenIcon />
Open Recent
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuGroup>
<DropdownMenuLabel>Recent Projects</DropdownMenuLabel>
<DropdownMenuItem>
<FileCodeIcon />
Project Alpha
</DropdownMenuItem>
<DropdownMenuItem>
<FileCodeIcon />
Project Beta
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<MoreHorizontalIcon />
More Projects
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuItem>
<FileCodeIcon />
Project Gamma
</DropdownMenuItem>
<DropdownMenuItem>
<FileCodeIcon />
Project Delta
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<FolderSearchIcon />
Browse...
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem>
<SaveIcon />
Save
<DropdownMenuShortcut>S</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>
<DownloadIcon />
Export
<DropdownMenuShortcut>E</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel>View</DropdownMenuLabel>
<DropdownMenuCheckboxItem
checked={notifications.email}
onCheckedChange={(checked) =>
setNotifications({
...notifications,
email: checked === true,
})
}
>
<EyeIcon />
Show Sidebar
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={notifications.sms}
onCheckedChange={(checked) =>
setNotifications({
...notifications,
sms: checked === true,
})
}
>
<LayoutIcon />
Show Status Bar
</DropdownMenuCheckboxItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<PaletteIcon />
Theme
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuGroup>
<DropdownMenuLabel>Appearance</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={theme}
onValueChange={setTheme}
>
<DropdownMenuRadioItem value="light">
<SunIcon />
Light
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="dark">
<MoonIcon />
Dark
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="system">
<MonitorIcon />
System
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuGroup>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel>Account</DropdownMenuLabel>
<DropdownMenuItem>
<UserIcon />
Profile
<DropdownMenuShortcut>P</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>
<CreditCardIcon />
Billing
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<SettingsIcon />
Settings
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuGroup>
<DropdownMenuLabel>Preferences</DropdownMenuLabel>
<DropdownMenuItem>
<KeyboardIcon />
Keyboard Shortcuts
</DropdownMenuItem>
<DropdownMenuItem>
<LanguagesIcon />
Language
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<BellIcon />
Notifications
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuGroup>
<DropdownMenuLabel>
Notification Types
</DropdownMenuLabel>
<DropdownMenuCheckboxItem
checked={notifications.push}
onCheckedChange={(checked) =>
setNotifications({
...notifications,
push: checked === true,
})
}
>
<BellIcon />
Push Notifications
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={notifications.email}
onCheckedChange={(checked) =>
setNotifications({
...notifications,
email: checked === true,
})
}
>
<MailIcon />
Email Notifications
</DropdownMenuCheckboxItem>
</DropdownMenuGroup>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<ShieldIcon />
Privacy & Security
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<HelpCircleIcon />
Help & Support
</DropdownMenuItem>
<DropdownMenuItem>
<FileTextIcon />
Documentation
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem variant="destructive">
<LogOutIcon />
Sign Out
<DropdownMenuShortcut>Q</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</CardAction>
</CardHeader>
<CardContent>
<form>
<FieldGroup>
<div className="grid grid-cols-2 gap-4">
<Field>
<FieldLabel htmlFor="small-form-name">Name</FieldLabel>
<Input
id="small-form-name"
placeholder="Enter your name"
required
/>
</Field>
<Field>
<FieldLabel htmlFor="small-form-role">Role</FieldLabel>
<Select defaultValue="">
<SelectTrigger id="small-form-role">
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="developer">Developer</SelectItem>
<SelectItem value="designer">Designer</SelectItem>
<SelectItem value="manager">Manager</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</Field>
</div>
<Field>
<FieldLabel htmlFor="small-form-framework">
Framework
</FieldLabel>
<Combobox items={frameworks}>
<ComboboxInput
id="small-form-framework"
placeholder="Select a framework"
required
/>
<ComboboxContent>
<ComboboxEmpty>No frameworks found.</ComboboxEmpty>
<ComboboxList>
{(item) => (
<ComboboxItem key={item} value={item}>
{item}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</Field>
<Field>
<FieldLabel htmlFor="small-form-comments">Comments</FieldLabel>
<Textarea
id="small-form-comments"
placeholder="Add any additional comments"
/>
</Field>
<Field orientation="horizontal">
<Button type="submit">Submit</Button>
<Button variant="outline" type="button">
Cancel
</Button>
</Field>
</FieldGroup>
</form>
</CardContent>
</Card>
</Example>
);
}

View File

@@ -0,0 +1,55 @@
import { cn } from "@/lib/utils";
function ExampleWrapper({ className, ...props }: React.ComponentProps<"div">) {
return (
<div className="bg-background w-full">
<div
data-slot="example-wrapper"
className={cn(
"mx-auto grid min-h-screen w-full max-w-5xl min-w-0 content-center items-start gap-8 p-4 pt-2 sm:gap-12 sm:p-6 md:grid-cols-2 md:gap-8 lg:p-12 2xl:max-w-6xl",
className,
)}
{...props}
/>
</div>
);
}
function Example({
title,
children,
className,
containerClassName,
...props
}: React.ComponentProps<"div"> & {
title?: string;
containerClassName?: string;
}) {
return (
<div
data-slot="example"
className={cn(
"mx-auto flex w-full max-w-lg min-w-0 flex-col gap-1 self-stretch lg:max-w-none",
containerClassName,
)}
{...props}
>
{title && (
<div className="text-muted-foreground px-1.5 py-2 text-xs font-medium">
{title}
</div>
)}
<div
data-slot="example-content"
className={cn(
"bg-background text-foreground flex min-w-0 flex-1 flex-col items-start gap-6 border border-dashed p-4 sm:p-6 *:[div:not([class*='w-'])]:w-full",
className,
)}
>
{children}
</div>
</div>
);
}
export { ExampleWrapper, Example };

View File

@@ -0,0 +1,87 @@
"use client";
import * as React from "react";
import { Accordion as AccordionPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
function Accordion({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return (
<AccordionPrimitive.Root
data-slot="accordion"
className={cn("flex w-full flex-col", className)}
{...props}
/>
);
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("not-last:border-b", className)}
{...props}
/>
);
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:ring-ring/50 focus-visible:border-ring focus-visible:after:border-ring **:data-[slot=accordion-trigger-icon]:text-muted-foreground rounded-lg py-2.5 text-left text-sm font-medium hover:underline focus-visible:ring-[3px] **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 group/accordion-trigger relative flex flex-1 items-start justify-between border border-transparent transition-all outline-none disabled:pointer-events-none disabled:opacity-50",
className,
)}
{...props}
>
{children}
<ChevronDownIcon
data-slot="accordion-trigger-icon"
className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden"
/>
<ChevronUpIcon
data-slot="accordion-trigger-icon"
className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline"
/>
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-open:animate-accordion-down data-closed:animate-accordion-up text-sm overflow-hidden"
{...props}
>
<div
className={cn(
"pt-0 pb-2.5 [&_a]:hover:text-foreground h-(--radix-accordion-content-height) [&_a]:underline [&_a]:underline-offset-3 [&_p:not(:last-child)]:mb-4",
className,
)}
>
{children}
</div>
</AccordionPrimitive.Content>
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@@ -0,0 +1,199 @@
"use client";
import * as React from "react";
import { AlertDialog as AlertDialogPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
);
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
);
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50",
className,
)}
{...props}
/>
);
}
function AlertDialogContent({
className,
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm";
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 bg-background ring-foreground/10 gap-4 rounded-xl p-4 ring-1 duration-100 data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 outline-none",
className,
)}
{...props}
/>
</AlertDialogPortal>
);
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className,
)}
{...props}
/>
);
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4 flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"bg-muted mb-2 inline-flex size-10 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
className,
)}
{...props}
/>
);
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className,
)}
{...props}
/>
);
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn(
"text-muted-foreground *:[a]:hover:text-foreground text-sm text-balance md:text-pretty *:[a]:underline *:[a]:underline-offset-3",
className,
)}
{...props}
/>
);
}
function AlertDialogAction({
className,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Action
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
</Button>
);
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Cancel
data-slot="alert-dialog-cancel"
className={cn(className)}
{...props}
/>
</Button>
);
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
};

View File

@@ -0,0 +1,76 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"grid gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4 w-full relative group/alert",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3",
className,
)}
{...props}
/>
);
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground text-sm text-balance md:text-pretty [&_p:not(:last-child)]:mb-4 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3",
className,
)}
{...props}
/>
);
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-action"
className={cn("absolute top-2 right-2", className)}
{...props}
/>
);
}
export { Alert, AlertTitle, AlertDescription, AlertAction };

View File

@@ -0,0 +1,11 @@
"use client";
import { AspectRatio as AspectRatioPrimitive } from "radix-ui";
function AspectRatio({
...props
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />;
}
export { AspectRatio };

View File

@@ -0,0 +1,112 @@
"use client";
import * as React from "react";
import { Avatar as AvatarPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Avatar({
className,
size = "default",
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
size?: "default" | "sm" | "lg";
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"size-8 rounded-full after:rounded-full data-[size=lg]:size-10 data-[size=sm]:size-6 after:border-border group/avatar relative flex shrink-0 select-none after:absolute after:inset-0 after:border after:mix-blend-darken dark:after:mix-blend-lighten",
className,
)}
{...props}
/>
);
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"rounded-full aspect-square size-full object-cover",
className,
)}
{...props}
/>
);
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted text-muted-foreground rounded-full flex size-full items-center justify-center text-sm group-data-[size=sm]/avatar:text-xs",
className,
)}
{...props}
/>
);
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className,
)}
{...props}
/>
);
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"*:data-[slot=avatar]:ring-background group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2",
className,
)}
{...props}
/>
);
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"bg-muted text-muted-foreground size-8 rounded-full text-sm group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 ring-background relative flex shrink-0 items-center justify-center ring-2",
className,
)}
{...props}
/>
);
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
};

View File

@@ -0,0 +1,49 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! inline-flex items-center justify-center w-fit whitespace-nowrap shrink-0 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive overflow-hidden group/badge",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span";
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,119 @@
import * as React from "react";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react";
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
aria-label="breadcrumb"
data-slot="breadcrumb"
className={cn(className)}
{...props}
/>
);
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground gap-1.5 text-sm flex flex-wrap items-center break-words",
className,
)}
{...props}
/>
);
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("gap-1 inline-flex items-center", className)}
{...props}
/>
);
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "a";
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
);
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
);
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRightIcon />}
</li>
);
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn(
"size-5 [&>svg]:size-4 flex items-center justify-center",
className,
)}
{...props}
>
<MoreHorizontalIcon />
<span className="sr-only">More</span>
</span>
);
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};

View File

@@ -0,0 +1,83 @@
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
const buttonGroupVariants = cva(
"has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
{
variants: {
orientation: {
horizontal:
"[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg! [&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
vertical:
"[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg! flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
},
},
defaultVariants: {
orientation: "horizontal",
},
},
);
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
);
}
function ButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "div";
return (
<Comp
className={cn(
"bg-muted gap-2 rounded-lg border px-2.5 text-sm font-medium [&_svg:not([class*='size-'])]:size-4 flex items-center [&_svg]:pointer-events-none",
className,
)}
{...props}
/>
);
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"bg-input relative self-stretch data-[orientation=horizontal]:mx-px data-[orientation=horizontal]:w-auto data-[orientation=vertical]:my-px data-[orientation=vertical]:h-auto",
className,
)}
{...props}
/>
);
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
};

View File

@@ -0,0 +1,67 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
destructive:
"bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View File

@@ -0,0 +1,223 @@
"use client";
import * as React from "react";
import {
DayPicker,
getDefaultClassNames,
type DayButton,
} from "react-day-picker";
import { cn } from "@/lib/utils";
import { Button, buttonVariants } from "@/components/ui/button";
import {
ChevronLeftIcon,
ChevronRightIcon,
ChevronDownIcon,
} from "lucide-react";
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
}) {
const defaultClassNames = getDefaultClassNames();
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] bg-background group/calendar [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className,
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"flex gap-4 flex-col md:flex-row relative",
defaultClassNames.months,
),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav,
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous,
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next,
),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption,
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns,
),
dropdown_root: cn(
"relative cn-calendar-dropdown-root rounded-(--cell-radius)",
defaultClassNames.dropdown_root,
),
dropdown: cn(
"absolute bg-popover inset-0 opacity-0",
defaultClassNames.dropdown,
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "cn-calendar-caption-label rounded-(--cell-radius) flex items-center gap-1 text-sm [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label,
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground rounded-(--cell-radius) flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday,
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn(
"select-none w-(--cell-size)",
defaultClassNames.week_number_header,
),
week_number: cn(
"text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number,
),
day: cn(
"relative w-full rounded-(--cell-radius) h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius) group/day aspect-square select-none",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)"
: "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)",
defaultClassNames.day,
),
range_start: cn(
"rounded-l-(--cell-radius) bg-muted relative after:bg-muted after:absolute after:inset-y-0 after:w-4 after:right-0 -z-0 isolate",
defaultClassNames.range_start,
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn(
"rounded-r-(--cell-radius) bg-muted relative after:bg-muted-200 after:absolute after:inset-y-0 after:w-4 after:left-0 -z-0 isolate",
defaultClassNames.range_end,
),
today: cn(
"bg-muted text-foreground rounded-(--cell-radius) data-[selected=true]:rounded-none",
defaultClassNames.today,
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside,
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled,
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
);
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
);
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
);
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
);
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
);
},
...components,
}}
{...props}
/>
);
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames();
const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus();
}, [modifiers.focused]);
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-foreground relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className,
)}
{...props}
/>
);
}
export { Calendar, CalendarDayButton };

View File

@@ -0,0 +1,103 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-xl py-4 text-sm ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className,
)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className,
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"bg-muted/50 rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3 flex items-center",
className,
)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};

View File

@@ -0,0 +1,242 @@
"use client";
import * as React from "react";
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins,
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return;
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
} else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext],
);
React.useEffect(() => {
if (!api || !setApi) return;
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) return;
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
api?.off("select", onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel();
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className,
)}
{...props}
/>
</div>
);
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel();
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className,
)}
{...props}
/>
);
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon-sm",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"rounded-full absolute touch-manipulation",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ChevronLeftIcon />
<span className="sr-only">Previous slide</span>
</Button>
);
}
function CarouselNext({
className,
variant = "outline",
size = "icon-sm",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"rounded-full absolute touch-manipulation",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ChevronRightIcon />
<span className="sr-only">Next slide</span>
</Button>
);
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
useCarousel,
};

View File

@@ -0,0 +1,356 @@
"use client";
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "@/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"];
}) {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color,
);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}) {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
className={cn(
"border-border/50 bg-background gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl grid min-w-[8rem] items-start",
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center",
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
},
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}) {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className,
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3",
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
}
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string,
) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};

View File

@@ -0,0 +1,32 @@
"use client";
import * as React from "react";
import { Checkbox as CheckboxPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
import { CheckIcon } from "lucide-react";
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex size-4 items-center justify-center rounded-[4px] border transition-colors group-has-disabled/field:opacity-50 focus-visible:ring-[3px] aria-invalid:ring-[3px] peer relative shrink-0 outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="[&>svg]:size-3.5 grid place-content-center text-current transition-none"
>
<CheckIcon />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };

View File

@@ -0,0 +1,33 @@
"use client";
import { Collapsible as CollapsiblePrimitive } from "radix-ui";
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
);
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
);
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@@ -0,0 +1,302 @@
"use client";
import * as React from "react";
import { Combobox as ComboboxPrimitive } from "@base-ui/react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group";
import { ChevronDownIcon, XIcon, CheckIcon } from "lucide-react";
const Combobox = ComboboxPrimitive.Root;
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />;
}
function ComboboxTrigger({
className,
children,
...props
}: ComboboxPrimitive.Trigger.Props) {
return (
<ComboboxPrimitive.Trigger
data-slot="combobox-trigger"
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground size-4 pointer-events-none" />
</ComboboxPrimitive.Trigger>
);
}
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
return (
<ComboboxPrimitive.Clear
data-slot="combobox-clear"
render={<InputGroupButton variant="ghost" size="icon-xs" />}
className={cn(className)}
{...props}
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.Clear>
);
}
function ComboboxInput({
className,
children,
disabled = false,
showTrigger = true,
showClear = false,
...props
}: ComboboxPrimitive.Input.Props & {
showTrigger?: boolean;
showClear?: boolean;
}) {
return (
<InputGroup className={cn("w-auto", className)}>
<ComboboxPrimitive.Input
render={<InputGroupInput disabled={disabled} />}
{...props}
/>
<InputGroupAddon align="inline-end">
{showTrigger && (
<InputGroupButton
size="icon-xs"
variant="ghost"
asChild
data-slot="input-group-button"
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
disabled={disabled}
>
<ComboboxTrigger />
</InputGroupButton>
)}
{showClear && <ComboboxClear disabled={disabled} />}
</InputGroupAddon>
{children}
</InputGroup>
);
}
function ComboboxContent({
className,
side = "bottom",
sideOffset = 6,
align = "start",
alignOffset = 0,
anchor,
...props
}: ComboboxPrimitive.Popup.Props &
Pick<
ComboboxPrimitive.Positioner.Props,
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
>) {
return (
<ComboboxPrimitive.Portal>
<ComboboxPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
anchor={anchor}
className="isolate z-50"
>
<ComboboxPrimitive.Popup
data-slot="combobox-content"
data-chips={!!anchor}
className={cn(
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:border-input/30 max-h-72 min-w-36 overflow-hidden rounded-lg shadow-md ring-1 duration-100 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:shadow-none group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) data-[chips=true]:min-w-(--anchor-width)",
className,
)}
{...props}
/>
</ComboboxPrimitive.Positioner>
</ComboboxPrimitive.Portal>
);
}
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
return (
<ComboboxPrimitive.List
data-slot="combobox-list"
className={cn(
"no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto p-1 data-empty:p-0 overflow-y-auto overscroll-contain",
className,
)}
{...props}
/>
);
}
function ComboboxItem({
className,
children,
...props
}: ComboboxPrimitive.Item.Props) {
return (
<ComboboxPrimitive.Item
data-slot="combobox-item"
className={cn(
"data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground gap-2 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*='size-'])]:size-4 relative flex w-full cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
>
{children}
<ComboboxPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</ComboboxPrimitive.ItemIndicator>
</ComboboxPrimitive.Item>
);
}
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
return (
<ComboboxPrimitive.Group
data-slot="combobox-group"
className={cn(className)}
{...props}
/>
);
}
function ComboboxLabel({
className,
...props
}: ComboboxPrimitive.GroupLabel.Props) {
return (
<ComboboxPrimitive.GroupLabel
data-slot="combobox-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
);
}
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
return (
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
);
}
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
return (
<ComboboxPrimitive.Empty
data-slot="combobox-empty"
className={cn(
"text-muted-foreground hidden w-full justify-center py-2 text-center text-sm group-data-empty/combobox-content:flex",
className,
)}
{...props}
/>
);
}
function ComboboxSeparator({
className,
...props
}: ComboboxPrimitive.Separator.Props) {
return (
<ComboboxPrimitive.Separator
data-slot="combobox-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function ComboboxChips({
className,
...props
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
ComboboxPrimitive.Chips.Props) {
return (
<ComboboxPrimitive.Chips
data-slot="combobox-chips"
className={cn(
"dark:bg-input/30 border-input focus-within:border-ring focus-within:ring-ring/50 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40 has-aria-invalid:border-destructive dark:has-aria-invalid:border-destructive/50 flex min-h-8 flex-wrap items-center gap-1 rounded-lg border bg-transparent bg-clip-padding px-2.5 py-1 text-sm transition-colors focus-within:ring-[3px] has-aria-invalid:ring-[3px] has-data-[slot=combobox-chip]:px-1",
className,
)}
{...props}
/>
);
}
function ComboboxChip({
className,
children,
showRemove = true,
...props
}: ComboboxPrimitive.Chip.Props & {
showRemove?: boolean;
}) {
return (
<ComboboxPrimitive.Chip
data-slot="combobox-chip"
className={cn(
"bg-muted text-foreground flex h-[calc(--spacing(5.25))] w-fit items-center justify-center gap-1 rounded-sm px-1.5 text-xs font-medium whitespace-nowrap has-data-[slot=combobox-chip-remove]:pr-0 has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50",
className,
)}
{...props}
>
{children}
{showRemove && (
<ComboboxPrimitive.ChipRemove
render={<Button variant="ghost" size="icon-xs" />}
className="-ml-1 opacity-50 hover:opacity-100"
data-slot="combobox-chip-remove"
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.ChipRemove>
)}
</ComboboxPrimitive.Chip>
);
}
function ComboboxChipsInput({
className,
...props
}: ComboboxPrimitive.Input.Props) {
return (
<ComboboxPrimitive.Input
data-slot="combobox-chip-input"
className={cn("min-w-16 flex-1 outline-none", className)}
{...props}
/>
);
}
function useComboboxAnchor() {
return React.useRef<HTMLDivElement | null>(null);
}
export {
Combobox,
ComboboxInput,
ComboboxContent,
ComboboxList,
ComboboxItem,
ComboboxGroup,
ComboboxLabel,
ComboboxCollection,
ComboboxEmpty,
ComboboxSeparator,
ComboboxChips,
ComboboxChip,
ComboboxChipsInput,
ComboboxTrigger,
ComboboxValue,
useComboboxAnchor,
};

View File

@@ -0,0 +1,192 @@
"use client";
import * as React from "react";
import { Command as CommandPrimitive } from "cmdk";
import { cn } from "@/lib/utils";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { InputGroup, InputGroupAddon } from "@/components/ui/input-group";
import { SearchIcon, CheckIcon } from "lucide-react";
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground rounded-xl! p-1 flex size-full flex-col overflow-hidden",
className,
)}
{...props}
/>
);
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = false,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string;
description?: string;
className?: string;
showCloseButton?: boolean;
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn(
"rounded-xl! top-1/3 translate-y-0 overflow-hidden p-0",
className,
)}
showCloseButton={showCloseButton}
>
{children}
</DialogContent>
</Dialog>
);
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div data-slot="command-input-wrapper" className="p-1 pb-0">
<InputGroup className="bg-input/30 border-input/30 h-8! rounded-lg! shadow-none! *:data-[slot=input-group-addon]:pl-2!">
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
<InputGroupAddon>
<SearchIcon className="size-4 shrink-0 opacity-50" />
</InputGroupAddon>
</InputGroup>
</div>
);
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"no-scrollbar max-h-72 scroll-py-1 outline-none overflow-x-hidden overflow-y-auto",
className,
)}
{...props}
/>
);
}
function CommandEmpty({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className={cn("py-6 text-center text-sm", className)}
{...props}
/>
);
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className,
)}
{...props}
/>
);
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
);
}
function CommandItem({
className,
children,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-selected:bg-muted data-selected:text-foreground data-selected:*:[svg]:text-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none [&_svg:not([class*='size-'])]:size-4 [[data-slot=dialog-content]_&]:rounded-lg! group/command-item data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
>
{children}
<CheckIcon className="ml-auto opacity-0 group-has-[[data-slot=command-shortcut]]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
</CommandPrimitive.Item>
);
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground group-data-selected/command-item:text-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View File

@@ -0,0 +1,259 @@
"use client";
import * as React from "react";
import { ContextMenu as ContextMenuPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
import { ChevronRightIcon, CheckIcon } from "lucide-react";
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
}
function ContextMenuTrigger({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger
data-slot="context-menu-trigger"
className={cn("select-none", className)}
{...props}
/>
);
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
);
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
);
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />;
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
);
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left";
}) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-36 rounded-lg p-1 shadow-md ring-1 duration-100 z-50 max-h-(--radix-context-menu-content-available-height) origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto",
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
);
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive focus:*:[svg]:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm [&_svg:not([class*='size-'])]:size-4 group/context-menu-item relative flex cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
/>
);
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm [&_svg:not([class*='size-'])]:size-4 flex cursor-default items-center outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
);
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-popover text-popover-foreground min-w-32 rounded-lg border p-1 shadow-lg duration-100 z-50 origin-(--radix-context-menu-content-transform-origin) overflow-hidden",
className,
)}
{...props}
/>
);
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
checked={checked}
{...props}
>
<span className="absolute right-2 pointer-events-none">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
);
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
>
<span className="absolute right-2 pointer-events-none">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
);
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"text-muted-foreground px-1.5 py-1 text-xs font-medium data-[inset]:pl-8",
className,
)}
{...props}
/>
);
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"text-muted-foreground group-focus/context-menu-item:text-accent-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};

View File

@@ -0,0 +1,164 @@
"use client";
import * as React from "react";
import { Dialog as DialogPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { XIcon } from "lucide-react";
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-4 rounded-xl p-4 text-sm ring-1 duration-100 sm:max-w-sm fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
>
<XIcon />
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("gap-2 flex flex-col", className)}
{...props}
/>
);
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-base leading-none font-medium", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3",
className,
)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View File

@@ -0,0 +1,131 @@
"use client";
import * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "@/lib/utils";
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50",
className,
)}
{...props}
/>
);
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"bg-background flex h-auto flex-col text-sm data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-xl data-[vaul-drawer-direction=bottom]:border-t data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:rounded-r-xl data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:rounded-l-xl data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-xl data-[vaul-drawer-direction=top]:border-b data-[vaul-drawer-direction=left]:sm:max-w-sm data-[vaul-drawer-direction=right]:sm:max-w-sm group/drawer-content fixed z-50",
className,
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-1 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block bg-muted mx-auto hidden shrink-0 group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
);
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-0.5 md:text-left flex flex-col",
className,
)}
{...props}
/>
);
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("gap-2 p-4 mt-auto flex flex-col", className)}
{...props}
/>
);
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground text-base font-medium", className)}
{...props}
/>
);
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};

View File

@@ -0,0 +1,265 @@
"use client";
import * as React from "react";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
import { CheckIcon, ChevronRightIcon } from "lucide-react";
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
);
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
);
}
function DropdownMenuContent({
className,
align = "start",
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
align={align}
className={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-32 rounded-lg p-1 shadow-md ring-1 duration-100 z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto data-[state=closed]:overflow-hidden",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm [&_svg:not([class*='size-'])]:size-4 group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center pointer-events-none"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center pointer-events-none"
data-slot="dropdown-menu-radio-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"text-muted-foreground px-1.5 py-1 text-xs font-medium data-[inset]:pl-8",
className,
)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm [&_svg:not([class*='size-'])]:size-4 flex cursor-default items-center outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-[96px] rounded-md p-1 shadow-lg ring-1 duration-100 z-50 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden",
className,
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};

View File

@@ -0,0 +1,101 @@
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"gap-4 rounded-lg border-dashed p-6 flex w-full min-w-0 flex-1 flex-col items-center justify-center text-center text-balance",
className,
)}
{...props}
/>
);
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn("gap-2 flex max-w-sm flex-col items-center", className)}
{...props}
/>
);
}
const emptyMediaVariants = cva(
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "bg-muted text-foreground flex size-8 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-4",
},
},
defaultVariants: {
variant: "default",
},
},
);
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
);
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn("text-sm font-medium tracking-tight", className)}
{...props}
/>
);
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-sm/relaxed text-muted-foreground [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className,
)}
{...props}
/>
);
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"gap-2.5 text-sm flex w-full max-w-sm min-w-0 flex-col items-center text-balance",
className,
)}
{...props}
/>
);
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
};

View File

@@ -0,0 +1,238 @@
"use client";
import { useMemo } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3 flex flex-col",
className,
)}
{...props}
/>
);
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
className,
)}
{...props}
/>
);
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"gap-5 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4 group/field-group @container/field-group flex w-full flex-col",
className,
)}
{...props}
/>
);
}
const fieldVariants = cva(
"data-[invalid=true]:text-destructive gap-2 group/field flex w-full",
{
variants: {
orientation: {
vertical: "flex-col [&>*]:w-full [&>.sr-only]:w-auto",
horizontal:
"flex-row items-center [&>[data-slot=field-label]]:flex-auto has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
responsive:
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto @md/field-group:[&>[data-slot=field-label]]:flex-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
},
},
defaultVariants: {
orientation: "vertical",
},
},
);
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
);
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"gap-0.5 group/field-content flex flex-1 flex-col leading-snug",
className,
)}
{...props}
/>
);
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"has-data-checked:bg-primary/5 has-data-checked:border-primary dark:has-data-checked:bg-primary/10 gap-2 group-data-[disabled=true]/field:opacity-50 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-2.5 group/field-label peer/field-label flex w-fit leading-snug",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
className,
)}
{...props}
/>
);
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50 flex w-fit items-center leading-snug",
className,
)}
{...props}
/>
);
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-muted-foreground text-left text-sm [[data-variant=legend]+&]:-mt-1.5 leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
"last:mt-0 nth-last-2:-mt-1",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className,
)}
{...props}
/>
);
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode;
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"-my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2 relative",
className,
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="text-muted-foreground px-2 bg-background relative mx-auto block w-fit"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
);
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>;
}) {
const content = useMemo(() => {
if (children) {
return children;
}
if (!errors?.length) {
return null;
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
];
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message;
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>,
)}
</ul>
);
}, [children, errors]);
if (!content) {
return null;
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-destructive text-sm font-normal", className)}
{...props}
>
{content}
</div>
);
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
};

View File

@@ -0,0 +1,44 @@
"use client";
import * as React from "react";
import { HoverCard as HoverCardPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
);
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground w-64 rounded-lg p-2.5 text-sm shadow-md ring-1 duration-100 z-50 origin-(--radix-hover-card-content-transform-origin) outline-hidden",
className,
)}
{...props}
/>
</HoverCardPrimitive.Portal>
);
}
export { HoverCard, HoverCardTrigger, HoverCardContent };

View File

@@ -0,0 +1,156 @@
"use client";
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"border-input dark:bg-input/30 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-disabled:bg-input/50 dark:has-disabled:bg-input/80 h-8 rounded-lg border transition-colors has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot][aria-invalid=true]]:ring-[3px] has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5 [[data-slot=combobox-content]_&]:focus-within:border-inherit [[data-slot=combobox-content]_&]:focus-within:ring-0 group/input-group relative flex w-full min-w-0 items-center outline-none has-[>textarea]:h-auto",
className,
)}
{...props}
/>
);
}
const inputGroupAddonVariants = cva(
"text-muted-foreground h-auto gap-2 py-1.5 text-sm font-medium group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4 flex cursor-text items-center justify-center select-none",
{
variants: {
align: {
"inline-start":
"pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem] order-first",
"inline-end":
"pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem] order-last",
"block-start":
"px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2 order-first w-full justify-start",
"block-end":
"px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2 order-last w-full justify-start",
},
},
defaultVariants: {
align: "inline-start",
},
},
);
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return;
}
e.currentTarget.parentElement?.querySelector("input")?.focus();
}}
{...props}
/>
);
}
const inputGroupButtonVariants = cva(
"gap-2 text-sm shadow-none flex items-center",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
},
);
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
);
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"text-muted-foreground gap-2 text-sm [&_svg:not([class*='size-'])]:size-4 flex items-center [&_svg]:pointer-events-none",
className,
)}
{...props}
/>
);
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent flex-1",
className,
)}
{...props}
/>
);
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent flex-1 resize-none",
className,
)}
{...props}
/>
);
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
};

View File

@@ -0,0 +1,19 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors file:h-6 file:text-sm file:font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Input };

View File

@@ -0,0 +1,196 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
role="list"
data-slot="item-group"
className={cn(
"gap-4 has-[[data-size=sm]]:gap-2.5 has-[[data-size=xs]]:gap-2 group/item-group flex w-full flex-col",
className,
)}
{...props}
/>
);
}
function ItemSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="item-separator"
orientation="horizontal"
className={cn("my-2", className)}
{...props}
/>
);
}
const itemVariants = cva(
"[a]:hover:bg-muted rounded-lg border text-sm w-full group/item focus-visible:border-ring focus-visible:ring-ring/50 flex items-center flex-wrap outline-none transition-colors duration-100 focus-visible:ring-[3px] [a]:transition-colors",
{
variants: {
variant: {
default: "border-transparent",
outline: "border-border",
muted: "bg-muted/50 border-transparent",
},
size: {
default: "gap-2.5 px-3 py-2.5",
sm: "gap-2.5 px-3 py-2.5",
xs: "gap-2 px-2.5 py-2 [[data-slot=dropdown-menu-content]_&]:p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Item({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "div";
return (
<Comp
data-slot="item"
data-variant={variant}
data-size={size}
className={cn(itemVariants({ variant, size, className }))}
{...props}
/>
);
}
const itemMediaVariants = cva(
"gap-2 group-has-[[data-slot=item-description]]/item:translate-y-0.5 group-has-[[data-slot=item-description]]/item:self-start flex shrink-0 items-center justify-center [&_svg]:pointer-events-none",
{
variants: {
variant: {
default: "bg-transparent",
icon: "[&_svg:not([class*='size-'])]:size-4",
image:
"size-10 overflow-hidden rounded-sm group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 [&_img]:size-full [&_img]:object-cover",
},
},
defaultVariants: {
variant: "default",
},
},
);
function ItemMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
return (
<div
data-slot="item-media"
data-variant={variant}
className={cn(itemMediaVariants({ variant, className }))}
{...props}
/>
);
}
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-content"
className={cn(
"gap-1 group-data-[size=xs]/item:gap-0 flex flex-1 flex-col [&+[data-slot=item-content]]:flex-none",
className,
)}
{...props}
/>
);
}
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-title"
className={cn(
"gap-2 text-sm leading-snug font-medium underline-offset-4 line-clamp-1 flex w-fit items-center",
className,
)}
{...props}
/>
);
}
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="item-description"
className={cn(
"text-muted-foreground text-left text-sm leading-normal group-data-[size=xs]/item:text-xs [&>a:hover]:text-primary line-clamp-2 font-normal [&>a]:underline [&>a]:underline-offset-4",
className,
)}
{...props}
/>
);
}
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-actions"
className={cn("gap-2 flex items-center", className)}
{...props}
/>
);
}
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-header"
className={cn(
"gap-2 flex basis-full items-center justify-between",
className,
)}
{...props}
/>
);
}
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-footer"
className={cn(
"gap-2 flex basis-full items-center justify-between",
className,
)}
{...props}
/>
);
}
export {
Item,
ItemMedia,
ItemContent,
ItemActions,
ItemGroup,
ItemSeparator,
ItemTitle,
ItemDescription,
ItemHeader,
ItemFooter,
};

View File

@@ -0,0 +1,26 @@
import { cn } from "@/lib/utils";
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
return (
<kbd
data-slot="kbd"
className={cn(
"bg-muted text-muted-foreground [[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10 h-5 w-fit min-w-5 gap-1 rounded-sm px-1 font-sans text-xs font-medium [&_svg:not([class*='size-'])]:size-3 pointer-events-none inline-flex items-center justify-center select-none",
className,
)}
{...props}
/>
);
}
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<kbd
data-slot="kbd-group"
className={cn("gap-1 inline-flex items-center", className)}
{...props}
/>
);
}
export { Kbd, KbdGroup };

Some files were not shown because too many files have changed in this diff Show More