Compare commits

..

51 Commits

Author SHA1 Message Date
Justin Tahara
d50a5e0e27 chore(helm): Bumping Python Sandbox to v0.3.2 (#9955) 2026-04-06 22:55:14 +00:00
Evan Lohn
697a679409 chore: context gitignore (#9949) 2026-04-06 22:44:23 +00:00
Raunak Bhagat
0c95650176 fix(llm-config): extract first-class fields from custom provider key-value list (#9945) 2026-04-06 22:00:44 +00:00
Raunak Bhagat
0d3a6b255b chore: update custom LLM modal descriptions (#9946) 2026-04-06 21:55:31 +00:00
Raunak Bhagat
01748efe6a refactor: clean up KeyValueInput and EmptyMessageCard (#9947) 2026-04-06 21:18:45 +00:00
dependabot[bot]
de6c4f4a51 chore(deps-dev): bump vite from 7.3.1 to 7.3.2 in /widget (#9950)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-06 14:22:24 -07:00
dependabot[bot]
689f61ce08 chore(deps-dev): bump vite from 6.4.1 to 6.4.2 in /web (#9944)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jamison Lahman <jamison@lahman.dev>
2026-04-06 20:23:33 +00:00
acaprau
dec836a172 chore(db): Add env var for multiple postgres hosts (#9942) 2026-04-06 19:52:04 +00:00
dependabot[bot]
b6e623ef5c chore(deps): bump actions/stale from 10.1.1 to 10.2.0 (#9936)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-06 12:45:26 -07:00
Wenxi
ec9e340656 fix: set correct ee mode for mcp server (#9933) 2026-04-06 17:44:42 +00:00
dependabot[bot]
885006cb7a chore(deps): bump softprops/action-gh-release from 2.2.2 to 2.6.1 (#9935)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-06 10:47:44 -07:00
dependabot[bot]
472073cac0 chore(deps): bump azure/setup-helm from 4.3.1 to 5.0.0 (#9934)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-06 10:46:39 -07:00
Evan Lohn
5e61659e3a chore: bump sleep time in flaky test (#9900) 2026-04-06 16:22:29 +00:00
Alex Kim
7b18949b63 feat(helm): add optional CA certificate update step to api-server startup (#9378)
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2026-04-06 15:51:21 +00:00
Wenxi
efe51c108e refactor: remove dead LLM provider code from chat page load path (#9925) 2026-04-06 04:33:57 +00:00
Nikolas Garza
c092d16c01 feat(chat): add multi-model selector and chat hook (#9854) 2026-04-05 23:01:32 +00:00
Nikolas Garza
da715eaa58 fix(federated): prevent masked credentials from corrupting stored secrets (#9868) 2026-04-05 22:41:39 +00:00
Wenxi
bb18d39765 chore: rm remnants of old kombu psql broker code (#9924) 2026-04-05 20:18:47 +00:00
Raunak Bhagat
abc2cd5572 refactor: flatten opal card layouts, add children to CardHeaderLayout (#9907) 2026-04-04 02:50:55 +00:00
Raunak Bhagat
a704acbf73 fix: Edit AccountPopover + Separator's appearances when folded (#9906) 2026-04-04 01:24:59 +00:00
Jamison Lahman
8737122133 Revert "chore(deps): bump litellm from 1.81.6 to 1.83.0 (#9898)" (#9908) 2026-04-03 18:06:54 -07:00
Raunak Bhagat
c5d7cfa896 refactor: rework admin sidebar footer (#9895) 2026-04-04 00:08:42 +00:00
Jamison Lahman
297c931191 feat(cli): render markdown while streaming (experiment) (#9893)
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-04-03 16:18:46 -07:00
dependabot[bot]
ae343c718b chore(deps): bump litellm from 1.81.6 to 1.83.0 (#9898)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jamison Lahman <jamison@lahman.dev>
2026-04-03 22:44:19 +00:00
Justin Tahara
ce39442478 fix(mt): Update Preprovision Workflow (#9896) 2026-04-03 22:22:55 +00:00
Raunak Bhagat
256996f27c fix: Edit bifrost colour (#9897) 2026-04-03 22:11:22 +00:00
Jamison Lahman
9dbe7acac6 fix(mobile): sidebar overlaps content on medium-sized screens (#9870) 2026-04-03 14:36:52 -07:00
Evan Lohn
8d43d73f83 fix: user files deleted by cleanup task (#9890) 2026-04-03 21:28:18 +00:00
Jessica Singh
559bac9f78 fix(notion): extract people properties and inline table content (#9891) 2026-04-03 20:39:53 +00:00
Jamison Lahman
e81bbe6f69 fix(mobile): update sidebar responsiveness (#9862) 2026-04-03 13:31:24 -07:00
Jamison Lahman
b59f8cf453 feat(cli): onyx install-skill (#9889) 2026-04-03 12:41:39 -07:00
Bo-Onyx
456ecc7b9a feat(hook): UI improve disconnect error popover (#9877) 2026-04-03 19:15:19 +00:00
Jamison Lahman
fdc2bc9ee2 fix(fe): closed sidebar button tooltip text color (#9876) 2026-04-03 18:57:48 +00:00
Jamison Lahman
1c3f371549 fix(fe): projects buttons transition in like other sidebar items (#9875) 2026-04-03 18:50:14 +00:00
Evan Lohn
a120add37b feat: filestore delete missing error (#9878) 2026-04-03 18:19:41 +00:00
Raunak Bhagat
757e4e979b feat: cluster disabled admin sidebar tabs at the bottom (#9867) 2026-04-03 18:01:03 +00:00
Wenxi
cbcdfee56e fix(mcp server): propagate detailed error messages to mcp client instead of generic message and migrate to OnyxError (#9880) 2026-04-03 16:29:22 +00:00
Jamison Lahman
b06700314b fix(fe): fix sticky sidebar headers overlapping scrollbars (#9884) 2026-04-03 16:16:10 +00:00
roshan
01f573cdcb feat(cli): make onyx-cli agent-friendly (#9874)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 16:08:57 +00:00
Jamison Lahman
d4a96d70f3 fix(desktop): prefer native scrollbar styling (#9879) 2026-04-03 00:33:18 +00:00
Evan Lohn
5b000c2173 chore: remove unused db rows (#9869) 2026-04-02 22:17:10 +00:00
acaprau
d62af28e40 fix(opensearch): Doc IDs whose length would exceed OpenSearch's ID length are hashed (#9847) 2026-04-02 21:35:17 +00:00
acaprau
593678a14f fix(opensearch): Re-order migration task logic to not hold DB sessions too long (#9872) 2026-04-02 21:26:08 +00:00
roshan
e6f7c2b45c feat(install): add GitHub star prompt at end of install script (#9861) 2026-04-02 19:12:10 +00:00
Raunak Bhagat
f77128d929 refactor: move SidebarTab to Opal with disabled prop and variant/state API (v2) (#9866) 2026-04-02 19:07:52 +00:00
Jamison Lahman
1d4ca769e7 chore(playwright): stabalize icon loading, users table timestamp (#9864) 2026-04-02 18:58:28 +00:00
Raunak Bhagat
e002f6c195 Revert "refactor: move SidebarTab to opal" (#9865) 2026-04-02 11:38:03 -07:00
Raunak Bhagat
10d696262f refactor: move SidebarTab to opal (#9863) 2026-04-02 18:22:19 +00:00
Jamison Lahman
608e151443 fix(offline): fallback to system sans-serif font (#9860)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 17:26:57 +00:00
Raunak Bhagat
41d1a33093 refactor: simplify opal/Disabled by removing its context (#9852) 2026-04-02 17:12:01 +00:00
Bo-Onyx
f396ebbdbb feat(hook): Show connection lost status (#9848) 2026-04-02 16:58:28 +00:00
145 changed files with 5195 additions and 2524 deletions

View File

@@ -1,186 +0,0 @@
---
name: onyx-cli
description: Query the Onyx knowledge base using the onyx-cli command. Use when the user wants to search company documents, ask questions about internal knowledge, query connected data sources, or look up information stored in Onyx.
---
# Onyx CLI — Agent Tool
Onyx is an enterprise search and Gen-AI platform that connects to company documents, apps, and people. The `onyx-cli` CLI provides non-interactive commands to query the Onyx knowledge base and list available agents.
## Prerequisites
### 1. Check if installed
```bash
which onyx-cli
```
### 2. Install (if needed)
**Primary — pip:**
```bash
pip install onyx-cli
```
**From source (Go):**
```bash
cd cli && go build -o onyx-cli . && sudo mv onyx-cli /usr/local/bin/
```
### 3. Check if configured
```bash
onyx-cli validate-config
```
This checks the config file exists, API key is present, and tests the server connection via `/api/me`. Exit code 0 on success, non-zero with a descriptive error on failure.
If unconfigured, you have two options:
**Option A — Interactive setup (requires user input):**
```bash
onyx-cli configure
```
This prompts for the Onyx server URL and API key, tests the connection, and saves config.
**Option B — Environment variables (non-interactive, preferred for agents):**
```bash
export ONYX_SERVER_URL="https://your-onyx-server.com" # default: https://cloud.onyx.app
export ONYX_API_KEY="your-api-key"
```
Environment variables override the config file. If these are set, no config file is needed.
| Variable | Required | Description |
|----------|----------|-------------|
| `ONYX_SERVER_URL` | No | Onyx server base URL (default: `https://cloud.onyx.app`) |
| `ONYX_API_KEY` | Yes | API key for authentication |
| `ONYX_PERSONA_ID` | No | Default agent/persona ID |
If neither the config file nor environment variables are set, tell the user that `onyx-cli` needs to be configured and ask them to either:
- Run `onyx-cli configure` interactively, or
- Set `ONYX_SERVER_URL` and `ONYX_API_KEY` environment variables
## Commands
### Validate configuration
```bash
onyx-cli validate-config
```
Checks config file exists, API key is present, and tests the server connection. Use this before `ask` or `agents` to confirm the CLI is properly set up.
### List available agents
```bash
onyx-cli agents
```
Prints a table of agent IDs, names, and descriptions. Use `--json` for structured output:
```bash
onyx-cli agents --json
```
Use agent IDs with `ask --agent-id` to query a specific agent.
### Basic query (plain text output)
```bash
onyx-cli ask "What is our company's PTO policy?"
```
Streams the answer as plain text to stdout. Exit code 0 on success, non-zero on error.
### JSON output (structured events)
```bash
onyx-cli ask --json "What authentication methods do we support?"
```
Outputs JSON-encoded parsed stream events (one object per line). Key event objects include message deltas, stop, errors, search-start, and citation payloads.
Each line is a JSON object with this envelope:
```json
{"type": "<event_type>", "event": { ... }}
```
| Event Type | Description |
|------------|-------------|
| `message_delta` | Content token — concatenate all `content` fields for the full answer |
| `stop` | Stream complete |
| `error` | Error with `error` message field |
| `search_tool_start` | Onyx started searching documents |
| `citation_info` | Source citation — see shape below |
`citation_info` event shape:
```json
{
"type": "citation_info",
"event": {
"citation_number": 1,
"document_id": "abc123def456",
"placement": {"turn_index": 0, "tab_index": 0, "sub_turn_index": null}
}
}
```
`placement` is metadata about where in the conversation the citation appeared and can be ignored for most use cases.
### Specify an agent
```bash
onyx-cli ask --agent-id 5 "Summarize our Q4 roadmap"
```
Uses a specific Onyx agent/persona instead of the default.
### All flags
| Flag | Type | Description |
|------|------|-------------|
| `--agent-id` | int | Agent ID to use (overrides default) |
| `--json` | bool | Output raw NDJSON events instead of plain text |
## Statelessness
Each `onyx-cli ask` call creates an independent chat session. There is no built-in way to chain context across multiple `ask` invocations — every call starts fresh. If you need multi-turn conversation with memory, use the interactive TUI (`onyx-cli` or `onyx-cli chat`) instead.
## When to Use
Use `onyx-cli ask` when:
- The user asks about company-specific information (policies, docs, processes)
- You need to search internal knowledge bases or connected data sources
- The user references Onyx, asks you to "search Onyx", or wants to query their documents
- You need context from company wikis, Confluence, Google Drive, Slack, or other connected sources
Do NOT use when:
- The question is about general programming knowledge (use your own knowledge)
- The user is asking about code in the current repository (use grep/read tools)
- The user hasn't mentioned Onyx and the question doesn't require internal company data
## Examples
```bash
# Simple question
onyx-cli ask "What are the steps to deploy to production?"
# Get structured output for parsing
onyx-cli ask --json "List all active API integrations"
# Use a specialized agent
onyx-cli ask --agent-id 3 "What were the action items from last week's standup?"
# Pipe the answer into another command
onyx-cli ask "What is the database schema for users?" | head -20
```

View File

@@ -0,0 +1 @@
../../../cli/internal/embedded/SKILL.md

View File

@@ -228,7 +228,7 @@ jobs:
- name: Create GitHub Release
id: create-release
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # ratchet:softprops/action-gh-release@v2
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # ratchet:softprops/action-gh-release@v2
with:
tag_name: ${{ steps.release-tag.outputs.tag }}
name: ${{ steps.release-tag.outputs.tag }}

View File

@@ -21,7 +21,7 @@ jobs:
persist-credentials: false
- name: Install Helm CLI
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # ratchet:azure/setup-helm@v4
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # ratchet:azure/setup-helm@v5.0.0
with:
version: v3.12.1

View File

@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # ratchet:actions/stale@v10
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # ratchet:actions/stale@v10
with:
stale-issue-message: 'This issue is stale because it has been open 75 days with no activity. Remove stale label or comment or this will be closed in 15 days.'
stale-pr-message: 'This PR is stale because it has been open 75 days with no activity. Remove stale label or comment or this will be closed in 15 days.'

View File

@@ -36,7 +36,7 @@ jobs:
persist-credentials: false
- name: Set up Helm
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # ratchet:azure/setup-helm@v4.3.1
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # ratchet:azure/setup-helm@v5.0.0
with:
version: v3.19.0

3
.gitignore vendored
View File

@@ -59,3 +59,6 @@ node_modules
# plans
plans/
# Added context for LLMs
onyx-llm-context/

View File

@@ -1,4 +1,4 @@
from typing import Any, Literal
from typing import Any
from onyx.db.engine.iam_auth import get_iam_auth_token
from onyx.configs.app_configs import USE_IAM_AUTH
from onyx.configs.app_configs import POSTGRES_HOST
@@ -19,7 +19,6 @@ from logging.config import fileConfig
from alembic import context
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.sql.schema import SchemaItem
from onyx.configs.constants import SSL_CERT_FILE
from shared_configs.configs import (
MULTI_TENANT,
@@ -45,8 +44,6 @@ if config.config_file_name is not None and config.attributes.get(
target_metadata = [Base.metadata, ResultModelBase.metadata]
EXCLUDE_TABLES = {"kombu_queue", "kombu_message"}
logger = logging.getLogger(__name__)
ssl_context: ssl.SSLContext | None = None
@@ -56,25 +53,6 @@ if USE_IAM_AUTH:
ssl_context = ssl.create_default_context(cafile=SSL_CERT_FILE)
def include_object(
object: SchemaItem, # noqa: ARG001
name: str | None,
type_: Literal[
"schema",
"table",
"column",
"index",
"unique_constraint",
"foreign_key_constraint",
],
reflected: bool, # noqa: ARG001
compare_to: SchemaItem | None, # noqa: ARG001
) -> bool:
if type_ == "table" and name in EXCLUDE_TABLES:
return False
return True
def filter_tenants_by_range(
tenant_ids: list[str], start_range: int | None = None, end_range: int | None = None
) -> list[str]:
@@ -231,7 +209,6 @@ def do_run_migrations(
context.configure(
connection=connection,
target_metadata=target_metadata, # type: ignore
include_object=include_object,
version_table_schema=schema_name,
include_schemas=True,
compare_type=True,
@@ -405,7 +382,6 @@ def run_migrations_offline() -> None:
url=url,
target_metadata=target_metadata, # type: ignore
literal_binds=True,
include_object=include_object,
version_table_schema=schema,
include_schemas=True,
script_location=config.get_main_option("script_location"),
@@ -447,7 +423,6 @@ def run_migrations_offline() -> None:
url=url,
target_metadata=target_metadata, # type: ignore
literal_binds=True,
include_object=include_object,
version_table_schema=schema,
include_schemas=True,
script_location=config.get_main_option("script_location"),
@@ -490,7 +465,6 @@ def run_migrations_online() -> None:
context.configure(
connection=connection,
target_metadata=target_metadata, # type: ignore
include_object=include_object,
version_table_schema=schema_name,
include_schemas=True,
compare_type=True,

View File

@@ -1,11 +1,9 @@
import asyncio
from logging.config import fileConfig
from typing import Literal
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.schema import SchemaItem
from alembic import context
from onyx.db.engine.sql_engine import build_connection_string
@@ -35,27 +33,6 @@ target_metadata = [PublicBase.metadata]
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
EXCLUDE_TABLES = {"kombu_queue", "kombu_message"}
def include_object(
object: SchemaItem, # noqa: ARG001
name: str | None,
type_: Literal[
"schema",
"table",
"column",
"index",
"unique_constraint",
"foreign_key_constraint",
],
reflected: bool, # noqa: ARG001
compare_to: SchemaItem | None, # noqa: ARG001
) -> bool:
if type_ == "table" and name in EXCLUDE_TABLES:
return False
return True
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
@@ -85,7 +62,6 @@ def do_run_migrations(connection: Connection) -> None:
context.configure(
connection=connection,
target_metadata=target_metadata, # type: ignore[arg-type]
include_object=include_object,
)
with context.begin_transaction():

View File

@@ -27,13 +27,13 @@ from shared_configs.configs import MULTI_TENANT
from shared_configs.configs import TENANT_ID_PREFIX
# Maximum tenants to provision in a single task run.
# Each tenant takes ~80s (alembic migrations), so 5 tenants ≈ 7 minutes.
_MAX_TENANTS_PER_RUN = 5
# Each tenant takes ~80s (alembic migrations), so 15 tenants ≈ 20 minutes.
_MAX_TENANTS_PER_RUN = 15
# Time limits sized for worst-case: provisioning up to _MAX_TENANTS_PER_RUN new tenants
# (~90s each) plus migrating up to TARGET_AVAILABLE_TENANTS pool tenants (~90s each).
_TENANT_PROVISIONING_SOFT_TIME_LIMIT = 60 * 20 # 20 minutes
_TENANT_PROVISIONING_TIME_LIMIT = 60 * 25 # 25 minutes
_TENANT_PROVISIONING_SOFT_TIME_LIMIT = 60 * 40 # 40 minutes
_TENANT_PROVISIONING_TIME_LIMIT = 60 * 45 # 45 minutes
@shared_task(

View File

@@ -1,20 +1,14 @@
from datetime import datetime
from datetime import timezone
from uuid import UUID
from celery import shared_task
from celery import Task
from ee.onyx.background.celery_utils import should_perform_chat_ttl_check
from ee.onyx.background.task_name_builders import name_chat_ttl_task
from onyx.configs.app_configs import JOB_TIMEOUT
from onyx.configs.constants import OnyxCeleryTask
from onyx.db.chat import delete_chat_session
from onyx.db.chat import get_chat_sessions_older_than
from onyx.db.engine.sql_engine import get_session_with_current_tenant
from onyx.db.enums import TaskStatus
from onyx.db.tasks import mark_task_as_finished_with_id
from onyx.db.tasks import register_task
from onyx.server.settings.store import load_settings
from onyx.utils.logger import setup_logger
@@ -29,59 +23,42 @@ logger = setup_logger()
trail=False,
)
def perform_ttl_management_task(
self: Task, retention_limit_days: int, *, tenant_id: str
self: Task, retention_limit_days: int, *, tenant_id: str # noqa: ARG001
) -> None:
task_id = self.request.id
if not task_id:
raise RuntimeError("No task id defined for this task; cannot identify it")
start_time = datetime.now(tz=timezone.utc)
user_id: UUID | None = None
session_id: UUID | None = None
try:
with get_session_with_current_tenant() as db_session:
# we generally want to move off this, but keeping for now
register_task(
db_session=db_session,
task_name=name_chat_ttl_task(retention_limit_days, tenant_id),
task_id=task_id,
status=TaskStatus.STARTED,
start_time=start_time,
)
old_chat_sessions = get_chat_sessions_older_than(
retention_limit_days, db_session
)
for user_id, session_id in old_chat_sessions:
# one session per delete so that we don't blow up if a deletion fails.
with get_session_with_current_tenant() as db_session:
delete_chat_session(
user_id,
session_id,
db_session,
include_deleted=True,
hard_delete=True,
try:
with get_session_with_current_tenant() as db_session:
delete_chat_session(
user_id,
session_id,
db_session,
include_deleted=True,
hard_delete=True,
)
except Exception:
logger.exception(
"Failed to delete chat session "
f"user_id={user_id} session_id={session_id}, "
"continuing with remaining sessions"
)
with get_session_with_current_tenant() as db_session:
mark_task_as_finished_with_id(
db_session=db_session,
task_id=task_id,
success=True,
)
except Exception:
logger.exception(
f"delete_chat_session exceptioned. user_id={user_id} session_id={session_id}"
)
with get_session_with_current_tenant() as db_session:
mark_task_as_finished_with_id(
db_session=db_session,
task_id=task_id,
success=False,
)
raise

View File

@@ -56,7 +56,6 @@ Then it cycles through its tasks as scheduled by Celery Beat:
| `check_for_user_file_processing` | 20s | Checks for user uploads → dispatches to `USER_FILE_PROCESSING` queue |
| `check_for_checkpoint_cleanup` | 1h | Cleans up old indexing checkpoints |
| `check_for_index_attempt_cleanup` | 30m | Cleans up old index attempts |
| `kombu_message_cleanup_task` | periodic | Cleans orphaned Kombu messages from DB (Kombu being the messaging framework used by Celery) |
| `celery_beat_heartbeat` | 1m | Heartbeat for Beat watchdog |
Watchdog is a separate Python process managed by supervisord which runs alongside celery workers. It checks the ONYX_CELERY_BEAT_HEARTBEAT_KEY in

View File

@@ -317,7 +317,6 @@ celery_app.autodiscover_tasks(
"onyx.background.celery.tasks.docprocessing",
"onyx.background.celery.tasks.evals",
"onyx.background.celery.tasks.hierarchyfetching",
"onyx.background.celery.tasks.periodic",
"onyx.background.celery.tasks.pruning",
"onyx.background.celery.tasks.shared",
"onyx.background.celery.tasks.vespa",

View File

@@ -302,7 +302,7 @@ beat_cloud_tasks: list[dict] = [
{
"name": f"{ONYX_CLOUD_CELERY_TASK_PREFIX}_check-available-tenants",
"task": OnyxCeleryTask.CLOUD_CHECK_AVAILABLE_TENANTS,
"schedule": timedelta(minutes=10),
"schedule": timedelta(minutes=2),
"options": {
"queue": OnyxCeleryQueues.MONITORING,
"priority": OnyxCeleryPriority.HIGH,

View File

@@ -36,6 +36,7 @@ from onyx.configs.constants import OnyxRedisLocks
from onyx.db.engine.sql_engine import get_session_with_current_tenant
from onyx.db.opensearch_migration import build_sanitized_to_original_doc_id_mapping
from onyx.db.opensearch_migration import get_vespa_visit_state
from onyx.db.opensearch_migration import is_migration_completed
from onyx.db.opensearch_migration import (
mark_migration_completed_time_if_not_set_with_commit,
)
@@ -106,14 +107,19 @@ def migrate_chunks_from_vespa_to_opensearch_task(
acquired; effectively a no-op. True if the task completed
successfully. False if the task errored.
"""
# 1. Check if we should run the task.
# 1.a. If OpenSearch indexing is disabled, we don't run the task.
if not ENABLE_OPENSEARCH_INDEXING_FOR_ONYX:
task_logger.warning(
"OpenSearch migration is not enabled, skipping chunk migration task."
)
return None
task_logger.info("Starting chunk-level migration from Vespa to OpenSearch.")
task_start_time = time.monotonic()
# 1.b. Only one instance per tenant of this task may run concurrently at
# once. If we fail to acquire a lock, we assume it is because another task
# has one and we exit.
r = get_redis_client()
lock: RedisLock = r.lock(
name=OnyxRedisLocks.OPENSEARCH_MIGRATION_BEAT_LOCK,
@@ -136,10 +142,11 @@ def migrate_chunks_from_vespa_to_opensearch_task(
f"Token: {lock.local.token}"
)
# 2. Prepare to migrate.
total_chunks_migrated_this_task = 0
total_chunks_errored_this_task = 0
try:
# Double check that tenant info is correct.
# 2.a. Double-check that tenant info is correct.
if tenant_id != get_current_tenant_id():
err_str = (
f"Tenant ID mismatch in the OpenSearch migration task: "
@@ -148,16 +155,62 @@ def migrate_chunks_from_vespa_to_opensearch_task(
task_logger.error(err_str)
return False
with (
get_session_with_current_tenant() as db_session,
get_vespa_http_client(
timeout=VESPA_MIGRATION_REQUEST_TIMEOUT_S
) as vespa_client,
):
# Do as much as we can with a DB session in one spot to not hold a
# session during a migration batch.
with get_session_with_current_tenant() as db_session:
# 2.b. Immediately check to see if this tenant is done, to save
# having to do any other work. This function does not require a
# migration record to necessarily exist.
if is_migration_completed(db_session):
return True
# 2.c. Try to insert the OpenSearchTenantMigrationRecord table if it
# does not exist.
try_insert_opensearch_tenant_migration_record_with_commit(db_session)
# 2.d. Get search settings.
search_settings = get_current_search_settings(db_session)
tenant_state = TenantState(tenant_id=tenant_id, multitenant=MULTI_TENANT)
indexing_setting = IndexingSetting.from_db_model(search_settings)
# 2.e. Build sanitized to original doc ID mapping to check for
# conflicts in the event we sanitize a doc ID to an
# already-existing doc ID.
# We reconstruct this mapping for every task invocation because
# a document may have been added in the time between two tasks.
sanitized_doc_start_time = time.monotonic()
sanitized_to_original_doc_id_mapping = (
build_sanitized_to_original_doc_id_mapping(db_session)
)
task_logger.debug(
f"Built sanitized_to_original_doc_id_mapping with {len(sanitized_to_original_doc_id_mapping)} entries "
f"in {time.monotonic() - sanitized_doc_start_time:.3f} seconds."
)
# 2.f. Get the current migration state.
continuation_token_map, total_chunks_migrated = get_vespa_visit_state(
db_session
)
# 2.f.1. Double-check that the migration state does not imply
# completion. Really we should never have to enter this block as we
# would expect is_migration_completed to return True, but in the
# strange event that the migration is complete but the migration
# completed time was never stamped, we do so here.
if is_continuation_token_done_for_all_slices(continuation_token_map):
task_logger.info(
f"OpenSearch migration COMPLETED for tenant {tenant_id}. Total chunks migrated: {total_chunks_migrated}."
)
mark_migration_completed_time_if_not_set_with_commit(db_session)
return True
task_logger.debug(
f"Read the tenant migration record. Total chunks migrated: {total_chunks_migrated}. "
f"Continuation token map: {continuation_token_map}"
)
with get_vespa_http_client(
timeout=VESPA_MIGRATION_REQUEST_TIMEOUT_S
) as vespa_client:
# 2.g. Create the OpenSearch and Vespa document indexes.
tenant_state = TenantState(tenant_id=tenant_id, multitenant=MULTI_TENANT)
opensearch_document_index = OpenSearchDocumentIndex(
tenant_state=tenant_state,
index_name=search_settings.index_name,
@@ -171,22 +224,14 @@ def migrate_chunks_from_vespa_to_opensearch_task(
httpx_client=vespa_client,
)
sanitized_doc_start_time = time.monotonic()
# We reconstruct this mapping for every task invocation because a
# document may have been added in the time between two tasks.
sanitized_to_original_doc_id_mapping = (
build_sanitized_to_original_doc_id_mapping(db_session)
)
task_logger.debug(
f"Built sanitized_to_original_doc_id_mapping with {len(sanitized_to_original_doc_id_mapping)} entries "
f"in {time.monotonic() - sanitized_doc_start_time:.3f} seconds."
)
# 2.h. Get the approximate chunk count in Vespa as of this time to
# update the migration record.
approx_chunk_count_in_vespa: int | None = None
get_chunk_count_start_time = time.monotonic()
try:
approx_chunk_count_in_vespa = vespa_document_index.get_chunk_count()
except Exception:
# This failure should not be blocking.
task_logger.exception(
"Error getting approximate chunk count in Vespa. Moving on..."
)
@@ -195,25 +240,12 @@ def migrate_chunks_from_vespa_to_opensearch_task(
f"approximate chunk count in Vespa. Got {approx_chunk_count_in_vespa}."
)
# 3. Do the actual migration in batches until we run out of time.
while (
time.monotonic() - task_start_time < MIGRATION_TASK_SOFT_TIME_LIMIT_S
and lock.owned()
):
(
continuation_token_map,
total_chunks_migrated,
) = get_vespa_visit_state(db_session)
if is_continuation_token_done_for_all_slices(continuation_token_map):
task_logger.info(
f"OpenSearch migration COMPLETED for tenant {tenant_id}. Total chunks migrated: {total_chunks_migrated}."
)
mark_migration_completed_time_if_not_set_with_commit(db_session)
break
task_logger.debug(
f"Read the tenant migration record. Total chunks migrated: {total_chunks_migrated}. "
f"Continuation token map: {continuation_token_map}"
)
# 3.a. Get the next batch of raw chunks from Vespa.
get_vespa_chunks_start_time = time.monotonic()
raw_vespa_chunks, next_continuation_token_map = (
vespa_document_index.get_all_raw_document_chunks_paginated(
@@ -226,6 +258,7 @@ def migrate_chunks_from_vespa_to_opensearch_task(
f"seconds. Next continuation token map: {next_continuation_token_map}"
)
# 3.b. Transform the raw chunks to OpenSearch chunks in memory.
opensearch_document_chunks, errored_chunks = (
transform_vespa_chunks_to_opensearch_chunks(
raw_vespa_chunks,
@@ -240,6 +273,7 @@ def migrate_chunks_from_vespa_to_opensearch_task(
"errored."
)
# 3.c. Index the OpenSearch chunks into OpenSearch.
index_opensearch_chunks_start_time = time.monotonic()
opensearch_document_index.index_raw_chunks(
chunks=opensearch_document_chunks
@@ -251,12 +285,38 @@ def migrate_chunks_from_vespa_to_opensearch_task(
total_chunks_migrated_this_task += len(opensearch_document_chunks)
total_chunks_errored_this_task += len(errored_chunks)
update_vespa_visit_progress_with_commit(
db_session,
continuation_token_map=next_continuation_token_map,
chunks_processed=len(opensearch_document_chunks),
chunks_errored=len(errored_chunks),
approx_chunk_count_in_vespa=approx_chunk_count_in_vespa,
# Do as much as we can with a DB session in one spot to not hold a
# session during a migration batch.
with get_session_with_current_tenant() as db_session:
# 3.d. Update the migration state.
update_vespa_visit_progress_with_commit(
db_session,
continuation_token_map=next_continuation_token_map,
chunks_processed=len(opensearch_document_chunks),
chunks_errored=len(errored_chunks),
approx_chunk_count_in_vespa=approx_chunk_count_in_vespa,
)
# 3.e. Get the current migration state. Even thought we
# technically have it in-memory since we just wrote it, we
# want to reference the DB as the source of truth at all
# times.
continuation_token_map, total_chunks_migrated = (
get_vespa_visit_state(db_session)
)
# 3.e.1. Check if the migration is done.
if is_continuation_token_done_for_all_slices(
continuation_token_map
):
task_logger.info(
f"OpenSearch migration COMPLETED for tenant {tenant_id}. Total chunks migrated: {total_chunks_migrated}."
)
mark_migration_completed_time_if_not_set_with_commit(db_session)
return True
task_logger.debug(
f"Read the tenant migration record. Total chunks migrated: {total_chunks_migrated}. "
f"Continuation token map: {continuation_token_map}"
)
except Exception:
traceback.print_exc()

View File

@@ -1,138 +0,0 @@
#####
# Periodic Tasks
#####
import json
from typing import Any
from celery import shared_task
from celery.contrib.abortable import AbortableTask # type: ignore
from celery.exceptions import TaskRevokedError
from sqlalchemy import inspect
from sqlalchemy import text
from sqlalchemy.orm import Session
from onyx.background.celery.apps.app_base import task_logger
from onyx.configs.app_configs import JOB_TIMEOUT
from onyx.configs.constants import OnyxCeleryTask
from onyx.configs.constants import PostgresAdvisoryLocks
from onyx.db.engine.sql_engine import get_session_with_current_tenant
@shared_task(
name=OnyxCeleryTask.KOMBU_MESSAGE_CLEANUP_TASK,
soft_time_limit=JOB_TIMEOUT,
bind=True,
base=AbortableTask,
)
def kombu_message_cleanup_task(self: Any, tenant_id: str) -> int: # noqa: ARG001
"""Runs periodically to clean up the kombu_message table"""
# we will select messages older than this amount to clean up
KOMBU_MESSAGE_CLEANUP_AGE = 7 # days
KOMBU_MESSAGE_CLEANUP_PAGE_LIMIT = 1000
ctx = {}
ctx["last_processed_id"] = 0
ctx["deleted"] = 0
ctx["cleanup_age"] = KOMBU_MESSAGE_CLEANUP_AGE
ctx["page_limit"] = KOMBU_MESSAGE_CLEANUP_PAGE_LIMIT
with get_session_with_current_tenant() as db_session:
# Exit the task if we can't take the advisory lock
result = db_session.execute(
text("SELECT pg_try_advisory_lock(:id)"),
{"id": PostgresAdvisoryLocks.KOMBU_MESSAGE_CLEANUP_LOCK_ID.value},
).scalar()
if not result:
return 0
while True:
if self.is_aborted():
raise TaskRevokedError("kombu_message_cleanup_task was aborted.")
b = kombu_message_cleanup_task_helper(ctx, db_session)
if not b:
break
db_session.commit()
if ctx["deleted"] > 0:
task_logger.info(
f"Deleted {ctx['deleted']} orphaned messages from kombu_message."
)
return ctx["deleted"]
def kombu_message_cleanup_task_helper(ctx: dict, db_session: Session) -> bool:
"""
Helper function to clean up old messages from the `kombu_message` table that are no longer relevant.
This function retrieves messages from the `kombu_message` table that are no longer visible and
older than a specified interval. It checks if the corresponding task_id exists in the
`celery_taskmeta` table. If the task_id does not exist, the message is deleted.
Args:
ctx (dict): A context dictionary containing configuration parameters such as:
- 'cleanup_age' (int): The age in days after which messages are considered old.
- 'page_limit' (int): The maximum number of messages to process in one batch.
- 'last_processed_id' (int): The ID of the last processed message to handle pagination.
- 'deleted' (int): A counter to track the number of deleted messages.
db_session (Session): The SQLAlchemy database session for executing queries.
Returns:
bool: Returns True if there are more rows to process, False if not.
"""
inspector = inspect(db_session.bind)
if not inspector:
return False
# With the move to redis as celery's broker and backend, kombu tables may not even exist.
# We can fail silently.
if not inspector.has_table("kombu_message"):
return False
query = text(
"""
SELECT id, timestamp, payload
FROM kombu_message WHERE visible = 'false'
AND timestamp < CURRENT_TIMESTAMP - INTERVAL :interval_days
AND id > :last_processed_id
ORDER BY id
LIMIT :page_limit
"""
)
kombu_messages = db_session.execute(
query,
{
"interval_days": f"{ctx['cleanup_age']} days",
"page_limit": ctx["page_limit"],
"last_processed_id": ctx["last_processed_id"],
},
).fetchall()
if len(kombu_messages) == 0:
return False
for msg in kombu_messages:
payload = json.loads(msg[2])
task_id = payload["headers"]["id"]
# Check if task_id exists in celery_taskmeta
task_exists = db_session.execute(
text("SELECT 1 FROM celery_taskmeta WHERE task_id = :task_id"),
{"task_id": task_id},
).fetchone()
# If task_id does not exist, delete the message
if not task_exists:
result = db_session.execute(
text("DELETE FROM kombu_message WHERE id = :message_id"),
{"message_id": msg[0]},
)
if result.rowcount > 0: # type: ignore
ctx["deleted"] += 1
ctx["last_processed_id"] = msg[0]
return True

View File

@@ -379,6 +379,14 @@ POSTGRES_HOST = os.environ.get("POSTGRES_HOST") or "127.0.0.1"
POSTGRES_PORT = os.environ.get("POSTGRES_PORT") or "5432"
POSTGRES_DB = os.environ.get("POSTGRES_DB") or "postgres"
AWS_REGION_NAME = os.environ.get("AWS_REGION_NAME") or "us-east-2"
# Comma-separated replica / multi-host list. If unset, defaults to POSTGRES_HOST
# only.
_POSTGRES_HOSTS_STR = os.environ.get("POSTGRES_HOSTS", "").strip()
POSTGRES_HOSTS: list[str] = (
[h.strip() for h in _POSTGRES_HOSTS_STR.split(",") if h.strip()]
if _POSTGRES_HOSTS_STR
else [POSTGRES_HOST]
)
POSTGRES_API_SERVER_POOL_SIZE = int(
os.environ.get("POSTGRES_API_SERVER_POOL_SIZE") or 40

View File

@@ -12,6 +12,11 @@ SLACK_USER_TOKEN_PREFIX = "xoxp-"
SLACK_BOT_TOKEN_PREFIX = "xoxb-"
ONYX_EMAILABLE_LOGO_MAX_DIM = 512
# The mask_string() function in encryption.py uses "•" (U+2022 BULLET) to mask secrets.
MASK_CREDENTIAL_CHAR = "\u2022"
# Pattern produced by mask_string for strings >= 14 chars: "abcd...wxyz" (exactly 11 chars)
MASK_CREDENTIAL_LONG_RE = re.compile(r"^.{4}\.{3}.{4}$")
SOURCE_TYPE = "source_type"
# stored in the `metadata` of a chunk. Used to signify that this chunk should
# not be used for QA. For example, Google Drive file types which can't be parsed
@@ -391,10 +396,6 @@ class MilestoneRecordType(str, Enum):
REQUESTED_CONNECTOR = "requested_connector"
class PostgresAdvisoryLocks(Enum):
KOMBU_MESSAGE_CLEANUP_LOCK_ID = auto()
class OnyxCeleryQueues:
# "celery" is the default queue defined by celery and also the queue
# we are running in the primary worker to run system tasks
@@ -577,7 +578,6 @@ class OnyxCeleryTask:
MONITOR_PROCESS_MEMORY = "monitor_process_memory"
CELERY_BEAT_HEARTBEAT = "celery_beat_heartbeat"
KOMBU_MESSAGE_CLEANUP_TASK = "kombu_message_cleanup_task"
CONNECTOR_PERMISSION_SYNC_GENERATOR_TASK = (
"connector_permission_sync_generator_task"
)

View File

@@ -44,7 +44,7 @@ _NOTION_CALL_TIMEOUT = 30 # 30 seconds
_MAX_PAGES = 1000
# TODO: Tables need to be ingested, Pages need to have their metadata ingested
# TODO: Pages need to have their metadata ingested
class NotionPage(BaseModel):
@@ -452,6 +452,19 @@ class NotionConnector(LoadConnector, PollConnector):
sub_inner_dict: dict[str, Any] | list[Any] | str = inner_dict
while isinstance(sub_inner_dict, dict) and "type" in sub_inner_dict:
type_name = sub_inner_dict["type"]
# Notion user objects (people properties, created_by, etc.) have
# "name" at the same level as "type": "person"/"bot". If we drill
# into the person/bot sub-dict we lose the name. Capture it here
# before descending, but skip "title"-type properties where "name"
# is not the display value we want.
if (
"name" in sub_inner_dict
and isinstance(sub_inner_dict["name"], str)
and type_name not in ("title",)
):
return sub_inner_dict["name"]
sub_inner_dict = sub_inner_dict[type_name]
# If the innermost layer is None, the value is not set
@@ -663,6 +676,19 @@ class NotionConnector(LoadConnector, PollConnector):
text = rich_text["text"]["content"]
cur_result_text_arr.append(text)
# table_row blocks store content in "cells" (list of lists
# of rich text objects) rather than "rich_text"
if "cells" in result_obj:
row_cells: list[str] = []
for cell in result_obj["cells"]:
cell_texts = [
rt.get("plain_text", "")
for rt in cell
if isinstance(rt, dict)
]
row_cells.append(" ".join(cell_texts))
cur_result_text_arr.append("\t".join(row_cells))
if result["has_children"]:
if result_type == "child_page":
# Child pages will not be included at this top level, it will be a separate document.

View File

@@ -190,16 +190,23 @@ def delete_messages_and_files_from_chat_session(
chat_session_id: UUID, db_session: Session
) -> None:
# Select messages older than cutoff_time with files
messages_with_files = db_session.execute(
select(ChatMessage.id, ChatMessage.files).where(
ChatMessage.chat_session_id == chat_session_id,
messages_with_files = (
db_session.execute(
select(ChatMessage.id, ChatMessage.files).where(
ChatMessage.chat_session_id == chat_session_id,
)
)
).fetchall()
.tuples()
.all()
)
file_store = get_default_file_store()
for _, files in messages_with_files:
file_store = get_default_file_store()
for file_info in files or []:
file_store.delete_file(file_id=file_info.get("id"))
if file_info.get("user_file_id"):
# user files are managed by the user file lifecycle
continue
file_store.delete_file(file_id=file_info["id"], error_on_missing=False)
# Delete ChatMessage records - CASCADE constraints will automatically handle:
# - ChatMessage__StandardAnswer relationship records

View File

@@ -8,6 +8,8 @@ from sqlalchemy.orm import selectinload
from sqlalchemy.orm import Session
from onyx.configs.constants import FederatedConnectorSource
from onyx.configs.constants import MASK_CREDENTIAL_CHAR
from onyx.configs.constants import MASK_CREDENTIAL_LONG_RE
from onyx.db.engine.sql_engine import get_session_with_current_tenant
from onyx.db.models import DocumentSet
from onyx.db.models import FederatedConnector
@@ -45,6 +47,23 @@ def fetch_all_federated_connectors_parallel() -> list[FederatedConnector]:
return fetch_all_federated_connectors(db_session)
def _reject_masked_credentials(credentials: dict[str, Any]) -> None:
"""Raise if any credential string value contains mask placeholder characters.
mask_string() has two output formats:
- Short strings (< 14 chars): "••••••••••••" (U+2022 BULLET)
- Long strings (>= 14 chars): "abcd...wxyz" (first4 + "..." + last4)
Both must be rejected.
"""
for key, val in credentials.items():
if isinstance(val, str) and (
MASK_CREDENTIAL_CHAR in val or MASK_CREDENTIAL_LONG_RE.match(val)
):
raise ValueError(
f"Credential field '{key}' contains masked placeholder characters. Please provide the actual credential value."
)
def validate_federated_connector_credentials(
source: FederatedConnectorSource,
credentials: dict[str, Any],
@@ -66,6 +85,8 @@ def create_federated_connector(
config: dict[str, Any] | None = None,
) -> FederatedConnector:
"""Create a new federated connector with credential and config validation."""
_reject_masked_credentials(credentials)
# Validate credentials before creating
if not validate_federated_connector_credentials(source, credentials):
raise ValueError(
@@ -277,6 +298,8 @@ def update_federated_connector(
)
if credentials is not None:
_reject_masked_credentials(credentials)
# Validate credentials before updating
if not validate_federated_connector_credentials(
federated_connector.source, credentials

View File

@@ -324,6 +324,15 @@ def mark_migration_completed_time_if_not_set_with_commit(
db_session.commit()
def is_migration_completed(db_session: Session) -> bool:
"""Returns True if the migration is completed.
Can be run even if the migration record does not exist.
"""
record = db_session.query(OpenSearchTenantMigrationRecord).first()
return record is not None and record.migration_completed_at is not None
def build_sanitized_to_original_doc_id_mapping(
db_session: Session,
) -> dict[str, str]:

View File

@@ -1,3 +1,4 @@
import hashlib
from datetime import datetime
from datetime import timezone
from typing import Any
@@ -20,9 +21,13 @@ from onyx.document_index.opensearch.constants import DEFAULT_MAX_CHUNK_SIZE
from onyx.document_index.opensearch.constants import EF_CONSTRUCTION
from onyx.document_index.opensearch.constants import EF_SEARCH
from onyx.document_index.opensearch.constants import M
from onyx.document_index.opensearch.string_filtering import DocumentIDTooLongError
from onyx.document_index.opensearch.string_filtering import (
filter_and_validate_document_id,
)
from onyx.document_index.opensearch.string_filtering import (
MAX_DOCUMENT_ID_ENCODED_LENGTH,
)
from onyx.utils.tenant import get_tenant_id_short_string
from shared_configs.configs import MULTI_TENANT
from shared_configs.contextvars import get_current_tenant_id
@@ -75,17 +80,50 @@ def get_opensearch_doc_chunk_id(
This will be the string used to identify the chunk in OpenSearch. Any direct
chunk queries should use this function.
If the document ID is too long, a hash of the ID is used instead.
"""
sanitized_document_id = filter_and_validate_document_id(document_id)
opensearch_doc_chunk_id = (
f"{sanitized_document_id}__{max_chunk_size}__{chunk_index}"
opensearch_doc_chunk_id_suffix: str = f"__{max_chunk_size}__{chunk_index}"
encoded_suffix_length: int = len(opensearch_doc_chunk_id_suffix.encode("utf-8"))
max_encoded_permissible_doc_id_length: int = (
MAX_DOCUMENT_ID_ENCODED_LENGTH - encoded_suffix_length
)
opensearch_doc_chunk_id_tenant_prefix: str = ""
if tenant_state.multitenant:
short_tenant_id: str = get_tenant_id_short_string(tenant_state.tenant_id)
# Use tenant ID because in multitenant mode each tenant has its own
# Documents table, so there is a very small chance that doc IDs are not
# actually unique across all tenants.
short_tenant_id = get_tenant_id_short_string(tenant_state.tenant_id)
opensearch_doc_chunk_id = f"{short_tenant_id}__{opensearch_doc_chunk_id}"
opensearch_doc_chunk_id_tenant_prefix = f"{short_tenant_id}__"
encoded_prefix_length: int = len(
opensearch_doc_chunk_id_tenant_prefix.encode("utf-8")
)
max_encoded_permissible_doc_id_length -= encoded_prefix_length
try:
sanitized_document_id: str = filter_and_validate_document_id(
document_id, max_encoded_length=max_encoded_permissible_doc_id_length
)
except DocumentIDTooLongError:
# If the document ID is too long, use a hash instead.
# We use blake2b because it is faster and equally secure as SHA256, and
# accepts digest_size which controls the number of bytes returned in the
# hash.
# digest_size is the size of the returned hash in bytes. Since we're
# decoding the hash bytes as a hex string, the digest_size should be
# half the max target size of the hash string.
# Subtract 1 because filter_and_validate_document_id compares on >= on
# max_encoded_length.
# 64 is the max digest_size blake2b returns.
digest_size: int = min((max_encoded_permissible_doc_id_length - 1) // 2, 64)
sanitized_document_id = hashlib.blake2b(
document_id.encode("utf-8"), digest_size=digest_size
).hexdigest()
opensearch_doc_chunk_id: str = (
f"{opensearch_doc_chunk_id_tenant_prefix}{sanitized_document_id}{opensearch_doc_chunk_id_suffix}"
)
# Do one more validation to ensure we haven't exceeded the max length.
opensearch_doc_chunk_id = filter_and_validate_document_id(opensearch_doc_chunk_id)
return opensearch_doc_chunk_id

View File

@@ -1,7 +1,15 @@
import re
MAX_DOCUMENT_ID_ENCODED_LENGTH: int = 512
def filter_and_validate_document_id(document_id: str) -> str:
class DocumentIDTooLongError(ValueError):
"""Raised when a document ID is too long for OpenSearch after filtering."""
def filter_and_validate_document_id(
document_id: str, max_encoded_length: int = MAX_DOCUMENT_ID_ENCODED_LENGTH
) -> str:
"""
Filters and validates a document ID such that it can be used as an ID in
OpenSearch.
@@ -19,9 +27,13 @@ def filter_and_validate_document_id(document_id: str) -> str:
Args:
document_id: The document ID to filter and validate.
max_encoded_length: The maximum length of the document ID after
filtering in bytes. Compared with >= for extra resilience, so
encoded values of this length will fail.
Raises:
ValueError: If the document ID is empty or too long after filtering.
DocumentIDTooLongError: If the document ID is too long after filtering.
ValueError: If the document ID is empty after filtering.
Returns:
str: The filtered document ID.
@@ -29,6 +41,8 @@ def filter_and_validate_document_id(document_id: str) -> str:
filtered_document_id = re.sub(r"[^A-Za-z0-9_.\-~]", "", document_id)
if not filtered_document_id:
raise ValueError(f"Document ID {document_id} is empty after filtering.")
if len(filtered_document_id.encode("utf-8")) >= 512:
raise ValueError(f"Document ID {document_id} is too long after filtering.")
if len(filtered_document_id.encode("utf-8")) >= max_encoded_length:
raise DocumentIDTooLongError(
f"Document ID {document_id} is too long after filtering."
)
return filtered_document_id

View File

@@ -136,12 +136,14 @@ class FileStore(ABC):
"""
@abstractmethod
def delete_file(self, file_id: str) -> None:
def delete_file(self, file_id: str, error_on_missing: bool = True) -> None:
"""
Delete a file by its ID.
Parameters:
- file_name: Name of file to delete
- file_id: ID of file to delete
- error_on_missing: If False, silently return when the file record
does not exist instead of raising.
"""
@abstractmethod
@@ -452,12 +454,23 @@ class S3BackedFileStore(FileStore):
logger.warning(f"Error getting file size for {file_id}: {e}")
return None
def delete_file(self, file_id: str, db_session: Session | None = None) -> None:
def delete_file(
self,
file_id: str,
error_on_missing: bool = True,
db_session: Session | None = None,
) -> None:
with get_session_with_current_tenant_if_none(db_session) as db_session:
try:
file_record = get_filerecord_by_file_id(
file_record = get_filerecord_by_file_id_optional(
file_id=file_id, db_session=db_session
)
if file_record is None:
if error_on_missing:
raise RuntimeError(
f"File by id {file_id} does not exist or was deleted"
)
return
if not file_record.bucket_name:
logger.error(
f"File record {file_id} with key {file_record.object_key} "

View File

@@ -222,12 +222,23 @@ class PostgresBackedFileStore(FileStore):
logger.warning(f"Error getting file size for {file_id}: {e}")
return None
def delete_file(self, file_id: str, db_session: Session | None = None) -> None:
def delete_file(
self,
file_id: str,
error_on_missing: bool = True,
db_session: Session | None = None,
) -> None:
with get_session_with_current_tenant_if_none(db_session) as session:
try:
file_content = get_file_content_by_file_id(
file_content = get_file_content_by_file_id_optional(
file_id=file_id, db_session=session
)
if file_content is None:
if error_on_missing:
raise RuntimeError(
f"File content for file_id {file_id} does not exist or was deleted"
)
return
raw_conn = _get_raw_connection(session)
try:

View File

@@ -3,6 +3,8 @@
from datetime import datetime
from typing import Any
import httpx
from onyx.configs.constants import DocumentSource
from onyx.mcp_server.api import mcp_server
from onyx.mcp_server.utils import get_http_client
@@ -15,6 +17,21 @@ from onyx.utils.variable_functionality import global_version
logger = setup_logger()
def _extract_error_detail(response: httpx.Response) -> str:
"""Extract a human-readable error message from a failed backend response.
The backend returns OnyxError responses as
``{"error_code": "...", "detail": "..."}``.
"""
try:
body = response.json()
if detail := body.get("detail"):
return str(detail)
except Exception:
pass
return f"Request failed with status {response.status_code}"
@mcp_server.tool()
async def search_indexed_documents(
query: str,
@@ -158,7 +175,14 @@ async def search_indexed_documents(
json=search_request,
headers=auth_headers,
)
response.raise_for_status()
if not response.is_success:
error_detail = _extract_error_detail(response)
return {
"documents": [],
"total_results": 0,
"query": query,
"error": error_detail,
}
result = response.json()
# Check for error in response
@@ -234,7 +258,13 @@ async def search_web(
json=request_payload,
headers={"Authorization": f"Bearer {access_token.token}"},
)
response.raise_for_status()
if not response.is_success:
error_detail = _extract_error_detail(response)
return {
"error": error_detail,
"results": [],
"query": query,
}
response_payload = response.json()
results = response_payload.get("results", [])
return {
@@ -280,7 +310,12 @@ async def open_urls(
json={"urls": urls},
headers={"Authorization": f"Bearer {access_token.token}"},
)
response.raise_for_status()
if not response.is_success:
error_detail = _extract_error_detail(response)
return {
"error": error_detail,
"results": [],
}
response_payload = response.json()
results = response_payload.get("results", [])
return {

View File

@@ -6,6 +6,7 @@ from onyx.configs.app_configs import MCP_SERVER_ENABLED
from onyx.configs.app_configs import MCP_SERVER_HOST
from onyx.configs.app_configs import MCP_SERVER_PORT
from onyx.utils.logger import setup_logger
from onyx.utils.variable_functionality import set_is_ee_based_on_env_variable
logger = setup_logger()
@@ -16,6 +17,7 @@ def main() -> None:
logger.info("MCP server is disabled (MCP_SERVER_ENABLED=false)")
return
set_is_ee_based_on_env_variable()
logger.info(f"Starting MCP server on {MCP_SERVER_HOST}:{MCP_SERVER_PORT}")
from onyx.mcp_server.api import mcp_app

View File

@@ -1,6 +1,5 @@
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from sqlalchemy.orm import Session
from onyx.auth.users import current_user
@@ -9,6 +8,8 @@ from onyx.db.engine.sql_engine import get_session
from onyx.db.models import User
from onyx.db.web_search import fetch_active_web_content_provider
from onyx.db.web_search import fetch_active_web_search_provider
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.server.features.web_search.models import OpenUrlsToolRequest
from onyx.server.features.web_search.models import OpenUrlsToolResponse
from onyx.server.features.web_search.models import WebSearchToolRequest
@@ -61,9 +62,10 @@ def _get_active_search_provider(
) -> tuple[WebSearchProviderView, WebSearchProvider]:
provider_model = fetch_active_web_search_provider(db_session)
if provider_model is None:
raise HTTPException(
status_code=400,
detail="No web search provider configured.",
raise OnyxError(
OnyxErrorCode.INVALID_INPUT,
"No web search provider configured. Please configure one in "
"Admin > Web Search settings.",
)
provider_view = WebSearchProviderView(
@@ -76,9 +78,10 @@ def _get_active_search_provider(
)
if provider_model.api_key is None:
raise HTTPException(
status_code=400,
detail="Web search provider requires an API key.",
raise OnyxError(
OnyxErrorCode.INVALID_INPUT,
"Web search provider requires an API key. Please configure one in "
"Admin > Web Search settings.",
)
try:
@@ -88,7 +91,7 @@ def _get_active_search_provider(
config=provider_model.config or {},
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
raise OnyxError(OnyxErrorCode.INVALID_INPUT, str(exc)) from exc
return provider_view, provider
@@ -110,9 +113,9 @@ def _get_active_content_provider(
if provider_model.api_key is None:
# TODO - this is not a great error, in fact, this key should not be nullable.
raise HTTPException(
status_code=400,
detail="Web content provider requires an API key.",
raise OnyxError(
OnyxErrorCode.INVALID_INPUT,
"Web content provider requires an API key.",
)
try:
@@ -125,12 +128,12 @@ def _get_active_content_provider(
config=config,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
raise OnyxError(OnyxErrorCode.INVALID_INPUT, str(exc)) from exc
if provider is None:
raise HTTPException(
status_code=400,
detail="Unable to initialize the configured web content provider.",
raise OnyxError(
OnyxErrorCode.INVALID_INPUT,
"Unable to initialize the configured web content provider.",
)
provider_view = WebContentProviderView(
@@ -154,12 +157,13 @@ def _run_web_search(
for query in request.queries:
try:
search_results = provider.search(query)
except HTTPException:
except OnyxError:
raise
except Exception as exc:
logger.exception("Web search provider failed for query '%s'", query)
raise HTTPException(
status_code=502, detail="Web search provider failed to execute query."
raise OnyxError(
OnyxErrorCode.BAD_GATEWAY,
"Web search provider failed to execute query.",
) from exc
filtered_results = filter_web_search_results_with_no_title_or_snippet(
@@ -192,12 +196,13 @@ def _open_urls(
docs = filter_web_contents_with_no_title_or_content(
list(provider.contents(urls))
)
except HTTPException:
except OnyxError:
raise
except Exception as exc:
logger.exception("Web content provider failed to fetch URLs")
raise HTTPException(
status_code=502, detail="Web content provider failed to fetch URLs."
raise OnyxError(
OnyxErrorCode.BAD_GATEWAY,
"Web content provider failed to fetch URLs.",
) from exc
results: list[LlmOpenUrlResult] = []

View File

@@ -186,7 +186,7 @@ class TestDocumentIndexNew:
)
document_index.index(chunks=[pre_chunk], indexing_metadata=pre_metadata)
time.sleep(1)
time.sleep(2)
# Now index a batch with the existing doc and a new doc.
chunks = [

View File

@@ -9,6 +9,7 @@ This test verifies the full flow: provisioning failure → rollback → schema c
"""
import uuid
from unittest.mock import MagicMock
from unittest.mock import patch
from sqlalchemy import text
@@ -55,18 +56,28 @@ class TestTenantProvisioningRollback:
created_tenant_id = tenant_id
return create_schema_if_not_exists(tenant_id)
# Mock setup_tenant to fail after schema creation
# Mock setup_tenant to fail after schema creation.
# Also mock the Redis lock so the test doesn't compete with a live
# monitoring worker that may already hold the provision lock.
mock_lock = MagicMock()
mock_lock.acquire.return_value = True
with patch(
"ee.onyx.background.celery.tasks.tenant_provisioning.tasks.setup_tenant"
) as mock_setup:
mock_setup.side_effect = Exception("Simulated provisioning failure")
"ee.onyx.background.celery.tasks.tenant_provisioning.tasks.get_redis_client"
) as mock_redis:
mock_redis.return_value.lock.return_value = mock_lock
with patch(
"ee.onyx.background.celery.tasks.tenant_provisioning.tasks.create_schema_if_not_exists",
side_effect=track_schema_creation,
):
# Run pre-provisioning - it should fail and trigger rollback
pre_provision_tenant()
"ee.onyx.background.celery.tasks.tenant_provisioning.tasks.setup_tenant"
) as mock_setup:
mock_setup.side_effect = Exception("Simulated provisioning failure")
with patch(
"ee.onyx.background.celery.tasks.tenant_provisioning.tasks.create_schema_if_not_exists",
side_effect=track_schema_creation,
):
# Run pre-provisioning - it should fail and trigger rollback
pre_provision_tenant()
# Verify that the schema was created and then cleaned up
assert created_tenant_id is not None, "Schema should have been created"

View File

@@ -0,0 +1,58 @@
import pytest
from onyx.configs.constants import MASK_CREDENTIAL_CHAR
from onyx.db.federated import _reject_masked_credentials
class TestRejectMaskedCredentials:
"""Verify that masked credential values are never accepted for DB writes.
mask_string() has two output formats:
- Short strings (< 14 chars): "••••••••••••" (U+2022 BULLET)
- Long strings (>= 14 chars): "abcd...wxyz" (first4 + "..." + last4)
_reject_masked_credentials must catch both.
"""
def test_rejects_fully_masked_value(self) -> None:
masked = MASK_CREDENTIAL_CHAR * 12 # "••••••••••••"
with pytest.raises(ValueError, match="masked placeholder"):
_reject_masked_credentials({"client_id": masked})
def test_rejects_long_string_masked_value(self) -> None:
"""mask_string returns 'first4...last4' for long strings — the real
format used for OAuth credentials like client_id and client_secret."""
with pytest.raises(ValueError, match="masked placeholder"):
_reject_masked_credentials({"client_id": "1234...7890"})
def test_rejects_when_any_field_is_masked(self) -> None:
"""Even if client_id is real, a masked client_secret must be caught."""
with pytest.raises(ValueError, match="client_secret"):
_reject_masked_credentials(
{
"client_id": "1234567890.1234567890",
"client_secret": MASK_CREDENTIAL_CHAR * 12,
}
)
def test_accepts_real_credentials(self) -> None:
# Should not raise
_reject_masked_credentials(
{
"client_id": "1234567890.1234567890",
"client_secret": "test_client_secret_value",
}
)
def test_accepts_empty_dict(self) -> None:
# Should not raise — empty credentials are handled elsewhere
_reject_masked_credentials({})
def test_ignores_non_string_values(self) -> None:
# Non-string values (None, bool, int) should pass through
_reject_masked_credentials(
{
"client_id": "real_value",
"redirect_uri": None,
"some_flag": True,
}
)

View File

@@ -0,0 +1,318 @@
"""Unit tests for Notion connector handling of people properties and table blocks.
Reproduces two bugs:
1. ENG-3970: People-type database properties (user mentions) are not extracted —
the user's "name" field is lost when _recurse_properties drills into the
"person" sub-dict.
2. ENG-3971: Inline table blocks (table/table_row) are not indexed — table_row
blocks store content in "cells" rather than "rich_text", so no text is extracted.
"""
from unittest.mock import patch
from onyx.connectors.notion.connector import NotionConnector
def _make_connector() -> NotionConnector:
connector = NotionConnector()
connector.load_credentials({"notion_integration_token": "fake-token"})
return connector
class TestPeoplePropertyExtraction:
"""ENG-3970: Verifies that 'people' type database properties extract user names."""
def test_single_person_property(self) -> None:
"""A database cell with a single @mention should extract the user name."""
properties = {
"Team Lead": {
"id": "abc",
"type": "people",
"people": [
{
"object": "user",
"id": "user-uuid-1",
"name": "Arturo Martinez",
"type": "person",
"person": {"email": "arturo@example.com"},
}
],
}
}
result = NotionConnector._properties_to_str(properties)
assert (
"Arturo Martinez" in result
), f"Expected 'Arturo Martinez' in extracted text, got: {result!r}"
def test_multiple_people_property(self) -> None:
"""A database cell with multiple @mentions should extract all user names."""
properties = {
"Members": {
"id": "def",
"type": "people",
"people": [
{
"object": "user",
"id": "user-uuid-1",
"name": "Arturo Martinez",
"type": "person",
"person": {"email": "arturo@example.com"},
},
{
"object": "user",
"id": "user-uuid-2",
"name": "Jane Smith",
"type": "person",
"person": {"email": "jane@example.com"},
},
],
}
}
result = NotionConnector._properties_to_str(properties)
assert (
"Arturo Martinez" in result
), f"Expected 'Arturo Martinez' in extracted text, got: {result!r}"
assert (
"Jane Smith" in result
), f"Expected 'Jane Smith' in extracted text, got: {result!r}"
def test_bot_user_property(self) -> None:
"""Bot users (integrations) have 'type': 'bot' — name should still be extracted."""
properties = {
"Created By": {
"id": "ghi",
"type": "people",
"people": [
{
"object": "user",
"id": "bot-uuid-1",
"name": "Onyx Integration",
"type": "bot",
"bot": {},
}
],
}
}
result = NotionConnector._properties_to_str(properties)
assert (
"Onyx Integration" in result
), f"Expected 'Onyx Integration' in extracted text, got: {result!r}"
def test_person_without_person_details(self) -> None:
"""Some user objects may have an empty/null person sub-dict."""
properties = {
"Assignee": {
"id": "jkl",
"type": "people",
"people": [
{
"object": "user",
"id": "user-uuid-3",
"name": "Ghost User",
"type": "person",
"person": {},
}
],
}
}
result = NotionConnector._properties_to_str(properties)
assert (
"Ghost User" in result
), f"Expected 'Ghost User' in extracted text, got: {result!r}"
def test_people_mixed_with_other_properties(self) -> None:
"""People property should work alongside other property types."""
properties = {
"Name": {
"id": "aaa",
"type": "title",
"title": [
{
"plain_text": "Project Alpha",
"type": "text",
"text": {"content": "Project Alpha"},
}
],
},
"Lead": {
"id": "bbb",
"type": "people",
"people": [
{
"object": "user",
"id": "user-uuid-1",
"name": "Arturo Martinez",
"type": "person",
"person": {"email": "arturo@example.com"},
}
],
},
"Status": {
"id": "ccc",
"type": "status",
"status": {"name": "In Progress", "id": "status-1"},
},
}
result = NotionConnector._properties_to_str(properties)
assert "Arturo Martinez" in result
assert "In Progress" in result
class TestTableBlockExtraction:
"""ENG-3971: Verifies that inline table blocks (table/table_row) are indexed."""
def _make_blocks_response(self, results: list) -> dict:
return {"results": results, "next_cursor": None}
def test_table_row_cells_are_extracted(self) -> None:
"""table_row blocks store content in 'cells', not 'rich_text'.
The connector should extract text from cells."""
connector = _make_connector()
connector.workspace_id = "ws-1"
table_block = {
"id": "table-block-1",
"type": "table",
"table": {
"has_column_header": True,
"has_row_header": False,
"table_width": 3,
},
"has_children": True,
}
header_row = {
"id": "row-1",
"type": "table_row",
"table_row": {
"cells": [
[
{
"type": "text",
"text": {"content": "Name"},
"plain_text": "Name",
}
],
[
{
"type": "text",
"text": {"content": "Role"},
"plain_text": "Role",
}
],
[
{
"type": "text",
"text": {"content": "Team"},
"plain_text": "Team",
}
],
]
},
"has_children": False,
}
data_row = {
"id": "row-2",
"type": "table_row",
"table_row": {
"cells": [
[
{
"type": "text",
"text": {"content": "Arturo Martinez"},
"plain_text": "Arturo Martinez",
}
],
[
{
"type": "text",
"text": {"content": "Engineer"},
"plain_text": "Engineer",
}
],
[
{
"type": "text",
"text": {"content": "Platform"},
"plain_text": "Platform",
}
],
]
},
"has_children": False,
}
with patch.object(
connector,
"_fetch_child_blocks",
side_effect=[
self._make_blocks_response([table_block]),
self._make_blocks_response([header_row, data_row]),
],
):
output = connector._read_blocks("page-1")
all_text = " ".join(block.text for block in output.blocks)
assert "Arturo Martinez" in all_text, (
f"Expected 'Arturo Martinez' in table row text, got blocks: "
f"{[(b.id, b.text) for b in output.blocks]}"
)
assert "Engineer" in all_text, (
f"Expected 'Engineer' in table row text, got blocks: "
f"{[(b.id, b.text) for b in output.blocks]}"
)
assert "Platform" in all_text, (
f"Expected 'Platform' in table row text, got blocks: "
f"{[(b.id, b.text) for b in output.blocks]}"
)
def test_table_with_empty_cells(self) -> None:
"""Table rows with some empty cells should still extract non-empty content."""
connector = _make_connector()
connector.workspace_id = "ws-1"
table_block = {
"id": "table-block-2",
"type": "table",
"table": {
"has_column_header": False,
"has_row_header": False,
"table_width": 2,
},
"has_children": True,
}
row_with_empty = {
"id": "row-3",
"type": "table_row",
"table_row": {
"cells": [
[
{
"type": "text",
"text": {"content": "Has Value"},
"plain_text": "Has Value",
}
],
[], # empty cell
]
},
"has_children": False,
}
with patch.object(
connector,
"_fetch_child_blocks",
side_effect=[
self._make_blocks_response([table_block]),
self._make_blocks_response([row_with_empty]),
],
):
output = connector._read_blocks("page-2")
all_text = " ".join(block.text for block in output.blocks)
assert "Has Value" in all_text, (
f"Expected 'Has Value' in table row text, got blocks: "
f"{[(b.id, b.text) for b in output.blocks]}"
)

View File

@@ -0,0 +1,100 @@
"""Regression tests for delete_messages_and_files_from_chat_session.
Verifies that user-owned files (those with user_file_id) are never deleted
during chat session cleanup — only chat-only files should be removed.
"""
from unittest.mock import call
from unittest.mock import MagicMock
from unittest.mock import patch
from uuid import uuid4
from onyx.db.chat import delete_messages_and_files_from_chat_session
_MODULE = "onyx.db.chat"
def _make_db_session(
rows: list[tuple[int, list[dict[str, str]] | None]],
) -> MagicMock:
db_session = MagicMock()
db_session.execute.return_value.tuples.return_value.all.return_value = rows
return db_session
@patch(f"{_MODULE}.delete_orphaned_search_docs")
@patch(f"{_MODULE}.get_default_file_store")
def test_user_files_are_not_deleted(
mock_get_file_store: MagicMock,
_mock_orphan_cleanup: MagicMock,
) -> None:
"""User files (with user_file_id) must be skipped during cleanup."""
file_store = MagicMock()
mock_get_file_store.return_value = file_store
db_session = _make_db_session(
[
(
1,
[
{"id": "chat-file-1", "type": "image"},
{"id": "user-file-1", "type": "document", "user_file_id": "uf-1"},
{"id": "chat-file-2", "type": "image"},
],
),
]
)
delete_messages_and_files_from_chat_session(uuid4(), db_session)
assert file_store.delete_file.call_count == 2
file_store.delete_file.assert_has_calls(
[
call(file_id="chat-file-1", error_on_missing=False),
call(file_id="chat-file-2", error_on_missing=False),
]
)
@patch(f"{_MODULE}.delete_orphaned_search_docs")
@patch(f"{_MODULE}.get_default_file_store")
def test_only_user_files_means_no_deletions(
mock_get_file_store: MagicMock,
_mock_orphan_cleanup: MagicMock,
) -> None:
"""When every file in the session is a user file, nothing should be deleted."""
file_store = MagicMock()
mock_get_file_store.return_value = file_store
db_session = _make_db_session(
[
(1, [{"id": "uf-a", "type": "document", "user_file_id": "uf-1"}]),
(2, [{"id": "uf-b", "type": "document", "user_file_id": "uf-2"}]),
]
)
delete_messages_and_files_from_chat_session(uuid4(), db_session)
file_store.delete_file.assert_not_called()
@patch(f"{_MODULE}.delete_orphaned_search_docs")
@patch(f"{_MODULE}.get_default_file_store")
def test_messages_with_no_files(
mock_get_file_store: MagicMock,
_mock_orphan_cleanup: MagicMock,
) -> None:
"""Messages with None or empty file lists should not trigger any deletions."""
file_store = MagicMock()
mock_get_file_store.return_value = file_store
db_session = _make_db_session(
[
(1, None),
(2, []),
]
)
delete_messages_and_files_from_chat_session(uuid4(), db_session)
file_store.delete_file.assert_not_called()

View File

@@ -0,0 +1,203 @@
import pytest
from onyx.document_index.interfaces_new import TenantState
from onyx.document_index.opensearch.constants import DEFAULT_MAX_CHUNK_SIZE
from onyx.document_index.opensearch.schema import get_opensearch_doc_chunk_id
from onyx.document_index.opensearch.string_filtering import (
MAX_DOCUMENT_ID_ENCODED_LENGTH,
)
from shared_configs.configs import POSTGRES_DEFAULT_SCHEMA_STANDARD_VALUE
SINGLE_TENANT_STATE = TenantState(
tenant_id=POSTGRES_DEFAULT_SCHEMA_STANDARD_VALUE, multitenant=False
)
MULTI_TENANT_STATE = TenantState(
tenant_id="tenant_abcdef12-3456-7890-abcd-ef1234567890", multitenant=True
)
EXPECTED_SHORT_TENANT = "abcdef12"
class TestGetOpensearchDocChunkIdSingleTenant:
def test_basic(self) -> None:
result = get_opensearch_doc_chunk_id(
SINGLE_TENANT_STATE, "my-doc-id", chunk_index=0
)
assert result == f"my-doc-id__{DEFAULT_MAX_CHUNK_SIZE}__0"
def test_custom_chunk_size(self) -> None:
result = get_opensearch_doc_chunk_id(
SINGLE_TENANT_STATE, "doc1", chunk_index=3, max_chunk_size=1024
)
assert result == "doc1__1024__3"
def test_special_chars_are_stripped(self) -> None:
"""Tests characters not matching [A-Za-z0-9_.-~] are removed."""
result = get_opensearch_doc_chunk_id(
SINGLE_TENANT_STATE, "doc/with?special#chars&more%stuff", chunk_index=0
)
assert "/" not in result
assert "?" not in result
assert "#" not in result
assert result == f"docwithspecialcharsmorestuff__{DEFAULT_MAX_CHUNK_SIZE}__0"
def test_short_doc_id_not_hashed(self) -> None:
"""
Tests that a short doc ID should appear directly in the result, not as a
hash.
"""
doc_id = "short-id"
result = get_opensearch_doc_chunk_id(SINGLE_TENANT_STATE, doc_id, chunk_index=0)
assert "short-id" in result
def test_long_doc_id_is_hashed(self) -> None:
"""
Tests that a doc ID exceeding the max length should be replaced with a
blake2b hash.
"""
# Create a doc ID that will exceed max length after the suffix is
# appended.
doc_id = "a" * MAX_DOCUMENT_ID_ENCODED_LENGTH
result = get_opensearch_doc_chunk_id(SINGLE_TENANT_STATE, doc_id, chunk_index=0)
# The original doc ID should NOT appear in the result.
assert doc_id not in result
# The suffix should still be present.
assert f"__{DEFAULT_MAX_CHUNK_SIZE}__0" in result
def test_long_doc_id_hash_is_deterministic(self) -> None:
doc_id = "x" * MAX_DOCUMENT_ID_ENCODED_LENGTH
result1 = get_opensearch_doc_chunk_id(
SINGLE_TENANT_STATE, doc_id, chunk_index=5
)
result2 = get_opensearch_doc_chunk_id(
SINGLE_TENANT_STATE, doc_id, chunk_index=5
)
assert result1 == result2
def test_long_doc_id_different_inputs_produce_different_hashes(self) -> None:
doc_id_a = "a" * MAX_DOCUMENT_ID_ENCODED_LENGTH
doc_id_b = "b" * MAX_DOCUMENT_ID_ENCODED_LENGTH
result_a = get_opensearch_doc_chunk_id(
SINGLE_TENANT_STATE, doc_id_a, chunk_index=0
)
result_b = get_opensearch_doc_chunk_id(
SINGLE_TENANT_STATE, doc_id_b, chunk_index=0
)
assert result_a != result_b
def test_result_never_exceeds_max_length(self) -> None:
"""
Tests that the final result should always be under
MAX_DOCUMENT_ID_ENCODED_LENGTH bytes.
"""
doc_id = "z" * (MAX_DOCUMENT_ID_ENCODED_LENGTH * 2)
result = get_opensearch_doc_chunk_id(
SINGLE_TENANT_STATE, doc_id, chunk_index=999, max_chunk_size=99999
)
assert len(result.encode("utf-8")) < MAX_DOCUMENT_ID_ENCODED_LENGTH
def test_no_tenant_prefix_in_single_tenant(self) -> None:
result = get_opensearch_doc_chunk_id(
SINGLE_TENANT_STATE, "mydoc", chunk_index=0
)
assert not result.startswith(SINGLE_TENANT_STATE.tenant_id)
class TestGetOpensearchDocChunkIdMultiTenant:
def test_includes_tenant_prefix(self) -> None:
result = get_opensearch_doc_chunk_id(MULTI_TENANT_STATE, "mydoc", chunk_index=0)
assert result.startswith(f"{EXPECTED_SHORT_TENANT}__")
def test_format(self) -> None:
result = get_opensearch_doc_chunk_id(
MULTI_TENANT_STATE, "mydoc", chunk_index=2, max_chunk_size=256
)
assert result == f"{EXPECTED_SHORT_TENANT}__mydoc__256__2"
def test_long_doc_id_is_hashed_multitenant(self) -> None:
doc_id = "d" * MAX_DOCUMENT_ID_ENCODED_LENGTH
result = get_opensearch_doc_chunk_id(MULTI_TENANT_STATE, doc_id, chunk_index=0)
# Should still have tenant prefix.
assert result.startswith(f"{EXPECTED_SHORT_TENANT}__")
# The original doc ID should NOT appear in the result.
assert doc_id not in result
# The suffix should still be present.
assert f"__{DEFAULT_MAX_CHUNK_SIZE}__0" in result
def test_result_never_exceeds_max_length_multitenant(self) -> None:
doc_id = "q" * (MAX_DOCUMENT_ID_ENCODED_LENGTH * 2)
result = get_opensearch_doc_chunk_id(
MULTI_TENANT_STATE, doc_id, chunk_index=999, max_chunk_size=99999
)
assert len(result.encode("utf-8")) < MAX_DOCUMENT_ID_ENCODED_LENGTH
def test_different_tenants_produce_different_ids(self) -> None:
tenant_a = TenantState(
tenant_id="tenant_aaaaaaaa-0000-0000-0000-000000000000", multitenant=True
)
tenant_b = TenantState(
tenant_id="tenant_bbbbbbbb-0000-0000-0000-000000000000", multitenant=True
)
result_a = get_opensearch_doc_chunk_id(tenant_a, "same-doc", chunk_index=0)
result_b = get_opensearch_doc_chunk_id(tenant_b, "same-doc", chunk_index=0)
assert result_a != result_b
class TestGetOpensearchDocChunkIdEdgeCases:
def test_chunk_index_zero(self) -> None:
result = get_opensearch_doc_chunk_id(SINGLE_TENANT_STATE, "doc", chunk_index=0)
assert result.endswith("__0")
def test_large_chunk_index(self) -> None:
result = get_opensearch_doc_chunk_id(
SINGLE_TENANT_STATE, "doc", chunk_index=99999
)
assert result.endswith("__99999")
def test_doc_id_with_only_special_chars_raises(self) -> None:
"""
Tests that a doc ID that becomes empty after filtering should raise
ValueError.
"""
with pytest.raises(ValueError, match="empty after filtering"):
get_opensearch_doc_chunk_id(SINGLE_TENANT_STATE, "###???///", chunk_index=0)
def test_doc_id_at_boundary_length(self) -> None:
"""
Tests that a doc ID right at the boundary should not be hashed.
"""
suffix = f"__{DEFAULT_MAX_CHUNK_SIZE}__0"
suffix_len = len(suffix.encode("utf-8"))
# Max doc ID length that won't trigger hashing (must be <
# max_encoded_length).
max_doc_len = MAX_DOCUMENT_ID_ENCODED_LENGTH - suffix_len - 1
doc_id = "a" * max_doc_len
result = get_opensearch_doc_chunk_id(SINGLE_TENANT_STATE, doc_id, chunk_index=0)
assert doc_id in result
def test_doc_id_at_boundary_length_multitenant(self) -> None:
"""
Tests that a doc ID right at the boundary should not be hashed in
multitenant mode.
"""
suffix = f"__{DEFAULT_MAX_CHUNK_SIZE}__0"
suffix_len = len(suffix.encode("utf-8"))
prefix = f"{EXPECTED_SHORT_TENANT}__"
prefix_len = len(prefix.encode("utf-8"))
# Max doc ID length that won't trigger hashing (must be <
# max_encoded_length).
max_doc_len = MAX_DOCUMENT_ID_ENCODED_LENGTH - suffix_len - prefix_len - 1
doc_id = "a" * max_doc_len
result = get_opensearch_doc_chunk_id(MULTI_TENANT_STATE, doc_id, chunk_index=0)
assert doc_id in result
def test_doc_id_one_over_boundary_is_hashed(self) -> None:
"""
Tests that a doc ID one byte over the boundary should be hashed.
"""
suffix = f"__{DEFAULT_MAX_CHUNK_SIZE}__0"
suffix_len = len(suffix.encode("utf-8"))
# This length will trigger the >= check in filter_and_validate_document_id
doc_id = "a" * (MAX_DOCUMENT_ID_ENCODED_LENGTH - suffix_len)
result = get_opensearch_doc_chunk_id(SINGLE_TENANT_STATE, doc_id, chunk_index=0)
assert doc_id not in result

View File

@@ -0,0 +1,91 @@
"""Tests for FileStore.delete_file error_on_missing behavior."""
from unittest.mock import MagicMock
from unittest.mock import patch
import pytest
_S3_MODULE = "onyx.file_store.file_store"
_PG_MODULE = "onyx.file_store.postgres_file_store"
def _mock_db_session() -> MagicMock:
session = MagicMock()
session.__enter__ = MagicMock(return_value=session)
session.__exit__ = MagicMock(return_value=False)
return session
# ── S3BackedFileStore ────────────────────────────────────────────────
@patch(f"{_S3_MODULE}.get_session_with_current_tenant_if_none")
@patch(f"{_S3_MODULE}.get_filerecord_by_file_id_optional", return_value=None)
def test_s3_delete_missing_file_raises_by_default(
_mock_get_record: MagicMock,
mock_ctx: MagicMock,
) -> None:
from onyx.file_store.file_store import S3BackedFileStore
mock_ctx.return_value = _mock_db_session()
store = S3BackedFileStore(bucket_name="b")
with pytest.raises(RuntimeError, match="does not exist"):
store.delete_file("nonexistent")
@patch(f"{_S3_MODULE}.get_session_with_current_tenant_if_none")
@patch(f"{_S3_MODULE}.get_filerecord_by_file_id_optional", return_value=None)
@patch(f"{_S3_MODULE}.delete_filerecord_by_file_id")
def test_s3_delete_missing_file_silent_when_error_on_missing_false(
mock_delete_record: MagicMock,
_mock_get_record: MagicMock,
mock_ctx: MagicMock,
) -> None:
from onyx.file_store.file_store import S3BackedFileStore
mock_ctx.return_value = _mock_db_session()
store = S3BackedFileStore(bucket_name="b")
store.delete_file("nonexistent", error_on_missing=False)
mock_delete_record.assert_not_called()
# ── PostgresBackedFileStore ──────────────────────────────────────────
@patch(f"{_PG_MODULE}.get_session_with_current_tenant_if_none")
@patch(f"{_PG_MODULE}.get_file_content_by_file_id_optional", return_value=None)
def test_pg_delete_missing_file_raises_by_default(
_mock_get_content: MagicMock,
mock_ctx: MagicMock,
) -> None:
from onyx.file_store.postgres_file_store import PostgresBackedFileStore
mock_ctx.return_value = _mock_db_session()
store = PostgresBackedFileStore()
with pytest.raises(RuntimeError, match="does not exist"):
store.delete_file("nonexistent")
@patch(f"{_PG_MODULE}.get_session_with_current_tenant_if_none")
@patch(f"{_PG_MODULE}.get_file_content_by_file_id_optional", return_value=None)
@patch(f"{_PG_MODULE}.delete_file_content_by_file_id")
@patch(f"{_PG_MODULE}.delete_filerecord_by_file_id")
def test_pg_delete_missing_file_silent_when_error_on_missing_false(
mock_delete_record: MagicMock,
mock_delete_content: MagicMock,
_mock_get_content: MagicMock,
mock_ctx: MagicMock,
) -> None:
from onyx.file_store.postgres_file_store import PostgresBackedFileStore
mock_ctx.return_value = _mock_db_session()
store = PostgresBackedFileStore()
store.delete_file("nonexistent", error_on_missing=False)
mock_delete_record.assert_not_called()
mock_delete_content.assert_not_called()

View File

@@ -98,6 +98,7 @@ Useful hardening flags:
| `serve` | Serve the interactive chat TUI over SSH |
| `configure` | Configure server URL and API key |
| `validate-config` | Validate configuration and test connection |
| `install-skill` | Install the agent skill file into a project |
## Slash Commands (in TUI)

View File

@@ -7,6 +7,7 @@ import (
"github.com/onyx-dot-app/onyx/cli/internal/api"
"github.com/onyx-dot-app/onyx/cli/internal/config"
"github.com/onyx-dot-app/onyx/cli/internal/exitcodes"
"github.com/spf13/cobra"
)
@@ -16,16 +17,23 @@ func newAgentsCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "agents",
Short: "List available agents",
Long: `List all visible agents configured on the Onyx server.
By default, output is a human-readable table with ID, name, and description.
Use --json for machine-readable output.`,
Example: ` onyx-cli agents
onyx-cli agents --json
onyx-cli agents --json | jq '.[].name'`,
RunE: func(cmd *cobra.Command, args []string) error {
cfg := config.Load()
if !cfg.IsConfigured() {
return fmt.Errorf("onyx CLI is not configured — run 'onyx-cli configure' first")
return exitcodes.New(exitcodes.NotConfigured, "onyx CLI is not configured\n Run: onyx-cli configure")
}
client := api.NewClient(cfg)
agents, err := client.ListAgents(cmd.Context())
if err != nil {
return fmt.Errorf("failed to list agents: %w", err)
return fmt.Errorf("failed to list agents: %w\n Check your connection with: onyx-cli validate-config", err)
}
if agentsJSON {

View File

@@ -4,33 +4,65 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"os/signal"
"strings"
"syscall"
"github.com/onyx-dot-app/onyx/cli/internal/api"
"github.com/onyx-dot-app/onyx/cli/internal/config"
"github.com/onyx-dot-app/onyx/cli/internal/exitcodes"
"github.com/onyx-dot-app/onyx/cli/internal/models"
"github.com/onyx-dot-app/onyx/cli/internal/overflow"
"github.com/spf13/cobra"
"golang.org/x/term"
)
const defaultMaxOutputBytes = 4096
func newAskCmd() *cobra.Command {
var (
askAgentID int
askJSON bool
askQuiet bool
askPrompt string
maxOutput int
)
cmd := &cobra.Command{
Use: "ask [question]",
Short: "Ask a one-shot question (non-interactive)",
Args: cobra.ExactArgs(1),
Long: `Send a one-shot question to an Onyx agent and print the response.
The question can be provided as a positional argument, via --prompt, or piped
through stdin. When stdin contains piped data, it is sent as context along
with the question from --prompt (or used as the question itself).
When stdout is not a TTY (e.g., called by a script or AI agent), output is
automatically truncated to --max-output bytes and the full response is saved
to a temp file. Set --max-output 0 to disable truncation.`,
Args: cobra.MaximumNArgs(1),
Example: ` onyx-cli ask "What connectors are available?"
onyx-cli ask --agent-id 3 "Summarize our Q4 revenue"
onyx-cli ask --json "List all users" | jq '.event.content'
cat error.log | onyx-cli ask --prompt "Find the root cause"
echo "what is onyx?" | onyx-cli ask`,
RunE: func(cmd *cobra.Command, args []string) error {
cfg := config.Load()
if !cfg.IsConfigured() {
return fmt.Errorf("onyx CLI is not configured — run 'onyx-cli configure' first")
return exitcodes.New(exitcodes.NotConfigured, "onyx CLI is not configured\n Run: onyx-cli configure")
}
if askJSON && askQuiet {
return exitcodes.New(exitcodes.BadRequest, "--json and --quiet cannot be used together")
}
question, err := resolveQuestion(args, askPrompt)
if err != nil {
return err
}
question := args[0]
agentID := cfg.DefaultAgentID
if cmd.Flags().Changed("agent-id") {
agentID = askAgentID
@@ -50,9 +82,23 @@ func newAskCmd() *cobra.Command {
nil,
)
// Determine truncation threshold.
isTTY := term.IsTerminal(int(os.Stdout.Fd()))
truncateAt := 0 // 0 means no truncation
if cmd.Flags().Changed("max-output") {
truncateAt = maxOutput
} else if !isTTY {
truncateAt = defaultMaxOutputBytes
}
var sessionID string
var lastErr error
gotStop := false
// Overflow writer: tees to stdout and optionally to a temp file.
// In quiet mode, buffer everything and print once at the end.
ow := &overflow.Writer{Limit: truncateAt, Quiet: askQuiet}
for event := range ch {
if e, ok := event.(models.SessionCreatedEvent); ok {
sessionID = e.ChatSessionID
@@ -82,22 +128,50 @@ func newAskCmd() *cobra.Command {
switch e := event.(type) {
case models.MessageDeltaEvent:
fmt.Print(e.Content)
ow.Write(e.Content)
case models.SearchStartEvent:
if isTTY && !askQuiet {
if e.IsInternetSearch {
fmt.Fprintf(os.Stderr, "\033[2mSearching the web...\033[0m\n")
} else {
fmt.Fprintf(os.Stderr, "\033[2mSearching documents...\033[0m\n")
}
}
case models.SearchQueriesEvent:
if isTTY && !askQuiet {
for _, q := range e.Queries {
fmt.Fprintf(os.Stderr, "\033[2m → %s\033[0m\n", q)
}
}
case models.SearchDocumentsEvent:
if isTTY && !askQuiet && len(e.Documents) > 0 {
fmt.Fprintf(os.Stderr, "\033[2mFound %d documents\033[0m\n", len(e.Documents))
}
case models.ReasoningStartEvent:
if isTTY && !askQuiet {
fmt.Fprintf(os.Stderr, "\033[2mThinking...\033[0m\n")
}
case models.ToolStartEvent:
if isTTY && !askQuiet && e.ToolName != "" {
fmt.Fprintf(os.Stderr, "\033[2mUsing %s...\033[0m\n", e.ToolName)
}
case models.ErrorEvent:
ow.Finish()
return fmt.Errorf("%s", e.Error)
case models.StopEvent:
fmt.Println()
ow.Finish()
return nil
}
}
if !askJSON {
ow.Finish()
}
if ctx.Err() != nil {
if sessionID != "" {
client.StopChatSession(context.Background(), sessionID)
}
if !askJSON {
fmt.Println()
}
return nil
}
@@ -105,20 +179,56 @@ func newAskCmd() *cobra.Command {
return lastErr
}
if !gotStop {
if !askJSON {
fmt.Println()
}
return fmt.Errorf("stream ended unexpectedly")
}
if !askJSON {
fmt.Println()
}
return nil
},
}
cmd.Flags().IntVar(&askAgentID, "agent-id", 0, "Agent ID to use")
cmd.Flags().BoolVar(&askJSON, "json", false, "Output raw JSON events")
// Suppress cobra's default error/usage on RunE errors
cmd.Flags().BoolVarP(&askQuiet, "quiet", "q", false, "Buffer output and print once at end (no streaming)")
cmd.Flags().StringVar(&askPrompt, "prompt", "", "Question text (use with piped stdin context)")
cmd.Flags().IntVar(&maxOutput, "max-output", defaultMaxOutputBytes,
"Max bytes to print before truncating (0 to disable, auto-enabled for non-TTY)")
return cmd
}
// resolveQuestion builds the final question string from args, --prompt, and stdin.
func resolveQuestion(args []string, prompt string) (string, error) {
hasArg := len(args) > 0
hasPrompt := prompt != ""
hasStdin := !term.IsTerminal(int(os.Stdin.Fd()))
if hasArg && hasPrompt {
return "", exitcodes.New(exitcodes.BadRequest, "specify the question as an argument or --prompt, not both")
}
var stdinContent string
if hasStdin {
const maxStdinBytes = 10 * 1024 * 1024 // 10MB
data, err := io.ReadAll(io.LimitReader(os.Stdin, maxStdinBytes))
if err != nil {
return "", fmt.Errorf("failed to read stdin: %w", err)
}
stdinContent = strings.TrimSpace(string(data))
}
switch {
case hasArg && stdinContent != "":
// arg is the question, stdin is context
return args[0] + "\n\n" + stdinContent, nil
case hasArg:
return args[0], nil
case hasPrompt && stdinContent != "":
// --prompt is the question, stdin is context
return prompt + "\n\n" + stdinContent, nil
case hasPrompt:
return prompt, nil
case stdinContent != "":
return stdinContent, nil
default:
return "", exitcodes.New(exitcodes.BadRequest, "no question provided\n Usage: onyx-cli ask \"your question\"\n Or: echo \"context\" | onyx-cli ask --prompt \"your question\"")
}
}

View File

@@ -4,14 +4,22 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/onyx-dot-app/onyx/cli/internal/config"
"github.com/onyx-dot-app/onyx/cli/internal/onboarding"
"github.com/onyx-dot-app/onyx/cli/internal/starprompt"
"github.com/onyx-dot-app/onyx/cli/internal/tui"
"github.com/spf13/cobra"
)
func newChatCmd() *cobra.Command {
return &cobra.Command{
var noStreamMarkdown bool
cmd := &cobra.Command{
Use: "chat",
Short: "Launch the interactive chat TUI (default)",
Long: `Launch the interactive terminal UI for chatting with your Onyx agent.
This is the default command when no subcommand is specified. On first run,
an interactive setup wizard will guide you through configuration.`,
Example: ` onyx-cli chat
onyx-cli`,
RunE: func(cmd *cobra.Command, args []string) error {
cfg := config.Load()
@@ -24,10 +32,22 @@ func newChatCmd() *cobra.Command {
cfg = *result
}
// CLI flag overrides config/env
if cmd.Flags().Changed("no-stream-markdown") {
v := !noStreamMarkdown
cfg.Features.StreamMarkdown = &v
}
starprompt.MaybePrompt()
m := tui.NewModel(cfg)
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
_, err := p.Run()
return err
},
}
cmd.Flags().BoolVar(&noStreamMarkdown, "no-stream-markdown", false, "Disable progressive markdown rendering during streaming")
return cmd
}

View File

@@ -1,19 +1,126 @@
package cmd
import (
"context"
"errors"
"fmt"
"io"
"os"
"strings"
"time"
"github.com/onyx-dot-app/onyx/cli/internal/api"
"github.com/onyx-dot-app/onyx/cli/internal/config"
"github.com/onyx-dot-app/onyx/cli/internal/exitcodes"
"github.com/onyx-dot-app/onyx/cli/internal/onboarding"
"github.com/spf13/cobra"
"golang.org/x/term"
)
func newConfigureCmd() *cobra.Command {
return &cobra.Command{
var (
serverURL string
apiKey string
apiKeyStdin bool
dryRun bool
)
cmd := &cobra.Command{
Use: "configure",
Short: "Configure server URL and API key",
Long: `Set up the Onyx CLI with your server URL and API key.
When --server-url and --api-key are both provided, the configuration is saved
non-interactively (useful for scripts and AI agents). Otherwise, an interactive
setup wizard is launched.
If --api-key is omitted but stdin has piped data, the API key is read from
stdin automatically. You can also use --api-key-stdin to make this explicit.
This avoids leaking the key in shell history.
Use --dry-run to test the connection without saving the configuration.`,
Example: ` onyx-cli configure
onyx-cli configure --server-url https://my-onyx.com --api-key sk-...
echo "$ONYX_API_KEY" | onyx-cli configure --server-url https://my-onyx.com
echo "$ONYX_API_KEY" | onyx-cli configure --server-url https://my-onyx.com --api-key-stdin
onyx-cli configure --server-url https://my-onyx.com --api-key sk-... --dry-run`,
RunE: func(cmd *cobra.Command, args []string) error {
// Read API key from stdin if piped (implicit) or --api-key-stdin (explicit)
if apiKeyStdin && apiKey != "" {
return exitcodes.New(exitcodes.BadRequest, "--api-key and --api-key-stdin cannot be used together")
}
if (apiKey == "" && !term.IsTerminal(int(os.Stdin.Fd()))) || apiKeyStdin {
data, err := io.ReadAll(os.Stdin)
if err != nil {
return fmt.Errorf("failed to read API key from stdin: %w", err)
}
apiKey = strings.TrimSpace(string(data))
}
if serverURL != "" && apiKey != "" {
return configureNonInteractive(serverURL, apiKey, dryRun)
}
if dryRun {
return exitcodes.New(exitcodes.BadRequest, "--dry-run requires --server-url and --api-key")
}
if serverURL != "" || apiKey != "" {
return exitcodes.New(exitcodes.BadRequest, "both --server-url and --api-key are required for non-interactive setup\n Run 'onyx-cli configure' without flags for interactive setup")
}
cfg := config.Load()
onboarding.Run(&cfg)
return nil
},
}
cmd.Flags().StringVar(&serverURL, "server-url", "", "Onyx server URL (e.g., https://cloud.onyx.app)")
cmd.Flags().StringVar(&apiKey, "api-key", "", "API key for authentication (or pipe via stdin)")
cmd.Flags().BoolVar(&apiKeyStdin, "api-key-stdin", false, "Read API key from stdin (explicit; also happens automatically when stdin is piped)")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Test connection without saving config (requires --server-url and --api-key)")
return cmd
}
func configureNonInteractive(serverURL, apiKey string, dryRun bool) error {
cfg := config.OnyxCliConfig{
ServerURL: serverURL,
APIKey: apiKey,
DefaultAgentID: 0,
}
// Preserve existing default agent ID from disk (not env overrides)
if existing := config.LoadFromDisk(); existing.DefaultAgentID != 0 {
cfg.DefaultAgentID = existing.DefaultAgentID
}
// Test connection
client := api.NewClient(cfg)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := client.TestConnection(ctx); err != nil {
var authErr *api.AuthError
if errors.As(err, &authErr) {
return exitcodes.Newf(exitcodes.AuthFailure, "authentication failed: %v\n Check your API key", err)
}
return exitcodes.Newf(exitcodes.Unreachable, "connection failed: %v\n Check your server URL", err)
}
if dryRun {
fmt.Printf("Server: %s\n", serverURL)
fmt.Println("Status: connected and authenticated")
fmt.Println("Dry run: config was NOT saved")
return nil
}
if err := config.Save(cfg); err != nil {
return fmt.Errorf("could not save config: %w", err)
}
fmt.Printf("Config: %s\n", config.ConfigFilePath())
fmt.Printf("Server: %s\n", serverURL)
fmt.Println("Status: connected and authenticated")
return nil
}

20
cli/cmd/experiments.go Normal file
View File

@@ -0,0 +1,20 @@
package cmd
import (
"fmt"
"github.com/onyx-dot-app/onyx/cli/internal/config"
"github.com/spf13/cobra"
)
func newExperimentsCmd() *cobra.Command {
return &cobra.Command{
Use: "experiments",
Short: "List experimental features and their status",
RunE: func(cmd *cobra.Command, args []string) error {
cfg := config.Load()
_, _ = fmt.Fprintln(cmd.OutOrStdout(), config.ExperimentsText(cfg.Features))
return nil
},
}
}

176
cli/cmd/install_skill.go Normal file
View File

@@ -0,0 +1,176 @@
package cmd
import (
"fmt"
"os"
"path/filepath"
"github.com/onyx-dot-app/onyx/cli/internal/embedded"
"github.com/onyx-dot-app/onyx/cli/internal/fsutil"
"github.com/spf13/cobra"
)
// agentSkillDirs maps agent names to their skill directory paths (relative to
// the project or home root). "Universal" agents like Cursor and Codex read
// from .agents/skills directly, so they don't need their own entry here.
var agentSkillDirs = map[string]string{
"claude-code": filepath.Join(".claude", "skills"),
}
const (
canonicalDir = ".agents/skills"
skillName = "onyx-cli"
)
func newInstallSkillCmd() *cobra.Command {
var (
global bool
copyMode bool
agents []string
)
cmd := &cobra.Command{
Use: "install-skill",
Short: "Install the Onyx CLI agent skill file",
Long: `Install the bundled SKILL.md so that AI coding agents can discover and use
the Onyx CLI as a tool.
Files are written to the canonical .agents/skills/onyx-cli/ directory. For
agents that use their own skill directory (e.g. Claude Code uses .claude/skills/),
a symlink is created pointing back to the canonical copy.
By default the skill is installed at the project level (current directory).
Use --global to install under your home directory instead.
Use --copy to write independent copies instead of symlinks.
Use --agent to target specific agents (can be repeated).`,
Example: ` onyx-cli install-skill
onyx-cli install-skill --global
onyx-cli install-skill --agent claude-code
onyx-cli install-skill --copy`,
RunE: func(cmd *cobra.Command, args []string) error {
base, err := installBase(global)
if err != nil {
return err
}
// Write the canonical copy.
canonicalSkillDir := filepath.Join(base, canonicalDir, skillName)
dest := filepath.Join(canonicalSkillDir, "SKILL.md")
content := []byte(embedded.SkillMD)
status, err := fsutil.CompareFile(dest, content)
if err != nil {
return err
}
switch status {
case fsutil.StatusUpToDate:
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Up to date %s\n", dest)
case fsutil.StatusDiffers:
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: overwriting modified %s\n", dest)
if err := os.WriteFile(dest, content, 0o644); err != nil {
return fmt.Errorf("could not write skill file: %w", err)
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Installed %s\n", dest)
default: // statusMissing
if err := os.MkdirAll(canonicalSkillDir, 0o755); err != nil {
return fmt.Errorf("could not create directory: %w", err)
}
if err := os.WriteFile(dest, content, 0o644); err != nil {
return fmt.Errorf("could not write skill file: %w", err)
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Installed %s\n", dest)
}
// Determine which agents to link.
targets := agentSkillDirs
if len(agents) > 0 {
targets = make(map[string]string)
for _, a := range agents {
dir, ok := agentSkillDirs[a]
if !ok {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Unknown agent %q (skipped) — known agents:", a)
for name := range agentSkillDirs {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), " %s", name)
}
_, _ = fmt.Fprintln(cmd.ErrOrStderr())
continue
}
targets[a] = dir
}
}
// Create symlinks (or copies) from agent-specific dirs to canonical.
for name, skillsDir := range targets {
agentSkillDir := filepath.Join(base, skillsDir, skillName)
if copyMode {
copyDest := filepath.Join(agentSkillDir, "SKILL.md")
if err := fsutil.EnsureDirForCopy(agentSkillDir); err != nil {
return fmt.Errorf("could not prepare %s directory: %w", name, err)
}
if err := os.MkdirAll(agentSkillDir, 0o755); err != nil {
return fmt.Errorf("could not create %s directory: %w", name, err)
}
if err := os.WriteFile(copyDest, []byte(embedded.SkillMD), 0o644); err != nil {
return fmt.Errorf("could not write %s skill file: %w", name, err)
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Copied %s\n", copyDest)
continue
}
// Compute relative symlink target. Symlinks resolve relative to
// the parent directory of the link, not the link itself.
rel, err := filepath.Rel(filepath.Dir(agentSkillDir), canonicalSkillDir)
if err != nil {
return fmt.Errorf("could not compute relative path for %s: %w", name, err)
}
if err := os.MkdirAll(filepath.Dir(agentSkillDir), 0o755); err != nil {
return fmt.Errorf("could not create %s directory: %w", name, err)
}
// Remove existing symlink/dir before creating.
_ = os.Remove(agentSkillDir)
if err := os.Symlink(rel, agentSkillDir); err != nil {
// Fall back to copy if symlink fails (e.g. Windows without dev mode).
copyDest := filepath.Join(agentSkillDir, "SKILL.md")
if mkErr := os.MkdirAll(agentSkillDir, 0o755); mkErr != nil {
return fmt.Errorf("could not create %s directory: %w", name, mkErr)
}
if wErr := os.WriteFile(copyDest, []byte(embedded.SkillMD), 0o644); wErr != nil {
return fmt.Errorf("could not write %s skill file: %w", name, wErr)
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Copied %s (symlink failed)\n", copyDest)
continue
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Linked %s -> %s\n", agentSkillDir, rel)
}
return nil
},
}
cmd.Flags().BoolVarP(&global, "global", "g", false, "Install to home directory instead of project")
cmd.Flags().BoolVar(&copyMode, "copy", false, "Copy files instead of symlinking")
cmd.Flags().StringSliceVarP(&agents, "agent", "a", nil, "Target specific agents (e.g. claude-code)")
return cmd
}
func installBase(global bool) (string, error) {
if global {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("could not determine home directory: %w", err)
}
return home, nil
}
cwd, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("could not determine working directory: %w", err)
}
return cwd, nil
}

View File

@@ -97,6 +97,8 @@ func Execute() error {
rootCmd.AddCommand(newConfigureCmd())
rootCmd.AddCommand(newValidateConfigCmd())
rootCmd.AddCommand(newServeCmd())
rootCmd.AddCommand(newInstallSkillCmd())
rootCmd.AddCommand(newExperimentsCmd())
// Default command is chat, but intercept --version first
rootCmd.RunE = func(cmd *cobra.Command, args []string) error {

View File

@@ -23,6 +23,7 @@ import (
"github.com/charmbracelet/wish/ratelimiter"
"github.com/onyx-dot-app/onyx/cli/internal/api"
"github.com/onyx-dot-app/onyx/cli/internal/config"
"github.com/onyx-dot-app/onyx/cli/internal/exitcodes"
"github.com/onyx-dot-app/onyx/cli/internal/tui"
"github.com/spf13/cobra"
"golang.org/x/time/rate"
@@ -295,15 +296,15 @@ provided via the ONYX_API_KEY environment variable to skip the prompt:
The server URL is taken from the server operator's config. The server
auto-generates an Ed25519 host key on first run if the key file does not
already exist. The host key path can also be set via the ONYX_SSH_HOST_KEY
environment variable (the --host-key flag takes precedence).
Example:
onyx-cli serve --port 2222
ssh localhost -p 2222`,
environment variable (the --host-key flag takes precedence).`,
Example: ` onyx-cli serve --port 2222
ssh localhost -p 2222
onyx-cli serve --host 0.0.0.0 --port 2222
onyx-cli serve --idle-timeout 30m --max-session-timeout 2h`,
RunE: func(cmd *cobra.Command, args []string) error {
serverCfg := config.Load()
if serverCfg.ServerURL == "" {
return fmt.Errorf("server URL is not configured; run 'onyx-cli configure' first")
return exitcodes.New(exitcodes.NotConfigured, "server URL is not configured\n Run: onyx-cli configure")
}
if !cmd.Flags().Changed("host-key") {
if v := os.Getenv(config.EnvSSHHostKey); v != "" {

View File

@@ -2,11 +2,13 @@ package cmd
import (
"context"
"errors"
"fmt"
"time"
"github.com/onyx-dot-app/onyx/cli/internal/api"
"github.com/onyx-dot-app/onyx/cli/internal/config"
"github.com/onyx-dot-app/onyx/cli/internal/exitcodes"
"github.com/onyx-dot-app/onyx/cli/internal/version"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
@@ -16,17 +18,21 @@ func newValidateConfigCmd() *cobra.Command {
return &cobra.Command{
Use: "validate-config",
Short: "Validate configuration and test server connection",
Long: `Check that the CLI is configured, the server is reachable, and the API key
is valid. Also reports the server version and warns if it is below the
minimum required.`,
Example: ` onyx-cli validate-config`,
RunE: func(cmd *cobra.Command, args []string) error {
// Check config file
if !config.ConfigExists() {
return fmt.Errorf("config file not found at %s\n Run 'onyx-cli configure' to set up", config.ConfigFilePath())
return exitcodes.Newf(exitcodes.NotConfigured, "config file not found at %s\n Run: onyx-cli configure", config.ConfigFilePath())
}
cfg := config.Load()
// Check API key
if !cfg.IsConfigured() {
return fmt.Errorf("API key is missing\n Run 'onyx-cli configure' to set up")
return exitcodes.New(exitcodes.NotConfigured, "API key is missing\n Run: onyx-cli configure")
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Config: %s\n", config.ConfigFilePath())
@@ -35,7 +41,11 @@ func newValidateConfigCmd() *cobra.Command {
// Test connection
client := api.NewClient(cfg)
if err := client.TestConnection(cmd.Context()); err != nil {
return fmt.Errorf("connection failed: %w", err)
var authErr *api.AuthError
if errors.As(err, &authErr) {
return exitcodes.Newf(exitcodes.AuthFailure, "authentication failed: %v\n Reconfigure with: onyx-cli configure", err)
}
return exitcodes.Newf(exitcodes.Unreachable, "connection failed: %v\n Reconfigure with: onyx-cli configure", err)
}
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Status: connected and authenticated")

View File

@@ -149,12 +149,12 @@ func (c *Client) TestConnection(ctx context.Context) error {
if resp2.StatusCode == 401 || resp2.StatusCode == 403 {
if isHTML || strings.Contains(respServer, "awselb") {
return fmt.Errorf("HTTP %d from a reverse proxy (not the Onyx backend).\n Check your deployment's ingress / proxy configuration", resp2.StatusCode)
return &AuthError{Message: fmt.Sprintf("HTTP %d from a reverse proxy (not the Onyx backend).\n Check your deployment's ingress / proxy configuration", resp2.StatusCode)}
}
if resp2.StatusCode == 401 {
return fmt.Errorf("invalid API key or token.\n %s", body)
return &AuthError{Message: fmt.Sprintf("invalid API key or token.\n %s", body)}
}
return fmt.Errorf("access denied — check that the API key is valid.\n %s", body)
return &AuthError{Message: fmt.Sprintf("access denied — check that the API key is valid.\n %s", body)}
}
detail := fmt.Sprintf("HTTP %d", resp2.StatusCode)

View File

@@ -11,3 +11,12 @@ type OnyxAPIError struct {
func (e *OnyxAPIError) Error() string {
return fmt.Sprintf("HTTP %d: %s", e.StatusCode, e.Detail)
}
// AuthError is returned when authentication or authorization fails.
type AuthError struct {
Message string
}
func (e *AuthError) Error() string {
return e.Message
}

View File

@@ -9,28 +9,47 @@ import (
)
const (
EnvServerURL = "ONYX_SERVER_URL"
EnvAPIKey = "ONYX_API_KEY"
EnvAgentID = "ONYX_PERSONA_ID"
EnvSSHHostKey = "ONYX_SSH_HOST_KEY"
EnvServerURL = "ONYX_SERVER_URL"
EnvAPIKey = "ONYX_API_KEY"
EnvAgentID = "ONYX_PERSONA_ID"
EnvSSHHostKey = "ONYX_SSH_HOST_KEY"
EnvStreamMarkdown = "ONYX_STREAM_MARKDOWN"
)
// Features holds experimental feature flags for the CLI.
type Features struct {
// StreamMarkdown enables progressive markdown rendering during streaming,
// so output is formatted as it arrives rather than after completion.
// nil means use the app default (true).
StreamMarkdown *bool `json:"stream_markdown,omitempty"`
}
// OnyxCliConfig holds the CLI configuration.
type OnyxCliConfig struct {
ServerURL string `json:"server_url"`
APIKey string `json:"api_key"`
DefaultAgentID int `json:"default_persona_id"`
ServerURL string `json:"server_url"`
APIKey string `json:"api_key"`
DefaultAgentID int `json:"default_persona_id"`
Features Features `json:"features,omitempty"`
}
// DefaultConfig returns a config with default values.
func DefaultConfig() OnyxCliConfig {
return OnyxCliConfig{
ServerURL: "https://cloud.onyx.app",
APIKey: "",
ServerURL: "https://cloud.onyx.app",
APIKey: "",
DefaultAgentID: 0,
}
}
// StreamMarkdownEnabled returns whether stream markdown is enabled,
// defaulting to true when the user hasn't set an explicit preference.
func (f Features) StreamMarkdownEnabled() bool {
if f.StreamMarkdown != nil {
return *f.StreamMarkdown
}
return true
}
// IsConfigured returns true if the config has an API key.
func (c OnyxCliConfig) IsConfigured() bool {
return c.APIKey != ""
@@ -59,8 +78,10 @@ func ConfigExists() bool {
return err == nil
}
// Load reads config from file and applies environment variable overrides.
func Load() OnyxCliConfig {
// LoadFromDisk reads config from the file only, without applying environment
// variable overrides. Use this when you need the persisted config values
// (e.g., to preserve them during a save operation).
func LoadFromDisk() OnyxCliConfig {
cfg := DefaultConfig()
data, err := os.ReadFile(ConfigFilePath())
@@ -70,6 +91,13 @@ func Load() OnyxCliConfig {
}
}
return cfg
}
// Load reads config from file and applies environment variable overrides.
func Load() OnyxCliConfig {
cfg := LoadFromDisk()
// Environment overrides
if v := os.Getenv(EnvServerURL); v != "" {
cfg.ServerURL = v
@@ -82,6 +110,13 @@ func Load() OnyxCliConfig {
cfg.DefaultAgentID = id
}
}
if v := os.Getenv(EnvStreamMarkdown); v != "" {
if b, err := strconv.ParseBool(v); err == nil {
cfg.Features.StreamMarkdown = &b
} else {
fmt.Fprintf(os.Stderr, "warning: invalid value %q for %s (expected true/false), ignoring\n", v, EnvStreamMarkdown)
}
}
return cfg
}

View File

@@ -9,7 +9,7 @@ import (
func clearEnvVars(t *testing.T) {
t.Helper()
for _, key := range []string{EnvServerURL, EnvAPIKey, EnvAgentID} {
for _, key := range []string{EnvServerURL, EnvAPIKey, EnvAgentID, EnvStreamMarkdown} {
t.Setenv(key, "")
if err := os.Unsetenv(key); err != nil {
t.Fatal(err)
@@ -199,6 +199,48 @@ func TestSaveAndReload(t *testing.T) {
}
}
func TestDefaultFeaturesStreamMarkdownNil(t *testing.T) {
cfg := DefaultConfig()
if cfg.Features.StreamMarkdown != nil {
t.Error("expected StreamMarkdown to be nil by default")
}
if !cfg.Features.StreamMarkdownEnabled() {
t.Error("expected StreamMarkdownEnabled() to return true when nil")
}
}
func TestEnvOverrideStreamMarkdownFalse(t *testing.T) {
clearEnvVars(t)
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
t.Setenv(EnvStreamMarkdown, "false")
cfg := Load()
if cfg.Features.StreamMarkdown == nil || *cfg.Features.StreamMarkdown {
t.Error("expected StreamMarkdown=false from env override")
}
}
func TestLoadFeaturesFromFile(t *testing.T) {
clearEnvVars(t)
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
data, _ := json.Marshal(map[string]interface{}{
"server_url": "https://example.com",
"api_key": "key",
"features": map[string]interface{}{
"stream_markdown": true,
},
})
writeConfig(t, dir, data)
cfg := Load()
if cfg.Features.StreamMarkdown == nil || !*cfg.Features.StreamMarkdown {
t.Error("expected StreamMarkdown=true from config file")
}
}
func TestSaveCreatesParentDirs(t *testing.T) {
clearEnvVars(t)
dir := t.TempDir()

View File

@@ -0,0 +1,46 @@
package config
import "fmt"
// Experiment describes an experimental feature flag.
type Experiment struct {
Name string
Flag string // CLI flag name
EnvVar string // environment variable name
Config string // JSON path in config file
Enabled bool
Desc string
}
// Experiments returns the list of available experimental features
// with their current status based on the given feature flags.
func Experiments(f Features) []Experiment {
return []Experiment{
{
Name: "Stream Markdown",
Flag: "--no-stream-markdown",
EnvVar: EnvStreamMarkdown,
Config: "features.stream_markdown",
Enabled: f.StreamMarkdownEnabled(),
Desc: "Render markdown progressively as the response streams in (enabled by default)",
},
}
}
// ExperimentsText formats the experiments list for display.
func ExperimentsText(f Features) string {
exps := Experiments(f)
text := "Experimental Features\n\n"
for _, e := range exps {
status := "off"
if e.Enabled {
status = "on"
}
text += fmt.Sprintf(" %-20s [%s]\n", e.Name, status)
text += fmt.Sprintf(" %s\n", e.Desc)
text += fmt.Sprintf(" flag: %s env: %s config: %s\n\n", e.Flag, e.EnvVar, e.Config)
}
text += "Toggle via CLI flag, environment variable, or config file.\n"
text += "Example: onyx-cli chat --no-stream-markdown"
return text
}

View File

@@ -0,0 +1,187 @@
---
name: onyx-cli
description: Query the Onyx knowledge base using the onyx-cli command. Use when the user wants to search company documents, ask questions about internal knowledge, query connected data sources, or look up information stored in Onyx.
---
# Onyx CLI — Agent Tool
Onyx is an enterprise search and Gen-AI platform that connects to company documents, apps, and people. The `onyx-cli` CLI provides non-interactive commands to query the Onyx knowledge base and list available agents.
## Prerequisites
### 1. Check if installed
```bash
which onyx-cli
```
### 2. Install (if needed)
**Primary — pip:**
```bash
pip install onyx-cli
```
**From source (Go):**
```bash
go build -o onyx-cli github.com/onyx-dot-app/onyx/cli && sudo mv onyx-cli /usr/local/bin/
```
### 3. Check if configured
```bash
onyx-cli validate-config
```
This checks the config file exists, API key is present, and tests the server connection via `/api/me`. Exit code 0 on success, non-zero with a descriptive error on failure.
If unconfigured, you have two options:
**Option A — Interactive setup (requires user input):**
```bash
onyx-cli configure
```
This prompts for the Onyx server URL and API key, tests the connection, and saves config.
**Option B — Environment variables (non-interactive, preferred for agents):**
```bash
export ONYX_SERVER_URL="https://your-onyx-server.com" # default: https://cloud.onyx.app
export ONYX_API_KEY="your-api-key"
```
Environment variables override the config file. If these are set, no config file is needed.
| Variable | Required | Description |
| ----------------- | -------- | -------------------------------------------------------- |
| `ONYX_SERVER_URL` | No | Onyx server base URL (default: `https://cloud.onyx.app`) |
| `ONYX_API_KEY` | Yes | API key for authentication |
| `ONYX_PERSONA_ID` | No | Default agent/persona ID |
If neither the config file nor environment variables are set, tell the user that `onyx-cli` needs to be configured and ask them to either:
- Run `onyx-cli configure` interactively, or
- Set `ONYX_SERVER_URL` and `ONYX_API_KEY` environment variables
## Commands
### Validate configuration
```bash
onyx-cli validate-config
```
Checks config file exists, API key is present, and tests the server connection. Use this before `ask` or `agents` to confirm the CLI is properly set up.
### List available agents
```bash
onyx-cli agents
```
Prints a table of agent IDs, names, and descriptions. Use `--json` for structured output:
```bash
onyx-cli agents --json
```
Use agent IDs with `ask --agent-id` to query a specific agent.
### Basic query (plain text output)
```bash
onyx-cli ask "What is our company's PTO policy?"
```
Streams the answer as plain text to stdout. Exit code 0 on success, non-zero on error.
### JSON output (structured events)
```bash
onyx-cli ask --json "What authentication methods do we support?"
```
Outputs JSON-encoded parsed stream events (one object per line). Key event objects include message deltas, stop, errors, search-start, and citation payloads.
Each line is a JSON object with this envelope:
```json
{"type": "<event_type>", "event": { ... }}
```
| Event Type | Description |
| ------------------- | -------------------------------------------------------------------- |
| `message_delta` | Content token — concatenate all `content` fields for the full answer |
| `stop` | Stream complete |
| `error` | Error with `error` message field |
| `search_tool_start` | Onyx started searching documents |
| `citation_info` | Source citation — see shape below |
`citation_info` event shape:
```json
{
"type": "citation_info",
"event": {
"citation_number": 1,
"document_id": "abc123def456",
"placement": { "turn_index": 0, "tab_index": 0, "sub_turn_index": null }
}
}
```
`placement` is metadata about where in the conversation the citation appeared and can be ignored for most use cases.
### Specify an agent
```bash
onyx-cli ask --agent-id 5 "Summarize our Q4 roadmap"
```
Uses a specific Onyx agent/persona instead of the default.
### All flags
| Flag | Type | Description |
| ------------ | ---- | ---------------------------------------------- |
| `--agent-id` | int | Agent ID to use (overrides default) |
| `--json` | bool | Output raw NDJSON events instead of plain text |
## Statelessness
Each `onyx-cli ask` call creates an independent chat session. There is no built-in way to chain context across multiple `ask` invocations — every call starts fresh. If you need multi-turn conversation with memory, use the interactive TUI (`onyx-cli` or `onyx-cli chat`) instead.
## When to Use
Use `onyx-cli ask` when:
- The user asks about company-specific information (policies, docs, processes)
- You need to search internal knowledge bases or connected data sources
- The user references Onyx, asks you to "search Onyx", or wants to query their documents
- You need context from company wikis, Confluence, Google Drive, Slack, or other connected sources
Do NOT use when:
- The question is about general programming knowledge (use your own knowledge)
- The user is asking about code in the current repository (use grep/read tools)
- The user hasn't mentioned Onyx and the question doesn't require internal company data
## Examples
```bash
# Simple question
onyx-cli ask "What are the steps to deploy to production?"
# Get structured output for parsing
onyx-cli ask --json "List all active API integrations"
# Use a specialized agent
onyx-cli ask --agent-id 3 "What were the action items from last week's standup?"
# Pipe the answer into another command
onyx-cli ask "What is the database schema for users?" | head -20
```

View File

@@ -0,0 +1,7 @@
// Package embedded holds files that are compiled into the onyx-cli binary.
package embedded
import _ "embed"
//go:embed SKILL.md
var SkillMD string

View File

@@ -0,0 +1,33 @@
// Package exitcodes defines semantic exit codes for the Onyx CLI.
package exitcodes
import "fmt"
const (
Success = 0
General = 1
BadRequest = 2 // invalid args / command-line errors (convention)
NotConfigured = 3
AuthFailure = 4
Unreachable = 5
)
// ExitError wraps an error with a specific exit code.
type ExitError struct {
Code int
Err error
}
func (e *ExitError) Error() string {
return e.Err.Error()
}
// New creates an ExitError with the given code and message.
func New(code int, msg string) *ExitError {
return &ExitError{Code: code, Err: fmt.Errorf("%s", msg)}
}
// Newf creates an ExitError with a formatted message.
func Newf(code int, format string, args ...any) *ExitError {
return &ExitError{Code: code, Err: fmt.Errorf(format, args...)}
}

View File

@@ -0,0 +1,40 @@
package exitcodes
import (
"errors"
"fmt"
"testing"
)
func TestExitError_Error(t *testing.T) {
e := New(NotConfigured, "not configured")
if e.Error() != "not configured" {
t.Fatalf("expected 'not configured', got %q", e.Error())
}
if e.Code != NotConfigured {
t.Fatalf("expected code %d, got %d", NotConfigured, e.Code)
}
}
func TestExitError_Newf(t *testing.T) {
e := Newf(Unreachable, "cannot reach %s", "server")
if e.Error() != "cannot reach server" {
t.Fatalf("expected 'cannot reach server', got %q", e.Error())
}
if e.Code != Unreachable {
t.Fatalf("expected code %d, got %d", Unreachable, e.Code)
}
}
func TestExitError_ErrorsAs(t *testing.T) {
e := New(BadRequest, "bad input")
wrapped := fmt.Errorf("wrapper: %w", e)
var exitErr *ExitError
if !errors.As(wrapped, &exitErr) {
t.Fatal("errors.As should find ExitError")
}
if exitErr.Code != BadRequest {
t.Fatalf("expected code %d, got %d", BadRequest, exitErr.Code)
}
}

View File

@@ -0,0 +1,50 @@
// Package fsutil provides filesystem helper functions.
package fsutil
import (
"bytes"
"errors"
"fmt"
"os"
)
// FileStatus describes how an on-disk file compares to expected content.
type FileStatus int
const (
StatusMissing FileStatus = iota
StatusUpToDate // file exists with identical content
StatusDiffers // file exists with different content
)
// CompareFile checks whether the file at path matches the expected content.
func CompareFile(path string, expected []byte) (FileStatus, error) {
existing, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return StatusMissing, nil
}
return 0, fmt.Errorf("could not read %s: %w", path, err)
}
if bytes.Equal(existing, expected) {
return StatusUpToDate, nil
}
return StatusDiffers, nil
}
// EnsureDirForCopy makes sure path is a real directory, not a symlink or
// regular file. If a symlink or file exists at path it is removed so the
// caller can create a directory with independent content.
func EnsureDirForCopy(path string) error {
info, err := os.Lstat(path)
if err == nil {
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
if err := os.Remove(path); err != nil {
return err
}
}
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
return nil
}

View File

@@ -0,0 +1,116 @@
package fsutil
import (
"os"
"path/filepath"
"testing"
)
// TestCompareFile verifies that CompareFile correctly distinguishes between a
// missing file, a file with matching content, and a file with different content.
func TestCompareFile(t *testing.T) {
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "skill.md")
expected := []byte("expected content")
status, err := CompareFile(path, expected)
if err != nil {
t.Fatalf("CompareFile on missing file failed: %v", err)
}
if status != StatusMissing {
t.Fatalf("expected StatusMissing, got %v", status)
}
if err := os.WriteFile(path, expected, 0o644); err != nil {
t.Fatalf("write expected file failed: %v", err)
}
status, err = CompareFile(path, expected)
if err != nil {
t.Fatalf("CompareFile on matching file failed: %v", err)
}
if status != StatusUpToDate {
t.Fatalf("expected StatusUpToDate, got %v", status)
}
if err := os.WriteFile(path, []byte("different content"), 0o644); err != nil {
t.Fatalf("write different file failed: %v", err)
}
status, err = CompareFile(path, expected)
if err != nil {
t.Fatalf("CompareFile on different file failed: %v", err)
}
if status != StatusDiffers {
t.Fatalf("expected StatusDiffers, got %v", status)
}
}
// TestEnsureDirForCopy verifies that EnsureDirForCopy clears symlinks and
// regular files so --copy can write a real directory, while leaving existing
// directories and missing paths untouched.
func TestEnsureDirForCopy(t *testing.T) {
t.Run("removes symlink", func(t *testing.T) {
tmpDir := t.TempDir()
targetDir := filepath.Join(tmpDir, "target")
linkPath := filepath.Join(tmpDir, "link")
if err := os.MkdirAll(targetDir, 0o755); err != nil {
t.Fatalf("mkdir target failed: %v", err)
}
if err := os.Symlink(targetDir, linkPath); err != nil {
t.Fatalf("create symlink failed: %v", err)
}
if err := EnsureDirForCopy(linkPath); err != nil {
t.Fatalf("EnsureDirForCopy failed: %v", err)
}
if _, err := os.Lstat(linkPath); !os.IsNotExist(err) {
t.Fatalf("expected symlink path to be removed, got err=%v", err)
}
})
t.Run("removes regular file", func(t *testing.T) {
tmpDir := t.TempDir()
filePath := filepath.Join(tmpDir, "onyx-cli")
if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil {
t.Fatalf("write file failed: %v", err)
}
if err := EnsureDirForCopy(filePath); err != nil {
t.Fatalf("EnsureDirForCopy failed: %v", err)
}
if _, err := os.Lstat(filePath); !os.IsNotExist(err) {
t.Fatalf("expected file path to be removed, got err=%v", err)
}
})
t.Run("keeps existing directory", func(t *testing.T) {
tmpDir := t.TempDir()
dirPath := filepath.Join(tmpDir, "onyx-cli")
if err := os.MkdirAll(dirPath, 0o755); err != nil {
t.Fatalf("mkdir failed: %v", err)
}
if err := EnsureDirForCopy(dirPath); err != nil {
t.Fatalf("EnsureDirForCopy failed: %v", err)
}
info, err := os.Lstat(dirPath)
if err != nil {
t.Fatalf("lstat directory failed: %v", err)
}
if !info.IsDir() {
t.Fatalf("expected directory to remain, got mode %v", info.Mode())
}
})
t.Run("missing path is no-op", func(t *testing.T) {
tmpDir := t.TempDir()
missingPath := filepath.Join(tmpDir, "does-not-exist")
if err := EnsureDirForCopy(missingPath); err != nil {
t.Fatalf("EnsureDirForCopy failed: %v", err)
}
})
}

View File

@@ -0,0 +1,121 @@
// Package overflow provides a streaming writer that auto-truncates output
// for non-TTY callers (e.g., AI agents, scripts). Full content is saved to
// a temp file on disk; only the first N bytes are printed to stdout.
package overflow
import (
"fmt"
"os"
"strings"
log "github.com/sirupsen/logrus"
)
// Writer handles streaming output with optional truncation.
// When Limit > 0, it streams to a temp file on disk (not memory) and stops
// writing to stdout after Limit bytes. When Limit == 0, it writes directly
// to stdout. In Quiet mode, it buffers in memory and prints once at the end.
type Writer struct {
Limit int
Quiet bool
written int
totalBytes int
truncated bool
buf strings.Builder // used only in quiet mode
tmpFile *os.File // used only in truncation mode (Limit > 0)
}
// Write sends a chunk of content through the writer.
func (w *Writer) Write(s string) {
w.totalBytes += len(s)
// Quiet mode: buffer in memory, print nothing
if w.Quiet {
w.buf.WriteString(s)
return
}
if w.Limit <= 0 {
fmt.Print(s)
return
}
// Truncation mode: stream all content to temp file on disk
if w.tmpFile == nil {
f, err := os.CreateTemp("", "onyx-ask-*.txt")
if err != nil {
// Fall back to no-truncation if we can't create the file
fmt.Fprintf(os.Stderr, "warning: could not create temp file: %v\n", err)
w.Limit = 0
fmt.Print(s)
return
}
w.tmpFile = f
}
if _, err := w.tmpFile.WriteString(s); err != nil {
// Disk write failed — abandon truncation, stream directly to stdout
fmt.Fprintf(os.Stderr, "warning: temp file write failed: %v\n", err)
w.closeTmpFile(true)
w.Limit = 0
w.truncated = false
fmt.Print(s)
return
}
if w.truncated {
return
}
remaining := w.Limit - w.written
if len(s) <= remaining {
fmt.Print(s)
w.written += len(s)
} else {
if remaining > 0 {
fmt.Print(s[:remaining])
w.written += remaining
}
w.truncated = true
}
}
// Finish flushes remaining output. Call once after all Write calls are done.
func (w *Writer) Finish() {
// Quiet mode: print buffered content at once
if w.Quiet {
fmt.Println(w.buf.String())
return
}
if !w.truncated {
w.closeTmpFile(true) // clean up unused temp file
fmt.Println()
return
}
// Close the temp file so it's readable
tmpPath := w.tmpFile.Name()
w.closeTmpFile(false) // close but keep the file
fmt.Printf("\n\n--- response truncated (%d bytes total) ---\n", w.totalBytes)
fmt.Printf("Full response: %s\n", tmpPath)
fmt.Printf("Explore:\n")
fmt.Printf(" cat %s | grep \"<pattern>\"\n", tmpPath)
fmt.Printf(" cat %s | tail -50\n", tmpPath)
}
// closeTmpFile closes and optionally removes the temp file.
func (w *Writer) closeTmpFile(remove bool) {
if w.tmpFile == nil {
return
}
if err := w.tmpFile.Close(); err != nil {
log.Debugf("warning: failed to close temp file: %v", err)
}
if remove {
if err := os.Remove(w.tmpFile.Name()); err != nil {
log.Debugf("warning: failed to remove temp file: %v", err)
}
}
w.tmpFile = nil
}

View File

@@ -0,0 +1,95 @@
package overflow
import (
"os"
"testing"
)
func TestWriter_NoLimit(t *testing.T) {
w := &Writer{Limit: 0}
w.Write("hello world")
if w.truncated {
t.Fatal("should not be truncated with limit 0")
}
if w.totalBytes != 11 {
t.Fatalf("expected 11 total bytes, got %d", w.totalBytes)
}
}
func TestWriter_UnderLimit(t *testing.T) {
w := &Writer{Limit: 100}
w.Write("hello")
w.Write(" world")
if w.truncated {
t.Fatal("should not be truncated when under limit")
}
if w.written != 11 {
t.Fatalf("expected 11 written bytes, got %d", w.written)
}
}
func TestWriter_OverLimit(t *testing.T) {
w := &Writer{Limit: 5}
w.Write("hello world") // 11 bytes, limit 5
if !w.truncated {
t.Fatal("should be truncated")
}
if w.written != 5 {
t.Fatalf("expected 5 written bytes, got %d", w.written)
}
if w.totalBytes != 11 {
t.Fatalf("expected 11 total bytes, got %d", w.totalBytes)
}
if w.tmpFile == nil {
t.Fatal("temp file should have been created")
}
_ = w.tmpFile.Close()
data, _ := os.ReadFile(w.tmpFile.Name())
_ = os.Remove(w.tmpFile.Name())
if string(data) != "hello world" {
t.Fatalf("temp file should contain full content, got %q", string(data))
}
}
func TestWriter_MultipleChunks(t *testing.T) {
w := &Writer{Limit: 10}
w.Write("hello") // 5 bytes
w.Write(" ") // 6 bytes
w.Write("world") // 11 bytes, crosses limit
w.Write("!") // 12 bytes, already truncated
if !w.truncated {
t.Fatal("should be truncated")
}
if w.written != 10 {
t.Fatalf("expected 10 written bytes, got %d", w.written)
}
if w.totalBytes != 12 {
t.Fatalf("expected 12 total bytes, got %d", w.totalBytes)
}
if w.tmpFile == nil {
t.Fatal("temp file should have been created")
}
_ = w.tmpFile.Close()
data, _ := os.ReadFile(w.tmpFile.Name())
_ = os.Remove(w.tmpFile.Name())
if string(data) != "hello world!" {
t.Fatalf("temp file should contain full content, got %q", string(data))
}
}
func TestWriter_QuietMode(t *testing.T) {
w := &Writer{Limit: 0, Quiet: true}
w.Write("hello")
w.Write(" world")
if w.written != 0 {
t.Fatalf("quiet mode should not write to stdout, got %d written", w.written)
}
if w.totalBytes != 11 {
t.Fatalf("expected 11 total bytes, got %d", w.totalBytes)
}
if w.buf.String() != "hello world" {
t.Fatalf("buffer should contain full content, got %q", w.buf.String())
}
}

View File

@@ -0,0 +1,83 @@
// Package starprompt implements a one-time GitHub star prompt shown before the TUI.
// Skipped when stdin/stdout is not a TTY, when gh CLI is not installed,
// or when the user has already been prompted. State is stored in the
// config directory so it shows at most once per user.
package starprompt
import (
"bufio"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/onyx-dot-app/onyx/cli/internal/config"
"golang.org/x/term"
)
const repo = "onyx-dot-app/onyx"
func statePath() string {
return filepath.Join(config.ConfigDir(), ".star-prompted")
}
func hasBeenPrompted() bool {
_, err := os.Stat(statePath())
return err == nil
}
func markPrompted() {
_ = os.MkdirAll(config.ConfigDir(), 0o755)
f, err := os.Create(statePath())
if err == nil {
_ = f.Close()
}
}
func isGHInstalled() bool {
_, err := exec.LookPath("gh")
return err == nil
}
// MaybePrompt shows a one-time star prompt if conditions are met.
// It is safe to call unconditionally — it no-ops when not appropriate.
func MaybePrompt() {
if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) {
return
}
if hasBeenPrompted() {
return
}
if !isGHInstalled() {
return
}
// Mark before asking so Ctrl+C won't cause a re-prompt.
markPrompted()
fmt.Print("Enjoying Onyx? Star the repo on GitHub? [Y/n] ")
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
if answer == "n" || answer == "no" {
return
}
cmd := exec.Command("gh", "api", "-X", "PUT", "/user/starred/"+repo)
cmd.Env = append(os.Environ(), "GH_PAGER=")
if devnull, err := os.Open(os.DevNull); err == nil {
defer func() { _ = devnull.Close() }()
cmd.Stdin = devnull
cmd.Stdout = devnull
cmd.Stderr = devnull
}
if err := cmd.Run(); err != nil {
fmt.Println("Star us at: https://github.com/" + repo)
} else {
fmt.Println("Thanks for the star!")
time.Sleep(500 * time.Millisecond)
}
}

View File

@@ -55,7 +55,7 @@ func NewModel(cfg config.OnyxCliConfig) Model {
return Model{
config: cfg,
client: client,
viewport: newViewport(80),
viewport: newViewport(80, cfg.Features.StreamMarkdownEnabled()),
input: newInputModel(),
status: newStatusBar(),
agentID: cfg.DefaultAgentID,

View File

@@ -67,6 +67,10 @@ func handleSlashCommand(m Model, text string) (Model, tea.Cmd) {
}
return m, nil
case "/experiments":
m.viewport.addInfo(m.experimentsText())
return m, nil
case "/quit":
return m, tea.Quit

View File

@@ -0,0 +1,8 @@
package tui
import "github.com/onyx-dot-app/onyx/cli/internal/config"
// experimentsText returns the formatted experiments list for the current config.
func (m Model) experimentsText() string {
return config.ExperimentsText(m.config.Features)
}

View File

@@ -10,6 +10,7 @@ const helpText = `Onyx CLI Commands
/configure Re-run connection setup
/connectors Open connectors page in browser
/settings Open Onyx settings in browser
/experiments List experimental features and their status
/quit Exit Onyx CLI
Keyboard Shortcuts

View File

@@ -24,6 +24,7 @@ var slashCommands = []slashCommand{
{"/configure", "Re-run connection setup"},
{"/connectors", "Open connectors in browser"},
{"/settings", "Open settings in browser"},
{"/experiments", "List experimental features"},
{"/quit", "Exit Onyx CLI"},
}

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"sort"
"strings"
"time"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/glamour/styles"
@@ -44,6 +45,9 @@ type pickerItem struct {
label string
}
// streamRenderInterval is the minimum time between markdown re-renders during streaming.
const streamRenderInterval = 100 * time.Millisecond
// viewport manages the chat display.
type viewport struct {
entries []chatEntry
@@ -57,6 +61,12 @@ type viewport struct {
pickerIndex int
pickerType pickerKind
scrollOffset int // lines scrolled up from bottom (0 = pinned to bottom)
// Progressive markdown rendering during streaming
streamMarkdown bool // feature flag: render markdown while streaming
streamRendered string // cached rendered output during streaming
lastRenderTime time.Time
lastRenderLen int // length of streamBuf at last render (skip if unchanged)
}
// newMarkdownRenderer creates a Glamour renderer with zero left margin.
@@ -71,10 +81,11 @@ func newMarkdownRenderer(width int) *glamour.TermRenderer {
return r
}
func newViewport(width int) *viewport {
func newViewport(width int, streamMarkdown bool) *viewport {
return &viewport{
width: width,
renderer: newMarkdownRenderer(width),
width: width,
renderer: newMarkdownRenderer(width),
streamMarkdown: streamMarkdown,
}
}
@@ -108,12 +119,27 @@ func (v *viewport) addUserMessage(msg string) {
func (v *viewport) startAgent() {
v.streaming = true
v.streamBuf = ""
v.streamRendered = ""
v.lastRenderLen = 0
v.lastRenderTime = time.Time{}
// Add a blank-line spacer entry before the agent message
v.entries = append(v.entries, chatEntry{kind: entryInfo, rendered: ""})
}
func (v *viewport) appendToken(token string) {
v.streamBuf += token
if !v.streamMarkdown {
return
}
now := time.Now()
bufLen := len(v.streamBuf)
if bufLen != v.lastRenderLen && now.Sub(v.lastRenderTime) >= streamRenderInterval {
v.streamRendered = v.renderAgentContent(v.streamBuf)
v.lastRenderTime = now
v.lastRenderLen = bufLen
}
}
func (v *viewport) finishAgent() {
@@ -135,6 +161,8 @@ func (v *viewport) finishAgent() {
})
v.streaming = false
v.streamBuf = ""
v.streamRendered = ""
v.lastRenderLen = 0
}
func (v *viewport) renderAgentContent(content string) string {
@@ -358,6 +386,22 @@ func (v *viewport) renderPicker(width, height int) string {
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, panel)
}
// streamingContent returns the display content for the in-progress stream.
func (v *viewport) streamingContent() string {
if v.streamMarkdown && v.streamRendered != "" {
return v.streamRendered
}
// Fall back to raw text with agent dot prefix
bufLines := strings.Split(v.streamBuf, "\n")
if len(bufLines) > 0 {
bufLines[0] = agentDot + " " + bufLines[0]
for i := 1; i < len(bufLines); i++ {
bufLines[i] = " " + bufLines[i]
}
}
return strings.Join(bufLines, "\n")
}
// totalLines computes the total number of rendered content lines.
func (v *viewport) totalLines() int {
var lines []string
@@ -368,14 +412,7 @@ func (v *viewport) totalLines() int {
lines = append(lines, e.rendered)
}
if v.streaming && v.streamBuf != "" {
bufLines := strings.Split(v.streamBuf, "\n")
if len(bufLines) > 0 {
bufLines[0] = agentDot + " " + bufLines[0]
for i := 1; i < len(bufLines); i++ {
bufLines[i] = " " + bufLines[i]
}
}
lines = append(lines, strings.Join(bufLines, "\n"))
lines = append(lines, v.streamingContent())
} else if v.streaming {
lines = append(lines, agentDot+" ")
}
@@ -399,16 +436,9 @@ func (v *viewport) view(height int) string {
lines = append(lines, e.rendered)
}
// Streaming buffer (plain text, not markdown)
// Streaming buffer
if v.streaming && v.streamBuf != "" {
bufLines := strings.Split(v.streamBuf, "\n")
if len(bufLines) > 0 {
bufLines[0] = agentDot + " " + bufLines[0]
for i := 1; i < len(bufLines); i++ {
bufLines[i] = " " + bufLines[i]
}
}
lines = append(lines, strings.Join(bufLines, "\n"))
lines = append(lines, v.streamingContent())
} else if v.streaming {
lines = append(lines, agentDot+" ")
}

View File

@@ -4,6 +4,7 @@ import (
"regexp"
"strings"
"testing"
"time"
)
// stripANSI removes ANSI escape sequences for test comparisons.
@@ -14,7 +15,7 @@ func stripANSI(s string) string {
}
func TestAddUserMessage(t *testing.T) {
v := newViewport(80)
v := newViewport(80, false)
v.addUserMessage("hello world")
if len(v.entries) != 1 {
@@ -37,7 +38,7 @@ func TestAddUserMessage(t *testing.T) {
}
func TestStartAndFinishAgent(t *testing.T) {
v := newViewport(80)
v := newViewport(80, false)
v.startAgent()
if !v.streaming {
@@ -83,7 +84,7 @@ func TestStartAndFinishAgent(t *testing.T) {
}
func TestFinishAgentNoPadding(t *testing.T) {
v := newViewport(80)
v := newViewport(80, false)
v.startAgent()
v.appendToken("Test message")
v.finishAgent()
@@ -98,7 +99,7 @@ func TestFinishAgentNoPadding(t *testing.T) {
}
func TestFinishAgentMultiline(t *testing.T) {
v := newViewport(80)
v := newViewport(80, false)
v.startAgent()
v.appendToken("Line one\n\nLine three")
v.finishAgent()
@@ -115,7 +116,7 @@ func TestFinishAgentMultiline(t *testing.T) {
}
func TestFinishAgentEmpty(t *testing.T) {
v := newViewport(80)
v := newViewport(80, false)
v.startAgent()
v.finishAgent()
@@ -128,7 +129,7 @@ func TestFinishAgentEmpty(t *testing.T) {
}
func TestAddInfo(t *testing.T) {
v := newViewport(80)
v := newViewport(80, false)
v.addInfo("test info")
if len(v.entries) != 1 {
@@ -145,7 +146,7 @@ func TestAddInfo(t *testing.T) {
}
func TestAddError(t *testing.T) {
v := newViewport(80)
v := newViewport(80, false)
v.addError("something broke")
if len(v.entries) != 1 {
@@ -162,7 +163,7 @@ func TestAddError(t *testing.T) {
}
func TestAddCitations(t *testing.T) {
v := newViewport(80)
v := newViewport(80, false)
v.addCitations(map[int]string{1: "doc-a", 2: "doc-b"})
if len(v.entries) != 1 {
@@ -182,7 +183,7 @@ func TestAddCitations(t *testing.T) {
}
func TestAddCitationsEmpty(t *testing.T) {
v := newViewport(80)
v := newViewport(80, false)
v.addCitations(map[int]string{})
if len(v.entries) != 0 {
@@ -191,7 +192,7 @@ func TestAddCitationsEmpty(t *testing.T) {
}
func TestCitationVisibility(t *testing.T) {
v := newViewport(80)
v := newViewport(80, false)
v.addInfo("hello")
v.addCitations(map[int]string{1: "doc"})
@@ -211,7 +212,7 @@ func TestCitationVisibility(t *testing.T) {
}
func TestClearAll(t *testing.T) {
v := newViewport(80)
v := newViewport(80, false)
v.addUserMessage("test")
v.startAgent()
v.appendToken("response")
@@ -230,7 +231,7 @@ func TestClearAll(t *testing.T) {
}
func TestClearDisplay(t *testing.T) {
v := newViewport(80)
v := newViewport(80, false)
v.addUserMessage("test")
v.clearDisplay()
@@ -240,7 +241,7 @@ func TestClearDisplay(t *testing.T) {
}
func TestViewPadsShortContent(t *testing.T) {
v := newViewport(80)
v := newViewport(80, false)
v.addInfo("hello")
view := v.view(10)
@@ -251,7 +252,7 @@ func TestViewPadsShortContent(t *testing.T) {
}
func TestViewTruncatesTallContent(t *testing.T) {
v := newViewport(80)
v := newViewport(80, false)
for i := 0; i < 20; i++ {
v.addInfo("line")
}
@@ -262,3 +263,93 @@ func TestViewTruncatesTallContent(t *testing.T) {
t.Errorf("expected 5 lines (truncated), got %d", len(lines))
}
}
func TestStreamMarkdownRendersOnThrottle(t *testing.T) {
v := newViewport(80, true)
v.startAgent()
// First token: no prior render, so it should render immediately
v.appendToken("**bold text**")
if v.streamRendered == "" {
t.Error("expected streamRendered to be populated after first token")
}
plain := stripANSI(v.streamRendered)
if !strings.Contains(plain, "bold text") {
t.Errorf("expected rendered to contain 'bold text', got %q", plain)
}
// Should not contain raw markdown asterisks
if strings.Contains(plain, "**") {
t.Errorf("expected markdown to be rendered (no **), got %q", plain)
}
// Second token within throttle window: should NOT re-render
v.lastRenderTime = time.Now() // simulate recent render
prevRendered := v.streamRendered
v.appendToken(" more")
if v.streamRendered != prevRendered {
t.Error("expected streamRendered to be unchanged within throttle window")
}
// After throttle interval: should re-render
v.lastRenderTime = time.Now().Add(-streamRenderInterval - time.Millisecond)
v.appendToken("!")
if v.streamRendered == prevRendered {
t.Error("expected streamRendered to update after throttle interval")
}
plain = stripANSI(v.streamRendered)
if !strings.Contains(plain, "bold text more!") {
t.Errorf("expected updated rendered content, got %q", plain)
}
}
func TestStreamMarkdownDisabledNoRender(t *testing.T) {
v := newViewport(80, false)
v.startAgent()
v.appendToken("**bold**")
if v.streamRendered != "" {
t.Error("expected no streamRendered when streamMarkdown is disabled")
}
// View should show raw markdown
view := v.view(10)
plain := stripANSI(view)
if !strings.Contains(plain, "**bold**") {
t.Errorf("expected raw markdown in view, got %q", plain)
}
}
func TestStreamMarkdownViewUsesRendered(t *testing.T) {
v := newViewport(80, true)
v.startAgent()
v.appendToken("**formatted**")
view := v.view(10)
plain := stripANSI(view)
// Should show rendered content, not raw **formatted**
if strings.Contains(plain, "**") {
t.Errorf("expected rendered markdown in view (no **), got %q", plain)
}
if !strings.Contains(plain, "formatted") {
t.Errorf("expected 'formatted' in view, got %q", plain)
}
}
func TestStreamMarkdownResetOnStart(t *testing.T) {
v := newViewport(80, true)
// First stream cycle
v.startAgent()
v.appendToken("first")
v.finishAgent()
// Start second stream - state should be clean
v.startAgent()
if v.streamRendered != "" {
t.Error("expected streamRendered cleared on startAgent")
}
if v.lastRenderLen != 0 {
t.Error("expected lastRenderLen reset on startAgent")
}
}

View File

@@ -1,10 +1,12 @@
package main
import (
"errors"
"fmt"
"os"
"github.com/onyx-dot-app/onyx/cli/cmd"
"github.com/onyx-dot-app/onyx/cli/internal/exitcodes"
)
var (
@@ -18,6 +20,10 @@ func main() {
if err := cmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
var exitErr *exitcodes.ExitError
if errors.As(err, &exitErr) {
os.Exit(exitErr.Code)
}
os.Exit(1)
}
}

View File

@@ -1302,4 +1302,18 @@ echo ""
print_info "Refer to the README in the ${INSTALL_ROOT} directory for more information."
echo ""
print_info "For help or issues, contact: founders@onyx.app"
echo ""
echo ""
# --- GitHub star prompt (inspired by oh-my-codex) ---
# Only prompt in interactive mode and only if gh CLI is available.
# Uses the GitHub API directly (PUT /user/starred) like oh-my-codex.
if is_interactive && command -v gh &>/dev/null; then
prompt_yn_or_default "Enjoying Onyx? Star the repo on GitHub? [Y/n] " "Y"
if [[ ! "$REPLY" =~ ^[Nn] ]]; then
if GH_PAGER= gh api -X PUT /user/starred/onyx-dot-app/onyx < /dev/null >/dev/null 2>&1; then
print_success "Thanks for the star!"
else
print_info "Star us at: https://github.com/onyx-dot-app/onyx"
fi
fi
fi

View File

@@ -19,6 +19,6 @@ dependencies:
version: 5.4.0
- name: code-interpreter
repository: https://onyx-dot-app.github.io/python-sandbox/
version: 0.3.1
digest: sha256:4965b6ea3674c37163832a2192cd3bc8004f2228729fca170af0b9f457e8f987
generated: "2026-03-02T15:29:39.632344-08:00"
version: 0.3.2
digest: sha256:74908ea45ace2b4be913ff762772e6d87e40bab64e92c6662aa51730eaeb9d87
generated: "2026-04-06T15:34:02.597166-07:00"

View File

@@ -5,7 +5,7 @@ home: https://www.onyx.app/
sources:
- "https://github.com/onyx-dot-app/onyx"
type: application
version: 0.4.39
version: 0.4.40
appVersion: latest
annotations:
category: Productivity
@@ -45,6 +45,6 @@ dependencies:
repository: https://charts.min.io/
condition: minio.enabled
- name: code-interpreter
version: 0.3.1
version: 0.3.2
repository: https://onyx-dot-app.github.io/python-sandbox/
condition: codeInterpreter.enabled

View File

@@ -67,6 +67,9 @@ spec:
- "/bin/sh"
- "-c"
- |
{{- if .Values.api.runUpdateCaCertificates }}
update-ca-certificates &&
{{- end }}
alembic upgrade head &&
echo "Starting Onyx Api Server" &&
uvicorn onyx.main:app --host {{ .Values.global.host }} --port {{ .Values.api.containerPorts.server }}

View File

@@ -504,6 +504,18 @@ api:
tolerations: []
affinity: {}
# Run update-ca-certificates before starting the server.
# Useful when mounting custom CA certificates via volumes/volumeMounts.
# NOTE: Requires the container to run as root (runAsUser: 0).
# CA certificate files must be mounted under /usr/local/share/ca-certificates/
# with a .crt extension (e.g. /usr/local/share/ca-certificates/my-ca.crt).
# NOTE: Python HTTP clients (requests, httpx) use certifi's bundle by default
# and will not pick up the system CA store automatically. Set the following
# environment variables via configMap values (loaded through envFrom) to make them use the updated system bundle:
# REQUESTS_CA_BUNDLE: /etc/ssl/certs/ca-certificates.crt
# SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt
runUpdateCaCertificates: false
######################################################################
#

View File

@@ -16,7 +16,7 @@ div.relative
```
- **`sidebar-heavy`** (default) — muted when unselected (text-03/text-02), bold when selected (text-04/text-03)
- **`sidebar-light`** (via `lowlight`) — uniformly muted across all states (text-02/text-02)
- **`sidebar-light`** — uniformly muted across all states (text-02/text-02)
- **Disabled** — both variants use text-02 foreground, transparent background, no hover/active states
- **Navigation** uses an absolutely positioned `<Link>` overlay rather than `href` on the Interactive element, so `rightChildren` can sit above it with `pointer-events-auto`.
@@ -24,10 +24,10 @@ div.relative
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `variant` | `"sidebar-heavy" \| "sidebar-light"` | `"sidebar-heavy"` | Sidebar color variant |
| `selected` | `boolean` | `false` | Active/selected state |
| `icon` | `IconFunctionComponent` | — | Left icon |
| `children` | `ReactNode` | — | Label text or custom content |
| `selected` | `boolean` | `false` | Active/selected state |
| `lowlight` | `boolean` | `false` | Uses muted `sidebar-light` variant |
| `disabled` | `boolean` | `false` | Disables the tab |
| `folded` | `boolean` | `false` | Collapses label, shows tooltip on hover |
| `nested` | `boolean` | `false` | Renders spacer instead of icon for indented items |
@@ -47,6 +47,11 @@ import { SvgSettings, SvgLock } from "@opal/icons";
Settings
</SidebarTab>
// Muted variant
<SidebarTab icon={SvgSettings} variant="sidebar-light">
Exit Admin Panel
</SidebarTab>
// Disabled enterprise-only tab
<SidebarTab icon={SvgLock} disabled>
Groups

View File

@@ -1,9 +1,13 @@
import React from "react";
import type { Meta, StoryObj } from "@storybook/react";
import { SidebarTab } from "@opal/components/buttons/sidebar-tab/components";
import { SvgSettings, SvgUsers, SvgLock, SvgArrowUpCircle } from "@opal/icons";
import {
SvgSettings,
SvgUsers,
SvgLock,
SvgArrowUpCircle,
SvgTrash,
} from "@opal/icons";
import { Button } from "@opal/components";
import { SvgTrash } from "@opal/icons";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
const meta: Meta<typeof SidebarTab> = {
@@ -39,11 +43,11 @@ export const Selected: Story = {
},
};
export const Lowlight: Story = {
export const Light: Story = {
args: {
icon: SvgSettings,
children: "Settings",
lowlight: true,
variant: "sidebar-light",
},
};

View File

@@ -3,7 +3,7 @@
import React from "react";
import type { ButtonType, IconFunctionComponent } from "@opal/types";
import type { Route } from "next";
import { Interactive } from "@opal/core";
import { Interactive, type InteractiveStatefulVariant } from "@opal/core";
import { ContentAction } from "@opal/layouts";
import { Text } from "@opal/components";
import Link from "next/link";
@@ -21,8 +21,14 @@ interface SidebarTabProps {
/** Marks this tab as the currently active/selected item. */
selected?: boolean;
/** Uses the muted `sidebar-light` variant instead of `sidebar-heavy`. */
lowlight?: boolean;
/**
* Sidebar color variant.
* @default "sidebar-heavy"
*/
variant?: Extract<
InteractiveStatefulVariant,
"sidebar-light" | "sidebar-heavy"
>;
/** Renders an empty spacer in place of the icon for nested items. */
nested?: boolean;
@@ -47,14 +53,14 @@ interface SidebarTabProps {
/**
* Sidebar navigation tab built on `Interactive.Stateful` > `Interactive.Container`.
*
* Uses `sidebar-heavy` (default) or `sidebar-light` (when `lowlight`) variants
* Uses `sidebar-heavy` (default) or `sidebar-light` (via `variant`) variants
* for color styling. Supports an overlay `Link` for client-side navigation,
* `rightChildren` for inline actions, and folded mode with an auto-tooltip.
*/
function SidebarTab({
folded,
selected,
lowlight,
variant = "sidebar-heavy",
nested,
disabled,
@@ -82,7 +88,7 @@ function SidebarTab({
const content = (
<div className="relative">
<Interactive.Stateful
variant={lowlight ? "sidebar-light" : "sidebar-heavy"}
variant={variant}
state={selected ? "selected" : "empty"}
disabled={disabled}
onClick={onClick}
@@ -121,7 +127,7 @@ function SidebarTab({
rightChildren={truncationSpacer}
/>
) : (
<div className="flex flex-row items-center gap-2 flex-1">
<div className="flex flex-row items-center gap-2 w-full">
{Icon && (
<div className="flex items-center justify-center p-0.5">
<Icon className="h-[1rem] w-[1rem] text-text-03" />
@@ -147,7 +153,7 @@ function SidebarTab({
side="right"
sideOffset={4}
>
<Text>{children}</Text>
{children}
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
</TooltipPrimitive.Root>

View File

@@ -1,18 +1,22 @@
import { Card } from "@opal/components/cards/card/components";
import { Content } from "@opal/layouts";
import { Content, SizePreset } from "@opal/layouts";
import { SvgEmpty } from "@opal/icons";
import type { IconFunctionComponent, PaddingVariants } from "@opal/types";
import type {
IconFunctionComponent,
PaddingVariants,
RichStr,
} from "@opal/types";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type EmptyMessageCardProps = {
type EmptyMessageCardBaseProps = {
/** Icon displayed alongside the title. */
icon?: IconFunctionComponent;
/** Primary message text. */
title: string;
title: string | RichStr;
/** Padding preset for the card. @default "md" */
padding?: PaddingVariants;
@@ -21,16 +25,30 @@ type EmptyMessageCardProps = {
ref?: React.Ref<HTMLDivElement>;
};
type EmptyMessageCardProps =
| (EmptyMessageCardBaseProps & {
/** @default "secondary" */
sizePreset?: "secondary";
})
| (EmptyMessageCardBaseProps & {
sizePreset: "main-ui";
/** Description text. Only supported when `sizePreset` is `"main-ui"`. */
description?: string | RichStr;
});
// ---------------------------------------------------------------------------
// EmptyMessageCard
// ---------------------------------------------------------------------------
function EmptyMessageCard({
icon = SvgEmpty,
title,
padding = "md",
ref,
}: EmptyMessageCardProps) {
function EmptyMessageCard(props: EmptyMessageCardProps) {
const {
sizePreset = "secondary",
icon = SvgEmpty,
title,
padding = "md",
ref,
} = props;
return (
<Card
ref={ref}
@@ -39,13 +57,23 @@ function EmptyMessageCard({
padding={padding}
rounding="md"
>
<Content
icon={icon}
title={title}
sizePreset="secondary"
variant="body"
prominence="muted"
/>
{sizePreset === "secondary" ? (
<Content
icon={icon}
title={title}
sizePreset="secondary"
variant="body"
prominence="muted"
/>
) : (
<Content
icon={icon}
title={title}
description={"description" in props ? props.description : undefined}
sizePreset={sizePreset}
variant="section"
/>
)}
</Card>
);
}

View File

@@ -586,7 +586,10 @@ export function Table<TData>(props: DataTableProps<TData>) {
// Data / Display cell
return (
<TableCell key={cell.id}>
<TableCell
key={cell.id}
data-column-id={cell.column.id}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()

View File

@@ -554,9 +554,9 @@
Sidebar-Heavy — Disabled (all states)
--------------------------------------------------------------------------- */
.interactive[data-interactive-variant="sidebar-heavy"][data-disabled] {
@apply bg-transparent;
--interactive-foreground: var(--text-02);
--interactive-foreground-icon: var(--text-02);
@apply bg-transparent opacity-50;
--interactive-foreground: var(--text-03);
--interactive-foreground-icon: var(--text-03);
}
/* ===========================================================================
@@ -619,7 +619,7 @@
Sidebar-Light — Disabled (all states)
--------------------------------------------------------------------------- */
.interactive[data-interactive-variant="sidebar-light"][data-disabled] {
@apply bg-transparent;
--interactive-foreground: var(--text-02);
--interactive-foreground-icon: var(--text-02);
@apply bg-transparent opacity-50;
--interactive-foreground: var(--text-03);
--interactive-foreground-icon: var(--text-03);
}

View File

@@ -8,7 +8,7 @@ const SvgBifrost = ({ size, className, ...props }: IconProps) => (
viewBox="0 0 37 46"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={cn(className, "text-[#33C19E] dark:text-white")}
className={cn(className, "!text-[#33C19E]")}
{...props}
>
<title>Bifrost</title>

View File

@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react";
import { CardHeaderLayout } from "@opal/layouts";
import { Card } from "@opal/layouts";
import { Button } from "@opal/components";
import {
SvgArrowExchange,
@@ -18,14 +18,14 @@ const withTooltipProvider: Decorator = (Story) => (
);
const meta = {
title: "Layouts/CardHeaderLayout",
component: CardHeaderLayout,
title: "Layouts/Card.Header",
component: Card.Header,
tags: ["autodocs"],
decorators: [withTooltipProvider],
parameters: {
layout: "centered",
},
} satisfies Meta<typeof CardHeaderLayout>;
} satisfies Meta<typeof Card.Header>;
export default meta;
@@ -38,7 +38,7 @@ type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => (
<div className="w-[28rem] border rounded-16">
<CardHeaderLayout
<Card.Header
sizePreset="main-ui"
variant="section"
icon={SvgGlobe}
@@ -57,7 +57,7 @@ export const Default: Story = {
export const WithBothSlots: Story = {
render: () => (
<div className="w-[28rem] border rounded-16">
<CardHeaderLayout
<Card.Header
sizePreset="main-ui"
variant="section"
icon={SvgGlobe}
@@ -92,7 +92,7 @@ export const WithBothSlots: Story = {
export const RightChildrenOnly: Story = {
render: () => (
<div className="w-[28rem] border rounded-16">
<CardHeaderLayout
<Card.Header
sizePreset="main-ui"
variant="section"
icon={SvgGlobe}
@@ -111,7 +111,7 @@ export const RightChildrenOnly: Story = {
export const NoRightChildren: Story = {
render: () => (
<div className="w-[28rem] border rounded-16">
<CardHeaderLayout
<Card.Header
sizePreset="main-ui"
variant="section"
icon={SvgGlobe}
@@ -125,7 +125,7 @@ export const NoRightChildren: Story = {
export const LongContent: Story = {
render: () => (
<div className="w-[28rem] border rounded-16">
<CardHeaderLayout
<Card.Header
sizePreset="main-ui"
variant="section"
icon={SvgGlobe}

View File

@@ -0,0 +1,116 @@
# Card
**Import:** `import { Card } from "@opal/layouts";`
A namespace of card layout primitives. Each sub-component handles a specific region of a card.
## Card.Header
A card header layout that pairs a [`Content`](../content/README.md) block with a right-side column and an optional full-width children slot.
### Why Card.Header?
[`ContentAction`](../content-action/README.md) provides a single `rightChildren` slot. Card headers typically need two distinct right-side regions — a primary action on top and secondary actions on the bottom. `Card.Header` provides this with `rightChildren` and `bottomRightChildren` slots, plus a `children` slot for full-width content below the header row (e.g., search bars, expandable tool lists).
### Props
Inherits **all** props from [`Content`](../content/README.md) (icon, title, description, sizePreset, variant, editable, onTitleChange, suffix, etc.) plus:
| Prop | Type | Default | Description |
|---|---|---|---|
| `rightChildren` | `ReactNode` | `undefined` | Content rendered to the right of the Content block (top of right column). |
| `bottomRightChildren` | `ReactNode` | `undefined` | Content rendered below `rightChildren` in the same column. Laid out as `flex flex-row`. |
| `children` | `ReactNode` | `undefined` | Content rendered below the full header row, spanning the entire width. |
### Layout Structure
```
+---------------------------------------------------------+
| [Content (p-2, self-start)] [rightChildren] |
| icon + title + description [bottomRightChildren] |
+---------------------------------------------------------+
| [children — full width] |
+---------------------------------------------------------+
```
- Outer wrapper: `flex flex-col w-full`
- Header row: `flex flex-row items-stretch w-full`
- Content area: `flex-1 min-w-0 self-start p-2` — top-aligned with fixed padding
- Right column: `flex flex-col items-end shrink-0` — no padding, no gap
- `bottomRightChildren` wrapper: `flex flex-row` — lays children out horizontally
- `children` wrapper: `w-full` — only rendered when children are provided
### Usage
#### Card with primary and secondary actions
```tsx
import { Card } from "@opal/layouts";
import { Button } from "@opal/components";
import { SvgGlobe, SvgSettings, SvgUnplug, SvgCheckSquare } from "@opal/icons";
<Card.Header
icon={SvgGlobe}
title="Google Search"
description="Web search provider"
sizePreset="main-ui"
variant="section"
rightChildren={
<Button icon={SvgCheckSquare} variant="action" prominence="tertiary">
Current Default
</Button>
}
bottomRightChildren={
<>
<Button icon={SvgUnplug} size="sm" prominence="tertiary" tooltip="Disconnect" />
<Button icon={SvgSettings} size="sm" prominence="tertiary" tooltip="Edit" />
</>
}
/>
```
#### Card with only a connect action
```tsx
<Card.Header
icon={SvgCloud}
title="OpenAI"
description="Not configured"
sizePreset="main-ui"
variant="section"
rightChildren={
<Button rightIcon={SvgArrowExchange} prominence="tertiary">
Connect
</Button>
}
/>
```
#### Card with expandable children
```tsx
<Card.Header
icon={SvgServer}
title="MCP Server"
description="12 tools available"
sizePreset="main-ui"
variant="section"
rightChildren={<Button icon={SvgSettings} prominence="tertiary" />}
>
<SearchBar placeholder="Search tools..." />
</Card.Header>
```
#### No right children
```tsx
<Card.Header
icon={SvgInfo}
title="Section Header"
description="Description text"
sizePreset="main-content"
variant="section"
/>
```
When both `rightChildren` and `bottomRightChildren` are omitted and no `children` are provided, the component renders only the padded `Content`.

View File

@@ -4,16 +4,23 @@ import { Content, type ContentProps } from "@opal/layouts/content/components";
// Types
// ---------------------------------------------------------------------------
type CardHeaderLayoutProps = ContentProps & {
type CardHeaderProps = ContentProps & {
/** Content rendered to the right of the Content block. */
rightChildren?: React.ReactNode;
/** Content rendered below `rightChildren` in the same column. */
bottomRightChildren?: React.ReactNode;
/**
* Content rendered below the header row, full-width.
* Use for expandable sections, search bars, or any content
* that should appear beneath the icon/title/actions row.
*/
children?: React.ReactNode;
};
// ---------------------------------------------------------------------------
// CardHeaderLayout
// Card.Header
// ---------------------------------------------------------------------------
/**
@@ -24,9 +31,12 @@ type CardHeaderLayoutProps = ContentProps & {
* `rightChildren` on top, `bottomRightChildren` below with no
* padding or gap between them.
*
* The optional `children` slot renders below the full header row,
* spanning the entire width.
*
* @example
* ```tsx
* <CardHeaderLayout
* <Card.Header
* icon={SvgGlobe}
* title="Google"
* description="Search engine"
@@ -42,32 +52,42 @@ type CardHeaderLayoutProps = ContentProps & {
* />
* ```
*/
function CardHeaderLayout({
function Header({
rightChildren,
bottomRightChildren,
children,
...contentProps
}: CardHeaderLayoutProps) {
}: CardHeaderProps) {
const hasRight = rightChildren || bottomRightChildren;
return (
<div className="flex flex-row items-stretch w-full">
<div className="flex-1 min-w-0 self-start p-2">
<Content {...contentProps} />
</div>
{hasRight && (
<div className="flex flex-col items-end shrink-0">
{rightChildren && <div className="flex-1">{rightChildren}</div>}
{bottomRightChildren && (
<div className="flex flex-row">{bottomRightChildren}</div>
)}
<div className="flex flex-col w-full">
<div className="flex flex-row items-stretch w-full">
<div className="flex-1 min-w-0 self-start p-2">
<Content {...contentProps} />
</div>
)}
{hasRight && (
<div className="flex flex-col items-end shrink-0">
{rightChildren && <div className="flex-1">{rightChildren}</div>}
{bottomRightChildren && (
<div className="flex flex-row">{bottomRightChildren}</div>
)}
</div>
)}
</div>
{children && <div className="w-full">{children}</div>}
</div>
);
}
// ---------------------------------------------------------------------------
// Card namespace
// ---------------------------------------------------------------------------
const Card = { Header };
// ---------------------------------------------------------------------------
// Exports
// ---------------------------------------------------------------------------
export { CardHeaderLayout, type CardHeaderLayoutProps };
export { Card, type CardHeaderProps };

View File

@@ -1,94 +0,0 @@
# CardHeaderLayout
**Import:** `import { CardHeaderLayout, type CardHeaderLayoutProps } from "@opal/layouts";`
A card header layout that pairs a [`Content`](../../content/README.md) block with a right-side column of vertically stacked children.
## Why CardHeaderLayout?
[`ContentAction`](../../content-action/README.md) provides a single `rightChildren` slot. Card headers typically need two distinct right-side regions — a primary action on top and secondary actions on the bottom. `CardHeaderLayout` provides this with `rightChildren` and `bottomRightChildren` slots, with no padding or gap between them so the caller has full control over spacing.
## Props
Inherits **all** props from [`Content`](../../content/README.md) (icon, title, description, sizePreset, variant, etc.) plus:
| Prop | Type | Default | Description |
|---|---|---|---|
| `rightChildren` | `ReactNode` | `undefined` | Content rendered to the right of the Content block (top of right column). |
| `bottomRightChildren` | `ReactNode` | `undefined` | Content rendered below `rightChildren` in the same column. Laid out as `flex flex-row`. |
## Layout Structure
```
┌──────────────────────────────────────────────────────┐
│ [Content (p-2, self-start)] [rightChildren] │
│ icon + title + description [bottomRightChildren] │
└──────────────────────────────────────────────────────┘
```
- Outer wrapper: `flex flex-row items-stretch w-full`
- Content area: `flex-1 min-w-0 self-start p-2` — top-aligned with fixed padding
- Right column: `flex flex-col items-end justify-between shrink-0` — no padding, no gap
- `bottomRightChildren` wrapper: `flex flex-row` — lays children out horizontally
The right column uses `justify-between` so when both slots are present, `rightChildren` sits at the top and `bottomRightChildren` at the bottom.
## Usage
### Card with primary and secondary actions
```tsx
import { CardHeaderLayout } from "@opal/layouts";
import { Button } from "@opal/components";
import { SvgGlobe, SvgSettings, SvgUnplug, SvgCheckSquare } from "@opal/icons";
<CardHeaderLayout
icon={SvgGlobe}
title="Google Search"
description="Web search provider"
sizePreset="main-ui"
variant="section"
rightChildren={
<Button icon={SvgCheckSquare} variant="action" prominence="tertiary">
Current Default
</Button>
}
bottomRightChildren={
<>
<Button icon={SvgUnplug} size="sm" prominence="tertiary" tooltip="Disconnect" />
<Button icon={SvgSettings} size="sm" prominence="tertiary" tooltip="Edit" />
</>
}
/>
```
### Card with only a connect action
```tsx
<CardHeaderLayout
icon={SvgCloud}
title="OpenAI"
description="Not configured"
sizePreset="main-ui"
variant="section"
rightChildren={
<Button rightIcon={SvgArrowExchange} prominence="tertiary">
Connect
</Button>
}
/>
```
### No right children
```tsx
<CardHeaderLayout
icon={SvgInfo}
title="Section Header"
description="Description text"
sizePreset="main-content"
variant="section"
/>
```
When both `rightChildren` and `bottomRightChildren` are omitted, the component renders only the padded `Content`.

View File

@@ -12,11 +12,8 @@ export {
type ContentActionProps,
} from "@opal/layouts/content-action/components";
/* CardHeaderLayout */
export {
CardHeaderLayout,
type CardHeaderLayoutProps,
} from "@opal/layouts/cards/header-layout/components";
/* Card */
export { Card, type CardHeaderProps } from "@opal/layouts/cards/components";
/* IllustrationContent */
export {

6
web/package-lock.json generated
View File

@@ -18122,9 +18122,9 @@
}
},
"node_modules/vite": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
"dev": true,
"license": "MIT",
"peer": true,

View File

@@ -64,7 +64,6 @@ const BUSINESS_FEATURES: PlanFeature[] = [
{ icon: SvgKey, text: "Service Account API Keys" },
{ icon: SvgHardDrive, text: "Self-hosting (Optional)" },
{ icon: SvgPaintBrush, text: "Custom Theming" },
{ icon: SvgShareWebhook, text: "Hook Extensions" },
];
const ENTERPRISE_FEATURES: PlanFeature[] = [
@@ -72,6 +71,7 @@ const ENTERPRISE_FEATURES: PlanFeature[] = [
{ icon: SvgDashboard, text: "Full White-labeling" },
{ icon: SvgUserManage, text: "Custom Roles and Permissions" },
{ icon: SvgSliders, text: "Configurable Usage Limits" },
{ icon: SvgShareWebhook, text: "Hook Extensions" },
{ icon: SvgServer, text: "Custom Deployments" },
{ icon: SvgGlobe, text: "Region-Specific Data Processing" },
{ icon: SvgHeadsetMic, text: "Enterprise SLAs and Priority Support" },

View File

@@ -127,13 +127,13 @@ function Main() {
/>
)}
</div>
<div className="flex flex-col gap-2 desktop:flex-row desktop:items-center desktop:gap-2">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-2">
{isApiKeySet ? (
<>
<Button variant="danger" onClick={handleDelete}>
Delete API Key
</Button>
<Text as="p" mainContentBody text04 className="desktop:mt-0">
<Text as="p" mainContentBody text04 className="sm:mt-0">
Delete the current API key before updating.
</Text>
</>

View File

@@ -3,7 +3,7 @@
import { usePathname } from "next/navigation";
import * as AppLayouts from "@/layouts/app-layouts";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import SidebarTab from "@/refresh-components/buttons/SidebarTab";
import { SidebarTab } from "@opal/components";
import { SvgSliders } from "@opal/icons";
import { useUser } from "@/providers/UserProvider";
import { useAuthType } from "@/lib/hooks";

View File

@@ -11,12 +11,12 @@ import {
} from "@/app/craft/hooks/useBuildSessionStore";
import { useUsageLimits } from "@/app/craft/hooks/useUsageLimits";
import { CRAFT_SEARCH_PARAM_NAMES } from "@/app/craft/services/searchParams";
import SidebarTab from "@/refresh-components/buttons/SidebarTab";
import { SidebarTab } from "@opal/components";
import Text from "@/refresh-components/texts/Text";
import SidebarWrapper from "@/sections/sidebar/SidebarWrapper";
import SidebarBody from "@/sections/sidebar/SidebarBody";
import SidebarSection from "@/sections/sidebar/SidebarSection";
import UserAvatarPopover from "@/sections/sidebar/UserAvatarPopover";
import AccountPopover from "@/sections/sidebar/AccountPopover";
import Popover, { PopoverMenu } from "@/refresh-components/Popover";
import IconButton from "@/refresh-components/buttons/IconButton";
import ButtonRenaming from "@/refresh-components/buttons/ButtonRenaming";
@@ -238,9 +238,7 @@ function BuildSessionButton({
<Text
as="p"
data-state={isActive ? "active" : "inactive"}
className={cn(
"sidebar-tab-text-defaulted line-clamp-1 break-all text-left"
)}
className="line-clamp-1 break-all text-left"
mainUiBody
>
<TypewriterText
@@ -400,7 +398,7 @@ const MemoizedBuildSidebarInner = memo(
() => (
<div>
{backToChatButton}
<UserAvatarPopover folded={folded} />
<AccountPopover folded={folded} />
</div>
),
[folded, backToChatButton]

View File

@@ -467,6 +467,10 @@
/* Frost Overlay (for FrostedDiv component) - lighter in light mode */
--frost-overlay: var(--alpha-grey-00-10);
/* Scrollbar */
--scrollbar-track: transparent;
--scrollbar-thumb: var(--alpha-grey-100-20);
}
/* Dark Colors */
@@ -671,4 +675,8 @@
/* Frost Overlay (for FrostedDiv component) - darker in dark mode */
--frost-overlay: var(--alpha-grey-100-10);
/* Scrollbar */
--scrollbar-track: transparent;
--scrollbar-thumb: var(--alpha-grey-00-20);
}

View File

@@ -1,75 +0,0 @@
/* Background classes */
.sidebar-tab-background-defaulted[data-state="active"] {
background-color: var(--background-tint-00);
}
.sidebar-tab-background-defaulted[data-state="inactive"] {
background-color: transparent;
}
.sidebar-tab-background-defaulted:hover {
background-color: var(--background-tint-03);
}
.sidebar-tab-background-lowlight[data-state="active"] {
background-color: var(--background-tint-00);
}
.sidebar-tab-background-lowlight[data-state="inactive"] {
background-color: transparent;
}
.sidebar-tab-background-lowlight:hover {
background-color: var(--background-tint-03);
}
.sidebar-tab-background-focused {
border: 2px solid var(--background-tint-04);
background-color: var(--background-neutral-00);
}
/* Text classes */
.sidebar-tab-text-defaulted[data-state="active"] {
color: var(--text-04);
}
.sidebar-tab-text-defaulted[data-state="inactive"] {
color: var(--text-03);
}
.group\/SidebarTab:hover .sidebar-tab-text-defaulted {
color: var(--text-04);
}
.sidebar-tab-text-lowlight[data-state="active"] {
color: var(--text-03);
}
.sidebar-tab-text-lowlight[data-state="inactive"] {
color: var(--text-02);
}
.group\/SidebarTab:hover .sidebar-tab-text-lowlight {
color: var(--text-03);
}
.sidebar-tab-text-focused {
color: var(--text-03);
}
/* Icon classes */
.sidebar-tab-icon-defaulted[data-state="active"] {
stroke: var(--text-04);
}
.sidebar-tab-icon-defaulted[data-state="inactive"] {
stroke: var(--text-03);
}
.group\/SidebarTab:hover .sidebar-tab-icon-defaulted {
stroke: var(--text-04);
}
.sidebar-tab-icon-lowlight[data-state="active"] {
stroke: var(--text-03);
}
.sidebar-tab-icon-lowlight[data-state="inactive"] {
stroke: var(--text-02);
}
.group\/SidebarTab:hover .sidebar-tab-icon-lowlight {
stroke: var(--text-03);
}
.sidebar-tab-icon-focused {
stroke: var(--text-02);
}

View File

@@ -1,6 +1,3 @@
@import url("https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600;700&display=swap");
@import url("https://fonts.googleapis.com/css2?family=DM+Mono:wght@400&display=swap");
@import "css/attachment-button.css";
@import "css/button.css";
@import "css/card.css";
@@ -12,7 +9,6 @@
@import "css/inputs.css";
@import "css/knowledge-table.css";
@import "css/line-item.css";
@import "css/sidebar-tab.css";
@import "css/sizes.css";
@import "css/square-button.css";
@import "css/switch.css";
@@ -131,17 +127,8 @@
}
@layer utilities {
/* Hide scrollbar for Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.no-scrollbar {
-ms-overflow-style: none;
/* IE and Edge */
scrollbar-width: none;
/* Firefox */
}
/* SHADOWS */
@@ -366,27 +353,9 @@
/* SCROLL BAR */
.default-scrollbar::-webkit-scrollbar {
width: 6px;
}
.default-scrollbar::-webkit-scrollbar-track {
background: #f1f1f1;
}
.default-scrollbar::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
.default-scrollbar::-webkit-scrollbar-thumb:hover {
background: #555;
}
.default-scrollbar {
scrollbar-width: thin;
scrollbar-color: #888 transparent;
overflow: overlay;
overflow-y: scroll;
overflow-x: hidden;
}
@@ -396,78 +365,21 @@
height: 100%;
}
.inputscroll::-webkit-scrollbar-track {
background: #e5e7eb;
.inputscroll {
scrollbar-width: none;
}
::-webkit-scrollbar {
width: 0px;
/* Vertical scrollbar width */
height: 8px;
/* Horizontal scrollbar height */
}
::-webkit-scrollbar-track {
background: transparent;
/* background: theme("colors.scrollbar.track"); */
/* Track background color */
}
/* Style the scrollbar handle */
::-webkit-scrollbar-thumb {
background: transparent;
/* background: theme("colors.scrollbar.thumb"); */
/* Handle color */
border-radius: 10px;
}
/* Handle on hover */
::-webkit-scrollbar-thumb:hover {
background: transparent;
/* background: theme("colors.scrollbar.thumb-hover"); */
/* Handle color on hover */
}
.dark-scrollbar::-webkit-scrollbar-thumb {
background: transparent;
/* background: theme("colors.scrollbar.dark.thumb"); */
/* Handle color */
border-radius: 10px;
}
.dark-scrollbar::-webkit-scrollbar-thumb:hover {
background: transparent;
/* background: theme("colors.scrollbar.dark.thumb-hover"); */
/* Handle color on hover */
/* Ensure native scrollbars are visible */
@layer base {
* {
scrollbar-width: auto;
}
}
/* TEXTAREA */
textarea::-webkit-scrollbar {
width: 8px;
}
textarea::-webkit-scrollbar-track {
background: var(--scrollbar-track);
border-radius: 4px;
}
textarea::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 4px;
}
textarea::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover);
}
textarea {
resize: vertical;
}
/* For Firefox */
textarea {
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
}

View File

@@ -9,11 +9,12 @@ import { PHProvider } from "./providers";
import { Suspense } from "react";
import PostHogPageView from "./PostHogPageView";
import Script from "next/script";
import { Hanken_Grotesk } from "next/font/google";
import { DM_Mono, Hanken_Grotesk } from "next/font/google";
import { WebVitals } from "./web-vitals";
import { ThemeProvider } from "next-themes";
import { TooltipProvider } from "@/components/ui/tooltip";
import StatsOverlayLoader from "@/components/dev/StatsOverlayLoader";
import { cn } from "@/lib/utils";
import AppHealthBanner from "@/sections/AppHealthBanner";
import CustomAnalyticsScript from "@/providers/CustomAnalyticsScript";
import ProductGatingWrapper from "@/providers/ProductGatingWrapper";
@@ -23,6 +24,29 @@ const hankenGrotesk = Hanken_Grotesk({
subsets: ["latin"],
variable: "--font-hanken-grotesk",
display: "swap",
fallback: [
"-apple-system",
"BlinkMacSystemFont",
"Segoe UI",
"Roboto",
"sans-serif",
],
});
const dmMono = DM_Mono({
weight: "400",
subsets: ["latin"],
variable: "--font-dm-mono",
display: "swap",
fallback: [
"SF Mono",
"Monaco",
"Cascadia Code",
"Roboto Mono",
"Consolas",
"Courier New",
"monospace",
],
});
export const metadata: Metadata = {
@@ -44,7 +68,7 @@ export default function RootLayout({
return (
<html
lang="en"
className={`${hankenGrotesk.variable}`}
className={cn(hankenGrotesk.variable, dmMono.variable)}
suppressHydrationWarning
>
<head>

View File

@@ -15,7 +15,7 @@ import type { AppMode } from "@/providers/QueryControllerProvider";
import useAppFocus from "@/hooks/useAppFocus";
import { useQueryController } from "@/providers/QueryControllerProvider";
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
import { useAppSidebarContext } from "@/providers/AppSidebarProvider";
import { useSidebarState } from "@/layouts/sidebar-layouts";
import useScreenSize from "@/hooks/useScreenSize";
const footerMarkdownComponents = {
@@ -61,7 +61,7 @@ export default function NRFChrome() {
const { state, setAppMode } = useQueryController();
const settings = useSettingsContext();
const { isMobile } = useScreenSize();
const { setFolded } = useAppSidebarContext();
const { setFolded } = useSidebarState();
const appFocus = useAppFocus();
const [modePopoverOpen, setModePopoverOpen] = useState(false);

View File

@@ -7,6 +7,9 @@ import { ApplicationStatus } from "@/interfaces/settings";
import { Button } from "@opal/components";
import { cn } from "@/lib/utils";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import useScreenSize from "@/hooks/useScreenSize";
import { SvgSidebar } from "@opal/icons";
import { useSidebarState } from "@/layouts/sidebar-layouts";
export interface ClientLayoutProps {
children: React.ReactNode;
@@ -49,6 +52,9 @@ const SETTINGS_LAYOUT_PREFIXES = [
];
export function ClientLayout({ children, enableCloud }: ClientLayoutProps) {
const { folded: sidebarFolded, setFolded: setSidebarFolded } =
useSidebarState();
const { isMobile } = useScreenSize();
const pathname = usePathname();
const settings = useSettingsContext();
@@ -82,7 +88,11 @@ export function ClientLayout({ children, enableCloud }: ClientLayoutProps) {
<div className="flex-1 min-w-0 min-h-0 overflow-y-auto">{children}</div>
) : (
<>
<AdminSidebar enableCloudSS={enableCloud} />
<AdminSidebar
enableCloudSS={enableCloud}
folded={sidebarFolded}
onFoldChange={setSidebarFolded}
/>
<div
data-main-container
className={cn(
@@ -90,6 +100,15 @@ export function ClientLayout({ children, enableCloud }: ClientLayoutProps) {
!hasOwnLayout && "py-10 px-4 md:px-12"
)}
>
{isMobile && (
<div className="flex items-center px-4 pt-2">
<Button
prominence="internal"
icon={SvgSidebar}
onClick={() => setSidebarFolded(false)}
/>
</div>
)}
{children}
</div>
</>

View File

@@ -133,7 +133,7 @@ async function createFederatedConnector(
async function updateFederatedConnector(
id: number,
credentials: CredentialForm,
credentials: CredentialForm | null,
config?: ConfigForm
): Promise<{ success: boolean; message: string }> {
try {
@@ -143,7 +143,7 @@ async function updateFederatedConnector(
"Content-Type": "application/json",
},
body: JSON.stringify({
credentials,
credentials: credentials ?? undefined,
config: config || {},
}),
});
@@ -201,7 +201,9 @@ export function FederatedConnectorForm({
const isEditMode = connectorId !== undefined;
const [formState, setFormState] = useState<FormState>({
credentials: preloadedConnectorData?.credentials || {},
// In edit mode, don't populate credentials with masked values from the API.
// Masked values (e.g. "••••••••••••") would be saved back and corrupt the real credentials.
credentials: isEditMode ? {} : preloadedConnectorData?.credentials || {},
config: preloadedConnectorData?.config || {},
schema: preloadedCredentialSchema?.credentials || null,
configurationSchema: null,
@@ -209,6 +211,7 @@ export function FederatedConnectorForm({
configurationSchemaError: null,
connectorError: null,
});
const [credentialsModified, setCredentialsModified] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitMessage, setSubmitMessage] = useState<string | null>(null);
const [submitSuccess, setSubmitSuccess] = useState<boolean | null>(null);
@@ -333,6 +336,7 @@ export function FederatedConnectorForm({
}
const handleCredentialChange = (key: string, value: string) => {
setCredentialsModified(true);
setFormState((prev) => ({
...prev,
credentials: {
@@ -354,6 +358,11 @@ export function FederatedConnectorForm({
const handleValidateCredentials = async () => {
if (!formState.schema) return;
if (isEditMode && !credentialsModified) {
setSubmitMessage("Enter new credential values before validating.");
setSubmitSuccess(false);
return;
}
setIsValidating(true);
setSubmitMessage(null);
@@ -411,8 +420,10 @@ export function FederatedConnectorForm({
setSubmitSuccess(null);
try {
// Validate required fields
if (formState.schema) {
const shouldValidateCredentials = !isEditMode || credentialsModified;
// Validate required fields (skip for credentials in edit mode when unchanged)
if (formState.schema && shouldValidateCredentials) {
const missingRequired = Object.entries(formState.schema)
.filter(
([key, field]) => field.required && !formState.credentials[key]
@@ -442,16 +453,20 @@ export function FederatedConnectorForm({
}
setConfigValidationErrors({});
// Validate credentials before creating/updating
const validation = await validateCredentials(
connector,
formState.credentials
);
if (!validation.success) {
setSubmitMessage(`Credential validation failed: ${validation.message}`);
setSubmitSuccess(false);
setIsSubmitting(false);
return;
// Validate credentials before creating/updating (skip in edit mode when unchanged)
if (shouldValidateCredentials) {
const validation = await validateCredentials(
connector,
formState.credentials
);
if (!validation.success) {
setSubmitMessage(
`Credential validation failed: ${validation.message}`
);
setSubmitSuccess(false);
setIsSubmitting(false);
return;
}
}
// Create or update the connector
@@ -459,7 +474,7 @@ export function FederatedConnectorForm({
isEditMode && connectorId
? await updateFederatedConnector(
connectorId,
formState.credentials,
credentialsModified ? formState.credentials : null,
formState.config
)
: await createFederatedConnector(
@@ -538,14 +553,16 @@ export function FederatedConnectorForm({
id={fieldKey}
type={fieldSpec.secret ? "password" : "text"}
placeholder={
fieldSpec.example
? String(fieldSpec.example)
: fieldSpec.description
isEditMode && !credentialsModified
? "•••••••• (leave blank to keep current value)"
: fieldSpec.example
? String(fieldSpec.example)
: fieldSpec.description
}
value={formState.credentials[fieldKey] || ""}
onChange={(e) => handleCredentialChange(fieldKey, e.target.value)}
className="w-96"
required={fieldSpec.required}
required={!isEditMode && fieldSpec.required}
/>
</div>
))}

View File

@@ -1,25 +1,10 @@
"use client";
import {
WellKnownLLMProviderDescriptor,
LLMProviderDescriptor,
} from "@/interfaces/llm";
import React, {
createContext,
useContext,
useState,
useEffect,
useCallback,
} from "react";
import { useUser } from "@/providers/UserProvider";
import { LLMProviderDescriptor } from "@/interfaces/llm";
import React, { createContext, useContext, useCallback } from "react";
import { useLLMProviders } from "@/hooks/useLLMProviders";
import { useLLMProviderOptions } from "@/lib/hooks/useLLMProviderOptions";
import { testDefaultProvider as testDefaultProviderSvc } from "@/lib/llmConfig/svc";
interface ProviderContextType {
shouldShowConfigurationNeeded: boolean;
providerOptions: WellKnownLLMProviderDescriptor[];
refreshProviderInfo: () => Promise<void>;
// Expose configured provider instances for components that need it (e.g., onboarding)
llmProviders: LLMProviderDescriptor[] | undefined;
isLoadingProviders: boolean;
hasProviders: boolean;
@@ -29,79 +14,26 @@ const ProviderContext = createContext<ProviderContextType | undefined>(
undefined
);
const DEFAULT_LLM_PROVIDER_TEST_COMPLETE_KEY = "defaultLlmProviderTestComplete";
function checkDefaultLLMProviderTestComplete() {
if (typeof window === "undefined") return true;
return (
localStorage.getItem(DEFAULT_LLM_PROVIDER_TEST_COMPLETE_KEY) === "true"
);
}
function setDefaultLLMProviderTestComplete() {
if (typeof window === "undefined") return;
localStorage.setItem(DEFAULT_LLM_PROVIDER_TEST_COMPLETE_KEY, "true");
}
export function ProviderContextProvider({
children,
}: {
children: React.ReactNode;
}) {
const { user } = useUser();
// Use SWR hooks instead of raw fetch
const {
llmProviders,
isLoading: isLoadingProviders,
refetch: refetchProviders,
} = useLLMProviders();
const { llmProviderOptions: providerOptions, refetch: refetchOptions } =
useLLMProviderOptions();
const [defaultCheckSuccessful, setDefaultCheckSuccessful] =
useState<boolean>(true);
// Test the default provider - only runs if test hasn't passed yet
const testDefaultProvider = useCallback(async () => {
const shouldCheck =
!checkDefaultLLMProviderTestComplete() &&
(!user || user.role === "admin");
if (shouldCheck) {
const success = await testDefaultProviderSvc();
setDefaultCheckSuccessful(success);
if (success) {
setDefaultLLMProviderTestComplete();
}
}
}, [user]);
// Test default provider on mount
useEffect(() => {
testDefaultProvider();
}, [testDefaultProvider]);
const hasProviders = (llmProviders?.length ?? 0) > 0;
const validProviderExists = hasProviders && defaultCheckSuccessful;
const shouldShowConfigurationNeeded =
!validProviderExists && (providerOptions?.length ?? 0) > 0;
const refreshProviderInfo = useCallback(async () => {
// Refetch provider lists and re-test default provider if needed
await Promise.all([
refetchProviders(),
refetchOptions(),
testDefaultProvider(),
]);
}, [refetchProviders, refetchOptions, testDefaultProvider]);
await refetchProviders();
}, [refetchProviders]);
return (
<ProviderContext.Provider
value={{
shouldShowConfigurationNeeded,
providerOptions: providerOptions ?? [],
refreshProviderInfo,
llmProviders,
isLoadingProviders,

View File

@@ -4,7 +4,6 @@
* Tests logo icons to ensure they render correctly with proper accessibility
* and support various display sizes.
*/
import React from "react";
import { SvgBifrost } from "@opal/icons";
import { render } from "@tests/setup/test-utils";
import { GithubIcon, GitbookIcon, ConfluenceIcon } from "./icons";
@@ -60,7 +59,11 @@ describe("Logo Icons", () => {
const icon = container.querySelector("svg");
expect(icon).toBeInTheDocument();
expect(icon).toHaveClass("custom", "text-[#33C19E]", "dark:text-white");
expect(icon).not.toHaveClass("text-red-500", "dark:text-black");
expect(icon).toHaveClass(
"custom",
"text-red-500",
"dark:text-black",
"!text-[#33C19E]"
);
});
});

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