Compare commits

..

21 Commits

Author SHA1 Message Date
Nik
bab95d8bf0 fix(chat): remove duplicate drain_done declaration after rebase 2026-03-31 20:02:29 -07:00
Nik
eb7bc74e1b fix(chat): persist LLM response on HTTP disconnect via drain_done + worker self-completion
When the HTTP client disconnects, Starlette throws GeneratorExit into the
drain loop generator. The old code called executor.shutdown(wait=False) with
no completion handling, leaving the assistant DB message as the TERMINATED
placeholder forever (regressing test_send_message_disconnect_and_cleanup).

New design:
- drain_done (threading.Event) signals emitters to return immediately instead
  of blocking on queue.put — no retry loops, no daemon threads
- One-time queue drain in the else block releases any in-progress puts so
  workers exit within milliseconds
- Workers self-complete: after run_llm_loop returns, each worker checks
  drain_done.is_set() and, if true, opens its own DB session and calls
  llm_loop_completion_handle directly

Unit test updated to reflect the async self-completion semantics: the test
blocks the worker inside run_llm_loop until gen.close() sets drain_done,
then waits for completion_called inside the patch context (while mocks are
still active) to avoid calling the real get_session_with_current_tenant.
2026-03-31 20:02:29 -07:00
Nik
29da0aefb5 feat(chat): add multi-model parallel streaming (N=2-3 LLMs side-by-side)
Adds support for running 2-3 LLMs in parallel within a single chat turn,
with responses streamed interleaved to the frontend via the merged queue
infrastructure introduced in the preceding PR.

Backend changes
- process_message.py: restore llm_overrides param on build_chat_turn and
  _stream_chat_turn; restore is_multi branching for LLM setup, context
  window sizing, and message ID reservation; add _build_model_display_name
  and handle_multi_model_stream (public multi-model entrypoint)
- db/chat.py: add reserve_multi_model_message_ids (reserves N assistant
  message placeholders sharing the same parent), set_preferred_response
  (marks one response as the user's preferred), and extend
  translate_db_message_to_chat_message_detail with preferred_response_id
  and model_display_name fields
- chat_backend.py: route requests with llm_overrides >1 through
  handle_multi_model_stream; reject non-streaming multi-model requests with
  OnyxError; add /set-preferred-response endpoint

Tests
- test_multi_model_streaming.py: unit tests for _run_models drain loop
  (arrival-order yield, error isolation, cancellation), handle_multi_model_stream
  validation guards, and N=1 backwards-compatibility
2026-03-31 20:01:21 -07:00
Nik
6c86301c51 fix(chat): remove bounded queue and packet drops — match old behavior
Old code used queue.Queue() (unbounded, blocking put). New code introduced
queue.Queue(maxsize=100) + put(timeout=3.0) + silent drop on queue.Full —
a regression in all three callsites:

- Emitter.emit(): data packets silently dropped on queue full
- _run_model exception path: model errors silently lost
- _run_model finally (_MODEL_DONE): if dropped, drain loop hangs forever
  (models_remaining never reaches 0)

Fix: remove maxsize, remove all timeout= arguments, remove all
except queue.Full handlers. The drain_done early-return in emit() is the
correct disconnect mechanism; queue backpressure is not needed.

Also adds _completion_done: bool type annotation and fixes the queue drain
comment (no longer unblocking timed-out puts — just releasing memory).
2026-03-31 20:00:46 -07:00
Nik
631146f48f fix(chat): use model_succeeded instead of check_is_connected on self-completion
On HTTP disconnect, check_is_connected() returns False, causing
llm_loop_completion_handle to treat a completed response as
user-cancelled and append "Generation was stopped by the user."
Use lambda: model_succeeded[model_idx] (always True here) instead,
matching the cancellation path's functools.partial(bool, model_succeeded[i]).
2026-03-31 18:42:04 -07:00
Nik
f327278506 fix(chat): persist LLM response on HTTP disconnect via drain_done + worker self-completion
When the HTTP client disconnects, Starlette throws GeneratorExit into the
drain loop generator. The old else branch just called executor.shutdown(wait=False)
with no completion handling, leaving the assistant DB message as the TERMINATED
placeholder forever (regressing test_send_message_disconnect_and_cleanup).

New design:
- drain_done (threading.Event) signals emitters to return immediately instead
  of blocking on queue.put — no retry loops, no daemon threads
- One-time queue drain in the else block releases any in-progress puts so
  workers exit within milliseconds
- Workers self-complete: after run_llm_loop returns, each worker checks
  drain_done.is_set() and, if true, opens its own DB session and calls
  llm_loop_completion_handle directly
2026-03-31 18:14:50 -07:00
Nik
c7cc439862 fix(emitter): address Greptile P1/P2/P3 and Queue typing
- P1: executor.shutdown(wait=False) on early exit — don't block the
  server thread waiting for LLM workers; they will hit queue.Full
  timeouts and exit on their own (matches old run_chat_loop behavior)
- P2: wrap db_session.commit() in try/finally in build_chat_turn —
  reset processing status before propagating if commit fails, so the
  chat session isn't stuck at "processing" permanently
- P3: fix inaccurate comment "All worker threads have exited" — workers
  may still be closing their own DB sessions at that point; clarify
  that only the main-thread db_session is safe to use
- Queue[Any] → Queue[tuple[int, Packet | Exception | object]] in Emitter
2026-03-31 17:02:46 -07:00
Nik
3365a369e2 fix(review): address Greptile comments
- Add owner to bare TODO comment
- Restore placement field assertions weakened by Emitter refactor
2026-03-31 12:49:09 -07:00
Nik
470bda3fb5 refactor(chat): elegance pass on PR1 changed files
process_message.py:
- Fix `skip_clarification` field in ChatTurnSetup: inline comment inside
  the type annotation → separate `#` comment on the line above the field
- Flatten `model_tools` via list comprehension instead of manual extend loop
- `forced_tool_id` membership test: list → set comprehension (O(1) lookup)
- Trim `_run_model` inner-function docstring — private closure doesn't need
  10-line Args block
- Remove redundant inline param comments from `_stream_chat_turn` and
  `handle_stream_message_objects` where the docstring Args section already
  documents them
- Strip duplicate Args/Returns from `handle_stream_message_objects` docstring
  — it delegates entirely to `_stream_chat_turn`

emitter.py:
- Widen `merged_queue` annotation to `Queue[Any]`: Queue is invariant so
  `Queue[tuple[int, Packet]]` can't be passed a `Queue[tuple[int, Packet |
  Exception | object]]`; the emitter is a write-only producer and doesn't
  care what else lives on the queue
2026-03-31 12:16:38 -07:00
Nik
13f511e209 refactor(emitter): clean up string annotation and use model_copy
- Fix `"Queue"` forward-reference annotation → `Queue[tuple[int, Packet]]`
  (Queue is already imported, the string was unnecessary)
- Replace manual Placement field copy with `base.model_copy(update={...})`
- Remove redundant `key` variable (was just `self._model_idx`)
- Tighten docstring
2026-03-31 11:44:28 -07:00
Nik
c5e8ba1eab refactor(chat): replace bus-polling emitter with merged-queue streaming; fix 429 hang
Switch Emitter from a per-model event bus + polling thread to a single
bounded queue shared across all models.  Each emit() call puts directly onto
the queue; the drain loop in _run_models yields packets in arrival order.

Key changes
- emitter.py: remove Bus, get_default_emitter(); add Emitter(merged_queue, model_idx)
- chat_state.py: remove run_chat_loop_with_state_containers (113-line bus-poll loop)
- process_message.py: add ChatTurnSetup dataclass and build_chat_turn(); rewrite
  _stream_chat_turn + _run_models around the merged queue; single-model (N=1)
  path is fully backwards-compatible
- placement.py, override_models.py: add docstrings; LLMOverride gains display_name
- research_agent.py, custom_tool.py: update Emitter call sites
- test_emitter.py: new unit tests for queue routing, model_index tagging, placement

Frontend 429 fix
- lib.tsx: parse response body for human-readable detail on non-2xx responses
  instead of "HTTP error! status: 429"
- useChatController.ts: surface stack.error after the FIFO drain loop exits so
  the catch block replaces the thinking placeholder with an error message
2026-03-30 22:18:48 -07:00
dependabot[bot]
0b9d154a73 chore(deps): bump runs-on/cache from 50350ad4242587b6c8c2baa2e740b1bc11285ff4 to a5f51d6f3fece787d03b7b4e981c82538a0654ed (#9763)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-30 13:54:43 -07:00
dependabot[bot]
6e65e55bf5 chore(deps): bump actions/cache from 5.0.3 to 5.0.4 (#9765)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-30 13:46:53 -07:00
Raunak Bhagat
3f9e208759 feat(opal): SelectCard + CardHeaderLayout (#9760) 2026-03-30 19:54:54 +00:00
dependabot[bot]
fb8edda14a chore(deps): bump pygments from 2.19.2 to 2.20.0 (#9757)
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-03-30 18:30:18 +00:00
Jamison Lahman
58decd8a6b chore(gha): prefer ci-protected env (#9728) 2026-03-30 17:20:54 +00:00
Danelegend
e97204d9cc feat(indexing): Batch chunks during doc processing (#9468) 2026-03-30 11:49:36 +00:00
Danelegend
44ab02c94f refactor(indexing): Refactor indexing vector db abstraction (#9653) 2026-03-30 09:57:16 +00:00
Danelegend
a98cc30f25 refactor(indexing): Change adapters to support iterables (#9469) 2026-03-30 01:43:10 +00:00
Danelegend
a709dcb8fa feat(indexing): Max chunk processing (#9400) 2026-03-30 00:10:24 +00:00
Raunak Bhagat
a3dfe6aa1b refactor(opal): unify Interactive color system (#9717) 2026-03-28 00:40:23 +00:00
104 changed files with 5286 additions and 1990 deletions

View File

@@ -35,6 +35,7 @@ jobs:
needs: [provider-chat-test]
if: failure() && github.event_name == 'schedule'
runs-on: ubuntu-slim
environment: ci-protected
timeout-minutes: 5
steps:
- name: Checkout

View File

@@ -183,6 +183,7 @@ jobs:
- cherry-pick-to-latest-release
if: needs.resolve-cherry-pick-request.outputs.should_cherrypick == 'true' && needs.resolve-cherry-pick-request.result == 'success' && needs.cherry-pick-to-latest-release.result == 'success'
runs-on: ubuntu-slim
environment: ci-protected
timeout-minutes: 10
steps:
- name: Checkout
@@ -232,6 +233,7 @@ jobs:
- cherry-pick-to-latest-release
if: always() && needs.resolve-cherry-pick-request.outputs.should_cherrypick == 'true' && (needs.resolve-cherry-pick-request.result == 'failure' || needs.cherry-pick-to-latest-release.result == 'failure')
runs-on: ubuntu-slim
environment: ci-protected
timeout-minutes: 10
steps:
- name: Checkout

View File

@@ -63,7 +63,7 @@ jobs:
targets: ${{ matrix.target }}
- name: Cache Cargo registry and build
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # zizmor: ignore[cache-poisoning]
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # zizmor: ignore[cache-poisoning]
with:
path: |
~/.cargo/bin/

View File

@@ -284,7 +284,7 @@ jobs:
- name: Cache playwright cache
# zizmor: ignore[cache-poisoning] ephemeral runners; no release artifacts
uses: runs-on/cache@50350ad4242587b6c8c2baa2e740b1bc11285ff4 # ratchet:runs-on/cache@v4
uses: runs-on/cache@a5f51d6f3fece787d03b7b4e981c82538a0654ed # ratchet:runs-on/cache@v4
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-npm-${{ hashFiles('web/package-lock.json') }}
@@ -626,7 +626,7 @@ jobs:
- name: Cache playwright cache
# zizmor: ignore[cache-poisoning] ephemeral runners; no release artifacts
uses: runs-on/cache@50350ad4242587b6c8c2baa2e740b1bc11285ff4 # ratchet:runs-on/cache@v4
uses: runs-on/cache@a5f51d6f3fece787d03b7b4e981c82538a0654ed # ratchet:runs-on/cache@v4
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-npm-${{ hashFiles('web/package-lock.json') }}

View File

@@ -56,7 +56,7 @@ jobs:
- name: Cache mypy cache
if: ${{ vars.DISABLE_MYPY_CACHE != 'true' }}
uses: runs-on/cache@50350ad4242587b6c8c2baa2e740b1bc11285ff4 # ratchet:runs-on/cache@v4
uses: runs-on/cache@a5f51d6f3fece787d03b7b4e981c82538a0654ed # ratchet:runs-on/cache@v4
with:
path: .mypy_cache
key: mypy-${{ runner.os }}-${{ github.base_ref || github.event.merge_group.base_ref || 'main' }}-${{ hashFiles('**/*.py', '**/*.pyi', 'pyproject.toml') }}

View File

@@ -31,6 +31,7 @@ jobs:
- runner=4cpu-linux-arm64
- "run-id=${{ github.run_id }}-model-check"
- "extras=ecr-cache"
environment: ci-protected
timeout-minutes: 45
env:

View File

@@ -15,6 +15,7 @@ permissions:
jobs:
Deploy-Preview:
runs-on: ubuntu-latest
environment: ci-protected
timeout-minutes: 30
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd

View File

@@ -25,6 +25,7 @@ permissions:
jobs:
Deploy-Storybook:
runs-on: ubuntu-latest
environment: ci-protected
timeout-minutes: 30
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v4
@@ -54,6 +55,7 @@ jobs:
needs: Deploy-Storybook
if: always() && needs.Deploy-Storybook.result == 'failure'
runs-on: ubuntu-latest
environment: ci-protected
timeout-minutes: 10
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v4

View File

@@ -9,6 +9,7 @@ on:
jobs:
sync-foss:
runs-on: ubuntu-latest
environment: ci-protected
timeout-minutes: 45
permissions:
contents: read

View File

@@ -11,6 +11,7 @@ permissions:
jobs:
create-and-push-tag:
runs-on: ubuntu-slim
environment: ci-protected
timeout-minutes: 45
steps:

View File

@@ -1,19 +1,8 @@
import threading
import time
from collections.abc import Callable
from collections.abc import Generator
from queue import Empty
from onyx.chat.citation_processor import CitationMapping
from onyx.chat.emitter import Emitter
from onyx.context.search.models import SearchDoc
from onyx.server.query_and_chat.placement import Placement
from onyx.server.query_and_chat.streaming_models import OverallStop
from onyx.server.query_and_chat.streaming_models import Packet
from onyx.server.query_and_chat.streaming_models import PacketException
from onyx.tools.models import ToolCallInfo
from onyx.utils.threadpool_concurrency import run_in_background
from onyx.utils.threadpool_concurrency import wait_on_background
# Type alias for search doc deduplication key
# Simple key: just document_id (str)
@@ -159,114 +148,3 @@ class ChatStateContainer:
"""Thread-safe getter for emitted citations (returns a copy)."""
with self._lock:
return self._emitted_citations.copy()
def run_chat_loop_with_state_containers(
chat_loop_func: Callable[[Emitter, ChatStateContainer], None],
completion_callback: Callable[[ChatStateContainer], None],
is_connected: Callable[[], bool],
emitter: Emitter,
state_container: ChatStateContainer,
) -> Generator[Packet, None]:
"""
Explicit wrapper function that runs a function in a background thread
with event streaming capabilities.
The wrapped function should accept emitter as first arg and use it to emit
Packet objects. This wrapper polls every 300ms to check if stop signal is set.
Args:
func: The function to wrap (should accept emitter and state_container as first and second args)
completion_callback: Callback function to call when the function completes
emitter: Emitter instance for sending packets
state_container: ChatStateContainer instance for accumulating state
is_connected: Callable that returns False when stop signal is set
Usage:
packets = run_chat_loop_with_state_containers(
my_func,
completion_callback=completion_callback,
emitter=emitter,
state_container=state_container,
is_connected=check_func,
)
for packet in packets:
# Process packets
pass
"""
def run_with_exception_capture() -> None:
try:
chat_loop_func(emitter, state_container)
except Exception as e:
# If execution fails, emit an exception packet
emitter.emit(
Packet(
placement=Placement(turn_index=0),
obj=PacketException(type="error", exception=e),
)
)
# Run the function in a background thread
thread = run_in_background(run_with_exception_capture)
pkt: Packet | None = None
last_turn_index = 0 # Track the highest turn_index seen for stop packet
last_cancel_check = time.monotonic()
cancel_check_interval = 0.3 # Check for cancellation every 300ms
try:
while True:
# Poll queue with 300ms timeout for natural stop signal checking
# the 300ms timeout is to avoid busy-waiting and to allow the stop signal to be checked regularly
try:
pkt = emitter.bus.get(timeout=0.3)
except Empty:
if not is_connected():
# Stop signal detected
yield Packet(
placement=Placement(turn_index=last_turn_index + 1),
obj=OverallStop(type="stop", stop_reason="user_cancelled"),
)
break
last_cancel_check = time.monotonic()
continue
if pkt is not None:
# Track the highest turn_index for the stop packet
if pkt.placement and pkt.placement.turn_index > last_turn_index:
last_turn_index = pkt.placement.turn_index
if isinstance(pkt.obj, OverallStop):
yield pkt
break
elif isinstance(pkt.obj, PacketException):
raise pkt.obj.exception
else:
yield pkt
# Check for cancellation periodically even when packets are flowing
# This ensures stop signal is checked during active streaming
current_time = time.monotonic()
if current_time - last_cancel_check >= cancel_check_interval:
if not is_connected():
# Stop signal detected during streaming
yield Packet(
placement=Placement(turn_index=last_turn_index + 1),
obj=OverallStop(type="stop", stop_reason="user_cancelled"),
)
break
last_cancel_check = current_time
finally:
# Wait for thread to complete on normal exit to propagate exceptions and ensure cleanup.
# Skip waiting if user disconnected to exit quickly.
if is_connected():
wait_on_background(thread)
try:
completion_callback(state_container)
except Exception as e:
emitter.emit(
Packet(
placement=Placement(turn_index=last_turn_index + 1),
obj=PacketException(type="error", exception=e),
)
)

View File

@@ -1,19 +1,40 @@
import threading
from queue import Queue
from onyx.server.query_and_chat.placement import Placement
from onyx.server.query_and_chat.streaming_models import Packet
class Emitter:
"""Use this inside tools to emit arbitrary UI progress."""
"""Routes packets from LLM/tool execution to the ``_run_models`` drain loop.
def __init__(self, bus: Queue):
self.bus = bus
Tags every packet with ``model_index`` and places it on ``merged_queue``
as a ``(model_idx, packet)`` tuple for ordered consumption downstream.
Args:
merged_queue: Shared queue owned by ``_run_models``.
model_idx: Index embedded in packet placements (``0`` for N=1 runs).
drain_done: Optional event set by ``_run_models`` when the drain loop
exits early (e.g. HTTP disconnect). When set, ``emit`` returns
immediately so worker threads can exit fast.
"""
def __init__(
self,
merged_queue: Queue[tuple[int, Packet | Exception | object]],
model_idx: int = 0,
drain_done: threading.Event | None = None,
) -> None:
self._model_idx = model_idx
self._merged_queue = merged_queue
self._drain_done = drain_done
def emit(self, packet: Packet) -> None:
self.bus.put(packet) # Thread-safe
def get_default_emitter() -> Emitter:
bus: Queue[Packet] = Queue()
emitter = Emitter(bus)
return emitter
if self._drain_done is not None and self._drain_done.is_set():
return
base = packet.placement or Placement(turn_index=0)
tagged = Packet(
placement=base.model_copy(update={"model_index": self._model_idx}),
obj=packet.obj,
)
self._merged_queue.put((self._model_idx, tagged))

File diff suppressed because it is too large Load Diff

View File

@@ -805,6 +805,10 @@ MINI_CHUNK_SIZE = 150
# This is the number of regular chunks per large chunk
LARGE_CHUNK_RATIO = 4
# The maximum number of chunks that can be held for 1 document processing batch
# The purpose of this is to set an upper bound on memory usage
MAX_CHUNKS_PER_DOC_BATCH = int(os.environ.get("MAX_CHUNKS_PER_DOC_BATCH") or 1000)
# Include the document level metadata in each chunk. If the metadata is too long, then it is thrown out
# We don't want the metadata to overwhelm the actual contents of the chunk
SKIP_METADATA_IN_CHUNK = os.environ.get("SKIP_METADATA_IN_CHUNK", "").lower() == "true"

View File

@@ -617,6 +617,92 @@ def reserve_message_id(
return empty_message
def reserve_multi_model_message_ids(
db_session: Session,
chat_session_id: UUID,
parent_message_id: int,
model_display_names: list[str],
) -> list[ChatMessage]:
"""Reserve N assistant message placeholders for multi-model parallel streaming.
All messages share the same parent (the user message). The parent's
latest_child_message_id points to the LAST reserved message so that the
default history-chain walker picks it up.
"""
reserved: list[ChatMessage] = []
for display_name in model_display_names:
msg = ChatMessage(
chat_session_id=chat_session_id,
parent_message_id=parent_message_id,
latest_child_message_id=None,
message="Response was terminated prior to completion, try regenerating.",
token_count=15, # placeholder; updated on completion by llm_loop_completion_handle
message_type=MessageType.ASSISTANT,
model_display_name=display_name,
)
db_session.add(msg)
reserved.append(msg)
# Flush to assign IDs without committing yet
db_session.flush()
# Point parent's latest_child to the last reserved message
parent = (
db_session.query(ChatMessage)
.filter(ChatMessage.id == parent_message_id)
.first()
)
if parent:
parent.latest_child_message_id = reserved[-1].id
db_session.commit()
return reserved
def set_preferred_response(
db_session: Session,
user_message_id: int,
preferred_assistant_message_id: int,
) -> None:
"""Mark one assistant response as the user's preferred choice in a multi-model turn.
Also advances ``latest_child_message_id`` so the preferred response becomes
the active branch for any subsequent messages in the conversation.
Args:
db_session: Active database session.
user_message_id: Primary key of the ``USER``-type ``ChatMessage`` whose
preferred response is being set.
preferred_assistant_message_id: Primary key of the ``ASSISTANT``-type
``ChatMessage`` to prefer. Must be a direct child of ``user_message_id``.
Raises:
ValueError: If either message is not found, if ``user_message_id`` does not
refer to a USER message, or if the assistant message is not a direct child
of the user message.
"""
user_msg = db_session.get(ChatMessage, user_message_id)
if user_msg is None:
raise ValueError(f"User message {user_message_id} not found")
if user_msg.message_type != MessageType.USER:
raise ValueError(f"Message {user_message_id} is not a user message")
assistant_msg = db_session.get(ChatMessage, preferred_assistant_message_id)
if assistant_msg is None:
raise ValueError(
f"Assistant message {preferred_assistant_message_id} not found"
)
if assistant_msg.parent_message_id != user_message_id:
raise ValueError(
f"Assistant message {preferred_assistant_message_id} is not a child "
f"of user message {user_message_id}"
)
user_msg.preferred_response_id = preferred_assistant_message_id
user_msg.latest_child_message_id = preferred_assistant_message_id
db_session.commit()
def create_new_chat_message(
chat_session_id: UUID,
parent_message: ChatMessage,
@@ -839,6 +925,8 @@ def translate_db_message_to_chat_message_detail(
error=chat_message.error,
current_feedback=current_feedback,
processing_duration_seconds=chat_message.processing_duration_seconds,
preferred_response_id=chat_message.preferred_response_id,
model_display_name=chat_message.model_display_name,
)
return chat_msg_detail

View File

@@ -6,6 +6,7 @@ import httpx
from opensearchpy import NotFoundError
from onyx.access.models import DocumentAccess
from onyx.configs.app_configs import MAX_CHUNKS_PER_DOC_BATCH
from onyx.configs.app_configs import VERIFY_CREATE_OPENSEARCH_INDEX_ON_INIT_MT
from onyx.configs.chat_configs import NUM_RETURNED_HITS
from onyx.configs.chat_configs import TITLE_CONTENT_RATIO
@@ -738,6 +739,9 @@ class OpenSearchDocumentIndex(DocumentIndex):
_flush_chunks(current_chunks)
current_doc_id = doc_id
current_chunks = [chunk]
elif len(current_chunks) >= MAX_CHUNKS_PER_DOC_BATCH:
_flush_chunks(current_chunks)
current_chunks = [chunk]
else:
current_chunks.append(chunk)

View File

@@ -10,6 +10,7 @@ import httpx
from pydantic import BaseModel
from retry import retry
from onyx.configs.app_configs import MAX_CHUNKS_PER_DOC_BATCH
from onyx.configs.app_configs import RECENCY_BIAS_MULTIPLIER
from onyx.configs.app_configs import RERANK_COUNT
from onyx.configs.chat_configs import DOC_TIME_DECAY
@@ -427,7 +428,9 @@ class VespaDocumentIndex(DocumentIndex):
new_document_id_to_original_document_id,
all_cleaned_doc_ids,
)
for chunk_batch in batch_generator(cleaned_chunks, BATCH_SIZE):
for chunk_batch in batch_generator(
cleaned_chunks, min(BATCH_SIZE, MAX_CHUNKS_PER_DOC_BATCH)
):
batch_index_vespa_chunks(
chunks=chunk_batch,
index_name=self._index_name,

View File

@@ -19,7 +19,8 @@ from onyx.db.document import update_docs_updated_at__no_commit
from onyx.db.document_set import fetch_document_sets_for_documents
from onyx.indexing.indexing_pipeline import DocumentBatchPrepareContext
from onyx.indexing.indexing_pipeline import index_doc_batch_prepare
from onyx.indexing.models import BuildMetadataAwareChunksResult
from onyx.indexing.models import ChunkEnrichmentContext
from onyx.indexing.models import DocAwareChunk
from onyx.indexing.models import DocMetadataAwareIndexChunk
from onyx.indexing.models import IndexChunk
from onyx.indexing.models import UpdatableChunkData
@@ -85,14 +86,21 @@ class DocumentIndexingBatchAdapter:
) as transaction:
yield transaction
def build_metadata_aware_chunks(
def prepare_enrichment(
self,
chunks_with_embeddings: list[IndexChunk],
chunk_content_scores: list[float],
tenant_id: str,
context: DocumentBatchPrepareContext,
) -> BuildMetadataAwareChunksResult:
"""Enrich chunks with access, document sets, boosts, token counts, and hierarchy."""
tenant_id: str,
chunks: list[DocAwareChunk],
) -> "DocumentChunkEnricher":
"""Do all DB lookups once and return a per-chunk enricher."""
updatable_ids = [doc.id for doc in context.updatable_docs]
doc_id_to_new_chunk_cnt: dict[str, int] = {
doc_id: 0 for doc_id in updatable_ids
}
for chunk in chunks:
if chunk.source_document.id in doc_id_to_new_chunk_cnt:
doc_id_to_new_chunk_cnt[chunk.source_document.id] += 1
no_access = DocumentAccess.build(
user_emails=[],
@@ -102,67 +110,30 @@ class DocumentIndexingBatchAdapter:
is_public=False,
)
updatable_ids = [doc.id for doc in context.updatable_docs]
doc_id_to_access_info = get_access_for_documents(
document_ids=updatable_ids, db_session=self.db_session
)
doc_id_to_document_set = {
document_id: document_sets
for document_id, document_sets in fetch_document_sets_for_documents(
return DocumentChunkEnricher(
doc_id_to_access_info=get_access_for_documents(
document_ids=updatable_ids, db_session=self.db_session
)
}
doc_id_to_previous_chunk_cnt: dict[str, int] = {
document_id: chunk_count
for document_id, chunk_count in fetch_chunk_counts_for_documents(
document_ids=updatable_ids,
db_session=self.db_session,
)
}
doc_id_to_new_chunk_cnt: dict[str, int] = {
doc_id: 0 for doc_id in updatable_ids
}
for chunk in chunks_with_embeddings:
if chunk.source_document.id in doc_id_to_new_chunk_cnt:
doc_id_to_new_chunk_cnt[chunk.source_document.id] += 1
# Get ancestor hierarchy node IDs for each document
doc_id_to_ancestor_ids = self._get_ancestor_ids_for_documents(
context.updatable_docs, tenant_id
)
access_aware_chunks = [
DocMetadataAwareIndexChunk.from_index_chunk(
index_chunk=chunk,
access=doc_id_to_access_info.get(chunk.source_document.id, no_access),
document_sets=set(
doc_id_to_document_set.get(chunk.source_document.id, [])
),
user_project=[],
personas=[],
boost=(
context.id_to_boost_map[chunk.source_document.id]
if chunk.source_document.id in context.id_to_boost_map
else DEFAULT_BOOST
),
tenant_id=tenant_id,
aggregated_chunk_boost_factor=chunk_content_scores[chunk_num],
ancestor_hierarchy_node_ids=doc_id_to_ancestor_ids[
chunk.source_document.id
],
)
for chunk_num, chunk in enumerate(chunks_with_embeddings)
]
return BuildMetadataAwareChunksResult(
chunks=access_aware_chunks,
doc_id_to_previous_chunk_cnt=doc_id_to_previous_chunk_cnt,
doc_id_to_new_chunk_cnt=doc_id_to_new_chunk_cnt,
user_file_id_to_raw_text={},
user_file_id_to_token_count={},
),
doc_id_to_document_set={
document_id: document_sets
for document_id, document_sets in fetch_document_sets_for_documents(
document_ids=updatable_ids, db_session=self.db_session
)
},
doc_id_to_ancestor_ids=self._get_ancestor_ids_for_documents(
context.updatable_docs, tenant_id
),
id_to_boost_map=context.id_to_boost_map,
doc_id_to_previous_chunk_cnt={
document_id: chunk_count
for document_id, chunk_count in fetch_chunk_counts_for_documents(
document_ids=updatable_ids,
db_session=self.db_session,
)
},
doc_id_to_new_chunk_cnt=dict(doc_id_to_new_chunk_cnt),
no_access=no_access,
tenant_id=tenant_id,
)
def _get_ancestor_ids_for_documents(
@@ -203,7 +174,7 @@ class DocumentIndexingBatchAdapter:
context: DocumentBatchPrepareContext,
updatable_chunk_data: list[UpdatableChunkData],
filtered_documents: list[Document],
result: BuildMetadataAwareChunksResult,
enrichment: ChunkEnrichmentContext,
) -> None:
"""Finalize DB updates, store plaintext, and mark docs as indexed."""
updatable_ids = [doc.id for doc in context.updatable_docs]
@@ -227,7 +198,7 @@ class DocumentIndexingBatchAdapter:
update_docs_chunk_count__no_commit(
document_ids=updatable_ids,
doc_id_to_chunk_count=result.doc_id_to_new_chunk_cnt,
doc_id_to_chunk_count=enrichment.doc_id_to_new_chunk_cnt,
db_session=self.db_session,
)
@@ -249,3 +220,52 @@ class DocumentIndexingBatchAdapter:
)
self.db_session.commit()
class DocumentChunkEnricher:
"""Pre-computed metadata for per-chunk enrichment of connector documents."""
def __init__(
self,
doc_id_to_access_info: dict[str, DocumentAccess],
doc_id_to_document_set: dict[str, list[str]],
doc_id_to_ancestor_ids: dict[str, list[int]],
id_to_boost_map: dict[str, int],
doc_id_to_previous_chunk_cnt: dict[str, int],
doc_id_to_new_chunk_cnt: dict[str, int],
no_access: DocumentAccess,
tenant_id: str,
) -> None:
self._doc_id_to_access_info = doc_id_to_access_info
self._doc_id_to_document_set = doc_id_to_document_set
self._doc_id_to_ancestor_ids = doc_id_to_ancestor_ids
self._id_to_boost_map = id_to_boost_map
self._no_access = no_access
self._tenant_id = tenant_id
self.doc_id_to_previous_chunk_cnt = doc_id_to_previous_chunk_cnt
self.doc_id_to_new_chunk_cnt = doc_id_to_new_chunk_cnt
def enrich_chunk(
self, chunk: IndexChunk, score: float
) -> DocMetadataAwareIndexChunk:
return DocMetadataAwareIndexChunk.from_index_chunk(
index_chunk=chunk,
access=self._doc_id_to_access_info.get(
chunk.source_document.id, self._no_access
),
document_sets=set(
self._doc_id_to_document_set.get(chunk.source_document.id, [])
),
user_project=[],
personas=[],
boost=(
self._id_to_boost_map[chunk.source_document.id]
if chunk.source_document.id in self._id_to_boost_map
else DEFAULT_BOOST
),
tenant_id=self._tenant_id,
aggregated_chunk_boost_factor=score,
ancestor_hierarchy_node_ids=self._doc_id_to_ancestor_ids[
chunk.source_document.id
],
)

View File

@@ -1,6 +1,9 @@
from __future__ import annotations
import contextlib
import datetime
import time
from collections import defaultdict
from collections.abc import Generator
from uuid import UUID
@@ -24,7 +27,8 @@ from onyx.db.user_file import fetch_persona_ids_for_user_files
from onyx.db.user_file import fetch_user_project_ids_for_user_files
from onyx.file_store.utils import store_user_file_plaintext
from onyx.indexing.indexing_pipeline import DocumentBatchPrepareContext
from onyx.indexing.models import BuildMetadataAwareChunksResult
from onyx.indexing.models import ChunkEnrichmentContext
from onyx.indexing.models import DocAwareChunk
from onyx.indexing.models import DocMetadataAwareIndexChunk
from onyx.indexing.models import IndexChunk
from onyx.indexing.models import UpdatableChunkData
@@ -102,13 +106,20 @@ class UserFileIndexingAdapter:
f"Failed to acquire locks after {_NUM_LOCK_ATTEMPTS} attempts for user files: {[doc.id for doc in documents]}"
)
def build_metadata_aware_chunks(
def prepare_enrichment(
self,
chunks_with_embeddings: list[IndexChunk],
chunk_content_scores: list[float],
tenant_id: str,
context: DocumentBatchPrepareContext,
) -> BuildMetadataAwareChunksResult:
tenant_id: str,
chunks: list[DocAwareChunk],
) -> UserFileChunkEnricher:
"""Do all DB lookups and pre-compute file metadata from chunks."""
updatable_ids = [doc.id for doc in context.updatable_docs]
doc_id_to_new_chunk_cnt: dict[str, int] = defaultdict(int)
content_by_file: dict[str, list[str]] = defaultdict(list)
for chunk in chunks:
doc_id_to_new_chunk_cnt[chunk.source_document.id] += 1
content_by_file[chunk.source_document.id].append(chunk.content)
no_access = DocumentAccess.build(
user_emails=[],
@@ -118,7 +129,6 @@ class UserFileIndexingAdapter:
is_public=False,
)
updatable_ids = [doc.id for doc in context.updatable_docs]
user_file_id_to_project_ids = fetch_user_project_ids_for_user_files(
user_file_ids=updatable_ids,
db_session=self.db_session,
@@ -139,17 +149,6 @@ class UserFileIndexingAdapter:
)
}
user_file_id_to_new_chunk_cnt: dict[str, int] = {
user_file_id: len(
[
chunk
for chunk in chunks_with_embeddings
if chunk.source_document.id == user_file_id
]
)
for user_file_id in updatable_ids
}
# Initialize tokenizer used for token count calculation
try:
llm = get_default_llm()
@@ -164,15 +163,9 @@ class UserFileIndexingAdapter:
user_file_id_to_raw_text: dict[str, str] = {}
user_file_id_to_token_count: dict[str, int | None] = {}
for user_file_id in updatable_ids:
user_file_chunks = [
chunk
for chunk in chunks_with_embeddings
if chunk.source_document.id == user_file_id
]
if user_file_chunks:
combined_content = " ".join(
[chunk.content for chunk in user_file_chunks]
)
contents = content_by_file.get(user_file_id)
if contents:
combined_content = " ".join(contents)
user_file_id_to_raw_text[str(user_file_id)] = combined_content
token_count: int = (
count_tokens(combined_content, llm_tokenizer)
@@ -184,28 +177,16 @@ class UserFileIndexingAdapter:
user_file_id_to_raw_text[str(user_file_id)] = ""
user_file_id_to_token_count[str(user_file_id)] = None
access_aware_chunks = [
DocMetadataAwareIndexChunk.from_index_chunk(
index_chunk=chunk,
access=user_file_id_to_access.get(chunk.source_document.id, no_access),
document_sets=set(),
user_project=user_file_id_to_project_ids.get(
chunk.source_document.id, []
),
personas=user_file_id_to_persona_ids.get(chunk.source_document.id, []),
boost=DEFAULT_BOOST,
tenant_id=tenant_id,
aggregated_chunk_boost_factor=chunk_content_scores[chunk_num],
)
for chunk_num, chunk in enumerate(chunks_with_embeddings)
]
return BuildMetadataAwareChunksResult(
chunks=access_aware_chunks,
return UserFileChunkEnricher(
user_file_id_to_access=user_file_id_to_access,
user_file_id_to_project_ids=user_file_id_to_project_ids,
user_file_id_to_persona_ids=user_file_id_to_persona_ids,
doc_id_to_previous_chunk_cnt=user_file_id_to_previous_chunk_cnt,
doc_id_to_new_chunk_cnt=user_file_id_to_new_chunk_cnt,
doc_id_to_new_chunk_cnt=dict(doc_id_to_new_chunk_cnt),
user_file_id_to_raw_text=user_file_id_to_raw_text,
user_file_id_to_token_count=user_file_id_to_token_count,
no_access=no_access,
tenant_id=tenant_id,
)
def _notify_assistant_owners_if_files_ready(
@@ -249,8 +230,9 @@ class UserFileIndexingAdapter:
context: DocumentBatchPrepareContext,
updatable_chunk_data: list[UpdatableChunkData], # noqa: ARG002
filtered_documents: list[Document], # noqa: ARG002
result: BuildMetadataAwareChunksResult,
enrichment: ChunkEnrichmentContext,
) -> None:
assert isinstance(enrichment, UserFileChunkEnricher)
user_file_ids = [doc.id for doc in context.updatable_docs]
user_files = (
@@ -266,8 +248,10 @@ class UserFileIndexingAdapter:
user_file.last_project_sync_at = datetime.datetime.now(
datetime.timezone.utc
)
user_file.chunk_count = result.doc_id_to_new_chunk_cnt[str(user_file.id)]
user_file.token_count = result.user_file_id_to_token_count[
user_file.chunk_count = enrichment.doc_id_to_new_chunk_cnt.get(
str(user_file.id), 0
)
user_file.token_count = enrichment.user_file_id_to_token_count[
str(user_file.id)
]
@@ -279,8 +263,54 @@ class UserFileIndexingAdapter:
# Store the plaintext in the file store for faster retrieval
# NOTE: this creates its own session to avoid committing the overall
# transaction.
for user_file_id, raw_text in result.user_file_id_to_raw_text.items():
for user_file_id, raw_text in enrichment.user_file_id_to_raw_text.items():
store_user_file_plaintext(
user_file_id=UUID(user_file_id),
plaintext_content=raw_text,
)
class UserFileChunkEnricher:
"""Pre-computed metadata for per-chunk enrichment of user-uploaded files."""
def __init__(
self,
user_file_id_to_access: dict[str, DocumentAccess],
user_file_id_to_project_ids: dict[str, list[int]],
user_file_id_to_persona_ids: dict[str, list[int]],
doc_id_to_previous_chunk_cnt: dict[str, int],
doc_id_to_new_chunk_cnt: dict[str, int],
user_file_id_to_raw_text: dict[str, str],
user_file_id_to_token_count: dict[str, int | None],
no_access: DocumentAccess,
tenant_id: str,
) -> None:
self._user_file_id_to_access = user_file_id_to_access
self._user_file_id_to_project_ids = user_file_id_to_project_ids
self._user_file_id_to_persona_ids = user_file_id_to_persona_ids
self._no_access = no_access
self._tenant_id = tenant_id
self.doc_id_to_previous_chunk_cnt = doc_id_to_previous_chunk_cnt
self.doc_id_to_new_chunk_cnt = doc_id_to_new_chunk_cnt
self.user_file_id_to_raw_text = user_file_id_to_raw_text
self.user_file_id_to_token_count = user_file_id_to_token_count
def enrich_chunk(
self, chunk: IndexChunk, score: float
) -> DocMetadataAwareIndexChunk:
return DocMetadataAwareIndexChunk.from_index_chunk(
index_chunk=chunk,
access=self._user_file_id_to_access.get(
chunk.source_document.id, self._no_access
),
document_sets=set(),
user_project=self._user_file_id_to_project_ids.get(
chunk.source_document.id, []
),
personas=self._user_file_id_to_persona_ids.get(
chunk.source_document.id, []
),
boost=DEFAULT_BOOST,
tenant_id=self._tenant_id,
aggregated_chunk_boost_factor=score,
)

View File

@@ -0,0 +1,89 @@
import pickle
import shutil
import tempfile
from collections.abc import Iterator
from pathlib import Path
from onyx.indexing.models import IndexChunk
class ChunkBatchStore:
"""Manages serialization of embedded chunks to a temporary directory.
Owns the temp directory lifetime and provides save/load/stream/scrub
operations.
Use as a context manager to ensure cleanup::
with ChunkBatchStore() as store:
store.save(chunks, batch_idx=0)
for chunk in store.stream():
...
"""
_EXT = ".pkl"
def __init__(self) -> None:
self._tmpdir: Path | None = None
# -- context manager -----------------------------------------------------
def __enter__(self) -> "ChunkBatchStore":
self._tmpdir = Path(tempfile.mkdtemp(prefix="onyx_embeddings_"))
return self
def __exit__(self, *_exc: object) -> None:
if self._tmpdir is not None:
shutil.rmtree(self._tmpdir, ignore_errors=True)
self._tmpdir = None
@property
def _dir(self) -> Path:
assert self._tmpdir is not None, "ChunkBatchStore used outside context manager"
return self._tmpdir
# -- storage primitives --------------------------------------------------
def save(self, chunks: list[IndexChunk], batch_idx: int) -> None:
"""Serialize a batch of embedded chunks to disk."""
with open(self._dir / f"batch_{batch_idx}{self._EXT}", "wb") as f:
pickle.dump(chunks, f)
def _load(self, batch_file: Path) -> list[IndexChunk]:
"""Deserialize a batch of embedded chunks from a file."""
with open(batch_file, "rb") as f:
return pickle.load(f)
def _batch_files(self) -> list[Path]:
"""Return batch files sorted by numeric index."""
return sorted(
self._dir.glob(f"batch_*{self._EXT}"),
key=lambda p: int(p.stem.removeprefix("batch_")),
)
# -- higher-level operations ---------------------------------------------
def stream(self) -> Iterator[IndexChunk]:
"""Yield all chunks across all batch files.
Each call returns a fresh generator, so the data can be iterated
multiple times (e.g. once per document index).
"""
for batch_file in self._batch_files():
yield from self._load(batch_file)
def scrub_failed_docs(self, failed_doc_ids: set[str]) -> None:
"""Remove chunks belonging to *failed_doc_ids* from all batch files.
When a document fails embedding in batch N, earlier batches may
already contain successfully embedded chunks for that document.
This ensures the output is all-or-nothing per document.
"""
for batch_file in self._batch_files():
batch_chunks = self._load(batch_file)
cleaned = [
c for c in batch_chunks if c.source_document.id not in failed_doc_ids
]
if len(cleaned) != len(batch_chunks):
with open(batch_file, "wb") as f:
pickle.dump(cleaned, f)

View File

@@ -1,5 +1,8 @@
from collections import defaultdict
from collections.abc import Callable
from collections.abc import Generator
from collections.abc import Iterator
from contextlib import contextmanager
from typing import Protocol
from pydantic import BaseModel
@@ -9,6 +12,7 @@ from sqlalchemy.orm import Session
from onyx.configs.app_configs import DEFAULT_CONTEXTUAL_RAG_LLM_NAME
from onyx.configs.app_configs import DEFAULT_CONTEXTUAL_RAG_LLM_PROVIDER
from onyx.configs.app_configs import ENABLE_CONTEXTUAL_RAG
from onyx.configs.app_configs import MAX_CHUNKS_PER_DOC_BATCH
from onyx.configs.app_configs import MAX_DOCUMENT_CHARS
from onyx.configs.app_configs import MAX_TOKENS_FOR_FULL_INCLUSION
from onyx.configs.app_configs import USE_CHUNK_SUMMARY
@@ -43,10 +47,12 @@ from onyx.document_index.interfaces import DocumentMetadata
from onyx.document_index.interfaces import IndexBatchParams
from onyx.file_processing.image_summarization import summarize_image_with_error_handling
from onyx.file_store.file_store import get_default_file_store
from onyx.indexing.chunk_batch_store import ChunkBatchStore
from onyx.indexing.chunker import Chunker
from onyx.indexing.embedder import embed_chunks_with_failure_handling
from onyx.indexing.embedder import IndexingEmbedder
from onyx.indexing.models import DocAwareChunk
from onyx.indexing.models import DocMetadataAwareIndexChunk
from onyx.indexing.models import IndexingBatchAdapter
from onyx.indexing.models import UpdatableChunkData
from onyx.indexing.vector_db_insertion import write_chunks_to_vector_db_with_backoff
@@ -63,6 +69,7 @@ from onyx.natural_language_processing.utils import tokenizer_trim_middle
from onyx.prompts.contextual_retrieval import CONTEXTUAL_RAG_PROMPT1
from onyx.prompts.contextual_retrieval import CONTEXTUAL_RAG_PROMPT2
from onyx.prompts.contextual_retrieval import DOCUMENT_SUMMARY_PROMPT
from onyx.utils.batching import batch_generator
from onyx.utils.logger import setup_logger
from onyx.utils.postgres_sanitization import sanitize_documents_for_postgres
from onyx.utils.threadpool_concurrency import run_functions_tuples_in_parallel
@@ -91,6 +98,20 @@ class IndexingPipelineResult(BaseModel):
failures: list[ConnectorFailure]
@classmethod
def empty(cls, total_docs: int) -> "IndexingPipelineResult":
return cls(
new_docs=0,
total_docs=total_docs,
total_chunks=0,
failures=[],
)
class ChunkEmbeddingResult(BaseModel):
successful_chunk_ids: list[tuple[int, str]] # (chunk_id, document_id)
connector_failures: list[ConnectorFailure]
class IndexingPipelineProtocol(Protocol):
def __call__(
@@ -139,6 +160,110 @@ def _upsert_documents_in_db(
)
def _get_failed_doc_ids(failures: list[ConnectorFailure]) -> set[str]:
"""Extract document IDs from a list of connector failures."""
return {f.failed_document.document_id for f in failures if f.failed_document}
def _embed_chunks_to_store(
chunks: list[DocAwareChunk],
embedder: IndexingEmbedder,
tenant_id: str,
request_id: str | None,
store: ChunkBatchStore,
) -> ChunkEmbeddingResult:
"""Embed chunks in batches, spilling each batch to *store*.
If a document fails embedding in any batch, its chunks are excluded from
all batches (including earlier ones already written) so that the output
is all-or-nothing per document.
"""
successful_chunk_ids: list[tuple[int, str]] = []
all_embedding_failures: list[ConnectorFailure] = []
# Track failed doc IDs across all batches so that a failure in batch N
# causes chunks for that doc to be skipped in batch N+1 and stripped
# from earlier batches.
all_failed_doc_ids: set[str] = set()
for batch_idx, chunk_batch in enumerate(
batch_generator(chunks, MAX_CHUNKS_PER_DOC_BATCH)
):
# Skip chunks belonging to documents that failed in earlier batches.
chunk_batch = [
c for c in chunk_batch if c.source_document.id not in all_failed_doc_ids
]
if not chunk_batch:
continue
logger.debug(f"Embedding batch {batch_idx}: {len(chunk_batch)} chunks")
chunks_with_embeddings, embedding_failures = embed_chunks_with_failure_handling(
chunks=chunk_batch,
embedder=embedder,
tenant_id=tenant_id,
request_id=request_id,
)
all_embedding_failures.extend(embedding_failures)
all_failed_doc_ids.update(_get_failed_doc_ids(embedding_failures))
# Only keep successfully embedded chunks for non-failed docs.
chunks_with_embeddings = [
c
for c in chunks_with_embeddings
if c.source_document.id not in all_failed_doc_ids
]
successful_chunk_ids.extend(
(c.chunk_id, c.source_document.id) for c in chunks_with_embeddings
)
store.save(chunks_with_embeddings, batch_idx)
del chunks_with_embeddings
# Scrub earlier batches for docs that failed in later batches.
if all_failed_doc_ids:
store.scrub_failed_docs(all_failed_doc_ids)
successful_chunk_ids = [
(chunk_id, doc_id)
for chunk_id, doc_id in successful_chunk_ids
if doc_id not in all_failed_doc_ids
]
return ChunkEmbeddingResult(
successful_chunk_ids=successful_chunk_ids,
connector_failures=all_embedding_failures,
)
@contextmanager
def embed_and_stream(
chunks: list[DocAwareChunk],
embedder: IndexingEmbedder,
tenant_id: str,
request_id: str | None,
) -> Generator[tuple[ChunkEmbeddingResult, ChunkBatchStore], None, None]:
"""Embed chunks to disk and yield a ``(result, store)`` pair.
The store owns the temp directory — files are cleaned up when the context
manager exits.
Usage::
with embed_and_stream(chunks, embedder, tenant_id, req_id) as (result, store):
for chunk in store.stream():
...
"""
with ChunkBatchStore() as store:
result = _embed_chunks_to_store(
chunks=chunks,
embedder=embedder,
tenant_id=tenant_id,
request_id=request_id,
store=store,
)
yield result, store
def get_doc_ids_to_update(
documents: list[Document], db_docs: list[DBDocument]
) -> list[Document]:
@@ -637,6 +762,29 @@ def add_contextual_summaries(
return chunks
def _verify_indexing_completeness(
insertion_records: list[DocumentInsertionRecord],
write_failures: list[ConnectorFailure],
embedding_failed_doc_ids: set[str],
updatable_ids: list[str],
document_index_name: str,
) -> None:
"""Verify that every updatable document was either indexed or reported as failed."""
all_returned_doc_ids = (
{r.document_id for r in insertion_records}
| {f.failed_document.document_id for f in write_failures if f.failed_document}
| embedding_failed_doc_ids
)
if all_returned_doc_ids != set(updatable_ids):
raise RuntimeError(
f"Some documents were not successfully indexed. "
f"Updatable IDs: {updatable_ids}, "
f"Returned IDs: {all_returned_doc_ids}. "
f"This should never happen. "
f"This occured for document index {document_index_name}"
)
@log_function_time(debug_only=True)
def index_doc_batch(
*,
@@ -672,12 +820,7 @@ def index_doc_batch(
filtered_documents = filter_fnc(document_batch)
context = adapter.prepare(filtered_documents, ignore_time_skip)
if not context:
return IndexingPipelineResult(
new_docs=0,
total_docs=len(filtered_documents),
total_chunks=0,
failures=[],
)
return IndexingPipelineResult.empty(len(filtered_documents))
# Convert documents to IndexingDocument objects with processed section
# logger.debug("Processing image sections")
@@ -716,117 +859,99 @@ def index_doc_batch(
)
logger.debug("Starting embedding")
chunks_with_embeddings, embedding_failures = (
embed_chunks_with_failure_handling(
chunks=chunks,
embedder=embedder,
tenant_id=tenant_id,
request_id=request_id,
)
if chunks
else ([], [])
)
with embed_and_stream(chunks, embedder, tenant_id, request_id) as (
embedding_result,
chunk_store,
):
updatable_ids = [doc.id for doc in context.updatable_docs]
updatable_chunk_data = [
UpdatableChunkData(
chunk_id=chunk_id,
document_id=document_id,
boost_score=1.0,
)
for chunk_id, document_id in embedding_result.successful_chunk_ids
]
chunk_content_scores = [1.0] * len(chunks_with_embeddings)
updatable_ids = [doc.id for doc in context.updatable_docs]
updatable_chunk_data = [
UpdatableChunkData(
chunk_id=chunk.chunk_id,
document_id=chunk.source_document.id,
boost_score=score,
)
for chunk, score in zip(chunks_with_embeddings, chunk_content_scores)
]
# Acquires a lock on the documents so that no other process can modify them
# NOTE: don't need to acquire till here, since this is when the actual race condition
# with Vespa can occur.
with adapter.lock_context(context.updatable_docs):
# we're concerned about race conditions where multiple simultaneous indexings might result
# in one set of metadata overwriting another one in vespa.
# we still write data here for the immediate and most likely correct sync, but
# to resolve this, an update of the last modified field at the end of this loop
# always triggers a final metadata sync via the celery queue
result = adapter.build_metadata_aware_chunks(
chunks_with_embeddings=chunks_with_embeddings,
chunk_content_scores=chunk_content_scores,
tenant_id=tenant_id,
context=context,
embedding_failed_doc_ids = _get_failed_doc_ids(
embedding_result.connector_failures
)
short_descriptor_list = [chunk.to_short_descriptor() for chunk in result.chunks]
short_descriptor_log = str(short_descriptor_list)[:1024]
logger.debug(f"Indexing the following chunks: {short_descriptor_log}")
# Filter to only successfully embedded chunks so
# doc_id_to_new_chunk_cnt reflects what's actually written to Vespa.
embedded_chunks = [
c for c in chunks if c.source_document.id not in embedding_failed_doc_ids
]
primary_doc_idx_insertion_records: list[DocumentInsertionRecord] | None = None
primary_doc_idx_vector_db_write_failures: list[ConnectorFailure] | None = None
for document_index in document_indices:
# A document will not be spread across different batches, so all the
# documents with chunks in this set, are fully represented by the chunks
# in this set
(
insertion_records,
vector_db_write_failures,
) = write_chunks_to_vector_db_with_backoff(
document_index=document_index,
chunks=result.chunks,
index_batch_params=IndexBatchParams(
doc_id_to_previous_chunk_cnt=result.doc_id_to_previous_chunk_cnt,
doc_id_to_new_chunk_cnt=result.doc_id_to_new_chunk_cnt,
tenant_id=tenant_id,
large_chunks_enabled=chunker.enable_large_chunks,
),
# Acquires a lock on the documents so that no other process can modify
# them. Not needed until here, since this is when the actual race
# condition with vector db can occur.
with adapter.lock_context(context.updatable_docs):
enricher = adapter.prepare_enrichment(
context=context,
tenant_id=tenant_id,
chunks=embedded_chunks,
)
all_returned_doc_ids: set[str] = (
{record.document_id for record in insertion_records}
.union(
{
record.failed_document.document_id
for record in vector_db_write_failures
if record.failed_document
}
)
.union(
{
record.failed_document.document_id
for record in embedding_failures
if record.failed_document
}
)
index_batch_params = IndexBatchParams(
doc_id_to_previous_chunk_cnt=enricher.doc_id_to_previous_chunk_cnt,
doc_id_to_new_chunk_cnt=enricher.doc_id_to_new_chunk_cnt,
tenant_id=tenant_id,
large_chunks_enabled=chunker.enable_large_chunks,
)
if all_returned_doc_ids != set(updatable_ids):
raise RuntimeError(
f"Some documents were not successfully indexed. "
f"Updatable IDs: {updatable_ids}, "
f"Returned IDs: {all_returned_doc_ids}. "
"This should never happen."
f"This occured for document index {document_index.__class__.__name__}"
)
# We treat the first document index we got as the primary one used
# for reporting the state of indexing.
if primary_doc_idx_insertion_records is None:
primary_doc_idx_insertion_records = insertion_records
if primary_doc_idx_vector_db_write_failures is None:
primary_doc_idx_vector_db_write_failures = vector_db_write_failures
adapter.post_index(
context=context,
updatable_chunk_data=updatable_chunk_data,
filtered_documents=filtered_documents,
result=result,
)
primary_doc_idx_insertion_records: list[DocumentInsertionRecord] | None = (
None
)
primary_doc_idx_vector_db_write_failures: list[ConnectorFailure] | None = (
None
)
for document_index in document_indices:
def _enriched_stream() -> Iterator[DocMetadataAwareIndexChunk]:
for chunk in chunk_store.stream():
yield enricher.enrich_chunk(chunk, 1.0)
insertion_records, write_failures = (
write_chunks_to_vector_db_with_backoff(
document_index=document_index,
make_chunks=_enriched_stream,
index_batch_params=index_batch_params,
)
)
_verify_indexing_completeness(
insertion_records=insertion_records,
write_failures=write_failures,
embedding_failed_doc_ids=embedding_failed_doc_ids,
updatable_ids=updatable_ids,
document_index_name=document_index.__class__.__name__,
)
# We treat the first document index we got as the primary one used
# for reporting the state of indexing.
if primary_doc_idx_insertion_records is None:
primary_doc_idx_insertion_records = insertion_records
if primary_doc_idx_vector_db_write_failures is None:
primary_doc_idx_vector_db_write_failures = write_failures
adapter.post_index(
context=context,
updatable_chunk_data=updatable_chunk_data,
filtered_documents=filtered_documents,
enrichment=enricher,
)
assert primary_doc_idx_insertion_records is not None
assert primary_doc_idx_vector_db_write_failures is not None
return IndexingPipelineResult(
new_docs=len(
[r for r in primary_doc_idx_insertion_records if not r.already_existed]
new_docs=sum(
1 for r in primary_doc_idx_insertion_records if not r.already_existed
),
total_docs=len(filtered_documents),
total_chunks=len(chunks_with_embeddings),
failures=primary_doc_idx_vector_db_write_failures + embedding_failures,
total_chunks=len(embedding_result.successful_chunk_ids),
failures=primary_doc_idx_vector_db_write_failures
+ embedding_result.connector_failures,
)

View File

@@ -235,12 +235,16 @@ class UpdatableChunkData(BaseModel):
boost_score: float
class BuildMetadataAwareChunksResult(BaseModel):
chunks: list[DocMetadataAwareIndexChunk]
class ChunkEnrichmentContext(Protocol):
"""Returned by prepare_enrichment. Holds pre-computed metadata lookups
and provides per-chunk enrichment."""
doc_id_to_previous_chunk_cnt: dict[str, int]
doc_id_to_new_chunk_cnt: dict[str, int]
user_file_id_to_raw_text: dict[str, str]
user_file_id_to_token_count: dict[str, int | None]
def enrich_chunk(
self, chunk: IndexChunk, score: float
) -> DocMetadataAwareIndexChunk: ...
class IndexingBatchAdapter(Protocol):
@@ -254,18 +258,24 @@ class IndexingBatchAdapter(Protocol):
) -> Generator[TransactionalContext, None, None]:
"""Provide a transaction/row-lock context for critical updates."""
def build_metadata_aware_chunks(
def prepare_enrichment(
self,
chunks_with_embeddings: list[IndexChunk],
chunk_content_scores: list[float],
tenant_id: str,
context: "DocumentBatchPrepareContext",
) -> BuildMetadataAwareChunksResult: ...
tenant_id: str,
chunks: list[DocAwareChunk],
) -> ChunkEnrichmentContext:
"""Prepare per-chunk enrichment data (access, document sets, boost, etc.).
Precondition: ``chunks`` have already been through the embedding step
(i.e. they are ``IndexChunk`` instances with populated embeddings,
passed here as the base ``DocAwareChunk`` type).
"""
...
def post_index(
self,
context: "DocumentBatchPrepareContext",
updatable_chunk_data: list[UpdatableChunkData],
filtered_documents: list[Document],
result: BuildMetadataAwareChunksResult,
enrichment: ChunkEnrichmentContext,
) -> None: ...

View File

@@ -1,6 +1,9 @@
import time
from collections import defaultdict
from collections.abc import Callable
from collections.abc import Iterable
from http import HTTPStatus
from itertools import chain
from itertools import groupby
import httpx
@@ -28,22 +31,22 @@ def _log_insufficient_storage_error(e: Exception) -> None:
def write_chunks_to_vector_db_with_backoff(
document_index: DocumentIndex,
chunks: list[DocMetadataAwareIndexChunk],
make_chunks: Callable[[], Iterable[DocMetadataAwareIndexChunk]],
index_batch_params: IndexBatchParams,
) -> tuple[list[DocumentInsertionRecord], list[ConnectorFailure]]:
"""Tries to insert all chunks in one large batch. If that batch fails for any reason,
goes document by document to isolate the failure(s).
IMPORTANT: must pass in whole documents at a time not individual chunks, since the
vector DB interface assumes that all chunks for a single document are present.
vector DB interface assumes that all chunks for a single document are present. The
chunks must also be in contiguous batches
"""
# first try to write the chunks to the vector db
try:
return (
list(
document_index.index(
chunks=chunks,
chunks=make_chunks(),
index_batch_params=index_batch_params,
)
),
@@ -60,14 +63,23 @@ def write_chunks_to_vector_db_with_backoff(
# wait a couple seconds just to give the vector db a chance to recover
time.sleep(2)
# try writing each doc one by one
chunks_for_docs: dict[str, list[DocMetadataAwareIndexChunk]] = defaultdict(list)
for chunk in chunks:
chunks_for_docs[chunk.source_document.id].append(chunk)
insertion_records: list[DocumentInsertionRecord] = []
failures: list[ConnectorFailure] = []
for doc_id, chunks_for_doc in chunks_for_docs.items():
def key(chunk: DocMetadataAwareIndexChunk) -> str:
return chunk.source_document.id
seen_doc_ids: set[str] = set()
for doc_id, chunks_for_doc in groupby(make_chunks(), key=key):
if doc_id in seen_doc_ids:
raise RuntimeError(
f"Doc chunks are not arriving in order. Current doc_id={doc_id}, seen_doc_ids={list(seen_doc_ids)}"
)
seen_doc_ids.add(doc_id)
first_chunk = next(chunks_for_doc)
chunks_for_doc = chain([first_chunk], chunks_for_doc)
try:
insertion_records.extend(
document_index.index(
@@ -87,9 +99,7 @@ def write_chunks_to_vector_db_with_backoff(
ConnectorFailure(
failed_document=DocumentFailure(
document_id=doc_id,
document_link=(
chunks_for_doc[0].get_link() if chunks_for_doc else None
),
document_link=first_chunk.get_link(),
),
failure_message=str(e),
exception=e,

View File

@@ -8,6 +8,24 @@ from pydantic import BaseModel
class LLMOverride(BaseModel):
"""Per-request LLM settings that override persona defaults.
All fields are optional — only the fields that differ from the persona's
configured LLM need to be supplied. Used both over the wire (API requests)
and for multi-model comparison, where one override is supplied per model.
Attributes:
model_provider: LLM provider slug (e.g. ``"openai"``, ``"anthropic"``).
When ``None``, the persona's default provider is used.
model_version: Specific model version string (e.g. ``"gpt-4o"``).
When ``None``, the persona's default model is used.
temperature: Sampling temperature in ``[0, 2]``. When ``None``, the
persona's default temperature is used.
display_name: Human-readable label shown in the UI for this model,
e.g. ``"GPT-4 Turbo"``. Optional; falls back to ``model_version``
when not set.
"""
model_provider: str | None = None
model_version: str | None = None
temperature: float | None = None

View File

@@ -28,6 +28,7 @@ from onyx.chat.chat_utils import extract_headers
from onyx.chat.models import ChatFullResponse
from onyx.chat.models import CreateChatSessionID
from onyx.chat.process_message import gather_stream_full
from onyx.chat.process_message import handle_multi_model_stream
from onyx.chat.process_message import handle_stream_message_objects
from onyx.chat.prompt_utils import get_default_base_system_prompt
from onyx.chat.stop_signal_checker import set_fence
@@ -46,6 +47,7 @@ from onyx.db.chat import get_chat_messages_by_session
from onyx.db.chat import get_chat_session_by_id
from onyx.db.chat import get_chat_sessions_by_user
from onyx.db.chat import set_as_latest_chat_message
from onyx.db.chat import set_preferred_response
from onyx.db.chat import translate_db_message_to_chat_message_detail
from onyx.db.chat import update_chat_session
from onyx.db.chat_search import search_chat_sessions
@@ -60,6 +62,8 @@ from onyx.db.persona import get_persona_by_id
from onyx.db.usage import increment_usage
from onyx.db.usage import UsageType
from onyx.db.user_file import get_file_id_by_user_file_id
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.file_store.file_store import get_default_file_store
from onyx.llm.constants import LlmProviderNames
from onyx.llm.factory import get_default_llm
@@ -81,6 +85,7 @@ from onyx.server.query_and_chat.models import ChatSessionUpdateRequest
from onyx.server.query_and_chat.models import MessageOrigin
from onyx.server.query_and_chat.models import RenameChatSessionResponse
from onyx.server.query_and_chat.models import SendMessageRequest
from onyx.server.query_and_chat.models import SetPreferredResponseRequest
from onyx.server.query_and_chat.models import UpdateChatSessionTemperatureRequest
from onyx.server.query_and_chat.models import UpdateChatSessionThreadRequest
from onyx.server.query_and_chat.session_loading import (
@@ -570,6 +575,46 @@ def handle_send_chat_message(
if get_hashed_api_key_from_request(request) or get_hashed_pat_from_request(request):
chat_message_req.origin = MessageOrigin.API
# Multi-model streaming path: 2-3 LLMs in parallel (streaming only)
is_multi_model = (
chat_message_req.llm_overrides is not None
and len(chat_message_req.llm_overrides) > 1
)
if is_multi_model and chat_message_req.stream:
# Narrowed here; is_multi_model already checked llm_overrides is not None
llm_overrides = chat_message_req.llm_overrides or []
def multi_model_stream_generator() -> Generator[str, None, None]:
try:
with get_session_with_current_tenant() as db_session:
for obj in handle_multi_model_stream(
new_msg_req=chat_message_req,
user=user,
db_session=db_session,
llm_overrides=llm_overrides,
litellm_additional_headers=extract_headers(
request.headers, LITELLM_PASS_THROUGH_HEADERS
),
custom_tool_additional_headers=get_custom_tool_additional_request_headers(
request.headers
),
mcp_headers=chat_message_req.mcp_headers,
):
yield get_json_line(obj.model_dump())
except Exception as e:
logger.exception("Error in multi-model streaming")
yield json.dumps({"error": str(e)})
return StreamingResponse(
multi_model_stream_generator(), media_type="text/event-stream"
)
if is_multi_model and not chat_message_req.stream:
raise OnyxError(
OnyxErrorCode.INVALID_INPUT,
"Multi-model mode (llm_overrides with >1 entry) requires stream=True.",
)
# Non-streaming path: consume all packets and return complete response
if not chat_message_req.stream:
with get_session_with_current_tenant() as db_session:
@@ -660,6 +705,30 @@ def set_message_as_latest(
)
@router.put("/set-preferred-response")
def set_preferred_response_endpoint(
request_body: SetPreferredResponseRequest,
user: User | None = Depends(current_user),
db_session: Session = Depends(get_session),
) -> None:
"""Set the preferred assistant response for a multi-model turn."""
try:
# Ownership check: get_chat_message raises ValueError if the message
# doesn't belong to this user, preventing cross-user mutation.
get_chat_message(
chat_message_id=request_body.user_message_id,
user_id=user.id if user else None,
db_session=db_session,
)
set_preferred_response(
db_session=db_session,
user_message_id=request_body.user_message_id,
preferred_assistant_message_id=request_body.preferred_response_id,
)
except ValueError as e:
raise OnyxError(OnyxErrorCode.INVALID_INPUT, str(e))
@router.post("/create-chat-message-feedback")
def create_chat_feedback(
feedback: ChatFeedbackRequest,

View File

@@ -2,11 +2,25 @@ from pydantic import BaseModel
class Placement(BaseModel):
# Which iterative block in the UI is this part of, these are ordered and smaller ones happened first
"""Coordinates that identify where a streaming packet belongs in the UI.
The frontend uses these fields to route each packet to the correct turn,
tool tab, agent sub-turn, and (in multi-model mode) response column.
Attributes:
turn_index: Monotonically increasing index of the iterative reasoning block
(e.g. tool call round) within this chat message. Lower values happened first.
tab_index: Disambiguates parallel tool calls within the same turn so each
tool's output can be displayed in its own tab.
sub_turn_index: Nesting level for tools that invoke other tools. ``None`` for
top-level packets; an integer for tool-within-tool output.
model_index: Which model this packet belongs to. ``0`` for single-model
responses; ``0``, ``1``, or ``2`` for multi-model comparison. ``None``
for pre-LLM setup packets (e.g. message ID info) that are yielded
before any Emitter runs.
"""
turn_index: int
# For parallel tool calls to preserve order of execution
tab_index: int = 0
# Used for tools/agents that call other tools, this currently doesn't support nested agents but can be added later
sub_turn_index: int | None = None
# For multi-model streaming: identifies which model (0, 1, 2) this packet belongs to.
model_index: int | None = None

View File

@@ -1,3 +1,4 @@
import queue
import time
from collections.abc import Callable
from typing import Any
@@ -708,7 +709,6 @@ def run_research_agent_calls(
if __name__ == "__main__":
from queue import Queue
from uuid import uuid4
from onyx.chat.chat_state import ChatStateContainer
@@ -744,8 +744,8 @@ if __name__ == "__main__":
if user is None:
raise ValueError("No users found in database. Please create a user first.")
bus: Queue[Packet] = Queue()
emitter = Emitter(bus)
emitter_queue: queue.Queue = queue.Queue()
emitter = Emitter(merged_queue=emitter_queue)
state_container = ChatStateContainer()
tool_dict = construct_tools(
@@ -792,4 +792,4 @@ if __name__ == "__main__":
print(result.intermediate_report)
print("=" * 80)
print(f"Citations: {result.citation_mapping}")
print(f"Total packets emitted: {bus.qsize()}")
print(f"Total packets emitted: {emitter_queue.qsize()}")

View File

@@ -1,5 +1,6 @@
import csv
import json
import queue
import uuid
from io import BytesIO
from io import StringIO
@@ -11,7 +12,6 @@ import requests
from requests import JSONDecodeError
from onyx.chat.emitter import Emitter
from onyx.chat.emitter import get_default_emitter
from onyx.configs.constants import FileOrigin
from onyx.file_store.file_store import get_default_file_store
from onyx.server.query_and_chat.placement import Placement
@@ -296,9 +296,9 @@ def build_custom_tools_from_openapi_schema_and_headers(
url = openapi_to_url(openapi_schema)
method_specs = openapi_to_method_specs(openapi_schema)
# Use default emitter if none provided
# Use a discard emitter if none provided (packets go nowhere)
if emitter is None:
emitter = get_default_emitter()
emitter = Emitter(merged_queue=queue.Queue())
return [
CustomTool(
@@ -367,7 +367,7 @@ if __name__ == "__main__":
tools = build_custom_tools_from_openapi_schema_and_headers(
tool_id=0, # dummy tool id
openapi_schema=openapi_schema,
emitter=get_default_emitter(),
emitter=Emitter(merged_queue=queue.Queue()),
dynamic_schema_info=None,
)

View File

@@ -735,7 +735,7 @@ pyee==13.0.0
# via playwright
pygithub==2.5.0
# via onyx
pygments==2.19.2
pygments==2.20.0
# via rich
pyjwt==2.12.0
# via

View File

@@ -349,7 +349,7 @@ pydantic-core==2.33.2
# via pydantic
pydantic-settings==2.12.0
# via mcp
pygments==2.19.2
pygments==2.20.0
# via
# ipython
# ipython-pygments-lexers

View File

@@ -27,11 +27,13 @@ def create_placement(
turn_index: int,
tab_index: int = 0,
sub_turn_index: int | None = None,
model_index: int | None = 0,
) -> Placement:
return Placement(
turn_index=turn_index,
tab_index=tab_index,
sub_turn_index=sub_turn_index,
model_index=model_index,
)

View File

@@ -1,7 +1,7 @@
"""
External dependency unit tests for UserFileIndexingAdapter metadata writing.
Validates that build_metadata_aware_chunks produces DocMetadataAwareIndexChunk
Validates that prepare_enrichment produces DocMetadataAwareIndexChunk
objects with both `user_project` and `personas` fields populated correctly
based on actual DB associations.
@@ -127,7 +127,7 @@ def _make_index_chunk(user_file: UserFile) -> IndexChunk:
class TestAdapterWritesBothMetadataFields:
"""build_metadata_aware_chunks must populate user_project AND personas."""
"""prepare_enrichment must populate user_project AND personas."""
@patch(
"onyx.indexing.adapters.user_file_indexing_adapter.get_default_llm",
@@ -153,15 +153,13 @@ class TestAdapterWritesBothMetadataFields:
doc = chunk.source_document
context = DocumentBatchPrepareContext(updatable_docs=[doc], id_to_boost_map={})
result = adapter.build_metadata_aware_chunks(
chunks_with_embeddings=[chunk],
chunk_content_scores=[1.0],
tenant_id=TEST_TENANT_ID,
enricher = adapter.prepare_enrichment(
context=context,
tenant_id=TEST_TENANT_ID,
chunks=[chunk],
)
aware_chunk = enricher.enrich_chunk(chunk, 1.0)
assert len(result.chunks) == 1
aware_chunk = result.chunks[0]
assert persona.id in aware_chunk.personas
assert aware_chunk.user_project == []
@@ -190,15 +188,13 @@ class TestAdapterWritesBothMetadataFields:
updatable_docs=[chunk.source_document], id_to_boost_map={}
)
result = adapter.build_metadata_aware_chunks(
chunks_with_embeddings=[chunk],
chunk_content_scores=[1.0],
tenant_id=TEST_TENANT_ID,
enricher = adapter.prepare_enrichment(
context=context,
tenant_id=TEST_TENANT_ID,
chunks=[chunk],
)
aware_chunk = enricher.enrich_chunk(chunk, 1.0)
assert len(result.chunks) == 1
aware_chunk = result.chunks[0]
assert project.id in aware_chunk.user_project
assert aware_chunk.personas == []
@@ -229,14 +225,13 @@ class TestAdapterWritesBothMetadataFields:
updatable_docs=[chunk.source_document], id_to_boost_map={}
)
result = adapter.build_metadata_aware_chunks(
chunks_with_embeddings=[chunk],
chunk_content_scores=[1.0],
tenant_id=TEST_TENANT_ID,
enricher = adapter.prepare_enrichment(
context=context,
tenant_id=TEST_TENANT_ID,
chunks=[chunk],
)
aware_chunk = enricher.enrich_chunk(chunk, 1.0)
aware_chunk = result.chunks[0]
assert persona.id in aware_chunk.personas
assert project.id in aware_chunk.user_project
@@ -261,14 +256,13 @@ class TestAdapterWritesBothMetadataFields:
updatable_docs=[chunk.source_document], id_to_boost_map={}
)
result = adapter.build_metadata_aware_chunks(
chunks_with_embeddings=[chunk],
chunk_content_scores=[1.0],
tenant_id=TEST_TENANT_ID,
enricher = adapter.prepare_enrichment(
context=context,
tenant_id=TEST_TENANT_ID,
chunks=[chunk],
)
aware_chunk = enricher.enrich_chunk(chunk, 1.0)
aware_chunk = result.chunks[0]
assert aware_chunk.personas == []
assert aware_chunk.user_project == []
@@ -300,12 +294,11 @@ class TestAdapterWritesBothMetadataFields:
updatable_docs=[chunk.source_document], id_to_boost_map={}
)
result = adapter.build_metadata_aware_chunks(
chunks_with_embeddings=[chunk],
chunk_content_scores=[1.0],
tenant_id=TEST_TENANT_ID,
enricher = adapter.prepare_enrichment(
context=context,
tenant_id=TEST_TENANT_ID,
chunks=[chunk],
)
aware_chunk = enricher.enrich_chunk(chunk, 1.0)
aware_chunk = result.chunks[0]
assert set(aware_chunk.personas) == {persona_a.id, persona_b.id}

View File

@@ -13,6 +13,7 @@ This test:
All external HTTP calls are mocked, but Postgres and Redis are running.
"""
import queue
from typing import Any
from unittest.mock import patch
from uuid import uuid4
@@ -20,7 +21,7 @@ from uuid import uuid4
import pytest
from sqlalchemy.orm import Session
from onyx.chat.emitter import get_default_emitter
from onyx.chat.emitter import Emitter
from onyx.db.enums import MCPAuthenticationPerformer
from onyx.db.enums import MCPAuthenticationType
from onyx.db.enums import MCPTransport
@@ -137,7 +138,7 @@ class TestMCPPassThroughOAuth:
tool_dict = construct_tools(
persona=persona,
db_session=db_session,
emitter=get_default_emitter(),
emitter=Emitter(merged_queue=queue.Queue()),
user=user,
llm=llm,
search_tool_config=search_tool_config,
@@ -200,7 +201,7 @@ class TestMCPPassThroughOAuth:
tool_dict = construct_tools(
persona=persona,
db_session=db_session,
emitter=get_default_emitter(),
emitter=Emitter(merged_queue=queue.Queue()),
user=user,
llm=llm,
search_tool_config=SearchToolConfig(),
@@ -275,7 +276,7 @@ class TestMCPPassThroughOAuth:
tool_dict = construct_tools(
persona=persona,
db_session=db_session,
emitter=get_default_emitter(),
emitter=Emitter(merged_queue=queue.Queue()),
user=user,
llm=llm,
search_tool_config=SearchToolConfig(),
@@ -350,7 +351,7 @@ class TestMCPPassThroughOAuth:
tool_dict = construct_tools(
persona=persona,
db_session=db_session,
emitter=get_default_emitter(),
emitter=Emitter(merged_queue=queue.Queue()),
user=user,
llm=llm,
search_tool_config=SearchToolConfig(),
@@ -458,7 +459,7 @@ class TestMCPPassThroughOAuth:
tool_dict = construct_tools(
persona=persona,
db_session=db_session,
emitter=get_default_emitter(),
emitter=Emitter(merged_queue=queue.Queue()),
user=user,
llm=llm,
search_tool_config=SearchToolConfig(),
@@ -541,7 +542,7 @@ class TestMCPPassThroughOAuth:
tool_dict = construct_tools(
persona=persona,
db_session=db_session,
emitter=get_default_emitter(),
emitter=Emitter(merged_queue=queue.Queue()),
user=user,
llm=llm,
search_tool_config=SearchToolConfig(),

View File

@@ -8,6 +8,7 @@ Tests the priority logic for OAuth tokens when constructing custom tools:
All external HTTP calls are mocked, but Postgres and Redis are running.
"""
import queue
from typing import Any
from unittest.mock import Mock
from unittest.mock import patch
@@ -16,7 +17,7 @@ from uuid import uuid4
import pytest
from sqlalchemy.orm import Session
from onyx.chat.emitter import get_default_emitter
from onyx.chat.emitter import Emitter
from onyx.db.models import OAuthAccount
from onyx.db.models import OAuthConfig
from onyx.db.models import Persona
@@ -174,7 +175,7 @@ class TestOAuthToolIntegrationPriority:
tool_dict = construct_tools(
persona=persona,
db_session=db_session,
emitter=get_default_emitter(),
emitter=Emitter(merged_queue=queue.Queue()),
user=user,
llm=llm,
search_tool_config=search_tool_config,
@@ -232,7 +233,7 @@ class TestOAuthToolIntegrationPriority:
tool_dict = construct_tools(
persona=persona,
db_session=db_session,
emitter=get_default_emitter(),
emitter=Emitter(merged_queue=queue.Queue()),
user=user,
llm=llm,
)
@@ -284,7 +285,7 @@ class TestOAuthToolIntegrationPriority:
tool_dict = construct_tools(
persona=persona,
db_session=db_session,
emitter=get_default_emitter(),
emitter=Emitter(merged_queue=queue.Queue()),
user=user,
llm=llm,
)
@@ -345,7 +346,7 @@ class TestOAuthToolIntegrationPriority:
tool_dict = construct_tools(
persona=persona,
db_session=db_session,
emitter=get_default_emitter(),
emitter=Emitter(merged_queue=queue.Queue()),
user=user,
llm=llm,
)
@@ -416,7 +417,7 @@ class TestOAuthToolIntegrationPriority:
tool_dict = construct_tools(
persona=persona,
db_session=db_session,
emitter=get_default_emitter(),
emitter=Emitter(merged_queue=queue.Queue()),
user=user,
llm=llm,
)
@@ -483,7 +484,7 @@ class TestOAuthToolIntegrationPriority:
tool_dict = construct_tools(
persona=persona,
db_session=db_session,
emitter=get_default_emitter(),
emitter=Emitter(merged_queue=queue.Queue()),
user=user,
llm=llm,
)
@@ -536,7 +537,7 @@ class TestOAuthToolIntegrationPriority:
tool_dict = construct_tools(
persona=persona,
db_session=db_session,
emitter=get_default_emitter(),
emitter=Emitter(merged_queue=queue.Queue()),
user=user,
llm=llm,
)

View File

@@ -0,0 +1,173 @@
"""Unit tests for the Emitter class.
All tests use the streaming mode (merged_queue required). Emitter has a single
code path — no standalone bus.
"""
import queue
from onyx.chat.emitter import Emitter
from onyx.server.query_and_chat.placement import Placement
from onyx.server.query_and_chat.streaming_models import OverallStop
from onyx.server.query_and_chat.streaming_models import Packet
from onyx.server.query_and_chat.streaming_models import ReasoningStart
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _placement(
turn_index: int = 0,
tab_index: int = 0,
sub_turn_index: int | None = None,
) -> Placement:
return Placement(
turn_index=turn_index,
tab_index=tab_index,
sub_turn_index=sub_turn_index,
)
def _packet(
turn_index: int = 0,
tab_index: int = 0,
sub_turn_index: int | None = None,
) -> Packet:
"""Build a minimal valid packet with an OverallStop payload."""
return Packet(
placement=_placement(turn_index, tab_index, sub_turn_index),
obj=OverallStop(stop_reason="test"),
)
def _make_emitter(model_idx: int = 0) -> tuple["Emitter", "queue.Queue"]:
"""Return (emitter, queue) wired together."""
mq: queue.Queue = queue.Queue()
return Emitter(merged_queue=mq, model_idx=model_idx), mq
# ---------------------------------------------------------------------------
# Queue routing
# ---------------------------------------------------------------------------
class TestEmitterQueueRouting:
def test_emit_lands_on_merged_queue(self) -> None:
emitter, mq = _make_emitter()
emitter.emit(_packet())
assert not mq.empty()
def test_queue_item_is_tuple_of_key_and_packet(self) -> None:
emitter, mq = _make_emitter(model_idx=1)
emitter.emit(_packet())
item = mq.get_nowait()
assert isinstance(item, tuple)
assert len(item) == 2
def test_multiple_packets_delivered_fifo(self) -> None:
emitter, mq = _make_emitter()
p1 = _packet(turn_index=0)
p2 = _packet(turn_index=1)
emitter.emit(p1)
emitter.emit(p2)
_, t1 = mq.get_nowait()
_, t2 = mq.get_nowait()
assert t1.placement.turn_index == 0
assert t2.placement.turn_index == 1
# ---------------------------------------------------------------------------
# model_index tagging
# ---------------------------------------------------------------------------
class TestEmitterModelIndexTagging:
def test_n1_default_model_idx_tags_model_index_zero(self) -> None:
"""N=1: default model_idx=0, so packet gets model_index=0."""
emitter, mq = _make_emitter(model_idx=0)
emitter.emit(_packet())
_key, tagged = mq.get_nowait()
assert tagged.placement.model_index == 0
def test_model_idx_one_tags_packet(self) -> None:
emitter, mq = _make_emitter(model_idx=1)
emitter.emit(_packet())
_key, tagged = mq.get_nowait()
assert tagged.placement.model_index == 1
def test_model_idx_two_tags_packet(self) -> None:
"""Boundary: third model in a 3-model run."""
emitter, mq = _make_emitter(model_idx=2)
emitter.emit(_packet())
_key, tagged = mq.get_nowait()
assert tagged.placement.model_index == 2
# ---------------------------------------------------------------------------
# Queue key
# ---------------------------------------------------------------------------
class TestEmitterQueueKey:
def test_key_equals_model_idx(self) -> None:
"""Drain loop uses the key to route packets; it must match model_idx."""
emitter, mq = _make_emitter(model_idx=2)
emitter.emit(_packet())
key, _ = mq.get_nowait()
assert key == 2
def test_n1_key_is_zero(self) -> None:
emitter, mq = _make_emitter(model_idx=0)
emitter.emit(_packet())
key, _ = mq.get_nowait()
assert key == 0
# ---------------------------------------------------------------------------
# Placement field preservation
# ---------------------------------------------------------------------------
class TestEmitterPlacementPreservation:
def test_turn_index_is_preserved(self) -> None:
emitter, mq = _make_emitter()
emitter.emit(_packet(turn_index=5))
_, tagged = mq.get_nowait()
assert tagged.placement.turn_index == 5
def test_tab_index_is_preserved(self) -> None:
emitter, mq = _make_emitter()
emitter.emit(_packet(tab_index=3))
_, tagged = mq.get_nowait()
assert tagged.placement.tab_index == 3
def test_sub_turn_index_is_preserved(self) -> None:
emitter, mq = _make_emitter()
emitter.emit(_packet(sub_turn_index=2))
_, tagged = mq.get_nowait()
assert tagged.placement.sub_turn_index == 2
def test_sub_turn_index_none_is_preserved(self) -> None:
emitter, mq = _make_emitter()
emitter.emit(_packet(sub_turn_index=None))
_, tagged = mq.get_nowait()
assert tagged.placement.sub_turn_index is None
def test_packet_obj_is_not_modified(self) -> None:
"""The payload object must survive tagging untouched."""
emitter, mq = _make_emitter()
original_obj = OverallStop(stop_reason="sentinel")
pkt = Packet(placement=_placement(), obj=original_obj)
emitter.emit(pkt)
_, tagged = mq.get_nowait()
assert tagged.obj is original_obj
def test_different_obj_types_are_handled(self) -> None:
"""Any valid PacketObj type passes through correctly."""
emitter, mq = _make_emitter()
pkt = Packet(placement=_placement(), obj=ReasoningStart())
emitter.emit(pkt)
_, tagged = mq.get_nowait()
assert isinstance(tagged.obj, ReasoningStart)

View File

@@ -0,0 +1,676 @@
"""Unit tests for multi-model streaming validation and DB helpers.
These are pure unit tests — no real database or LLM calls required.
The validation logic in handle_multi_model_stream fires before any external
calls, so we can trigger it with lightweight mocks.
"""
import time
from collections.abc import Generator
from typing import Any
from typing import cast
from unittest.mock import MagicMock
from unittest.mock import patch
from uuid import uuid4
import pytest
from onyx.chat.models import StreamingError
from onyx.configs.constants import MessageType
from onyx.db.chat import set_preferred_response
from onyx.llm.override_models import LLMOverride
from onyx.server.query_and_chat.models import SendMessageRequest
from onyx.server.query_and_chat.placement import Placement
from onyx.server.query_and_chat.streaming_models import OverallStop
from onyx.server.query_and_chat.streaming_models import Packet
from onyx.server.query_and_chat.streaming_models import ReasoningStart
from onyx.utils.variable_functionality import global_version
@pytest.fixture(autouse=True)
def _restore_ee_version() -> Generator[None, None, None]:
"""Reset EE global state after each test.
Importing onyx.chat.process_message triggers set_is_ee_based_on_env_variable()
(via the celery client import chain). Without this fixture, the EE flag stays
True for the rest of the session and breaks unrelated tests that mock Confluence
or other connectors and assume EE is disabled.
"""
original = global_version._is_ee
yield
global_version._is_ee = original
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_request(**kwargs: Any) -> SendMessageRequest:
defaults: dict[str, Any] = {
"message": "hello",
"chat_session_id": uuid4(),
}
defaults.update(kwargs)
return SendMessageRequest(**defaults)
def _make_override(provider: str = "openai", version: str = "gpt-4") -> LLMOverride:
return LLMOverride(model_provider=provider, model_version=version)
def _first_from_stream(req: SendMessageRequest, overrides: list[LLMOverride]) -> Any:
"""Return the first item yielded by handle_multi_model_stream."""
from onyx.chat.process_message import handle_multi_model_stream
user = MagicMock()
user.is_anonymous = False
user.email = "test@example.com"
db = MagicMock()
gen = handle_multi_model_stream(req, user, db, overrides)
return next(gen)
# ---------------------------------------------------------------------------
# handle_multi_model_stream — validation
# ---------------------------------------------------------------------------
class TestRunMultiModelStreamValidation:
def test_single_override_yields_error(self) -> None:
"""Exactly 1 override is not multi-model — yields StreamingError."""
req = _make_request()
result = _first_from_stream(req, [_make_override()])
assert isinstance(result, StreamingError)
assert "2-3" in result.error
def test_four_overrides_yields_error(self) -> None:
"""4 overrides exceeds maximum — yields StreamingError."""
req = _make_request()
result = _first_from_stream(
req,
[
_make_override("openai", "gpt-4"),
_make_override("anthropic", "claude-3"),
_make_override("google", "gemini-pro"),
_make_override("cohere", "command-r"),
],
)
assert isinstance(result, StreamingError)
assert "2-3" in result.error
def test_zero_overrides_yields_error(self) -> None:
"""Empty override list yields StreamingError."""
req = _make_request()
result = _first_from_stream(req, [])
assert isinstance(result, StreamingError)
assert "2-3" in result.error
def test_deep_research_yields_error(self) -> None:
"""deep_research=True is incompatible with multi-model — yields StreamingError."""
req = _make_request(deep_research=True)
result = _first_from_stream(
req, [_make_override(), _make_override("anthropic", "claude-3")]
)
assert isinstance(result, StreamingError)
assert "not supported" in result.error
def test_exactly_two_overrides_is_minimum(self) -> None:
"""Boundary: 1 override yields error, 2 overrides passes validation."""
req = _make_request()
# 1 override must yield a StreamingError
result = _first_from_stream(req, [_make_override()])
assert isinstance(
result, StreamingError
), "1 override should yield StreamingError"
# 2 overrides must NOT yield a validation StreamingError (may raise later due to
# missing session, that's OK — validation itself passed)
try:
result2 = _first_from_stream(
req, [_make_override(), _make_override("anthropic", "claude-3")]
)
if isinstance(result2, StreamingError) and "2-3" in result2.error:
pytest.fail(
f"2 overrides should pass validation, got StreamingError: {result2.error}"
)
except Exception:
pass # Any non-validation error means validation passed
# ---------------------------------------------------------------------------
# set_preferred_response — validation (mocked db)
# ---------------------------------------------------------------------------
class TestSetPreferredResponseValidation:
def test_user_message_not_found(self) -> None:
db = MagicMock()
db.get.return_value = None
with pytest.raises(ValueError, match="not found"):
set_preferred_response(
db, user_message_id=999, preferred_assistant_message_id=1
)
def test_wrong_message_type(self) -> None:
"""Cannot set preferred response on a non-USER message."""
db = MagicMock()
user_msg = MagicMock()
user_msg.message_type = MessageType.ASSISTANT # wrong type
db.get.return_value = user_msg
with pytest.raises(ValueError, match="not a user message"):
set_preferred_response(
db, user_message_id=1, preferred_assistant_message_id=2
)
def test_assistant_message_not_found(self) -> None:
db = MagicMock()
user_msg = MagicMock()
user_msg.message_type = MessageType.USER
# First call returns user_msg, second call (for assistant) returns None
db.get.side_effect = [user_msg, None]
with pytest.raises(ValueError, match="not found"):
set_preferred_response(
db, user_message_id=1, preferred_assistant_message_id=2
)
def test_assistant_not_child_of_user(self) -> None:
db = MagicMock()
user_msg = MagicMock()
user_msg.message_type = MessageType.USER
assistant_msg = MagicMock()
assistant_msg.parent_message_id = 999 # different parent
db.get.side_effect = [user_msg, assistant_msg]
with pytest.raises(ValueError, match="not a child"):
set_preferred_response(
db, user_message_id=1, preferred_assistant_message_id=2
)
def test_valid_call_sets_preferred_response_id(self) -> None:
db = MagicMock()
user_msg = MagicMock()
user_msg.message_type = MessageType.USER
assistant_msg = MagicMock()
assistant_msg.parent_message_id = 1 # correct parent
db.get.side_effect = [user_msg, assistant_msg]
set_preferred_response(db, user_message_id=1, preferred_assistant_message_id=2)
assert user_msg.preferred_response_id == 2
assert user_msg.latest_child_message_id == 2
# ---------------------------------------------------------------------------
# LLMOverride — display_name field
# ---------------------------------------------------------------------------
class TestLLMOverrideDisplayName:
def test_display_name_defaults_none(self) -> None:
override = LLMOverride(model_provider="openai", model_version="gpt-4")
assert override.display_name is None
def test_display_name_set(self) -> None:
override = LLMOverride(
model_provider="openai",
model_version="gpt-4",
display_name="GPT-4 Turbo",
)
assert override.display_name == "GPT-4 Turbo"
def test_display_name_serializes(self) -> None:
override = LLMOverride(
model_provider="anthropic",
model_version="claude-opus-4-6",
display_name="Claude Opus",
)
d = override.model_dump()
assert d["display_name"] == "Claude Opus"
# ---------------------------------------------------------------------------
# _run_models — drain loop behaviour
# ---------------------------------------------------------------------------
def _make_setup(n_models: int = 1) -> MagicMock:
"""Minimal ChatTurnSetup mock whose fields pass Pydantic validation in _run_model."""
setup = MagicMock()
setup.llms = [MagicMock() for _ in range(n_models)]
setup.model_display_names = [f"model-{i}" for i in range(n_models)]
setup.check_is_connected = MagicMock(return_value=True)
setup.reserved_messages = [MagicMock() for _ in range(n_models)]
setup.reserved_token_count = 100
# Fields consumed by SearchToolConfig / CustomToolConfig / FileReaderToolConfig
# constructors inside _run_model — must be typed correctly for Pydantic.
setup.new_msg_req.deep_research = False
setup.new_msg_req.internal_search_filters = None
setup.new_msg_req.allowed_tool_ids = None
setup.new_msg_req.include_citations = True
setup.search_params.project_id_filter = None
setup.search_params.persona_id_filter = None
setup.bypass_acl = False
setup.slack_context = None
setup.available_files.user_file_ids = []
setup.available_files.chat_file_ids = []
setup.forced_tool_id = None
setup.simple_chat_history = []
setup.chat_session.id = uuid4()
setup.user_message.id = None
setup.custom_tool_additional_headers = None
setup.mcp_headers = None
return setup
_RUN_MODELS_PATCHES = [
patch("onyx.chat.process_message.run_llm_loop"),
patch("onyx.chat.process_message.run_deep_research_llm_loop"),
patch("onyx.chat.process_message.construct_tools", return_value={}),
patch("onyx.chat.process_message.get_session_with_current_tenant"),
patch("onyx.chat.process_message.llm_loop_completion_handle"),
patch("onyx.chat.process_message.get_llm_token_counter", return_value=lambda _: 0),
]
def _run_models_collect(setup: MagicMock) -> list:
"""Drive _run_models to completion and return all yielded items."""
from onyx.chat.process_message import _run_models
return list(_run_models(setup, MagicMock(), MagicMock()))
class TestRunModels:
"""Tests for the _run_models worker-thread drain loop.
All external dependencies (LLM, DB, tools) are patched out. Worker threads
still run but return immediately since run_llm_loop is mocked.
"""
def test_n1_overall_stop_from_llm_loop_passes_through(self) -> None:
"""OverallStop emitted by run_llm_loop is passed through the drain loop unchanged."""
def emit_stop(**kwargs: Any) -> None:
kwargs["emitter"].emit(
Packet(
placement=Placement(turn_index=0),
obj=OverallStop(stop_reason="complete"),
)
)
with (
patch("onyx.chat.process_message.run_llm_loop", side_effect=emit_stop),
patch("onyx.chat.process_message.run_deep_research_llm_loop"),
patch("onyx.chat.process_message.construct_tools", return_value={}),
patch("onyx.chat.process_message.get_session_with_current_tenant"),
patch("onyx.chat.process_message.llm_loop_completion_handle"),
patch(
"onyx.chat.process_message.get_llm_token_counter",
return_value=lambda _: 0,
),
):
packets = _run_models_collect(_make_setup(n_models=1))
stops = [
p
for p in packets
if isinstance(p, Packet) and isinstance(p.obj, OverallStop)
]
assert len(stops) == 1
stop_obj = stops[0].obj
assert isinstance(stop_obj, OverallStop)
assert stop_obj.stop_reason == "complete"
def test_n1_emitted_packet_has_model_index_zero(self) -> None:
"""Single-model path: model_index is 0 (Emitter defaults model_idx=0)."""
def emit_one(**kwargs: Any) -> None:
kwargs["emitter"].emit(
Packet(placement=Placement(turn_index=0), obj=ReasoningStart())
)
with (
patch("onyx.chat.process_message.run_llm_loop", side_effect=emit_one),
patch("onyx.chat.process_message.run_deep_research_llm_loop"),
patch("onyx.chat.process_message.construct_tools", return_value={}),
patch("onyx.chat.process_message.get_session_with_current_tenant"),
patch("onyx.chat.process_message.llm_loop_completion_handle"),
patch(
"onyx.chat.process_message.get_llm_token_counter",
return_value=lambda _: 0,
),
):
packets = _run_models_collect(_make_setup(n_models=1))
reasoning = [
p
for p in packets
if isinstance(p, Packet) and isinstance(p.obj, ReasoningStart)
]
assert len(reasoning) == 1
assert reasoning[0].placement.model_index == 0
def test_n2_each_model_packet_tagged_with_its_index(self) -> None:
"""Multi-model path: packets from model 0 get index=0, model 1 gets index=1."""
def emit_one(**kwargs: Any) -> None:
# _model_idx is set by _run_model based on position in setup.llms
emitter = kwargs["emitter"]
emitter.emit(
Packet(placement=Placement(turn_index=0), obj=ReasoningStart())
)
with (
patch("onyx.chat.process_message.run_llm_loop", side_effect=emit_one),
patch("onyx.chat.process_message.run_deep_research_llm_loop"),
patch("onyx.chat.process_message.construct_tools", return_value={}),
patch("onyx.chat.process_message.get_session_with_current_tenant"),
patch("onyx.chat.process_message.llm_loop_completion_handle"),
patch(
"onyx.chat.process_message.get_llm_token_counter",
return_value=lambda _: 0,
),
):
packets = _run_models_collect(_make_setup(n_models=2))
reasoning = [
p
for p in packets
if isinstance(p, Packet) and isinstance(p.obj, ReasoningStart)
]
assert len(reasoning) == 2
indices = {p.placement.model_index for p in reasoning}
assert indices == {0, 1}
def test_model_error_yields_streaming_error(self) -> None:
"""An exception inside a worker thread is surfaced as a StreamingError."""
def always_fail(**_kwargs: Any) -> None:
raise RuntimeError("intentional test failure")
with (
patch("onyx.chat.process_message.run_llm_loop", side_effect=always_fail),
patch("onyx.chat.process_message.run_deep_research_llm_loop"),
patch("onyx.chat.process_message.construct_tools", return_value={}),
patch("onyx.chat.process_message.get_session_with_current_tenant"),
patch("onyx.chat.process_message.llm_loop_completion_handle"),
patch(
"onyx.chat.process_message.get_llm_token_counter",
return_value=lambda _: 0,
),
):
packets = _run_models_collect(_make_setup(n_models=1))
errors = [p for p in packets if isinstance(p, StreamingError)]
assert len(errors) == 1
assert errors[0].error_code == "MODEL_ERROR"
assert "intentional test failure" in errors[0].error
def test_one_model_error_does_not_stop_other_models(self) -> None:
"""A failing model yields StreamingError; the surviving model's packets still arrive."""
def fail_model_0_succeed_model_1(**kwargs: Any) -> None:
emitter = kwargs["emitter"]
# _model_idx is always int (0 for N=1, 0/1/2… for N>1)
if emitter._model_idx == 0:
raise RuntimeError("model 0 failed")
emitter.emit(
Packet(placement=Placement(turn_index=0), obj=ReasoningStart())
)
with (
patch(
"onyx.chat.process_message.run_llm_loop",
side_effect=fail_model_0_succeed_model_1,
),
patch("onyx.chat.process_message.run_deep_research_llm_loop"),
patch("onyx.chat.process_message.construct_tools", return_value={}),
patch("onyx.chat.process_message.get_session_with_current_tenant"),
patch("onyx.chat.process_message.llm_loop_completion_handle"),
patch(
"onyx.chat.process_message.get_llm_token_counter",
return_value=lambda _: 0,
),
):
packets = _run_models_collect(_make_setup(n_models=2))
errors = [p for p in packets if isinstance(p, StreamingError)]
assert len(errors) == 1
reasoning = [
p
for p in packets
if isinstance(p, Packet) and isinstance(p.obj, ReasoningStart)
]
assert len(reasoning) == 1
assert reasoning[0].placement.model_index == 1
def test_cancellation_yields_user_cancelled_stop(self) -> None:
"""If check_is_connected returns False, drain loop emits user_cancelled."""
def slow_llm(**_kwargs: Any) -> None:
time.sleep(0.3) # Outlasts the 50 ms queue-poll interval
setup = _make_setup(n_models=1)
setup.check_is_connected = MagicMock(return_value=False)
with (
patch("onyx.chat.process_message.run_llm_loop", side_effect=slow_llm),
patch("onyx.chat.process_message.run_deep_research_llm_loop"),
patch("onyx.chat.process_message.construct_tools", return_value={}),
patch("onyx.chat.process_message.get_session_with_current_tenant"),
patch("onyx.chat.process_message.llm_loop_completion_handle"),
patch(
"onyx.chat.process_message.get_llm_token_counter",
return_value=lambda _: 0,
),
):
packets = _run_models_collect(setup)
stops = [
p
for p in packets
if isinstance(p, Packet) and isinstance(p.obj, OverallStop)
]
assert any(
isinstance(s.obj, OverallStop) and s.obj.stop_reason == "user_cancelled"
for s in stops
)
def test_completion_handle_called_on_disconnect(self) -> None:
"""llm_loop_completion_handle must still be called even when user disconnects.
Regression test for the disconnect-cleanup bug: the old
run_chat_loop_with_state_containers always called completion_callback in
its finally block (even on disconnect) so the DB message was updated from
the TERMINATED placeholder to a partial answer. The new _run_models must
replicate this — otherwise the integration test
test_send_message_disconnect_and_cleanup fails because the message stays
as "Response was terminated prior to completion, try regenerating."
"""
def slow_llm(**_kwargs: Any) -> None:
time.sleep(0.3)
setup = _make_setup(n_models=2)
setup.check_is_connected = MagicMock(return_value=False)
with (
patch("onyx.chat.process_message.run_llm_loop", side_effect=slow_llm),
patch("onyx.chat.process_message.run_deep_research_llm_loop"),
patch("onyx.chat.process_message.construct_tools", return_value={}),
patch("onyx.chat.process_message.get_session_with_current_tenant"),
patch(
"onyx.chat.process_message.llm_loop_completion_handle"
) as mock_handle,
patch(
"onyx.chat.process_message.get_llm_token_counter",
return_value=lambda _: 0,
),
):
_run_models_collect(setup)
# Must be called once per model, not zero times
assert mock_handle.call_count == 2
def test_completion_handle_called_for_each_successful_model(self) -> None:
"""llm_loop_completion_handle must be called once per model that succeeded."""
setup = _make_setup(n_models=2)
with (
patch("onyx.chat.process_message.run_llm_loop"),
patch("onyx.chat.process_message.run_deep_research_llm_loop"),
patch("onyx.chat.process_message.construct_tools", return_value={}),
patch("onyx.chat.process_message.get_session_with_current_tenant"),
patch(
"onyx.chat.process_message.llm_loop_completion_handle"
) as mock_handle,
patch(
"onyx.chat.process_message.get_llm_token_counter",
return_value=lambda _: 0,
),
):
_run_models_collect(setup)
assert mock_handle.call_count == 2
def test_completion_handle_not_called_for_failed_model(self) -> None:
"""llm_loop_completion_handle must be skipped for a model that raised."""
def always_fail(**_kwargs: Any) -> None:
raise RuntimeError("fail")
with (
patch("onyx.chat.process_message.run_llm_loop", side_effect=always_fail),
patch("onyx.chat.process_message.run_deep_research_llm_loop"),
patch("onyx.chat.process_message.construct_tools", return_value={}),
patch("onyx.chat.process_message.get_session_with_current_tenant"),
patch(
"onyx.chat.process_message.llm_loop_completion_handle"
) as mock_handle,
patch(
"onyx.chat.process_message.get_llm_token_counter",
return_value=lambda _: 0,
),
):
_run_models_collect(_make_setup(n_models=1))
mock_handle.assert_not_called()
def test_http_disconnect_completion_via_generator_exit(self) -> None:
"""GeneratorExit from HTTP disconnect triggers worker self-completion.
When the HTTP client closes the connection, Starlette throws GeneratorExit
into the stream generator. The finally block sets drain_done (signalling
emitters to stop blocking) and calls executor.shutdown(wait=False) so the
server thread is never blocked. Worker threads detect drain_done.is_set()
after run_llm_loop completes and self-persist the result via
llm_loop_completion_handle using their own DB session.
This is the primary regression for test_send_message_disconnect_and_cleanup:
the integration test disconnects mid-stream and expects the DB message to be
updated from the TERMINATED placeholder to the real response.
"""
import threading
# Signals the worker to unblock from run_llm_loop after gen.close() returns.
# This guarantees drain_done is set BEFORE the worker returns from run_llm_loop,
# so the self-completion path (drain_done.is_set() check) is always taken.
disconnect_received = threading.Event()
# Set by the llm_loop_completion_handle mock when called.
completion_called = threading.Event()
def emit_then_complete(**kwargs: Any) -> None:
"""Emit one packet (to give the drain loop a yield point), then block
until the main thread signals that gen.close() has been called. This
ensures drain_done is set before we return so model_succeeded is checked
against a set drain_done — no race condition.
"""
emitter = kwargs["emitter"]
emitter.emit(
Packet(placement=Placement(turn_index=0), obj=ReasoningStart())
)
disconnect_received.wait(timeout=5)
setup = _make_setup(n_models=1)
# is_connected() always True — HTTP disconnect does NOT set the Redis stop fence.
setup.check_is_connected = MagicMock(return_value=True)
with (
patch(
"onyx.chat.process_message.run_llm_loop",
side_effect=emit_then_complete,
),
patch("onyx.chat.process_message.run_deep_research_llm_loop"),
patch("onyx.chat.process_message.construct_tools", return_value={}),
patch("onyx.chat.process_message.get_session_with_current_tenant"),
patch(
"onyx.chat.process_message.llm_loop_completion_handle",
side_effect=lambda *_, **__: completion_called.set(),
) as mock_handle,
patch(
"onyx.chat.process_message.get_llm_token_counter",
return_value=lambda _: 0,
),
):
from onyx.chat.process_message import _run_models
# cast to Generator so .close() is available; _run_models returns
# AnswerStream (= Iterator) but the actual object is always a generator.
gen = cast(Generator, _run_models(setup, MagicMock(), MagicMock()))
# Advance to the first yielded packet — generator suspends at `yield item`.
first = next(gen)
assert isinstance(first, Packet)
# Simulate Starlette closing the stream on HTTP client disconnect.
# GeneratorExit is thrown at the `yield item` suspension point.
gen.close()
# Unblock the worker now that drain_done has been set by gen.close().
disconnect_received.set()
# Worker self-completes asynchronously (executor.shutdown(wait=False)).
# Wait here, inside the patch context, so that get_session_with_current_tenant
# and llm_loop_completion_handle mocks are still active when the worker calls them.
assert completion_called.wait(
timeout=5
), "worker must self-complete via drain_done within 5 seconds"
assert (
mock_handle.call_count == 1
), "completion handle must be called once for the successful model"
def test_external_state_container_used_for_model_zero(self) -> None:
"""When provided, external_state_container is used as state_containers[0]."""
from onyx.chat.chat_state import ChatStateContainer
from onyx.chat.process_message import _run_models
external = ChatStateContainer()
setup = _make_setup(n_models=1)
with (
patch("onyx.chat.process_message.run_llm_loop") as mock_llm,
patch("onyx.chat.process_message.run_deep_research_llm_loop"),
patch("onyx.chat.process_message.construct_tools", return_value={}),
patch("onyx.chat.process_message.get_session_with_current_tenant"),
patch("onyx.chat.process_message.llm_loop_completion_handle"),
patch(
"onyx.chat.process_message.get_llm_token_counter",
return_value=lambda _: 0,
),
):
list(
_run_models(
setup, MagicMock(), MagicMock(), external_state_container=external
)
)
# The state_container kwarg passed to run_llm_loop must be the external one
call_kwargs = mock_llm.call_args.kwargs
assert call_kwargs["state_container"] is external

View File

@@ -0,0 +1,223 @@
from unittest.mock import MagicMock
from unittest.mock import patch
from onyx.access.models import DocumentAccess
from onyx.configs.constants import DocumentSource
from onyx.connectors.models import Document
from onyx.connectors.models import TextSection
from onyx.document_index.interfaces_new import IndexingMetadata
from onyx.document_index.interfaces_new import TenantState
from onyx.document_index.opensearch.opensearch_document_index import (
OpenSearchDocumentIndex,
)
from onyx.indexing.models import ChunkEmbedding
from onyx.indexing.models import DocMetadataAwareIndexChunk
def _make_chunk(
doc_id: str,
chunk_id: int,
) -> DocMetadataAwareIndexChunk:
"""Creates a minimal DocMetadataAwareIndexChunk for testing."""
doc = Document(
id=doc_id,
sections=[TextSection(text="test", link="http://test.com")],
source=DocumentSource.FILE,
semantic_identifier="test_doc",
metadata={},
)
access = DocumentAccess.build(
user_emails=[],
user_groups=[],
external_user_emails=[],
external_user_group_ids=[],
is_public=True,
)
return DocMetadataAwareIndexChunk(
chunk_id=chunk_id,
blurb="test",
content="test content",
source_links={0: "http://test.com"},
image_file_id=None,
section_continuation=False,
source_document=doc,
title_prefix="",
metadata_suffix_semantic="",
metadata_suffix_keyword="",
mini_chunk_texts=None,
large_chunk_id=None,
doc_summary="",
chunk_context="",
contextual_rag_reserved_tokens=0,
embeddings=ChunkEmbedding(full_embedding=[0.1] * 10, mini_chunk_embeddings=[]),
title_embedding=[0.1] * 10,
tenant_id="test_tenant",
access=access,
document_sets=set(),
user_project=[],
personas=[],
boost=0,
aggregated_chunk_boost_factor=1.0,
ancestor_hierarchy_node_ids=[],
)
def _make_index() -> tuple[OpenSearchDocumentIndex, MagicMock]:
"""Creates an OpenSearchDocumentIndex with a mocked client.
Returns the index and the mock for bulk_index_documents."""
mock_client = MagicMock()
mock_bulk = MagicMock()
mock_client.bulk_index_documents = mock_bulk
tenant_state = TenantState(tenant_id="test_tenant", multitenant=False)
index = OpenSearchDocumentIndex.__new__(OpenSearchDocumentIndex)
index._index_name = "test_index"
index._client = mock_client
index._tenant_state = tenant_state
return index, mock_bulk
def _make_metadata(doc_id: str, chunk_count: int) -> IndexingMetadata:
return IndexingMetadata(
doc_id_to_chunk_cnt_diff={
doc_id: IndexingMetadata.ChunkCounts(
old_chunk_cnt=0,
new_chunk_cnt=chunk_count,
),
},
)
@patch(
"onyx.document_index.opensearch.opensearch_document_index.MAX_CHUNKS_PER_DOC_BATCH",
100,
)
def test_single_doc_under_batch_limit_flushes_once() -> None:
"""A document with fewer chunks than MAX_CHUNKS_PER_DOC_BATCH should flush once."""
index, mock_bulk = _make_index()
doc_id = "doc_1"
num_chunks = 50
chunks = [_make_chunk(doc_id, i) for i in range(num_chunks)]
metadata = _make_metadata(doc_id, num_chunks)
with patch.object(index, "delete", return_value=0):
index.index(chunks, metadata)
assert mock_bulk.call_count == 1
batch_arg = mock_bulk.call_args_list[0]
assert len(batch_arg.kwargs["documents"]) == num_chunks
@patch(
"onyx.document_index.opensearch.opensearch_document_index.MAX_CHUNKS_PER_DOC_BATCH",
100,
)
def test_single_doc_over_batch_limit_flushes_multiple_times() -> None:
"""A document with more chunks than MAX_CHUNKS_PER_DOC_BATCH should flush multiple times."""
index, mock_bulk = _make_index()
doc_id = "doc_1"
num_chunks = 250
chunks = [_make_chunk(doc_id, i) for i in range(num_chunks)]
metadata = _make_metadata(doc_id, num_chunks)
with patch.object(index, "delete", return_value=0):
index.index(chunks, metadata)
# 250 chunks / 100 per batch = 3 flushes (100 + 100 + 50)
assert mock_bulk.call_count == 3
batch_sizes = [len(call.kwargs["documents"]) for call in mock_bulk.call_args_list]
assert batch_sizes == [100, 100, 50]
@patch(
"onyx.document_index.opensearch.opensearch_document_index.MAX_CHUNKS_PER_DOC_BATCH",
100,
)
def test_single_doc_exactly_at_batch_limit() -> None:
"""A document with exactly MAX_CHUNKS_PER_DOC_BATCH chunks should flush once
(the flush happens on the next chunk, not at the boundary)."""
index, mock_bulk = _make_index()
doc_id = "doc_1"
num_chunks = 100
chunks = [_make_chunk(doc_id, i) for i in range(num_chunks)]
metadata = _make_metadata(doc_id, num_chunks)
with patch.object(index, "delete", return_value=0):
index.index(chunks, metadata)
# 100 chunks hit the >= check on chunk 101 which doesn't exist,
# so final flush handles all 100
# Actually: the elif fires when len(current_chunks) >= 100, which happens
# when current_chunks has 100 items and the 101st chunk arrives.
# With exactly 100 chunks, the 100th chunk makes len == 99, then appended -> 100.
# No 101st chunk arrives, so the final flush handles all 100.
assert mock_bulk.call_count == 1
@patch(
"onyx.document_index.opensearch.opensearch_document_index.MAX_CHUNKS_PER_DOC_BATCH",
100,
)
def test_single_doc_one_over_batch_limit() -> None:
"""101 chunks for one doc: first 100 flushed when the 101st arrives, then
the 101st is flushed at the end."""
index, mock_bulk = _make_index()
doc_id = "doc_1"
num_chunks = 101
chunks = [_make_chunk(doc_id, i) for i in range(num_chunks)]
metadata = _make_metadata(doc_id, num_chunks)
with patch.object(index, "delete", return_value=0):
index.index(chunks, metadata)
assert mock_bulk.call_count == 2
batch_sizes = [len(call.kwargs["documents"]) for call in mock_bulk.call_args_list]
assert batch_sizes == [100, 1]
@patch(
"onyx.document_index.opensearch.opensearch_document_index.MAX_CHUNKS_PER_DOC_BATCH",
100,
)
def test_multiple_docs_each_under_limit_flush_per_doc() -> None:
"""Multiple documents each under the batch limit should flush once per document."""
index, mock_bulk = _make_index()
chunks = []
for doc_idx in range(3):
doc_id = f"doc_{doc_idx}"
for chunk_idx in range(50):
chunks.append(_make_chunk(doc_id, chunk_idx))
metadata = IndexingMetadata(
doc_id_to_chunk_cnt_diff={
f"doc_{i}": IndexingMetadata.ChunkCounts(old_chunk_cnt=0, new_chunk_cnt=50)
for i in range(3)
},
)
with patch.object(index, "delete", return_value=0):
index.index(chunks, metadata)
# 3 documents = 3 flushes (one per doc boundary + final)
assert mock_bulk.call_count == 3
@patch(
"onyx.document_index.opensearch.opensearch_document_index.MAX_CHUNKS_PER_DOC_BATCH",
100,
)
def test_delete_called_once_per_document() -> None:
"""Even with multiple flushes for a single document, delete should only be
called once per document."""
index, _mock_bulk = _make_index()
doc_id = "doc_1"
num_chunks = 250
chunks = [_make_chunk(doc_id, i) for i in range(num_chunks)]
metadata = _make_metadata(doc_id, num_chunks)
with patch.object(index, "delete", return_value=0) as mock_delete:
index.index(chunks, metadata)
mock_delete.assert_called_once_with(doc_id, None)

View File

@@ -0,0 +1,152 @@
"""Unit tests for VespaDocumentIndex.index().
These tests mock all external I/O (HTTP calls, thread pools) and verify
the streaming logic, ID cleaning/mapping, and DocumentInsertionRecord
construction.
"""
from unittest.mock import MagicMock
from unittest.mock import patch
from onyx.access.models import DocumentAccess
from onyx.configs.constants import DocumentSource
from onyx.connectors.models import Document
from onyx.connectors.models import TextSection
from onyx.document_index.interfaces import EnrichedDocumentIndexingInfo
from onyx.document_index.interfaces_new import IndexingMetadata
from onyx.document_index.interfaces_new import TenantState
from onyx.document_index.vespa.vespa_document_index import VespaDocumentIndex
from onyx.indexing.models import ChunkEmbedding
from onyx.indexing.models import DocMetadataAwareIndexChunk
from onyx.indexing.models import IndexChunk
def _make_chunk(
doc_id: str,
chunk_id: int = 0,
content: str = "test content",
) -> DocMetadataAwareIndexChunk:
doc = Document(
id=doc_id,
semantic_identifier="test_doc",
sections=[TextSection(text=content, link=None)],
source=DocumentSource.NOT_APPLICABLE,
metadata={},
)
index_chunk = IndexChunk(
chunk_id=chunk_id,
blurb=content[:50],
content=content,
source_links=None,
image_file_id=None,
section_continuation=False,
source_document=doc,
title_prefix="",
metadata_suffix_semantic="",
metadata_suffix_keyword="",
contextual_rag_reserved_tokens=0,
doc_summary="",
chunk_context="",
mini_chunk_texts=None,
large_chunk_id=None,
embeddings=ChunkEmbedding(
full_embedding=[0.1] * 10,
mini_chunk_embeddings=[],
),
title_embedding=None,
)
access = DocumentAccess.build(
user_emails=[],
user_groups=[],
external_user_emails=[],
external_user_group_ids=[],
is_public=True,
)
return DocMetadataAwareIndexChunk.from_index_chunk(
index_chunk=index_chunk,
access=access,
document_sets=set(),
user_project=[],
personas=[],
boost=0,
aggregated_chunk_boost_factor=1.0,
tenant_id="test_tenant",
)
def _make_indexing_metadata(
doc_ids: list[str],
old_counts: list[int],
new_counts: list[int],
) -> IndexingMetadata:
return IndexingMetadata(
doc_id_to_chunk_cnt_diff={
doc_id: IndexingMetadata.ChunkCounts(
old_chunk_cnt=old,
new_chunk_cnt=new,
)
for doc_id, old, new in zip(doc_ids, old_counts, new_counts)
}
)
def _stub_enrich(
doc_id: str,
old_chunk_cnt: int,
) -> EnrichedDocumentIndexingInfo:
"""Build an EnrichedDocumentIndexingInfo that says 'no chunks to delete'
when old_chunk_cnt == 0, or 'has existing chunks' otherwise."""
return EnrichedDocumentIndexingInfo(
doc_id=doc_id,
chunk_start_index=0,
old_version=False,
chunk_end_index=old_chunk_cnt,
)
@patch("onyx.document_index.vespa.vespa_document_index.batch_index_vespa_chunks")
@patch("onyx.document_index.vespa.vespa_document_index.delete_vespa_chunks")
@patch(
"onyx.document_index.vespa.vespa_document_index.get_document_chunk_ids",
return_value=[],
)
@patch("onyx.document_index.vespa.vespa_document_index._enrich_basic_chunk_info")
@patch(
"onyx.document_index.vespa.vespa_document_index.BATCH_SIZE",
3,
)
def test_index_respects_batch_size(
mock_enrich: MagicMock,
mock_get_chunk_ids: MagicMock, # noqa: ARG001
mock_delete: MagicMock, # noqa: ARG001
mock_batch_index: MagicMock,
) -> None:
"""When chunks exceed BATCH_SIZE, batch_index_vespa_chunks is called
multiple times with correctly sized batches."""
mock_enrich.return_value = _stub_enrich("doc1", old_chunk_cnt=0)
index = VespaDocumentIndex(
index_name="test_index",
tenant_state=TenantState(tenant_id="test_tenant", multitenant=False),
large_chunks_enabled=False,
httpx_client=MagicMock(),
)
chunks = [_make_chunk("doc1", chunk_id=i) for i in range(7)]
metadata = _make_indexing_metadata(["doc1"], old_counts=[0], new_counts=[7])
results = index.index(chunks=chunks, indexing_metadata=metadata)
assert len(results) == 1
# With BATCH_SIZE=3 and 7 chunks: batches of 3, 3, 1
assert mock_batch_index.call_count == 3
batch_sizes = [len(c.kwargs["chunks"]) for c in mock_batch_index.call_args_list]
assert batch_sizes == [3, 3, 1]
# Verify all chunks are accounted for and in order
all_indexed = [
chunk for c in mock_batch_index.call_args_list for chunk in c.kwargs["chunks"]
]
assert len(all_indexed) == 7
assert [c.chunk_id for c in all_indexed] == list(range(7))

View File

@@ -0,0 +1,391 @@
"""Unit tests for _embed_chunks_to_store.
Tests cover:
- Single batch, no failures
- Multiple batches, no failures
- Failure in a single batch
- Cross-batch document failure scrubbing
- Later batches skip already-failed docs
- Empty input
- All chunks fail
"""
from collections.abc import Callable
from unittest.mock import MagicMock
from unittest.mock import patch
from onyx.connectors.models import ConnectorFailure
from onyx.connectors.models import Document
from onyx.connectors.models import DocumentFailure
from onyx.connectors.models import DocumentSource
from onyx.connectors.models import TextSection
from onyx.indexing.chunk_batch_store import ChunkBatchStore
from onyx.indexing.indexing_pipeline import _embed_chunks_to_store
from onyx.indexing.models import ChunkEmbedding
from onyx.indexing.models import DocAwareChunk
from onyx.indexing.models import IndexChunk
def _make_doc(doc_id: str) -> Document:
return Document(
id=doc_id,
semantic_identifier="test",
source=DocumentSource.FILE,
sections=[TextSection(text="test", link=None)],
metadata={},
)
def _make_chunk(doc_id: str, chunk_id: int) -> DocAwareChunk:
return DocAwareChunk(
chunk_id=chunk_id,
blurb="test",
content="test content",
source_links=None,
image_file_id=None,
section_continuation=False,
source_document=_make_doc(doc_id),
title_prefix="",
metadata_suffix_semantic="",
metadata_suffix_keyword="",
mini_chunk_texts=None,
large_chunk_id=None,
doc_summary="",
chunk_context="",
contextual_rag_reserved_tokens=0,
)
def _make_index_chunk(doc_id: str, chunk_id: int) -> IndexChunk:
"""Create an IndexChunk (a DocAwareChunk with embeddings)."""
return IndexChunk(
chunk_id=chunk_id,
blurb="test",
content="test content",
source_links=None,
image_file_id=None,
section_continuation=False,
source_document=_make_doc(doc_id),
title_prefix="",
metadata_suffix_semantic="",
metadata_suffix_keyword="",
mini_chunk_texts=None,
large_chunk_id=None,
doc_summary="",
chunk_context="",
contextual_rag_reserved_tokens=0,
embeddings=ChunkEmbedding(
full_embedding=[0.1] * 10,
mini_chunk_embeddings=[],
),
title_embedding=None,
)
def _make_failure(doc_id: str) -> ConnectorFailure:
return ConnectorFailure(
failed_document=DocumentFailure(document_id=doc_id, document_link=None),
failure_message="embedding failed",
exception=RuntimeError("embedding failed"),
)
def _mock_embed_success(
chunks: list[DocAwareChunk], **_kwargs: object
) -> tuple[list[IndexChunk], list[ConnectorFailure]]:
"""Simulate successful embedding of all chunks."""
return (
[_make_index_chunk(c.source_document.id, c.chunk_id) for c in chunks],
[],
)
def _mock_embed_fail_doc(
fail_doc_id: str,
) -> Callable[..., tuple[list[IndexChunk], list[ConnectorFailure]]]:
"""Return an embed mock that fails all chunks for a specific doc."""
def _embed(
chunks: list[DocAwareChunk], **_kwargs: object
) -> tuple[list[IndexChunk], list[ConnectorFailure]]:
successes = [
_make_index_chunk(c.source_document.id, c.chunk_id)
for c in chunks
if c.source_document.id != fail_doc_id
]
failures = (
[_make_failure(fail_doc_id)]
if any(c.source_document.id == fail_doc_id for c in chunks)
else []
)
return successes, failures
return _embed
class TestEmbedChunksInBatches:
@patch(
"onyx.indexing.indexing_pipeline.embed_chunks_with_failure_handling",
)
@patch("onyx.indexing.indexing_pipeline.MAX_CHUNKS_PER_DOC_BATCH", 100)
def test_single_batch_no_failures(self, mock_embed: MagicMock) -> None:
"""All chunks fit in one batch and embed successfully."""
mock_embed.side_effect = _mock_embed_success
with ChunkBatchStore() as store:
chunks = [_make_chunk("doc1", i) for i in range(3)]
result = _embed_chunks_to_store(
chunks=chunks,
embedder=MagicMock(),
tenant_id="test",
request_id=None,
store=store,
)
assert len(result.successful_chunk_ids) == 3
assert len(result.connector_failures) == 0
# Verify stored contents
assert len(store._batch_files()) == 1
stored = list(store.stream())
assert len(stored) == 3
@patch(
"onyx.indexing.indexing_pipeline.embed_chunks_with_failure_handling",
)
@patch("onyx.indexing.indexing_pipeline.MAX_CHUNKS_PER_DOC_BATCH", 3)
def test_multiple_batches_no_failures(self, mock_embed: MagicMock) -> None:
"""Chunks are split across multiple batches, all succeed."""
mock_embed.side_effect = _mock_embed_success
with ChunkBatchStore() as store:
chunks = [_make_chunk("doc1", i) for i in range(7)]
result = _embed_chunks_to_store(
chunks=chunks,
embedder=MagicMock(),
tenant_id="test",
request_id=None,
store=store,
)
assert len(result.successful_chunk_ids) == 7
assert len(result.connector_failures) == 0
assert len(store._batch_files()) == 3 # 3 + 3 + 1
@patch(
"onyx.indexing.indexing_pipeline.embed_chunks_with_failure_handling",
)
@patch("onyx.indexing.indexing_pipeline.MAX_CHUNKS_PER_DOC_BATCH", 100)
def test_single_batch_with_failure(self, mock_embed: MagicMock) -> None:
"""One doc fails embedding, its chunks are excluded from results."""
mock_embed.side_effect = _mock_embed_fail_doc("doc2")
with ChunkBatchStore() as store:
chunks = [
_make_chunk("doc1", 0),
_make_chunk("doc2", 1),
_make_chunk("doc1", 2),
]
result = _embed_chunks_to_store(
chunks=chunks,
embedder=MagicMock(),
tenant_id="test",
request_id=None,
store=store,
)
assert len(result.connector_failures) == 1
successful_doc_ids = {doc_id for _, doc_id in result.successful_chunk_ids}
assert "doc2" not in successful_doc_ids
assert "doc1" in successful_doc_ids
@patch(
"onyx.indexing.indexing_pipeline.embed_chunks_with_failure_handling",
)
@patch("onyx.indexing.indexing_pipeline.MAX_CHUNKS_PER_DOC_BATCH", 3)
def test_cross_batch_failure_scrubs_earlier_batch(
self, mock_embed: MagicMock
) -> None:
"""Doc A spans batches 0 and 1. It succeeds in batch 0 but fails in
batch 1. Its chunks should be scrubbed from batch 0's batch file."""
call_count = 0
def _embed(
chunks: list[DocAwareChunk], **_kwargs: object
) -> tuple[list[IndexChunk], list[ConnectorFailure]]:
nonlocal call_count
call_count += 1
if call_count == 1:
return _mock_embed_success(chunks)
else:
return _mock_embed_fail_doc("docA")(chunks)
mock_embed.side_effect = _embed
with ChunkBatchStore() as store:
chunks = [
_make_chunk("docA", 0),
_make_chunk("docA", 1),
_make_chunk("docA", 2),
_make_chunk("docA", 3),
_make_chunk("docB", 0),
_make_chunk("docB", 1),
]
result = _embed_chunks_to_store(
chunks=chunks,
embedder=MagicMock(),
tenant_id="test",
request_id=None,
store=store,
)
# docA should be fully excluded from results
successful_doc_ids = {doc_id for _, doc_id in result.successful_chunk_ids}
assert "docA" not in successful_doc_ids
assert "docB" in successful_doc_ids
assert len(result.connector_failures) == 1
# Verify batch 0 was scrubbed of docA chunks
all_stored = list(store.stream())
stored_doc_ids = {c.source_document.id for c in all_stored}
assert "docA" not in stored_doc_ids
assert "docB" in stored_doc_ids
@patch(
"onyx.indexing.indexing_pipeline.embed_chunks_with_failure_handling",
)
@patch("onyx.indexing.indexing_pipeline.MAX_CHUNKS_PER_DOC_BATCH", 3)
def test_later_batch_skips_already_failed_doc(self, mock_embed: MagicMock) -> None:
"""If docA fails in batch 0, its chunks in batch 1 are skipped
entirely (never sent to the embedder)."""
embedded_doc_ids: list[str] = []
def _embed(
chunks: list[DocAwareChunk], **_kwargs: object
) -> tuple[list[IndexChunk], list[ConnectorFailure]]:
for c in chunks:
embedded_doc_ids.append(c.source_document.id)
return _mock_embed_fail_doc("docA")(chunks)
mock_embed.side_effect = _embed
with ChunkBatchStore() as store:
chunks = [
_make_chunk("docA", 0),
_make_chunk("docA", 1),
_make_chunk("docA", 2),
_make_chunk("docA", 3),
_make_chunk("docB", 0),
_make_chunk("docB", 1),
]
_embed_chunks_to_store(
chunks=chunks,
embedder=MagicMock(),
tenant_id="test",
request_id=None,
store=store,
)
# docA should only appear in batch 0, not batch 1
batch_1_doc_ids = embedded_doc_ids[3:]
assert "docA" not in batch_1_doc_ids
@patch(
"onyx.indexing.indexing_pipeline.embed_chunks_with_failure_handling",
)
@patch("onyx.indexing.indexing_pipeline.MAX_CHUNKS_PER_DOC_BATCH", 3)
def test_failed_doc_skipped_in_later_batch_while_other_doc_succeeds(
self, mock_embed: MagicMock
) -> None:
"""doc1 spans batches 0 and 1, doc2 only in batch 1. Batch 0 fails
doc1. In batch 1, doc1 chunks should be skipped but doc2 chunks
should still be embedded successfully."""
embedded_chunks: list[list[str]] = []
def _embed(
chunks: list[DocAwareChunk], **_kwargs: object
) -> tuple[list[IndexChunk], list[ConnectorFailure]]:
embedded_chunks.append([c.source_document.id for c in chunks])
return _mock_embed_fail_doc("doc1")(chunks)
mock_embed.side_effect = _embed
with ChunkBatchStore() as store:
chunks = [
_make_chunk("doc1", 0),
_make_chunk("doc1", 1),
_make_chunk("doc1", 2),
_make_chunk("doc1", 3),
_make_chunk("doc2", 0),
_make_chunk("doc2", 1),
]
result = _embed_chunks_to_store(
chunks=chunks,
embedder=MagicMock(),
tenant_id="test",
request_id=None,
store=store,
)
# doc1 should be fully excluded, doc2 fully included
successful_doc_ids = {doc_id for _, doc_id in result.successful_chunk_ids}
assert "doc1" not in successful_doc_ids
assert "doc2" in successful_doc_ids
assert len(result.successful_chunk_ids) == 2 # doc2's 2 chunks
# Batch 1 should only contain doc2 (doc1 was filtered before embedding)
assert len(embedded_chunks) == 2
assert "doc1" not in embedded_chunks[1]
assert embedded_chunks[1] == ["doc2", "doc2"]
# Verify on-disk state has no doc1 chunks
all_stored = list(store.stream())
assert all(c.source_document.id == "doc2" for c in all_stored)
@patch(
"onyx.indexing.indexing_pipeline.embed_chunks_with_failure_handling",
)
def test_empty_input(self, mock_embed: MagicMock) -> None:
"""Empty chunk list produces empty results."""
mock_embed.side_effect = _mock_embed_success
with ChunkBatchStore() as store:
result = _embed_chunks_to_store(
chunks=[],
embedder=MagicMock(),
tenant_id="test",
request_id=None,
store=store,
)
assert len(result.successful_chunk_ids) == 0
assert len(result.connector_failures) == 0
mock_embed.assert_not_called()
@patch(
"onyx.indexing.indexing_pipeline.embed_chunks_with_failure_handling",
)
@patch("onyx.indexing.indexing_pipeline.MAX_CHUNKS_PER_DOC_BATCH", 100)
def test_all_chunks_fail(self, mock_embed: MagicMock) -> None:
"""When all documents fail, results have no successful chunks."""
def _fail_all(
chunks: list[DocAwareChunk], **_kwargs: object
) -> tuple[list[IndexChunk], list[ConnectorFailure]]:
doc_ids = {c.source_document.id for c in chunks}
return [], [_make_failure(doc_id) for doc_id in doc_ids]
mock_embed.side_effect = _fail_all
with ChunkBatchStore() as store:
chunks = [_make_chunk("doc1", 0), _make_chunk("doc2", 1)]
result = _embed_chunks_to_store(
chunks=chunks,
embedder=MagicMock(),
tenant_id="test",
request_id=None,
store=store,
)
assert len(result.successful_chunk_ids) == 0
assert len(result.connector_failures) == 2

View File

@@ -116,7 +116,7 @@ def _run_adapter_build(
project_ids_map: dict[str, list[int]],
persona_ids_map: dict[str, list[int]],
) -> list[DocMetadataAwareIndexChunk]:
"""Helper that runs UserFileIndexingAdapter.build_metadata_aware_chunks
"""Helper that runs UserFileIndexingAdapter.prepare_enrichment + enrich_chunk
with all external dependencies mocked."""
from onyx.indexing.adapters.user_file_indexing_adapter import (
UserFileIndexingAdapter,
@@ -155,18 +155,16 @@ def _run_adapter_build(
side_effect=Exception("no LLM in tests"),
),
):
result = adapter.build_metadata_aware_chunks(
chunks_with_embeddings=[chunk],
chunk_content_scores=[1.0],
tenant_id="test_tenant",
enricher = adapter.prepare_enrichment(
context=context,
tenant_id="test_tenant",
chunks=[chunk],
)
return result.chunks
return [enricher.enrich_chunk(chunk, 1.0)]
def test_build_metadata_aware_chunks_includes_persona_ids() -> None:
"""UserFileIndexingAdapter.build_metadata_aware_chunks writes persona IDs
def test_prepare_enrichment_includes_persona_ids() -> None:
"""UserFileIndexingAdapter.prepare_enrichment writes persona IDs
fetched from the DB into each chunk's metadata."""
file_id = str(uuid4())
persona_ids = [5, 12]
@@ -183,7 +181,7 @@ def test_build_metadata_aware_chunks_includes_persona_ids() -> None:
assert chunks[0].user_project == project_ids
def test_build_metadata_aware_chunks_missing_file_defaults_to_empty() -> None:
def test_prepare_enrichment_missing_file_defaults_to_empty() -> None:
"""When a file has no persona or project associations in the DB, the
adapter should default to empty lists (not KeyError or None)."""
file_id = str(uuid4())

View File

@@ -1,6 +1,6 @@
"""Tests for memory tool streaming packet emissions."""
from queue import Queue
import queue
from unittest.mock import MagicMock
from unittest.mock import patch
@@ -18,9 +18,13 @@ from onyx.tools.tool_implementations.memory.models import MemoryToolResponse
@pytest.fixture
def emitter() -> Emitter:
bus: Queue = Queue()
return Emitter(bus)
def emitter_queue() -> queue.Queue:
return queue.Queue()
@pytest.fixture
def emitter(emitter_queue: queue.Queue) -> Emitter:
return Emitter(merged_queue=emitter_queue)
@pytest.fixture
@@ -53,24 +57,27 @@ class TestMemoryToolEmitStart:
def test_emit_start_emits_memory_tool_start_packet(
self,
memory_tool: MemoryTool,
emitter: Emitter,
emitter_queue: queue.Queue,
placement: Placement,
) -> None:
memory_tool.emit_start(placement)
packet = emitter.bus.get_nowait()
_key, packet = emitter_queue.get_nowait()
assert isinstance(packet.obj, MemoryToolStart)
assert packet.placement == placement
assert packet.placement is not None
assert packet.placement.turn_index == placement.turn_index
assert packet.placement.tab_index == placement.tab_index
assert packet.placement.model_index == 0 # emitter stamps model_index=0
def test_emit_start_with_different_placement(
self,
memory_tool: MemoryTool,
emitter: Emitter,
emitter_queue: queue.Queue,
) -> None:
placement = Placement(turn_index=2, tab_index=1)
memory_tool.emit_start(placement)
packet = emitter.bus.get_nowait()
_key, packet = emitter_queue.get_nowait()
assert packet.placement.turn_index == 2
assert packet.placement.tab_index == 1
@@ -81,7 +88,7 @@ class TestMemoryToolRun:
self,
mock_process: MagicMock,
memory_tool: MemoryTool,
emitter: Emitter,
emitter_queue: queue.Queue,
placement: Placement,
override_kwargs: MemoryToolOverrideKwargs,
) -> None:
@@ -93,21 +100,19 @@ class TestMemoryToolRun:
memory="User prefers Python",
)
# The delta packet should be in the queue
packet = emitter.bus.get_nowait()
_key, packet = emitter_queue.get_nowait()
assert isinstance(packet.obj, MemoryToolDelta)
assert packet.obj.memory_text == "User prefers Python"
assert packet.obj.operation == "add"
assert packet.obj.memory_id is None
assert packet.obj.index is None
assert packet.placement == placement
@patch("onyx.tools.tool_implementations.memory.memory_tool.process_memory_update")
def test_run_emits_delta_for_update_operation(
self,
mock_process: MagicMock,
memory_tool: MemoryTool,
emitter: Emitter,
emitter_queue: queue.Queue,
placement: Placement,
override_kwargs: MemoryToolOverrideKwargs,
) -> None:
@@ -119,7 +124,7 @@ class TestMemoryToolRun:
memory="User prefers light mode",
)
packet = emitter.bus.get_nowait()
_key, packet = emitter_queue.get_nowait()
assert isinstance(packet.obj, MemoryToolDelta)
assert packet.obj.memory_text == "User prefers light mode"
assert packet.obj.operation == "update"

6
uv.lock generated
View File

@@ -5634,11 +5634,11 @@ wheels = [
[[package]]
name = "pygments"
version = "2.19.2"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]

View File

@@ -104,7 +104,7 @@ function Button({
isLarge ? "default" : size === "2xs" ? "mini" : "compact"
}
>
<div className="flex flex-row items-center gap-1 interactive-foreground">
<div className="flex flex-row items-center gap-1">
{iconWrapper(Icon, size, !!children)}
{labelEl}

View File

@@ -67,7 +67,7 @@ function FilterButton({
state={active ? "selected" : "empty"}
>
<Interactive.Container type="button">
<div className="interactive-foreground flex flex-row items-center gap-1">
<div className="flex flex-row items-center gap-1">
{iconWrapper(Icon, "lg", true)}
<Text font="main-ui-action" color="inherit" nowrap>
{children}

View File

@@ -16,7 +16,7 @@ import * as TooltipPrimitive from "@radix-ui/react-tooltip";
type ContentPassthroughProps = DistributiveOmit<
ContentActionProps,
"paddingVariant" | "widthVariant" | "ref" | "withInteractive"
"paddingVariant" | "widthVariant" | "ref"
>;
type LineItemButtonOwnProps = Pick<
@@ -92,7 +92,6 @@ function LineItemButton({
>
<ContentAction
{...(contentActionProps as ContentActionProps)}
withInteractive
paddingVariant="fit"
/>
</Interactive.Container>

View File

@@ -132,7 +132,7 @@ function OpenButton({
>
<div
className={cn(
"interactive-foreground flex flex-row items-center",
"flex flex-row items-center",
justifyContent === "between" ? "w-full justify-between" : "gap-1",
foldable &&
justifyContent !== "between" &&

View File

@@ -1,3 +1,5 @@
"use client";
import "@opal/components/buttons/select-button/styles.css";
import {
Interactive,
@@ -84,11 +86,13 @@ function SelectButton({
const isLarge = size === "lg";
const labelEl = children ? (
<span className="opal-select-button-label">
<Text font={isLarge ? "main-ui-body" : "secondary-body"} color="inherit">
{children}
</Text>
</span>
<Text
font={isLarge ? "main-ui-body" : "secondary-body"}
color="inherit"
nowrap
>
{children}
</Text>
) : null;
const button = (
@@ -103,7 +107,7 @@ function SelectButton({
>
<div
className={cn(
"opal-select-button interactive-foreground",
"opal-select-button",
foldable && "interactive-foldable-host"
)}
>

View File

@@ -3,7 +3,3 @@
.opal-select-button {
@apply flex flex-row items-center gap-1;
}
.opal-select-button-label {
@apply whitespace-nowrap;
}

View File

@@ -0,0 +1,136 @@
# SelectCard
**Import:** `import { SelectCard, type SelectCardProps } from "@opal/components";`
A stateful interactive card — the card counterpart to [`SelectButton`](../../buttons/select-button/README.md). Built on `Interactive.Stateful` (Slot) with a structural `<div>` that owns padding, rounding, border, and overflow.
## Relationship to Card
`Card` is a plain, non-interactive container. `SelectCard` adds stateful interactivity (hover, active, disabled, state-driven colors) by wrapping its root div with `Interactive.Stateful`. The relationship mirrors `Button` (stateless) vs `SelectButton` (stateful).
## Relationship to SelectButton
SelectCard and SelectButton share the same call stack:
```
Interactive.Stateful → structural element → content
```
The key differences:
- SelectCard renders a `<div>` (not `Interactive.Container`) — cards have their own rounding scale (one notch larger than buttons) and don't need Container's height/min-width.
- SelectCard has no `foldable` prop — use `Interactive.Foldable` directly inside children.
- SelectCard's children are fully composable — use `CardHeaderLayout`, `ContentAction`, `Content`, buttons, etc. inside.
## Architecture
```
Interactive.Stateful <- variant, state, interaction, disabled, onClick
└─ div.opal-select-card <- padding, rounding, border, overflow
└─ children (composable)
```
The `Interactive.Stateful` Slot merges onto the div, producing a single DOM element with both `.opal-select-card` and `.interactive` classes plus `data-interactive-*` attributes. This activates the Stateful color matrix for backgrounds and `--interactive-foreground` / `--interactive-foreground-icon` CSS properties for descendants.
## Props
Inherits **all** props from `InteractiveStatefulProps` (variant, state, interaction, onClick, href, etc.) plus:
| Prop | Type | Default | Description |
|---|---|---|---|
| `sizeVariant` | `ContainerSizeVariants` | `"lg"` | Controls padding and border-radius |
| `ref` | `React.Ref<HTMLDivElement>` | — | Ref forwarded to the root div |
| `children` | `React.ReactNode` | — | Card content |
### Rounding scale
Cards use a bumped-up rounding scale compared to buttons:
| Size | Rounding | Effective radius |
|---|---|---|
| `lg` | `rounded-16` | 1rem (16px) |
| `md``sm` | `rounded-12` | 0.75rem (12px) |
| `xs``2xs` | `rounded-08` | 0.5rem (8px) |
| `fit` | `rounded-16` | 1rem (16px) |
### Recommended variant: `select-card`
The `select-card` Interactive.Stateful variant is specifically designed for cards. Unlike `select-heavy` (which only changes foreground color between empty and filled), `select-card` gives the filled state a visible background — important on larger surfaces where background carries more of the visual distinction.
| State | Rest background | Rest foreground |
|---|---|---|
| `empty` | transparent | `text-04` / icon `text-03` |
| `filled` | `background-tint-00` | `text-04` / icon `text-03` |
| `selected` | `action-link-01` | `action-link-05` |
The selected state also gets a `border-action-link-05` via SelectCard's CSS.
## CSS
SelectCard's stylesheet (`styles.css`) provides:
- `w-full overflow-clip border` on all states
- `border-action-link-05` when `data-interactive-state="selected"`
All background and foreground colors come from the Interactive.Stateful CSS, not from SelectCard.
## Usage
### Provider selection card
```tsx
import { SelectCard } from "@opal/components";
import { CardHeaderLayout } from "@opal/layouts";
<SelectCard variant="select-card" state="selected" onClick={handleClick}>
<CardHeaderLayout
icon={SvgGlobe}
title="Google"
description="Search engine"
sizePreset="main-ui"
variant="section"
rightChildren={<Button icon={SvgCheckSquare} variant="action" prominence="tertiary">Current Default</Button>}
bottomRightChildren={
<Button icon={SvgSettings} size="sm" prominence="tertiary" />
}
/>
</SelectCard>
```
### Disconnected state (clickable)
```tsx
<SelectCard variant="select-card" state="empty" onClick={handleConnect}>
<CardHeaderLayout
icon={SvgCloud}
title="OpenAI"
description="Not configured"
sizePreset="main-ui"
variant="section"
rightChildren={<Button rightIcon={SvgArrowExchange} prominence="tertiary">Connect</Button>}
/>
</SelectCard>
```
### With foldable hover-reveal
```tsx
<SelectCard variant="select-card" state="filled">
<CardHeaderLayout
icon={SvgCloud}
title="OpenAI"
description="Connected"
sizePreset="main-ui"
variant="section"
rightChildren={
<div className="interactive-foldable-host flex items-center">
<Interactive.Foldable>
<Button rightIcon={SvgArrowRightCircle} prominence="tertiary">
Set as Default
</Button>
</Interactive.Foldable>
</div>
}
/>
</SelectCard>
```

View File

@@ -0,0 +1,247 @@
import type { Meta, StoryObj } from "@storybook/react";
import { SelectCard } from "@opal/components";
import { Button } from "@opal/components";
import { Content } from "@opal/layouts";
import {
SvgArrowExchange,
SvgArrowRightCircle,
SvgCheckSquare,
SvgGlobe,
SvgSettings,
SvgUnplug,
} from "@opal/icons";
import { Interactive } from "@opal/core";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import type { Decorator } from "@storybook/react";
const withTooltipProvider: Decorator = (Story) => (
<TooltipPrimitive.Provider>
<Story />
</TooltipPrimitive.Provider>
);
const STATES = ["empty", "filled", "selected"] as const;
const SIZE_VARIANTS = ["lg", "md", "sm", "xs", "2xs", "fit"] as const;
const meta = {
title: "opal/components/SelectCard",
component: SelectCard,
tags: ["autodocs"],
decorators: [withTooltipProvider],
parameters: {
layout: "centered",
},
} satisfies Meta<typeof SelectCard>;
export default meta;
type Story = StoryObj<typeof meta>;
// ---------------------------------------------------------------------------
// Stories
// ---------------------------------------------------------------------------
export const Default: Story = {
render: () => (
<div className="w-96">
<SelectCard variant="select-card" state="empty">
<div className="p-2">
<Content
sizePreset="main-ui"
variant="section"
icon={SvgGlobe}
title="Google Search"
description="Web search provider"
/>
</div>
</SelectCard>
</div>
),
};
export const AllStates: Story = {
render: () => (
<div className="flex flex-col gap-4 w-96">
{STATES.map((state) => (
<SelectCard key={state} variant="select-card" state={state}>
<div className="p-2">
<Content
sizePreset="main-ui"
variant="section"
icon={SvgGlobe}
title={`State: ${state}`}
description="Hover to see interaction states."
/>
</div>
</SelectCard>
))}
</div>
),
};
export const Clickable: Story = {
render: () => (
<div className="w-96">
<SelectCard
variant="select-card"
state="empty"
onClick={() => alert("Card clicked")}
>
<div className="p-2">
<Content
sizePreset="main-ui"
variant="section"
icon={SvgGlobe}
title="Clickable Card"
description="Click anywhere on this card."
/>
</div>
</SelectCard>
</div>
),
};
export const WithActions: Story = {
render: () => (
<div className="flex flex-col gap-4 w-[28rem]">
{/* Disconnected */}
<SelectCard variant="select-card" state="empty" onClick={() => {}}>
<div className="flex flex-row items-stretch w-full">
<div className="flex-1 p-2">
<Content
sizePreset="main-ui"
variant="section"
icon={SvgGlobe}
title="Disconnected"
description="Click to connect."
/>
</div>
<div className="flex items-center">
<Button prominence="tertiary" rightIcon={SvgArrowExchange}>
Connect
</Button>
</div>
</div>
</SelectCard>
{/* Connected with foldable */}
<SelectCard variant="select-card" state="filled">
<div className="flex flex-row items-stretch w-full">
<div className="flex-1 p-2">
<Content
sizePreset="main-ui"
variant="section"
icon={SvgGlobe}
title="Connected"
description="Hover to reveal Set as Default."
/>
</div>
<div className="flex flex-col items-end justify-between">
<div className="interactive-foldable-host flex items-center">
<Interactive.Foldable>
<Button prominence="tertiary" rightIcon={SvgArrowRightCircle}>
Set as Default
</Button>
</Interactive.Foldable>
</div>
<div className="flex flex-row px-1 pb-1">
<Button
icon={SvgUnplug}
tooltip="Disconnect"
prominence="tertiary"
size="sm"
/>
<Button
icon={SvgSettings}
tooltip="Edit"
prominence="tertiary"
size="sm"
/>
</div>
</div>
</div>
</SelectCard>
{/* Selected */}
<SelectCard variant="select-card" state="selected">
<div className="flex flex-row items-stretch w-full">
<div className="flex-1 p-2">
<Content
sizePreset="main-ui"
variant="section"
icon={SvgGlobe}
title="Selected"
description="Currently the default provider."
/>
</div>
<div className="flex flex-col items-end justify-between">
<Button
variant="action"
prominence="tertiary"
icon={SvgCheckSquare}
>
Current Default
</Button>
<div className="flex flex-row px-1 pb-1">
<Button
icon={SvgUnplug}
tooltip="Disconnect"
prominence="tertiary"
size="sm"
/>
<Button
icon={SvgSettings}
tooltip="Edit"
prominence="tertiary"
size="sm"
/>
</div>
</div>
</div>
</SelectCard>
</div>
),
};
export const SizeVariants: Story = {
render: () => (
<div className="flex flex-col gap-4 w-96">
{SIZE_VARIANTS.map((size) => (
<SelectCard
key={size}
variant="select-card"
state="filled"
sizeVariant={size}
>
<Content
sizePreset="main-ui"
variant="section"
icon={SvgGlobe}
title={`sizeVariant: ${size}`}
description="Shows padding and rounding differences."
/>
</SelectCard>
))}
</div>
),
};
export const SelectHeavyVariant: Story = {
render: () => (
<div className="flex flex-col gap-4 w-96">
{STATES.map((state) => (
<SelectCard key={state} variant="select-heavy" state={state}>
<div className="p-2">
<Content
sizePreset="main-ui"
variant="section"
icon={SvgGlobe}
title={`select-heavy / ${state}`}
description="For comparison with select-card variant."
/>
</div>
</SelectCard>
))}
</div>
),
};

View File

@@ -0,0 +1,96 @@
import "@opal/components/cards/select-card/styles.css";
import type { ContainerSizeVariants } from "@opal/types";
import { containerSizeVariants } from "@opal/shared";
import { cn } from "@opal/utils";
import { Interactive, type InteractiveStatefulProps } from "@opal/core";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type SelectCardProps = InteractiveStatefulProps & {
/**
* Size preset — controls padding and border-radius.
*
* Padding comes from the shared size scale. Rounding follows the same
* mapping as `Card` / `Button` / `Interactive.Container`:
*
* | Size | Rounding |
* |------------|--------------|
* | `lg` | `rounded-16` |
* | `md``sm` | `rounded-12` |
* | `xs``2xs` | `rounded-08` |
* | `fit` | `rounded-16` |
*
* @default "lg"
*/
sizeVariant?: ContainerSizeVariants;
/** Ref forwarded to the root `<div>`. */
ref?: React.Ref<HTMLDivElement>;
children?: React.ReactNode;
};
// ---------------------------------------------------------------------------
// Rounding
// ---------------------------------------------------------------------------
const roundingForSize: Record<ContainerSizeVariants, string> = {
lg: "rounded-16",
md: "rounded-12",
sm: "rounded-12",
xs: "rounded-08",
"2xs": "rounded-08",
fit: "rounded-16",
};
// ---------------------------------------------------------------------------
// SelectCard
// ---------------------------------------------------------------------------
/**
* A stateful interactive card — the card counterpart to `SelectButton`.
*
* Built on `Interactive.Stateful` (Slot) → a structural `<div>`. The
* Stateful system owns background and foreground colors; the card owns
* padding, rounding, border, and overflow.
*
* Children are fully composable — use `ContentAction`, `Content`, buttons,
* `Interactive.Foldable`, etc. inside.
*
* @example
* ```tsx
* <SelectCard variant="select-card" state="selected" onClick={handleClick}>
* <ContentAction
* icon={SvgGlobe}
* title="Google"
* description="Search engine"
* rightChildren={<Button>Set as Default</Button>}
* />
* </SelectCard>
* ```
*/
function SelectCard({
sizeVariant = "lg",
ref,
children,
...statefulProps
}: SelectCardProps) {
const { padding } = containerSizeVariants[sizeVariant];
const rounding = roundingForSize[sizeVariant];
return (
<Interactive.Stateful {...statefulProps}>
<div ref={ref} className={cn("opal-select-card", padding, rounding)}>
{children}
</div>
</Interactive.Stateful>
);
}
// ---------------------------------------------------------------------------
// Exports
// ---------------------------------------------------------------------------
export { SelectCard, type SelectCardProps };

View File

@@ -0,0 +1,9 @@
/* SelectCard — structural styles; colors handled by Interactive.Stateful */
.opal-select-card {
@apply w-full overflow-clip border;
}
.opal-select-card[data-interactive-state="selected"] {
@apply border-action-link-05;
}

View File

@@ -56,6 +56,12 @@ export {
type BorderVariant,
} from "@opal/components/cards/card/components";
/* SelectCard */
export {
SelectCard,
type SelectCardProps,
} from "@opal/components/cards/select-card/components";
/* EmptyMessageCard */
export {
EmptyMessageCard,

View File

@@ -390,14 +390,12 @@ function PaginationCount({
<span className={textClasses(size, "muted")}>of</span>
{totalItems}
{units && (
<span className="ml-1">
<Text
color="inherit"
font={size === "sm" ? "secondary-body" : "main-ui-muted"}
>
{units}
</Text>
</span>
<Text
color="inherit"
font={size === "sm" ? "secondary-body" : "main-ui-muted"}
>
{units}
</Text>
)}
</span>

View File

@@ -1,5 +1,4 @@
import "@opal/components/tag/styles.css";
import type { IconFunctionComponent, RichStr } from "@opal/types";
import { Text } from "@opal/components";
import { cn } from "@opal/utils";
@@ -46,20 +45,22 @@ function Tag({ icon: Icon, title, color = "gray", size = "sm" }: TagProps) {
const config = COLOR_CONFIG[color];
return (
<div className={cn("opal-auxiliary-tag", config.bg)} data-size={size}>
<div
className={cn("opal-auxiliary-tag", config.bg, config.text)}
data-size={size}
>
{Icon && (
<div className="opal-auxiliary-tag-icon-container">
<Icon className={cn("opal-auxiliary-tag-icon", config.text)} />
</div>
)}
<span className={cn("opal-auxiliary-tag-title px-[2px]", config.text)}>
<Text
font={size === "md" ? "secondary-body" : "figure-small-value"}
color="inherit"
>
{title}
</Text>
</span>
<Text
font={size === "md" ? "secondary-body" : "figure-small-value"}
color="inherit"
nowrap
>
{title}
</Text>
</div>
);
}

View File

@@ -13,7 +13,9 @@ const SAFE_PROTOCOL = /^https?:|^mailto:|^tel:/i;
const ALLOWED_ELEMENTS = ["p", "br", "a", "strong", "em", "code", "del"];
const INLINE_COMPONENTS = {
p: ({ children }: { children?: ReactNode }) => <>{children}</>,
p: ({ children }: { children?: ReactNode }) => (
<span className="block">{children}</span>
),
a: ({ children, href }: { children?: ReactNode; href?: string }) => {
if (!href || !SAFE_PROTOCOL.test(href)) {
return <>{children}</>;

View File

@@ -125,6 +125,7 @@ function Text({
...rest
}: TextProps) {
const resolvedClassName = cn(
"px-[2px]",
FONT_CONFIG[font],
COLOR_CONFIG[color],
nowrap && "whitespace-nowrap",

View File

@@ -18,9 +18,11 @@ export {
import { InteractiveStateless } from "@opal/core/interactive/stateless/components";
import { InteractiveStateful } from "@opal/core/interactive/stateful/components";
import { InteractiveContainer } from "@opal/core/interactive/container/components";
import { InteractiveSimple } from "@opal/core/interactive/simple/components";
import { Foldable } from "@opal/core/interactive/foldable/components";
const Interactive = {
Simple: InteractiveSimple,
Stateless: InteractiveStateless,
Stateful: InteractiveStateful,
Container: InteractiveContainer,
@@ -50,3 +52,5 @@ export type {
} from "@opal/core/interactive/container/components";
export type { FoldableProps } from "@opal/core/interactive/foldable/components";
export type { InteractiveSimpleProps } from "@opal/core/interactive/simple/components";

View File

@@ -9,7 +9,6 @@ const VARIANT_PROMINENCE_MAP: Record<string, string[]> = {
default: ["primary", "secondary", "tertiary", "internal"],
action: ["primary", "secondary", "tertiary", "internal"],
danger: ["primary", "secondary", "tertiary", "internal"],
none: [],
};
const SIZE_VARIANTS = ["lg", "md", "sm", "xs", "2xs", "fit"] as const;
@@ -43,7 +42,7 @@ export const Default: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border>
<span className="interactive-foreground">Secondary</span>
<span>Secondary</span>
</Interactive.Container>
</Interactive.Stateless>
@@ -53,7 +52,7 @@ export const Default: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border>
<span className="interactive-foreground">Primary</span>
<span>Primary</span>
</Interactive.Container>
</Interactive.Stateless>
@@ -63,7 +62,7 @@ export const Default: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border>
<span className="interactive-foreground">Tertiary</span>
<span>Tertiary</span>
</Interactive.Container>
</Interactive.Stateless>
</div>
@@ -115,9 +114,7 @@ export const VariantMatrix: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border>
<span className="interactive-foreground">
{prominence}
</span>
<span>{prominence}</span>
</Interactive.Container>
</Interactive.Stateless>
<span
@@ -150,7 +147,7 @@ export const Sizes: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border heightVariant={size}>
<span className="interactive-foreground">{size}</span>
<span>{size}</span>
</Interactive.Container>
</Interactive.Stateless>
))}
@@ -168,7 +165,7 @@ export const WidthFull: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border widthVariant="full">
<span className="interactive-foreground">Full width container</span>
<span>Full width container</span>
</Interactive.Container>
</Interactive.Stateless>
</div>
@@ -187,7 +184,7 @@ export const Rounding: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border roundingVariant={rounding}>
<span className="interactive-foreground">{rounding}</span>
<span>{rounding}</span>
</Interactive.Container>
</Interactive.Stateless>
))}
@@ -207,7 +204,7 @@ export const DisabledStory: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border>
<span className="interactive-foreground">Disabled</span>
<span>Disabled</span>
</Interactive.Container>
</Interactive.Stateless>
</Disabled>
@@ -218,7 +215,7 @@ export const DisabledStory: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border>
<span className="interactive-foreground">Enabled</span>
<span>Enabled</span>
</Interactive.Container>
</Interactive.Stateless>
</div>
@@ -236,7 +233,7 @@ export const Interaction: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border>
<span className="interactive-foreground">Forced hover</span>
<span>Forced hover</span>
</Interactive.Container>
</Interactive.Stateless>
@@ -247,7 +244,7 @@ export const Interaction: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border>
<span className="interactive-foreground">Forced active</span>
<span>Forced active</span>
</Interactive.Container>
</Interactive.Stateless>
@@ -257,7 +254,7 @@ export const Interaction: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border>
<span className="interactive-foreground">Normal (rest)</span>
<span>Normal (rest)</span>
</Interactive.Container>
</Interactive.Stateless>
</div>
@@ -274,7 +271,7 @@ export const WithBorder: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border>
<span className="interactive-foreground">With border</span>
<span>With border</span>
</Interactive.Container>
</Interactive.Stateless>
@@ -284,7 +281,7 @@ export const WithBorder: StoryObj = {
onClick={() => {}}
>
<Interactive.Container>
<span className="interactive-foreground">Without border</span>
<span>Without border</span>
</Interactive.Container>
</Interactive.Stateless>
</div>
@@ -296,7 +293,7 @@ export const AsLink: StoryObj = {
render: () => (
<Interactive.Stateless variant="action" href="/settings">
<Interactive.Container border>
<span className="interactive-foreground">Go to Settings</span>
<span>Go to Settings</span>
</Interactive.Container>
</Interactive.Stateless>
),
@@ -312,7 +309,7 @@ export const SelectVariant: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border>
<span className="interactive-foreground">Selected (light)</span>
<span>Selected (light)</span>
</Interactive.Container>
</Interactive.Stateful>
@@ -322,7 +319,7 @@ export const SelectVariant: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border>
<span className="interactive-foreground">Unselected (light)</span>
<span>Unselected (light)</span>
</Interactive.Container>
</Interactive.Stateful>
@@ -332,7 +329,7 @@ export const SelectVariant: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border>
<span className="interactive-foreground">Selected (heavy)</span>
<span>Selected (heavy)</span>
</Interactive.Container>
</Interactive.Stateful>
@@ -342,7 +339,7 @@ export const SelectVariant: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border>
<span className="interactive-foreground">Unselected (heavy)</span>
<span>Unselected (heavy)</span>
</Interactive.Container>
</Interactive.Stateful>
</div>

View File

@@ -21,9 +21,10 @@
--interactive-easing: ease-in-out;
}
/* Base interactive surface */
/* Base interactive surface — sets color directly so all descendants inherit. */
.interactive {
@apply cursor-pointer select-none;
color: var(--interactive-foreground);
transition:
background-color var(--interactive-duration) var(--interactive-easing),
--interactive-foreground var(--interactive-duration)
@@ -43,14 +44,8 @@
@apply border;
}
/* Utility classes — descendants opt in to parent-controlled foreground color.
No transition here — the parent interpolates the variables directly. */
.interactive-foreground {
color: var(--interactive-foreground);
}
/* Icon foreground — reads from --interactive-foreground-icon, which defaults
to --interactive-foreground via the variant CSS rules. */
/* Icon foreground — reads from --interactive-foreground-icon, which may differ
from --interactive-foreground (e.g. muted icons beside normal text). */
.interactive-foreground-icon {
color: var(--interactive-foreground-icon);
}

View File

@@ -0,0 +1,100 @@
import React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cn } from "@opal/utils";
import { useDisabled } from "@opal/core/disabled/components";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface InteractiveSimpleProps
extends Omit<
React.HTMLAttributes<HTMLElement>,
"className" | "style" | "color"
> {
ref?: React.Ref<HTMLElement>;
/**
* Tailwind group class (e.g. `"group/Card"`) for `group-hover:*` utilities.
*/
group?: string;
/**
* URL to navigate to when clicked. Passed through Slot to the child.
*/
href?: string;
/**
* Link target (e.g. `"_blank"`). Only used when `href` is provided.
*/
target?: string;
}
// ---------------------------------------------------------------------------
// InteractiveSimple
// ---------------------------------------------------------------------------
/**
* Minimal interactive surface primitive.
*
* Provides cursor styling, click handling, disabled integration, and
* optional link/group support — but **no color or background styling**.
*
* Use this for elements that need interactivity (click, cursor, disabled)
* without participating in the Interactive color system.
*
* Uses Radix `Slot` — merges props onto a single child element without
* adding any DOM node.
*
* @example
* ```tsx
* <Interactive.Simple onClick={handleClick} group="group/Card">
* <Card>...</Card>
* </Interactive.Simple>
* ```
*/
function InteractiveSimple({
ref,
group,
href,
target,
...props
}: InteractiveSimpleProps) {
const { isDisabled, allowClick } = useDisabled();
const classes = cn(
"cursor-pointer select-none",
isDisabled && "cursor-not-allowed",
!props.onClick && !href && "!cursor-default !select-auto",
group
);
const { onClick, ...slotProps } = props;
const linkAttrs = href
? {
href: isDisabled ? undefined : href,
target,
rel: target === "_blank" ? "noopener noreferrer" : undefined,
}
: {};
return (
<Slot
ref={ref}
className={classes}
aria-disabled={isDisabled || undefined}
{...linkAttrs}
{...slotProps}
onClick={
isDisabled && !allowClick
? href
? (e: React.MouseEvent) => e.preventDefault()
: undefined
: onClick
}
/>
);
}
export { InteractiveSimple, type InteractiveSimpleProps };

View File

@@ -8,7 +8,7 @@ Stateful interactive surface primitive for elements that maintain a value state
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `variant` | `"select-light" \| "select-heavy" \| "select-tinted" \| "select-filter" \| "sidebar"` | `"select-heavy"` | Color variant |
| `variant` | `"select-light" \| "select-heavy" \| "select-card" \| "select-tinted" \| "select-filter" \| "sidebar-heavy" \| "sidebar-light"` | `"select-heavy"` | Color variant |
| `state` | `"empty" \| "filled" \| "selected"` | `"empty"` | Current value state |
| `interaction` | `"rest" \| "hover" \| "active"` | `"rest"` | JS-controlled interaction override |
| `group` | `string` | — | Tailwind group class for `group-hover:*` |
@@ -16,6 +16,16 @@ Stateful interactive surface primitive for elements that maintain a value state
| `href` | `string` | — | URL for link behavior |
| `target` | `string` | — | Link target (e.g. `"_blank"`) |
## Variants
- **`select-light`** — Transparent selected background. For inline toggles.
- **`select-heavy`** — Tinted selected background (`action-link-01`). For list rows, model pickers, buttons.
- **`select-card`** — Like `select-heavy`, but the filled state gets a visible background (`background-tint-00`) with neutral foreground. Designed for larger surfaces (cards) where background carries more of the visual distinction than foreground color alone.
- **`select-tinted`** — Like `select-heavy` but with a tinted rest background (`background-tint-01`).
- **`select-filter`** — Like `select-tinted` for empty/filled; selected state uses inverted backgrounds and inverted text.
- **`sidebar-heavy`** — Sidebar navigation: muted when unselected, bold when selected.
- **`sidebar-light`** — Sidebar navigation: uniformly muted across all states.
## State attribute
Uses `data-interactive-state` (not `data-state`) to avoid conflicts with Radix UI, which injects its own `data-state` on trigger elements.

View File

@@ -13,9 +13,11 @@ import type { ButtonType, WithoutStyles } from "@opal/types";
type InteractiveStatefulVariant =
| "select-light"
| "select-heavy"
| "select-card"
| "select-tinted"
| "select-filter"
| "sidebar";
| "sidebar-heavy"
| "sidebar-light";
type InteractiveStatefulState = "empty" | "filled" | "selected";
type InteractiveStatefulInteraction = "rest" | "hover" | "active";
@@ -31,9 +33,11 @@ interface InteractiveStatefulProps
*
* - `"select-light"` — transparent selected background (for inline toggles)
* - `"select-heavy"` — tinted selected background (for list rows, model pickers)
* - `"select-card"` — like select-heavy but filled state has a visible background (for cards/larger surfaces)
* - `"select-tinted"` — like select-heavy but with a tinted rest background
* - `"select-filter"` — like select-tinted for empty/filled; selected state uses inverted tint backgrounds and inverted text (for filter buttons)
* - `"sidebar"` — for sidebar navigation items
* - `"sidebar-heavy"` — sidebar navigation items: muted when unselected (text-03/text-02), bold when selected (text-04/text-03)
* - `"sidebar-light"` — sidebar navigation items: uniformly muted across all states (text-02/text-02)
*
* @default "select-heavy"
*/

View File

@@ -11,7 +11,7 @@
Children read the variables with no independent transitions.
State dimension: `data-interactive-state` = "empty" | "filled" | "selected"
Variant dimension: `data-interactive-variant` = "select-light" | "select-heavy" | "select-tinted" | "sidebar"
Variant dimension: `data-interactive-variant` = "select-light" | "select-heavy" | "select-card" | "select-tinted" | "select-filter" | "sidebar-heavy" | "sidebar-light"
Interaction override: `data-interaction="hover"` and `data-interaction="active"`
allow JS-controlled visual state overrides.
@@ -114,6 +114,108 @@
--interactive-foreground-icon: var(--action-link-03);
}
/* ===========================================================================
Select-Card — like Select-Heavy but filled has a visible background.
Designed for larger surfaces (cards) where background carries more of
the visual distinction than foreground color alone.
=========================================================================== */
/* ---------------------------------------------------------------------------
Select-Card — Empty
--------------------------------------------------------------------------- */
.interactive[data-interactive-variant="select-card"][data-interactive-state="empty"] {
@apply bg-transparent;
--interactive-foreground: var(--text-04);
--interactive-foreground-icon: var(--text-03);
}
.interactive[data-interactive-variant="select-card"][data-interactive-state="empty"]:hover:not(
[data-disabled]
),
.interactive[data-interactive-variant="select-card"][data-interactive-state="empty"][data-interaction="hover"]:not(
[data-disabled]
) {
@apply bg-background-tint-02;
--interactive-foreground-icon: var(--text-04);
}
.interactive[data-interactive-variant="select-card"][data-interactive-state="empty"]:active:not(
[data-disabled]
),
.interactive[data-interactive-variant="select-card"][data-interactive-state="empty"][data-interaction="active"]:not(
[data-disabled]
) {
@apply bg-background-neutral-00;
--interactive-foreground: var(--text-05);
--interactive-foreground-icon: var(--text-05);
}
.interactive[data-interactive-variant="select-card"][data-interactive-state="empty"][data-disabled] {
@apply bg-transparent;
--interactive-foreground: var(--text-01);
--interactive-foreground-icon: var(--text-01);
}
/* ---------------------------------------------------------------------------
Select-Card — Filled (visible background, neutral foreground)
--------------------------------------------------------------------------- */
.interactive[data-interactive-variant="select-card"][data-interactive-state="filled"] {
@apply bg-background-tint-00;
--interactive-foreground: var(--text-04);
--interactive-foreground-icon: var(--text-03);
}
.interactive[data-interactive-variant="select-card"][data-interactive-state="filled"]:hover:not(
[data-disabled]
),
.interactive[data-interactive-variant="select-card"][data-interactive-state="filled"][data-interaction="hover"]:not(
[data-disabled]
) {
@apply bg-background-tint-02;
--interactive-foreground-icon: var(--text-04);
}
.interactive[data-interactive-variant="select-card"][data-interactive-state="filled"]:active:not(
[data-disabled]
),
.interactive[data-interactive-variant="select-card"][data-interactive-state="filled"][data-interaction="active"]:not(
[data-disabled]
) {
@apply bg-background-tint-00;
--interactive-foreground: var(--text-05);
--interactive-foreground-icon: var(--text-05);
}
.interactive[data-interactive-variant="select-card"][data-interactive-state="filled"][data-disabled] {
@apply bg-transparent;
--interactive-foreground: var(--text-01);
--interactive-foreground-icon: var(--text-01);
}
/* ---------------------------------------------------------------------------
Select-Card — Selected
--------------------------------------------------------------------------- */
.interactive[data-interactive-variant="select-card"][data-interactive-state="selected"] {
@apply bg-[var(--action-link-01)];
--interactive-foreground: var(--action-link-05);
--interactive-foreground-icon: var(--action-link-05);
}
.interactive[data-interactive-variant="select-card"][data-interactive-state="selected"]:hover:not(
[data-disabled]
),
.interactive[data-interactive-variant="select-card"][data-interactive-state="selected"][data-interaction="hover"]:not(
[data-disabled]
) {
@apply bg-background-tint-02;
}
.interactive[data-interactive-variant="select-card"][data-interactive-state="selected"]:active:not(
[data-disabled]
),
.interactive[data-interactive-variant="select-card"][data-interactive-state="selected"][data-interaction="active"]:not(
[data-disabled]
) {
@apply bg-background-tint-00;
}
.interactive[data-interactive-variant="select-card"][data-interactive-state="selected"][data-disabled] {
@apply bg-transparent;
--interactive-foreground: var(--action-link-03);
--interactive-foreground-icon: var(--action-link-03);
}
/* ===========================================================================
Select-Light — identical to Select-Heavy except selected bg is transparent
=========================================================================== */
@@ -392,49 +494,115 @@
}
/* ===========================================================================
Sidebar
Sidebar-Heavy
Not selected: muted (text-03 / icon text-02)
Selected: default (text-04 / icon text-03)
=========================================================================== */
/* ---------------------------------------------------------------------------
Sidebar — Empty
Sidebar-Heavy — Empty
--------------------------------------------------------------------------- */
.interactive[data-interactive-variant="sidebar"][data-interactive-state="empty"] {
.interactive[data-interactive-variant="sidebar-heavy"][data-interactive-state="empty"] {
@apply bg-transparent;
--interactive-foreground: var(--text-03);
--interactive-foreground-icon: var(--text-02);
}
.interactive[data-interactive-variant="sidebar"][data-interactive-state="empty"]:hover:not(
.interactive[data-interactive-variant="sidebar-heavy"][data-interactive-state="empty"]:hover:not(
[data-disabled]
),
.interactive[data-interactive-variant="sidebar"][data-interactive-state="empty"][data-interaction="hover"]:not(
.interactive[data-interactive-variant="sidebar-heavy"][data-interactive-state="empty"][data-interaction="hover"]:not(
[data-disabled]
) {
@apply bg-background-tint-03;
}
/* ---------------------------------------------------------------------------
Sidebar — Filled
Sidebar-Heavy — Filled
--------------------------------------------------------------------------- */
.interactive[data-interactive-variant="sidebar"][data-interactive-state="filled"] {
.interactive[data-interactive-variant="sidebar-heavy"][data-interactive-state="filled"] {
@apply bg-transparent;
--interactive-foreground: var(--text-03);
--interactive-foreground-icon: var(--text-02);
}
.interactive[data-interactive-variant="sidebar"][data-interactive-state="filled"]:hover:not(
.interactive[data-interactive-variant="sidebar-heavy"][data-interactive-state="filled"]:hover:not(
[data-disabled]
),
.interactive[data-interactive-variant="sidebar"][data-interactive-state="filled"][data-interaction="hover"]:not(
.interactive[data-interactive-variant="sidebar-heavy"][data-interactive-state="filled"][data-interaction="hover"]:not(
[data-disabled]
) {
@apply bg-background-tint-03;
}
/* ---------------------------------------------------------------------------
Sidebar — Selected
Sidebar-Heavy — Selected
--------------------------------------------------------------------------- */
.interactive[data-interactive-variant="sidebar"][data-interactive-state="selected"] {
.interactive[data-interactive-variant="sidebar-heavy"][data-interactive-state="selected"] {
@apply bg-background-tint-00;
--interactive-foreground: var(--text-04);
--interactive-foreground-icon: var(--text-03);
}
.interactive[data-interactive-variant="sidebar"][data-interactive-state="selected"]:hover:not(
.interactive[data-interactive-variant="sidebar-heavy"][data-interactive-state="selected"]:hover:not(
[data-disabled]
),
.interactive[data-interactive-variant="sidebar"][data-interactive-state="selected"][data-interaction="hover"]:not(
.interactive[data-interactive-variant="sidebar-heavy"][data-interactive-state="selected"][data-interaction="hover"]:not(
[data-disabled]
) {
@apply bg-background-tint-03;
}
/* ===========================================================================
Sidebar-Light
All states: prominence="muted-2x" colors (text-02 / icon text-02)
=========================================================================== */
/* ---------------------------------------------------------------------------
Sidebar-Light — Empty
--------------------------------------------------------------------------- */
.interactive[data-interactive-variant="sidebar-light"][data-interactive-state="empty"] {
@apply bg-transparent;
--interactive-foreground: var(--text-02);
--interactive-foreground-icon: var(--text-02);
}
.interactive[data-interactive-variant="sidebar-light"][data-interactive-state="empty"]:hover:not(
[data-disabled]
),
.interactive[data-interactive-variant="sidebar-light"][data-interactive-state="empty"][data-interaction="hover"]:not(
[data-disabled]
) {
@apply bg-background-tint-03;
}
/* ---------------------------------------------------------------------------
Sidebar-Light — Filled
--------------------------------------------------------------------------- */
.interactive[data-interactive-variant="sidebar-light"][data-interactive-state="filled"] {
@apply bg-transparent;
--interactive-foreground: var(--text-02);
--interactive-foreground-icon: var(--text-02);
}
.interactive[data-interactive-variant="sidebar-light"][data-interactive-state="filled"]:hover:not(
[data-disabled]
),
.interactive[data-interactive-variant="sidebar-light"][data-interactive-state="filled"][data-interaction="hover"]:not(
[data-disabled]
) {
@apply bg-background-tint-03;
}
/* ---------------------------------------------------------------------------
Sidebar-Light — Selected
--------------------------------------------------------------------------- */
.interactive[data-interactive-variant="sidebar-light"][data-interactive-state="selected"] {
@apply bg-background-tint-00;
--interactive-foreground: var(--text-02);
--interactive-foreground-icon: var(--text-02);
}
.interactive[data-interactive-variant="sidebar-light"][data-interactive-state="selected"]:hover:not(
[data-disabled]
),
.interactive[data-interactive-variant="sidebar-light"][data-interactive-state="selected"][data-interaction="hover"]:not(
[data-disabled]
) {
@apply bg-background-tint-03;

View File

@@ -10,7 +10,7 @@ import type { ButtonType, WithoutStyles } from "@opal/types";
// Types
// ---------------------------------------------------------------------------
type InteractiveStatelessVariant = "none" | "default" | "action" | "danger";
type InteractiveStatelessVariant = "default" | "action" | "danger";
type InteractiveStatelessProminence =
| "primary"
| "secondary"
@@ -108,8 +108,8 @@ function InteractiveStateless({
);
const dataAttrs = {
"data-interactive-variant": variant !== "none" ? variant : undefined,
"data-interactive-prominence": variant !== "none" ? prominence : undefined,
"data-interactive-variant": variant,
"data-interactive-prominence": prominence,
"data-interaction": interaction !== "rest" ? interaction : undefined,
"data-disabled": isDisabled ? "true" : undefined,
"aria-disabled": isDisabled || undefined,

View File

@@ -0,0 +1,158 @@
import type { Meta, StoryObj } from "@storybook/react";
import { CardHeaderLayout } from "@opal/layouts";
import { Button } from "@opal/components";
import {
SvgArrowExchange,
SvgCheckSquare,
SvgGlobe,
SvgSettings,
SvgUnplug,
} from "@opal/icons";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import type { Decorator } from "@storybook/react";
const withTooltipProvider: Decorator = (Story) => (
<TooltipPrimitive.Provider>
<Story />
</TooltipPrimitive.Provider>
);
const meta = {
title: "Layouts/CardHeaderLayout",
component: CardHeaderLayout,
tags: ["autodocs"],
decorators: [withTooltipProvider],
parameters: {
layout: "centered",
},
} satisfies Meta<typeof CardHeaderLayout>;
export default meta;
type Story = StoryObj<typeof meta>;
// ---------------------------------------------------------------------------
// Stories
// ---------------------------------------------------------------------------
export const Default: Story = {
render: () => (
<div className="w-[28rem] border rounded-16">
<CardHeaderLayout
sizePreset="main-ui"
variant="section"
icon={SvgGlobe}
title="Google Search"
description="Web search provider"
rightChildren={
<Button prominence="tertiary" rightIcon={SvgArrowExchange}>
Connect
</Button>
}
/>
</div>
),
};
export const WithBothSlots: Story = {
render: () => (
<div className="w-[28rem] border rounded-16">
<CardHeaderLayout
sizePreset="main-ui"
variant="section"
icon={SvgGlobe}
title="Google Search"
description="Currently the default provider."
rightChildren={
<Button variant="action" prominence="tertiary" icon={SvgCheckSquare}>
Current Default
</Button>
}
bottomRightChildren={
<>
<Button
icon={SvgUnplug}
tooltip="Disconnect"
prominence="tertiary"
size="sm"
/>
<Button
icon={SvgSettings}
tooltip="Edit"
prominence="tertiary"
size="sm"
/>
</>
}
/>
</div>
),
};
export const RightChildrenOnly: Story = {
render: () => (
<div className="w-[28rem] border rounded-16">
<CardHeaderLayout
sizePreset="main-ui"
variant="section"
icon={SvgGlobe}
title="OpenAI"
description="Not configured"
rightChildren={
<Button prominence="tertiary" rightIcon={SvgArrowExchange}>
Connect
</Button>
}
/>
</div>
),
};
export const NoRightChildren: Story = {
render: () => (
<div className="w-[28rem] border rounded-16">
<CardHeaderLayout
sizePreset="main-ui"
variant="section"
icon={SvgGlobe}
title="Section Header"
description="No actions on the right."
/>
</div>
),
};
export const LongContent: Story = {
render: () => (
<div className="w-[28rem] border rounded-16">
<CardHeaderLayout
sizePreset="main-ui"
variant="section"
icon={SvgGlobe}
title="Very Long Provider Name That Should Truncate"
description="This is a much longer description that tests how the layout handles overflow when the content area needs to shrink."
rightChildren={
<Button variant="action" prominence="tertiary" icon={SvgCheckSquare}>
Current Default
</Button>
}
bottomRightChildren={
<>
<Button
icon={SvgUnplug}
prominence="tertiary"
size="sm"
tooltip="Disconnect"
/>
<Button
icon={SvgSettings}
prominence="tertiary"
size="sm"
tooltip="Edit"
/>
</>
}
/>
</div>
),
};

View File

@@ -0,0 +1,94 @@
# 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

@@ -0,0 +1,73 @@
import { Content, type ContentProps } from "@opal/layouts/content/components";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type CardHeaderLayoutProps = ContentProps & {
/** Content rendered to the right of the Content block. */
rightChildren?: React.ReactNode;
/** Content rendered below `rightChildren` in the same column. */
bottomRightChildren?: React.ReactNode;
};
// ---------------------------------------------------------------------------
// CardHeaderLayout
// ---------------------------------------------------------------------------
/**
* A card header layout that pairs a {@link Content} block (with `p-2`)
* with a right-side column.
*
* The right column contains two vertically stacked slots —
* `rightChildren` on top, `bottomRightChildren` below — with no
* padding or gap between them.
*
* @example
* ```tsx
* <CardHeaderLayout
* icon={SvgGlobe}
* title="Google"
* description="Search engine"
* sizePreset="main-ui"
* variant="section"
* rightChildren={<Button>Connect</Button>}
* bottomRightChildren={
* <>
* <Button icon={SvgUnplug} size="sm" prominence="tertiary" />
* <Button icon={SvgSettings} size="sm" prominence="tertiary" />
* </>
* }
* />
* ```
*/
function CardHeaderLayout({
rightChildren,
bottomRightChildren,
...contentProps
}: CardHeaderLayoutProps) {
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>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Exports
// ---------------------------------------------------------------------------
export { CardHeaderLayout, type CardHeaderLayoutProps };

View File

@@ -4,10 +4,8 @@ import { Button } from "@opal/components/buttons/button/components";
import type { ContainerSizeVariants } from "@opal/types";
import SvgEdit from "@opal/icons/edit";
import type { IconFunctionComponent, RichStr } from "@opal/types";
import {
resolveStr,
toPlainString,
} from "@opal/components/text/InlineMarkdown";
import { Text, type TextFont } from "@opal/components/text/components";
import { toPlainString } from "@opal/components/text/InlineMarkdown";
import { cn } from "@opal/utils";
import { useState } from "react";
@@ -24,8 +22,8 @@ interface ContentLgPresetConfig {
iconContainerPadding: string;
/** Gap between icon container and content (CSS value). */
gap: string;
/** Tailwind font class for the title. */
titleFont: string;
/** Opal font name for the title (without `font-` prefix). */
titleFont: TextFont;
/** Title line-height — also used as icon container min-height (CSS value). */
lineHeight: string;
/** Button `size` prop for the edit button. Uses the shared `SizeVariant` scale. */
@@ -53,9 +51,6 @@ interface ContentLgProps {
/** Size preset. Default: `"headline"`. */
sizePreset?: ContentLgSizePreset;
/** When `true`, the title color hooks into `Interactive`'s `--interactive-foreground` variable. */
withInteractive?: boolean;
/** Ref forwarded to the root `<div>`. */
ref?: React.Ref<HTMLDivElement>;
}
@@ -69,7 +64,7 @@ const CONTENT_LG_PRESETS: Record<ContentLgSizePreset, ContentLgPresetConfig> = {
iconSize: "2rem",
iconContainerPadding: "p-0.5",
gap: "0.25rem",
titleFont: "font-heading-h2",
titleFont: "heading-h2",
lineHeight: "2.25rem",
editButtonSize: "md",
editButtonPadding: "p-1",
@@ -78,7 +73,7 @@ const CONTENT_LG_PRESETS: Record<ContentLgSizePreset, ContentLgPresetConfig> = {
iconSize: "1.25rem",
iconContainerPadding: "p-1",
gap: "0rem",
titleFont: "font-heading-h3-muted",
titleFont: "heading-h3-muted",
lineHeight: "1.75rem",
editButtonSize: "sm",
editButtonPadding: "p-0.5",
@@ -96,7 +91,6 @@ function ContentLg({
description,
editable,
onTitleChange,
withInteractive,
ref,
}: ContentLgProps) {
const [editing, setEditing] = useState(false);
@@ -116,12 +110,7 @@ function ContentLg({
}
return (
<div
ref={ref}
className="opal-content-lg"
data-interactive={withInteractive || undefined}
style={{ gap: config.gap }}
>
<div ref={ref} className="opal-content-lg" style={{ gap: config.gap }}>
{Icon && (
<div
className={cn(
@@ -142,14 +131,17 @@ function ContentLg({
{editing ? (
<div className="opal-content-lg-input-sizer">
<span
className={cn("opal-content-lg-input-mirror", config.titleFont)}
className={cn(
"opal-content-lg-input-mirror",
`font-${config.titleFont}`
)}
>
{editValue || "\u00A0"}
</span>
<input
className={cn(
"opal-content-lg-input",
config.titleFont,
`font-${config.titleFont}`,
"text-text-04"
)}
value={editValue}
@@ -169,19 +161,15 @@ function ContentLg({
/>
</div>
) : (
<span
className={cn(
"opal-content-lg-title",
config.titleFont,
"text-text-04",
editable && "cursor-pointer"
)}
onClick={editable ? startEditing : undefined}
style={{ height: config.lineHeight }}
<Text
font={config.titleFont}
color="inherit"
maxLines={1}
title={toPlainString(title)}
onClick={editable ? startEditing : undefined}
>
{resolveStr(title)}
</span>
{title}
</Text>
)}
{editable && !editing && (
@@ -204,8 +192,10 @@ function ContentLg({
</div>
{description && toPlainString(description) && (
<div className="opal-content-lg-description font-secondary-body text-text-03">
{resolveStr(description)}
<div className="opal-content-lg-description">
<Text font="secondary-body" color="text-03" as="p">
{description}
</Text>
</div>
)}
</div>

View File

@@ -8,10 +8,8 @@ import SvgAlertTriangle from "@opal/icons/alert-triangle";
import SvgEdit from "@opal/icons/edit";
import SvgXOctagon from "@opal/icons/x-octagon";
import type { IconFunctionComponent, RichStr } from "@opal/types";
import {
resolveStr,
toPlainString,
} from "@opal/components/text/InlineMarkdown";
import { Text, type TextFont } from "@opal/components/text/components";
import { toPlainString } from "@opal/components/text/InlineMarkdown";
import { cn } from "@opal/utils";
import { useRef, useState } from "react";
@@ -23,16 +21,18 @@ type ContentMdSizePreset = "main-content" | "main-ui" | "secondary";
type ContentMdAuxIcon = "info-gray" | "info-blue" | "warning" | "error";
type ContentMdSuffix = "optional" | (string & {});
interface ContentMdPresetConfig {
iconSize: string;
iconContainerPadding: string;
iconColorClass: string;
titleFont: string;
titleFont: TextFont;
lineHeight: string;
/** Button `size` prop for the edit button. Uses the shared `SizeVariant` scale. */
editButtonSize: ContainerSizeVariants;
editButtonPadding: string;
optionalFont: string;
optionalFont: TextFont;
/** Aux icon size = lineHeight 2 × p-0.5. */
auxIconSize: string;
/** Left indent for the description so it aligns with the title (past the icon). */
@@ -55,11 +55,11 @@ interface ContentMdProps {
/** Called when the user commits an edit. */
onTitleChange?: (newTitle: string) => void;
/** When `true`, renders "(Optional)" beside the title. */
optional?: boolean;
/** Custom muted suffix rendered beside the title. */
titleSuffix?: string;
/**
* Muted suffix rendered beside the title.
* Use `"optional"` for the standard "(Optional)" label, or pass any string.
*/
suffix?: ContentMdSuffix;
/** Auxiliary status icon rendered beside the title. */
auxIcon?: ContentMdAuxIcon;
@@ -70,18 +70,6 @@ interface ContentMdProps {
/** Size preset. Default: `"main-ui"`. */
sizePreset?: ContentMdSizePreset;
/** When `true`, the title color hooks into `Interactive`'s `--interactive-foreground` variable. */
withInteractive?: boolean;
/** Optional class name applied to the title element. */
titleClassName?: string;
/** Optional class name applied to the icon element. */
iconClassName?: string;
/** Content rendered below the description, indented to align with it. */
bottomChildren?: React.ReactNode;
/** Ref forwarded to the root `<div>`. */
ref?: React.Ref<HTMLDivElement>;
}
@@ -95,11 +83,11 @@ const CONTENT_MD_PRESETS: Record<ContentMdSizePreset, ContentMdPresetConfig> = {
iconSize: "1rem",
iconContainerPadding: "p-1",
iconColorClass: "text-text-04",
titleFont: "font-main-content-emphasis",
titleFont: "main-content-emphasis",
lineHeight: "1.5rem",
editButtonSize: "sm",
editButtonPadding: "p-0",
optionalFont: "font-main-content-muted",
optionalFont: "main-content-muted",
auxIconSize: "1.25rem",
descriptionIndent: "1.625rem",
},
@@ -107,11 +95,11 @@ const CONTENT_MD_PRESETS: Record<ContentMdSizePreset, ContentMdPresetConfig> = {
iconSize: "1rem",
iconContainerPadding: "p-0.5",
iconColorClass: "text-text-03",
titleFont: "font-main-ui-action",
titleFont: "main-ui-action",
lineHeight: "1.25rem",
editButtonSize: "xs",
editButtonPadding: "p-0",
optionalFont: "font-main-ui-muted",
optionalFont: "main-ui-muted",
auxIconSize: "1rem",
descriptionIndent: "1.375rem",
},
@@ -119,11 +107,11 @@ const CONTENT_MD_PRESETS: Record<ContentMdSizePreset, ContentMdPresetConfig> = {
iconSize: "0.75rem",
iconContainerPadding: "p-0.5",
iconColorClass: "text-text-04",
titleFont: "font-secondary-action",
titleFont: "secondary-action",
lineHeight: "1rem",
editButtonSize: "2xs",
editButtonPadding: "p-0",
optionalFont: "font-secondary-action",
optionalFont: "secondary-action",
auxIconSize: "0.75rem",
descriptionIndent: "1.125rem",
},
@@ -149,15 +137,10 @@ function ContentMd({
description,
editable,
onTitleChange,
optional,
titleSuffix,
suffix,
auxIcon,
tag,
sizePreset = "main-ui",
withInteractive,
titleClassName,
iconClassName,
bottomChildren,
ref,
}: ContentMdProps) {
const [editing, setEditing] = useState(false);
@@ -178,11 +161,7 @@ function ContentMd({
}
return (
<div
ref={ref}
className="opal-content-md"
data-interactive={withInteractive || undefined}
>
<div ref={ref} className="opal-content-md">
<div
className="opal-content-md-header"
data-editing={editing || undefined}
@@ -196,11 +175,7 @@ function ContentMd({
style={{ minHeight: config.lineHeight }}
>
<Icon
className={cn(
"opal-content-md-icon",
config.iconColorClass,
iconClassName
)}
className={cn("opal-content-md-icon", config.iconColorClass)}
style={{ width: config.iconSize, height: config.iconSize }}
/>
</div>
@@ -210,7 +185,10 @@ function ContentMd({
{editing ? (
<div className="opal-content-md-input-sizer">
<span
className={cn("opal-content-md-input-mirror", config.titleFont)}
className={cn(
"opal-content-md-input-mirror",
`font-${config.titleFont}`
)}
>
{editValue || "\u00A0"}
</span>
@@ -218,7 +196,7 @@ function ContentMd({
ref={inputRef}
className={cn(
"opal-content-md-input",
config.titleFont,
`font-${config.titleFont}`,
"text-text-04"
)}
value={editValue}
@@ -238,29 +216,21 @@ function ContentMd({
/>
</div>
) : (
<span
className={cn(
"opal-content-md-title",
config.titleFont,
"text-text-04",
editable && "cursor-pointer",
titleClassName
)}
<Text
font={config.titleFont}
color="inherit"
maxLines={1}
title={toPlainString(title)}
onClick={editable ? startEditing : undefined}
style={{ height: config.lineHeight }}
>
{resolveStr(title)}
</span>
{title}
</Text>
)}
{(optional || titleSuffix) && (
<span
className={cn(config.optionalFont, "text-text-03 shrink-0")}
style={{ height: config.lineHeight }}
>
{titleSuffix ?? "(Optional)"}
</span>
{suffix && (
<Text font={config.optionalFont} color="text-03">
{suffix === "optional" ? "(Optional)" : suffix}
</Text>
)}
{auxIcon &&
@@ -306,17 +276,12 @@ function ContentMd({
{description && toPlainString(description) && (
<div
className="opal-content-md-description font-secondary-body text-text-03"
className="opal-content-md-description"
style={Icon ? { paddingLeft: config.descriptionIndent } : undefined}
>
{resolveStr(description)}
</div>
)}
{bottomChildren && (
<div
style={Icon ? { paddingLeft: config.descriptionIndent } : undefined}
>
{bottomChildren}
<Text font="secondary-body" color="text-03" as="p">
{description}
</Text>
</div>
)}
</div>
@@ -327,5 +292,6 @@ export {
ContentMd,
type ContentMdProps,
type ContentMdSizePreset,
type ContentMdSuffix,
type ContentMdAuxIcon,
};

View File

@@ -1,10 +1,8 @@
"use client";
import type { IconFunctionComponent, RichStr } from "@opal/types";
import {
resolveStr,
toPlainString,
} from "@opal/components/text/InlineMarkdown";
import { Text, type TextFont } from "@opal/components/text/components";
import { toPlainString } from "@opal/components/text/InlineMarkdown";
import { cn } from "@opal/utils";
// ---------------------------------------------------------------------------
@@ -13,15 +11,15 @@ import { cn } from "@opal/utils";
type ContentSmSizePreset = "main-content" | "main-ui" | "secondary";
type ContentSmOrientation = "vertical" | "inline" | "reverse";
type ContentSmProminence = "default" | "muted" | "muted-2x";
type ContentSmProminence = "default" | "muted";
interface ContentSmPresetConfig {
/** Icon width/height (CSS value). */
iconSize: string;
/** Tailwind padding class for the icon container. */
iconContainerPadding: string;
/** Tailwind font class for the title. */
titleFont: string;
/** Font preset for the title. */
titleFont: TextFont;
/** Title line-height — also used as icon container min-height (CSS value). */
lineHeight: string;
/** Gap between icon container and title (CSS value). */
@@ -45,9 +43,6 @@ interface ContentSmProps {
/** Title prominence. Default: `"default"`. */
prominence?: ContentSmProminence;
/** When `true`, the title color hooks into `Interactive`'s `--interactive-foreground` variable. */
withInteractive?: boolean;
/** Ref forwarded to the root `<div>`. */
ref?: React.Ref<HTMLDivElement>;
}
@@ -60,21 +55,21 @@ const CONTENT_SM_PRESETS: Record<ContentSmSizePreset, ContentSmPresetConfig> = {
"main-content": {
iconSize: "1rem",
iconContainerPadding: "p-1",
titleFont: "font-main-content-body",
titleFont: "main-content-body",
lineHeight: "1.5rem",
gap: "0.125rem",
},
"main-ui": {
iconSize: "1rem",
iconContainerPadding: "p-0.5",
titleFont: "font-main-ui-action",
titleFont: "main-ui-action",
lineHeight: "1.25rem",
gap: "0.25rem",
},
secondary: {
iconSize: "0.75rem",
iconContainerPadding: "p-0.5",
titleFont: "font-secondary-action",
titleFont: "secondary-action",
lineHeight: "1rem",
gap: "0.125rem",
},
@@ -90,7 +85,6 @@ function ContentSm({
sizePreset = "main-ui",
orientation = "inline",
prominence = "default",
withInteractive,
ref,
}: ContentSmProps) {
const config = CONTENT_SM_PRESETS[sizePreset];
@@ -101,7 +95,6 @@ function ContentSm({
className="opal-content-sm"
data-orientation={orientation}
data-prominence={prominence}
data-interactive={withInteractive || undefined}
style={{ gap: config.gap }}
>
{Icon && (
@@ -119,13 +112,14 @@ function ContentSm({
</div>
)}
<span
className={cn("opal-content-sm-title", config.titleFont)}
style={{ height: config.lineHeight }}
<Text
font={config.titleFont}
color="inherit"
maxLines={1}
title={toPlainString(title)}
>
{resolveStr(title)}
</span>
{title}
</Text>
</div>
);
}

View File

@@ -4,10 +4,8 @@ import { Button } from "@opal/components/buttons/button/components";
import type { ContainerSizeVariants } from "@opal/types";
import SvgEdit from "@opal/icons/edit";
import type { IconFunctionComponent, RichStr } from "@opal/types";
import {
resolveStr,
toPlainString,
} from "@opal/components/text/InlineMarkdown";
import { Text, type TextFont } from "@opal/components/text/components";
import { toPlainString } from "@opal/components/text/InlineMarkdown";
import { cn } from "@opal/utils";
import { useState } from "react";
@@ -30,8 +28,8 @@ interface ContentXlPresetConfig {
moreIcon2Size: string;
/** Tailwind padding class for the more-icon-2 container. */
moreIcon2ContainerPadding: string;
/** Tailwind font class for the title. */
titleFont: string;
/** Opal font name for the title (without `font-` prefix). */
titleFont: TextFont;
/** Title line-height — also used as icon container min-height (CSS value). */
lineHeight: string;
/** Button `size` prop for the edit button. Uses the shared `SizeVariant` scale. */
@@ -65,9 +63,6 @@ interface ContentXlProps {
/** Optional tertiary icon rendered in the icon row. */
moreIcon2?: IconFunctionComponent;
/** When `true`, the title color hooks into `Interactive`'s `--interactive-foreground` variable. */
withInteractive?: boolean;
/** Ref forwarded to the root `<div>`. */
ref?: React.Ref<HTMLDivElement>;
}
@@ -84,7 +79,7 @@ const CONTENT_XL_PRESETS: Record<ContentXlSizePreset, ContentXlPresetConfig> = {
moreIcon1ContainerPadding: "p-0.5",
moreIcon2Size: "2rem",
moreIcon2ContainerPadding: "p-0.5",
titleFont: "font-heading-h2",
titleFont: "heading-h2",
lineHeight: "2.25rem",
editButtonSize: "md",
editButtonPadding: "p-1",
@@ -96,7 +91,7 @@ const CONTENT_XL_PRESETS: Record<ContentXlSizePreset, ContentXlPresetConfig> = {
moreIcon1ContainerPadding: "p-0.5",
moreIcon2Size: "1.5rem",
moreIcon2ContainerPadding: "p-0.5",
titleFont: "font-heading-h3",
titleFont: "heading-h3",
lineHeight: "1.75rem",
editButtonSize: "sm",
editButtonPadding: "p-0.5",
@@ -116,7 +111,6 @@ function ContentXl({
onTitleChange,
moreIcon1: MoreIcon1,
moreIcon2: MoreIcon2,
withInteractive,
ref,
}: ContentXlProps) {
const [editing, setEditing] = useState(false);
@@ -136,11 +130,7 @@ function ContentXl({
}
return (
<div
ref={ref}
className="opal-content-xl"
data-interactive={withInteractive || undefined}
>
<div ref={ref} className="opal-content-xl">
{(Icon || MoreIcon1 || MoreIcon2) && (
<div className="opal-content-xl-icon-row">
{Icon && (
@@ -199,14 +189,17 @@ function ContentXl({
{editing ? (
<div className="opal-content-xl-input-sizer">
<span
className={cn("opal-content-xl-input-mirror", config.titleFont)}
className={cn(
"opal-content-xl-input-mirror",
`font-${config.titleFont}`
)}
>
{editValue || "\u00A0"}
</span>
<input
className={cn(
"opal-content-xl-input",
config.titleFont,
`font-${config.titleFont}`,
"text-text-04"
)}
value={editValue}
@@ -226,19 +219,15 @@ function ContentXl({
/>
</div>
) : (
<span
className={cn(
"opal-content-xl-title",
config.titleFont,
"text-text-04",
editable && "cursor-pointer"
)}
onClick={editable ? startEditing : undefined}
style={{ height: config.lineHeight }}
<Text
font={config.titleFont}
color="inherit"
maxLines={1}
title={toPlainString(title)}
onClick={editable ? startEditing : undefined}
>
{resolveStr(title)}
</span>
{title}
</Text>
)}
{editable && !editing && (
@@ -261,8 +250,10 @@ function ContentXl({
</div>
{description && toPlainString(description) && (
<div className="opal-content-xl-description font-secondary-body text-text-03">
{resolveStr(description)}
<div className="opal-content-xl-description">
<Text font="secondary-body" color="text-03" as="p">
{description}
</Text>
</div>
)}
</div>

View File

@@ -1,39 +1,3 @@
// ---------------------------------------------------------------------------
// NOTE (@raunakab): Why Content uses resolveStr() instead of <Text>
//
// Content sub-components (ContentXl, ContentLg, ContentMd, ContentSm) render
// titles and descriptions inside styled <span> elements that carry CSS classes
// (e.g., `.opal-content-md-title`) for:
//
// 1. Truncation — `-webkit-box` + `-webkit-line-clamp` for single-line
// clamping with ellipsis. This requires the text to be a DIRECT child
// of the `-webkit-box` element. Wrapping it in a child <span> (which
// is what <Text> renders) breaks the clamping behavior.
//
// 2. Pixel-exact sizing — the wrapper <span> has an explicit `height`
// matching the font's `line-height`. Adding a child <Text> <span>
// inside creates a double-span where the inner element's line-height
// conflicts with the outer element's height, causing a ~4px vertical
// offset.
//
// 3. Interactive color overrides — CSS selectors like
// `.opal-content-md[data-interactive] .opal-content-md-title` set
// `color: var(--interactive-foreground)`. <Text> with `color="inherit"`
// can inherit this, but <Text> with any explicit color prop overrides
// it. And the wrapper <span> needs the CSS class for the selector to
// match — removing it breaks the cascade.
//
// 4. Horizontal padding — the title CSS class applies `padding: 0 0.125rem`
// (2px). Since <Text> uses WithoutStyles (no className/style), this
// padding cannot be applied to <Text> directly. A wrapper <div> was
// attempted but introduced additional layout conflicts.
//
// For these reasons, Content uses `resolveStr()` from InlineMarkdown.tsx to
// handle `string | RichStr` rendering. `resolveStr()` returns a ReactNode
// that slots directly into the existing single <span> — no extra wrapper,
// no layout conflicts, pixel-exact match with main.
// ---------------------------------------------------------------------------
import "@opal/layouts/content/styles.css";
import {
ContentSm,
@@ -98,9 +62,6 @@ interface ContentBaseProps {
*/
widthVariant?: ExtremaSizeVariants;
/** When `true`, the title color hooks into `Interactive.Stateful`/`Interactive.Stateless`'s `--interactive-foreground` variable. */
withInteractive?: boolean;
/** Ref forwarded to the root `<div>` of the resolved layout. */
ref?: React.Ref<HTMLDivElement>;
}
@@ -130,20 +91,12 @@ type LgContentProps = ContentBaseProps & {
type MdContentProps = ContentBaseProps & {
sizePreset: "main-content" | "main-ui" | "secondary";
variant?: "section";
/** When `true`, renders "(Optional)" beside the title in the muted font variant. */
optional?: boolean;
/** Custom muted suffix rendered beside the title. */
titleSuffix?: string;
/** Muted suffix rendered beside the title. Use `"optional"` for "(Optional)". */
suffix?: "optional" | (string & {});
/** Auxiliary status icon rendered beside the title. */
auxIcon?: "info-gray" | "info-blue" | "warning" | "error";
/** Tag rendered beside the title. */
tag?: TagProps;
/** Optional class name applied to the title element. */
titleClassName?: string;
/** Optional class name applied to the icon element. */
iconClassName?: string;
/** Content rendered below the description, indented to align with it. */
bottomChildren?: React.ReactNode;
};
/** ContentSm does not support descriptions or inline editing. */
@@ -174,7 +127,6 @@ function Content(props: ContentProps) {
sizePreset = "headline",
variant = "heading",
widthVariant = "full",
withInteractive,
ref,
...rest
} = props;
@@ -187,7 +139,6 @@ function Content(props: ContentProps) {
layout = (
<ContentXl
sizePreset={sizePreset}
withInteractive={withInteractive}
ref={ref}
{...(rest as Omit<ContentXlProps, "sizePreset">)}
/>
@@ -196,7 +147,6 @@ function Content(props: ContentProps) {
layout = (
<ContentLg
sizePreset={sizePreset}
withInteractive={withInteractive}
ref={ref}
{...(rest as Omit<ContentLgProps, "sizePreset">)}
/>
@@ -210,7 +160,6 @@ function Content(props: ContentProps) {
layout = (
<ContentMd
sizePreset={sizePreset}
withInteractive={withInteractive}
ref={ref}
{...(rest as Omit<ContentMdProps, "sizePreset">)}
/>
@@ -222,7 +171,6 @@ function Content(props: ContentProps) {
layout = (
<ContentSm
sizePreset={sizePreset}
withInteractive={withInteractive}
ref={ref}
{...(rest as Omit<
React.ComponentProps<typeof ContentSm>,

View File

@@ -13,7 +13,7 @@
--------------------------------------------------------------------------- */
.opal-content-xl {
@apply flex flex-col items-start;
@apply flex flex-col items-start text-text-04;
}
/* ---------------------------------------------------------------------------
@@ -63,15 +63,6 @@
gap: 0.25rem;
}
.opal-content-xl-title {
@apply text-left overflow-hidden text-text-04;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
padding: 0 0.125rem;
min-width: 0.0625rem;
}
.opal-content-xl-input-sizer {
display: inline-grid;
align-items: stretch;
@@ -110,7 +101,6 @@
.opal-content-xl-description {
@apply text-left w-full;
padding: 0 0.125rem;
}
/* ===========================================================================
@@ -127,7 +117,7 @@
--------------------------------------------------------------------------- */
.opal-content-lg {
@apply flex flex-row items-start;
@apply flex flex-row items-start text-text-04;
}
/* ---------------------------------------------------------------------------
@@ -162,15 +152,6 @@
gap: 0.25rem;
}
.opal-content-lg-title {
@apply text-left overflow-hidden text-text-04;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
padding: 0 0.125rem;
min-width: 0.0625rem;
}
.opal-content-lg-input-sizer {
display: inline-grid;
align-items: stretch;
@@ -209,7 +190,6 @@
.opal-content-lg-description {
@apply text-left w-full;
padding: 0 0.125rem;
}
/* ===========================================================================
@@ -224,7 +204,7 @@
--------------------------------------------------------------------------- */
.opal-content-md {
@apply flex flex-col items-start;
@apply flex flex-col items-start text-text-04;
}
.opal-content-md-header {
@@ -255,15 +235,6 @@
gap: 0.25rem;
}
.opal-content-md-title {
@apply text-left overflow-hidden text-text-04;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
padding: 0 0.125rem;
min-width: 0.0625rem;
}
.opal-content-md-input-sizer {
display: inline-grid;
align-items: stretch;
@@ -313,7 +284,6 @@
.opal-content-md-description {
@apply text-left w-full;
padding: 0 0.125rem;
}
/* ===========================================================================
@@ -325,7 +295,7 @@
reverse : flex-row-reverse — title left, icon right
Icon color is always text-03. Title color varies by prominence
(text-04 default, text-03 muted, text-02 muted-2x) via data-prominence.
(text-04 default, text-03 muted) via data-prominence.
=========================================================================== */
/* ---------------------------------------------------------------------------
@@ -334,7 +304,7 @@
.opal-content-sm {
/* since `ContentSm` doesn't have a description, it's possible to center-align the icon and text */
@apply flex items-center;
@apply flex items-center text-text-04;
}
.opal-content-sm[data-orientation="inline"] {
@@ -367,68 +337,51 @@
@apply text-text-02;
}
.opal-content-sm[data-prominence="muted-2x"] .opal-content-sm-icon {
@apply text-text-02;
}
/* ---------------------------------------------------------------------------
Title
--------------------------------------------------------------------------- */
.opal-content-sm-title {
@apply text-left overflow-hidden text-text-04 truncate;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
padding: 0 0.125rem;
min-width: 0.0625rem;
}
.opal-content-sm[data-prominence="muted"] .opal-content-sm-title {
.opal-content-sm[data-prominence="muted"] {
@apply text-text-03;
}
.opal-content-sm[data-prominence="muted-2x"] .opal-content-sm-title {
@apply text-text-02;
}
/* ===========================================================================
Interactive-foreground opt-in
Interactive override
When a Content variant is nested inside an Interactive and
`withInteractive` is set, the title and icon delegate their color to the
`--interactive-foreground` / `--interactive-foreground-icon` CSS variables
controlled by the ancestor Interactive variant.
When a Content variant is nested inside an `.interactive` element,
the title inherits color from the Interactive's `--interactive-foreground`
and icons switch to `--interactive-foreground-icon`. This is automatic —
no opt-in prop is required.
=========================================================================== */
.opal-content-xl[data-interactive] .opal-content-xl-title {
color: var(--interactive-foreground);
.interactive .opal-content-xl {
color: inherit;
}
.opal-content-xl[data-interactive] .opal-content-xl-icon {
.interactive .opal-content-xl .opal-content-xl-icon {
color: var(--interactive-foreground-icon);
}
.opal-content-lg[data-interactive] .opal-content-lg-title {
color: var(--interactive-foreground);
.interactive .opal-content-lg {
color: inherit;
}
.opal-content-lg[data-interactive] .opal-content-lg-icon {
.interactive .opal-content-lg .opal-content-lg-icon {
color: var(--interactive-foreground-icon);
}
.opal-content-md[data-interactive] .opal-content-md-title {
color: var(--interactive-foreground);
.interactive .opal-content-md {
color: inherit;
}
.opal-content-md[data-interactive] .opal-content-md-icon {
.interactive .opal-content-md .opal-content-md-icon {
color: var(--interactive-foreground-icon);
}
.opal-content-sm[data-interactive] .opal-content-sm-title {
color: var(--interactive-foreground);
.interactive .opal-content-sm {
color: inherit;
}
.opal-content-sm[data-interactive] .opal-content-sm-icon {
.interactive .opal-content-sm .opal-content-sm-icon {
color: var(--interactive-foreground-icon);
}

View File

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

View File

@@ -6,7 +6,14 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/** Wraps a string for inline markdown parsing by `Text` and other Opal components. */
export function markdown(content: string): RichStr {
return { __brand: "RichStr", raw: content };
/**
* Wraps strings for inline markdown parsing by `Text` and other Opal components.
*
* Multiple arguments are joined with newlines, so each string renders on its own line:
* ```tsx
* markdown("Line one", "Line two", "Line three")
* ```
*/
export function markdown(...lines: string[]): RichStr {
return { __brand: "RichStr", raw: lines.join("\n") };
}

View File

@@ -229,7 +229,7 @@ export const RenderField: FC<RenderFieldProps> = ({
name={field.name}
title={label}
description={description}
optional={field.optional}
suffix={field.optional ? "optional" : undefined}
>
<InputTextAreaField
name={field.name}

View File

@@ -1,5 +1,5 @@
import { SvgDownload, SvgKey, SvgRefreshCw } from "@opal/icons";
import { Interactive, Hoverable } from "@opal/core";
import { Interactive } from "@opal/core";
import { Section } from "@/layouts/general-layouts";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
@@ -83,30 +83,27 @@ export default function ScimModal({
onClose={onClose}
/>
<Modal.Body>
<Hoverable.Root group="token">
<Interactive.Stateless
onClick={() => copyToClipboard(view.rawToken)}
>
<InputTextArea
value={view.rawToken}
readOnly
autoResize
resizable={false}
rows={2}
className="font-main-ui-mono break-all cursor-pointer [&_textarea]:cursor-pointer"
rightSection={
<div onClick={(e) => e.stopPropagation()}>
<Hoverable.Item
group="token"
variant="opacity-on-hover"
>
<CopyIconButton getCopyText={() => view.rawToken} />
</Hoverable.Item>
</div>
}
/>
</Interactive.Stateless>
</Hoverable.Root>
<Interactive.Stateless
group="group/token"
onClick={() => copyToClipboard(view.rawToken)}
>
<InputTextArea
value={view.rawToken}
readOnly
autoResize
resizable={false}
rows={2}
className="font-main-ui-mono break-all cursor-pointer [&_textarea]:cursor-pointer"
rightSection={
<div
className="opacity-0 group-hover/token:opacity-100 transition-opacity"
onClick={(e) => e.stopPropagation()}
>
<CopyIconButton getCopyText={() => view.rawToken} />
</div>
}
/>
</Interactive.Stateless>
</Modal.Body>
<Modal.Footer>
<BasicModalFooter

View File

@@ -4,7 +4,6 @@ import { ImageShape } from "@/app/app/services/streamingModels";
import { FullImageModal } from "@/app/app/components/files/images/FullImageModal";
import { buildImgUrl } from "@/app/app/components/files/images/utils";
import { Button } from "@opal/components";
import { Hoverable } from "@opal/core";
import { cn } from "@/lib/utils";
const DEFAULT_SHAPE: ImageShape = "square";
@@ -77,42 +76,42 @@ export const InMessageImage = memo(function InMessageImage({
onOpenChange={(open) => setFullImageShowing(open)}
/>
<Hoverable.Root group="messageImage" widthVariant="fit">
<div className={cn("relative", shapeContainerClasses)}>
{!imageLoaded && (
<div className="absolute inset-0 bg-background-tint-02 animate-pulse rounded-lg" />
<div className={cn("relative group", shapeContainerClasses)}>
{!imageLoaded && (
<div className="absolute inset-0 bg-background-tint-02 animate-pulse rounded-lg" />
)}
<img
width={1200}
height={1200}
alt="Chat Message Image"
onLoad={() => {
loadedImages.add(fileId);
setImageLoaded(true);
}}
className={cn(
"object-contain object-left overflow-hidden rounded-lg w-full h-full transition-opacity duration-300 cursor-pointer",
shapeImageClasses,
imageLoaded ? "opacity-100" : "opacity-0"
)}
onClick={() => setFullImageShowing(true)}
src={buildImgUrl(fileId)}
loading="lazy"
/>
<img
width={1200}
height={1200}
alt="Chat Message Image"
onLoad={() => {
loadedImages.add(fileId);
setImageLoaded(true);
}}
className={cn(
"object-contain object-left overflow-hidden rounded-lg w-full h-full transition-opacity duration-300 cursor-pointer",
shapeImageClasses,
imageLoaded ? "opacity-100" : "opacity-0"
)}
onClick={() => setFullImageShowing(true)}
src={buildImgUrl(fileId)}
loading="lazy"
{/* Download button - appears on hover */}
<div
className={cn(
"absolute bottom-2 right-2 opacity-0 group-hover:opacity-100 z-10"
)}
>
<Button
icon={SvgDownload}
tooltip="Download"
onClick={handleDownload}
/>
{/* Download button - appears on hover */}
<div className="absolute bottom-2 right-2 z-10">
<Hoverable.Item group="messageImage" variant="opacity-on-hover">
<Button
icon={SvgDownload}
tooltip="Download"
onClick={handleDownload}
/>
</Hoverable.Item>
</div>
</div>
</Hoverable.Root>
</div>
</>
);
});

View File

@@ -20,7 +20,6 @@ import IconButton from "@/refresh-components/buttons/IconButton";
import ButtonRenaming from "@/refresh-components/buttons/ButtonRenaming";
import { UserFileStatus } from "../../projects/projectsService";
import { SvgAddLines, SvgEdit, SvgFiles, SvgFolderOpen } from "@opal/icons";
import { Hoverable } from "@opal/core";
export interface ProjectContextPanelProps {
projectTokenCount?: number;
@@ -134,40 +133,34 @@ export default function ProjectContextPanel({
<div className="flex flex-col gap-6 w-full max-w-[var(--app-page-main-content-width)] mx-auto p-4 pt-14 pb-6">
<div className="flex flex-col gap-1 text-text-04">
<SvgFolderOpen className="h-8 w-8 text-text-04" />
<Hoverable.Root group="projectName" widthVariant="fit">
<div className="flex items-center gap-2">
{isEditingName ? (
<ButtonRenaming
initialName={projectName}
onRename={async (newName) => {
if (currentProjectId) {
await renameProject(currentProjectId, newName);
}
}}
onClose={cancelEditing}
className="font-heading-h2 text-text-04"
<div className="group flex items-center gap-2">
{isEditingName ? (
<ButtonRenaming
initialName={projectName}
onRename={async (newName) => {
if (currentProjectId) {
await renameProject(currentProjectId, newName);
}
}}
onClose={cancelEditing}
className="font-heading-h2 text-text-04"
/>
) : (
<>
<Text as="p" headingH2 className="font-heading-h2">
{projectName}
</Text>
{/* TODO(@raunakab): migrate to opal Button once className/iconClassName is resolved */}
<IconButton
icon={SvgEdit}
internal
onClick={startEditing}
className="opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity"
tooltip="Edit project name"
/>
) : (
<>
<Text as="p" headingH2 className="font-heading-h2">
{projectName}
</Text>
{/* TODO(@raunakab): migrate to opal Button once className/iconClassName is resolved */}
<Hoverable.Item
group="projectName"
variant="opacity-on-hover"
>
<IconButton
icon={SvgEdit}
internal
onClick={startEditing}
tooltip="Edit project name"
/>
</Hoverable.Item>
</>
)}
</div>
</Hoverable.Root>
</>
)}
</div>
</div>
<Separator className="py-0" />

View File

@@ -10,7 +10,6 @@ import useScreenSize from "@/hooks/useScreenSize";
import CopyIconButton from "@/refresh-components/buttons/CopyIconButton";
import { Button } from "@opal/components";
import { SvgEdit } from "@opal/icons";
import { Hoverable } from "@opal/core";
import FileDisplay from "./FileDisplay";
interface MessageEditingProps {
@@ -171,9 +170,9 @@ const HumanMessage = React.memo(function HumanMessage({
return undefined;
};
const copyEditButtonContent = useMemo(
const copyEditButton = useMemo(
() => (
<div className="flex flex-row flex-shrink px-1">
<div className="flex flex-row flex-shrink px-1 opacity-0 group-hover:opacity-100 transition-opacity">
<CopyIconButton
getCopyText={() => content}
prominence="tertiary"
@@ -191,94 +190,86 @@ const HumanMessage = React.memo(function HumanMessage({
[content]
);
const copyEditButton = (
<Hoverable.Item group="humanMessage" variant="opacity-on-hover">
{copyEditButtonContent}
</Hoverable.Item>
);
return (
<Hoverable.Root group="humanMessage" widthVariant="full">
<div
id="onyx-human-message"
className="flex flex-col justify-end w-full relative"
>
<FileDisplay files={files || []} />
{isEditing ? (
<MessageEditing
content={content}
onSubmitEdit={(editedContent) => {
// Don't update UI for edits that can't be persisted
if (messageId === undefined || messageId === null) {
setIsEditing(false);
return;
}
onEdit?.(editedContent, messageId);
setContent(editedContent);
<div
id="onyx-human-message"
className="group flex flex-col justify-end w-full relative"
>
<FileDisplay files={files || []} />
{isEditing ? (
<MessageEditing
content={content}
onSubmitEdit={(editedContent) => {
// Don't update UI for edits that can't be persisted
if (messageId === undefined || messageId === null) {
setIsEditing(false);
}}
onCancelEdit={() => setIsEditing(false)}
/>
) : (
<div className="flex justify-end">
{onEdit && !isMobile && copyEditButton}
<div className="md:max-w-[37.5rem]">
<div
className={
"max-w-[30rem] md:max-w-[37.5rem] whitespace-break-spaces break-anywhere rounded-t-16 rounded-bl-16 bg-background-tint-02 py-2 px-3"
return;
}
onEdit?.(editedContent, messageId);
setContent(editedContent);
setIsEditing(false);
}}
onCancelEdit={() => setIsEditing(false)}
/>
) : (
<div className="flex justify-end">
{onEdit && !isMobile && copyEditButton}
<div className="md:max-w-[37.5rem]">
<div
className={
"max-w-[30rem] md:max-w-[37.5rem] whitespace-break-spaces break-anywhere rounded-t-16 rounded-bl-16 bg-background-tint-02 py-2 px-3"
}
onCopy={(e) => {
const selection = window.getSelection();
if (selection) {
e.preventDefault();
const text = selection
.toString()
.replace(/\n{2,}/g, "\n")
.trim();
e.clipboardData.setData("text/plain", text);
}
onCopy={(e) => {
const selection = window.getSelection();
if (selection) {
e.preventDefault();
const text = selection
.toString()
.replace(/\n{2,}/g, "\n")
.trim();
e.clipboardData.setData("text/plain", text);
}
}}
}}
>
<Text
as="p"
className="inline-block align-middle"
mainContentBody
>
<Text
as="p"
className="inline-block align-middle"
mainContentBody
>
{content}
</Text>
</div>
{content}
</Text>
</div>
</div>
)}
<div className="flex justify-end pt-1">
{!isEditing && onEdit && isMobile && copyEditButton}
{currentMessageInd !== undefined &&
onMessageSelection &&
otherMessagesCanSwitchTo &&
otherMessagesCanSwitchTo.length > 1 && (
<MessageSwitcher
disableForStreaming={disableSwitchingForStreaming}
currentPage={currentMessageInd + 1}
totalPages={otherMessagesCanSwitchTo.length}
handlePrevious={() => {
stopGenerating();
const prevMessage = getPreviousMessage();
if (prevMessage !== undefined) {
onMessageSelection(prevMessage);
}
}}
handleNext={() => {
stopGenerating();
const nextMessage = getNextMessage();
if (nextMessage !== undefined) {
onMessageSelection(nextMessage);
}
}}
/>
)}
</div>
)}
<div className="flex justify-end pt-1">
{!isEditing && onEdit && isMobile && copyEditButton}
{currentMessageInd !== undefined &&
onMessageSelection &&
otherMessagesCanSwitchTo &&
otherMessagesCanSwitchTo.length > 1 && (
<MessageSwitcher
disableForStreaming={disableSwitchingForStreaming}
currentPage={currentMessageInd + 1}
totalPages={otherMessagesCanSwitchTo.length}
handlePrevious={() => {
stopGenerating();
const prevMessage = getPreviousMessage();
if (prevMessage !== undefined) {
onMessageSelection(prevMessage);
}
}}
handleNext={() => {
stopGenerating();
const nextMessage = getNextMessage();
if (nextMessage !== undefined) {
onMessageSelection(nextMessage);
}
}}
/>
)}
</div>
</Hoverable.Root>
</div>
);
}, arePropsEqual);

View File

@@ -182,7 +182,8 @@ export async function* sendMessage({
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
const data = await response.json().catch(() => ({}));
throw new Error(data.detail ?? `HTTP error! status: ${response.status}`);
}
yield* handleSSEStream<PacketType>(response, signal);

View File

@@ -901,6 +901,11 @@ export default function useChatController({
});
}
}
// Surface FIFO errors (e.g. 429 before any packets arrive) so the
// catch block replaces the thinking placeholder with an error message.
if (stack.error) {
throw new Error(stack.error);
}
} catch (e: any) {
console.log("Error:", e);
const errorMsg = e.message;

View File

@@ -15,9 +15,8 @@ interface OrientationLayoutProps {
nonInteractive?: boolean;
children?: React.ReactNode;
title: string | RichStr;
titleSuffix?: string;
description?: string | RichStr;
optional?: boolean;
suffix?: "optional" | (string & {});
sizePreset?: "main-content" | "main-ui";
}
@@ -53,18 +52,16 @@ function VerticalInputLayout({
children,
subDescription,
title,
titleSuffix,
description,
optional,
suffix,
sizePreset = "main-content",
}: VerticalLayoutProps) {
const content = (
<Section gap={0.25} alignItems="start">
<Content
title={title}
titleSuffix={titleSuffix}
description={description}
optional={optional}
suffix={suffix}
sizePreset={sizePreset}
variant="section"
/>
@@ -130,9 +127,8 @@ function HorizontalInputLayout({
children,
center,
title,
titleSuffix,
description,
optional,
suffix,
sizePreset = "main-content",
}: HorizontalLayoutProps) {
const content = (
@@ -145,9 +141,8 @@ function HorizontalInputLayout({
<div className="flex flex-col flex-1 min-w-0 self-stretch">
<Content
title={title}
titleSuffix={titleSuffix}
description={description}
optional={optional}
suffix={suffix}
sizePreset={sizePreset}
variant="section"
widthVariant="full"

View File

@@ -57,7 +57,7 @@ export default function SidebarTab({
const content = (
<div className="relative">
<Interactive.Stateful
variant="sidebar"
variant={lowlight ? "sidebar-light" : "sidebar-heavy"}
state={selected ? "selected" : "empty"}
onClick={onClick}
type="button"
@@ -90,9 +90,6 @@ export default function SidebarTab({
title={folded ? "" : children}
sizePreset="main-ui"
variant="body"
prominence={
lowlight ? "muted-2x" : selected ? "default" : "muted"
}
widthVariant="full"
paddingVariant="fit"
rightChildren={truncationSpacer}

View File

@@ -1,8 +1,8 @@
"use client";
import React from "react";
import { cn, noProp } from "@/lib/utils";
import { SvgPlus, SvgX } from "@opal/icons";
import { Hoverable } from "@opal/core";
import IconButton from "@/refresh-components/buttons/IconButton";
import SimpleTooltip from "@/refresh-components/SimpleTooltip";
import Text from "@/refresh-components/texts/Text";
@@ -173,106 +173,103 @@ export default function InputImage({
const dropzoneProps = onDrop ? getRootProps() : {};
return (
<Hoverable.Root group="inputImage" widthVariant="fit">
<div
className={cn("relative", className)}
style={{ width: size, height: size }}
{...dropzoneProps}
<div
className={cn("relative group", className)}
style={{ width: size, height: size }}
{...dropzoneProps}
>
{/* Hidden input for file selection */}
{onDrop && <input {...getInputProps()} />}
{/* Main container */}
<button
type="button"
onClick={handleClick}
disabled={disabled}
className={cn(
"relative w-full h-full rounded-full overflow-hidden",
"border flex items-center justify-center",
"transition-all duration-150",
containerClass
)}
aria-label={
isInteractive ? (hasImage ? "Edit image" : "Upload image") : undefined
}
>
{/* Hidden input for file selection */}
{onDrop && <input {...getInputProps()} />}
{/* Content */}
{hasImage ? (
<img
src={src}
alt={alt}
className="absolute inset-0 w-full h-full object-cover pointer-events-none"
/>
) : (
<SvgPlus
className={cn("w-6 h-6", placeholderClass, "pointer-events-none")}
/>
)}
{/* Main container */}
<button
type="button"
onClick={handleClick}
disabled={disabled}
className={cn(
"group relative w-full h-full rounded-full overflow-hidden",
"border flex items-center justify-center",
"transition-all duration-150",
containerClass
)}
aria-label={
isInteractive
? hasImage
? "Edit image"
: "Upload image"
: undefined
}
>
{/* Content */}
{hasImage ? (
<img
src={src}
alt={alt}
className="absolute inset-0 w-full h-full object-cover pointer-events-none"
/>
) : (
<SvgPlus
className={cn("w-6 h-6", placeholderClass, "pointer-events-none")}
/>
)}
{/* Drag overlay indicator */}
{isDragActive && (
<div className="absolute inset-0 bg-action-link-05/10 flex items-center justify-center rounded-full pointer-events-none">
<SvgPlus className="w-8 h-8 stroke-action-link-05" />
</div>
)}
{/* Drag overlay indicator */}
{isDragActive && (
<div className="absolute inset-0 bg-action-link-05/10 flex items-center justify-center rounded-full pointer-events-none">
<SvgPlus className="w-8 h-8 stroke-action-link-05" />
</div>
)}
{/* Edit overlay - shows on hover/focus when image is uploaded */}
{showEditOverlay && isInteractive && hasImage && !isDragActive && (
<div className="absolute bottom-0 left-0 right-0 pointer-events-none">
<Hoverable.Item group="inputImage" variant="opacity-on-hover">
{/* Edit overlay - shows on hover/focus when image is uploaded */}
{showEditOverlay && isInteractive && hasImage && !isDragActive && (
<div
className={cn(
"absolute bottom-0 left-0 right-0",
"flex items-center justify-center",
"pb-2.5 pt-1.5",
"opacity-0 group-hover:opacity-100 group-focus-within:opacity-100",
"transition-opacity duration-150",
"backdrop-blur-sm bg-mask-01",
"pointer-events-none"
)}
>
<div className="pointer-events-auto">
<SimpleTooltip tooltip="Edit" side="top">
<div
className={cn(
"flex items-center justify-center",
"pb-2.5 pt-1.5",
"backdrop-blur-sm bg-mask-01",
"pointer-events-none"
"px-1 py-0.5 rounded-08"
)}
>
<div className="pointer-events-auto">
<SimpleTooltip tooltip="Edit" side="top">
<div
className={cn(
"flex items-center justify-center",
"px-1 py-0.5 rounded-08"
)}
>
<Text
className="text-text-03 font-secondary-action"
style={{ fontSize: "12px", lineHeight: "16px" }}
>
Edit
</Text>
</div>
</SimpleTooltip>
</div>
<Text
className="text-text-03 font-secondary-action"
style={{ fontSize: "12px", lineHeight: "16px" }}
>
Edit
</Text>
</div>
</Hoverable.Item>
</SimpleTooltip>
</div>
)}
</button>
{/* Remove button - top left corner (only when image is uploaded) */}
{isInteractive && hasImage && onRemove && (
<div className="absolute top-1 left-1">
<Hoverable.Item group="inputImage" variant="opacity-on-hover">
{/* TODO(@raunakab): migrate to opal Button once className/iconClassName is resolved */}
<IconButton
icon={SvgX}
onClick={noProp(onRemove)}
type="button"
primary
className="!w-5 !h-5 !p-0.5 !rounded-04"
aria-label="Remove image"
/>
</Hoverable.Item>
</div>
)}
</div>
</Hoverable.Root>
</button>
{/* Remove button - top left corner (only when image is uploaded) */}
{isInteractive && hasImage && onRemove && (
<div
className={cn(
"absolute top-1 left-1",
"opacity-0 group-hover:opacity-100 group-focus-within:opacity-100",
"transition-opacity duration-150"
)}
>
{/* TODO(@raunakab): migrate to opal Button once className/iconClassName is resolved */}
<IconButton
icon={SvgX}
onClick={noProp(onRemove)}
type="button"
primary
className="!w-5 !h-5 !p-0.5 !rounded-04"
aria-label="Remove image"
/>
</div>
)}
</div>
);
}

View File

@@ -3,7 +3,6 @@ import type { FunctionComponent } from "react";
import { cn, noProp } from "@/lib/utils";
import { SvgMaximize2, SvgTextLines, SvgX } from "@opal/icons";
import type { IconProps } from "@opal/types";
import { Hoverable } from "@opal/core";
import IconButton from "../buttons/IconButton";
import Text from "../texts/Text";
import Truncated from "../texts/Truncated";
@@ -33,32 +32,25 @@ interface RemoveButtonProps {
function RemoveButton({ onRemove }: RemoveButtonProps) {
return (
<div
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
title="Remove"
aria-label="Remove"
className={cn(
"absolute -left-1 -top-1 z-10",
"pointer-events-none focus-within:pointer-events-auto"
"absolute -left-1 -top-1 z-10 h-4 w-4",
"flex items-center justify-center",
"rounded-full bg-theme-primary-05 text-text-inverted-05",
"opacity-0 group-hover/Tile:opacity-100 focus:opacity-100",
"pointer-events-none group-hover/Tile:pointer-events-auto focus:pointer-events-auto",
"transition-opacity duration-150"
)}
>
<Hoverable.Item group="fileTile" variant="opacity-on-hover">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
title="Remove"
aria-label="Remove"
className={cn(
"h-4 w-4",
"flex items-center justify-center",
"rounded-full bg-theme-primary-05 text-text-inverted-05",
"pointer-events-auto"
)}
>
<SvgX size={10} />
</button>
</Hoverable.Item>
</div>
<SvgX size={10} />
</button>
);
}
@@ -78,7 +70,7 @@ export default function FileTile({
const isMuted = state === "processing" || state === "disabled";
return (
<Hoverable.Root group="fileTile" widthVariant="fit">
<div className="group/Tile">
<div
onClick={onOpen && state !== "disabled" ? () => onOpen() : undefined}
className={cn(
@@ -91,8 +83,8 @@ export default function FileTile({
? "bg-background-neutral-02 border-border-01"
: "bg-background-tint-00 border-border-01",
// Hover overrides (disabled gets none)
state !== "disabled" && "hover:border-border-02",
state === "default" && "hover:bg-background-tint-02",
state !== "disabled" && "group-hover/Tile:border-border-02",
state === "default" && "group-hover/Tile:bg-background-tint-02",
// Clickable cursor when onOpen is provided and not disabled
onOpen && state !== "disabled" && "cursor-pointer"
)}
@@ -122,7 +114,7 @@ export default function FileTile({
text02
className={cn(
"truncate",
state === "processing" && "hover:text-text-03"
state === "processing" && "group-hover/Tile:text-text-03"
)}
>
{title}
@@ -134,7 +126,7 @@ export default function FileTile({
text02
className={cn(
"line-clamp-2",
state === "processing" && "hover:text-text-03"
state === "processing" && "group-hover/Tile:text-text-03"
)}
>
{description}
@@ -167,6 +159,6 @@ export default function FileTile({
</div>
)}
</div>
</Hoverable.Root>
</div>
);
}

View File

@@ -6,7 +6,7 @@ import * as SettingsLayouts from "@/layouts/settings-layouts";
import * as GeneralLayouts from "@/layouts/general-layouts";
import Button from "@/refresh-components/buttons/Button";
import { Button as OpalButton } from "@opal/components";
import { Disabled, Hoverable } from "@opal/core";
import { Disabled } from "@opal/core";
import { FullPersona } from "@/app/admin/agents/interfaces";
import { buildImgUrl } from "@/app/app/components/files/images/utils";
import { Formik, Form, FieldArray } from "formik";
@@ -212,25 +212,22 @@ function AgentIconEditor({ existingAgent }: AgentIconEditorProps) {
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<Popover.Trigger asChild>
<Hoverable.Root group="inputAvatar" widthVariant="fit">
<InputAvatar className="relative flex flex-col items-center justify-center h-[7.5rem] w-[7.5rem]">
{/* We take the `InputAvatar`'s height/width (in REM) and multiply it by 16 (the REM -> px conversion factor). */}
<CustomAgentAvatar
size={imageSrc ? 7.5 * 16 : 40}
src={imageSrc}
iconName={values.icon_name ?? undefined}
name={values.name}
/>
{/* TODO(@raunakab): migrate to opal Button once className/iconClassName is resolved */}
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 mb-2">
<Hoverable.Item group="inputAvatar" variant="opacity-on-hover">
<Button className="h-[1.75rem]" secondary>
Edit
</Button>
</Hoverable.Item>
</div>
</InputAvatar>
</Hoverable.Root>
<InputAvatar className="group/InputAvatar relative flex flex-col items-center justify-center h-[7.5rem] w-[7.5rem]">
{/* We take the `InputAvatar`'s height/width (in REM) and multiply it by 16 (the REM -> px conversion factor). */}
<CustomAgentAvatar
size={imageSrc ? 7.5 * 16 : 40}
src={imageSrc}
iconName={values.icon_name ?? undefined}
name={values.name}
/>
{/* TODO(@raunakab): migrate to opal Button once className/iconClassName is resolved */}
<Button
className="absolute bottom-0 left-1/2 -translate-x-1/2 h-[1.75rem] mb-2 invisible group-hover/InputAvatar:visible"
secondary
>
Edit
</Button>
</InputAvatar>
</Popover.Trigger>
<Popover.Content>
<PopoverMenu>
@@ -1271,7 +1268,7 @@ export default function AgentEditorPage({
<InputLayouts.Vertical
name="description"
title="Description"
optional
suffix="optional"
>
<InputTextAreaField
name="description"
@@ -1296,7 +1293,7 @@ export default function AgentEditorPage({
<InputLayouts.Vertical
name="instructions"
title="Instructions"
optional
suffix="optional"
description="Add instructions to tailor the response for this agent."
>
<InputTextAreaField
@@ -1309,7 +1306,7 @@ export default function AgentEditorPage({
name="starter_messages"
title="Conversation Starters"
description="Example messages that help users understand what this agent can do and how to interact with it effectively."
optional
suffix="optional"
>
<StarterMessages />
</InputLayouts.Vertical>
@@ -1549,7 +1546,7 @@ export default function AgentEditorPage({
<InputLayouts.Horizontal
name="knowledge_cutoff_date"
title="Knowledge Cutoff Date"
optional
suffix="optional"
description="Documents with a last-updated date prior to this will be ignored."
>
<InputDatePickerField
@@ -1560,7 +1557,7 @@ export default function AgentEditorPage({
<InputLayouts.Horizontal
name="replace_base_system_prompt"
title="Overwrite System Prompt"
titleSuffix="(Not Recommended)"
suffix="(Not Recommended)"
description='Remove the base system prompt which includes useful instructions (e.g. "You can use Markdown tables"). This may affect response quality.'
>
<SwitchField name="replace_base_system_prompt" />
@@ -1571,7 +1568,7 @@ export default function AgentEditorPage({
<InputLayouts.Vertical
name="reminders"
title="Reminders"
optional
suffix="optional"
>
<InputTextAreaField
name="reminders"

View File

@@ -54,7 +54,7 @@ import useOpenApiTools from "@/hooks/useOpenApiTools";
import * as ExpandableCard from "@/layouts/expandable-card-layouts";
import * as ActionsLayouts from "@/layouts/actions-layouts";
import { getActionIcon } from "@/lib/tools/mcpUtils";
import { Disabled, Hoverable } from "@opal/core";
import { Disabled } from "@opal/core";
import IconButton from "@/refresh-components/buttons/IconButton";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import useFilter from "@/hooks/useFilter";
@@ -281,7 +281,7 @@ function NumericLimitField({
};
return (
<Hoverable.Root group="numericLimit" widthVariant="full">
<div className="group w-full">
<InputTypeInField
name={name}
inputMode="numeric"
@@ -291,7 +291,7 @@ function NumericLimitField({
variant={isOverMax ? "error" : undefined}
rightSection={
(value || "") !== defaultValue ? (
<Hoverable.Item group="numericLimit" variant="opacity-on-hover">
<div className="opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity">
<IconButton
icon={SvgRefreshCw}
tooltip="Restore default"
@@ -299,12 +299,12 @@ function NumericLimitField({
type="button"
onClick={handleRestore}
/>
</Hoverable.Item>
</div>
) : undefined
}
onBlur={handleBlur}
/>
</Hoverable.Root>
</div>
);
}

View File

@@ -5,7 +5,8 @@ import { toast } from "@/hooks/useToast";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import { cn } from "@/lib/utils";
import { ContentAction } from "@opal/layouts";
import { markdown } from "@opal/utils";
import { Content } from "@opal/layouts";
import Card from "@/refresh-components/cards/Card";
import Text from "@/refresh-components/texts/Text";
import { Section } from "@/layouts/general-layouts";
@@ -310,110 +311,109 @@ export default function ConnectedHookCard({
!hook.is_active && "!bg-background-neutral-02"
)}
>
<ContentAction
sizePreset="main-ui"
variant="section"
paddingVariant="sm"
icon={HookIcon}
title={hook.name}
titleClassName={!hook.is_active ? "line-through" : undefined}
iconClassName="text-text-04"
description={`Hook Point: ${spec?.display_name ?? hook.hook_point}`}
bottomChildren={
spec?.docs_url ? (
<div className="w-full flex flex-row">
<div className="flex-1 p-2">
<Content
sizePreset="main-ui"
variant="section"
icon={HookIcon}
title={!hook.is_active ? markdown(`~~${hook.name}~~`) : hook.name}
description={`Hook Point: ${
spec?.display_name ?? hook.hook_point
}`}
/>
{spec?.docs_url && (
<a
href={spec.docs_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 w-fit font-secondary-body text-text-03"
className="pl-6 flex items-center gap-1"
>
<span className="underline">Documentation</span>
<span className="underline font-secondary-body text-text-03">
Documentation
</span>
<SvgExternalLink size={12} className="shrink-0" />
</a>
) : undefined
}
rightChildren={
<Section
flexDirection="column"
alignItems="end"
width="fit"
height="fit"
gap={0}
>
<div className="flex items-center gap-1 p-2">
)}
</div>
<Section
flexDirection="column"
alignItems="end"
width="fit"
height="fit"
gap={0}
>
<div className="flex items-center gap-1 p-2">
{hook.is_active ? (
<>
<Text mainUiAction text03>
Connected
</Text>
<SvgCheckCircle
size={16}
className="text-status-success-05"
/>
</>
) : (
<div
className={cn(
"flex items-center gap-1",
isBusy ? "opacity-50 pointer-events-none" : "cursor-pointer"
)}
onClick={handleActivate}
>
<Text mainUiAction text03>
Reconnect
</Text>
<SvgPlug size={16} className="text-text-03 shrink-0" />
</div>
)}
</div>
<Disabled disabled={isBusy}>
<div className="flex items-center gap-0.5 pl-1 pr-1 pb-1">
{hook.is_active ? (
<>
<Text mainUiAction text03>
Connected
</Text>
<SvgCheckCircle
size={16}
className="text-status-success-05"
/>
</>
) : (
<div
className={cn(
"flex items-center gap-1",
isBusy
? "opacity-50 pointer-events-none"
: "cursor-pointer"
)}
onClick={handleActivate}
>
<Text mainUiAction text03>
Reconnect
</Text>
<SvgPlug size={16} className="text-text-03 shrink-0" />
</div>
)}
</div>
<Disabled disabled={isBusy}>
{/* Plain div instead of Section: Section applies style={{ padding }} inline which
overrides Tailwind padding classes, making per-side padding (pl/pr/pb) ineffective. */}
<div className="flex items-center gap-0.5 pl-1 pr-1 pb-1">
{hook.is_active ? (
<>
<Button
prominence="tertiary"
size="sm"
icon={SvgUnplug}
onClick={() => setDisconnectConfirmOpen(true)}
tooltip="Disconnect Hook"
aria-label="Deactivate hook"
/>
<Button
prominence="tertiary"
size="sm"
icon={SvgRefreshCw}
onClick={handleValidate}
tooltip="Test Connection"
aria-label="Re-validate hook"
/>
</>
) : (
<Button
prominence="tertiary"
size="sm"
icon={SvgTrash}
onClick={() => setDeleteConfirmOpen(true)}
tooltip="Delete"
aria-label="Delete hook"
icon={SvgUnplug}
onClick={() => setDisconnectConfirmOpen(true)}
tooltip="Disconnect Hook"
aria-label="Deactivate hook"
/>
)}
<Button
prominence="tertiary"
size="sm"
icon={SvgRefreshCw}
onClick={handleValidate}
tooltip="Test Connection"
aria-label="Re-validate hook"
/>
</>
) : (
<Button
prominence="tertiary"
size="sm"
icon={SvgSettings}
onClick={onEdit}
tooltip="Manage"
aria-label="Configure hook"
icon={SvgTrash}
onClick={() => setDeleteConfirmOpen(true)}
tooltip="Delete"
aria-label="Delete hook"
/>
</div>
</Disabled>
</Section>
}
/>
)}
<Button
prominence="tertiary"
size="sm"
icon={SvgSettings}
onClick={onEdit}
tooltip="Manage"
aria-label="Configure hook"
/>
</div>
</Disabled>
</Section>
</div>
</Card>
</>
);

View File

@@ -5,7 +5,7 @@ import { useHookSpecs } from "@/hooks/useHookSpecs";
import { useHooks } from "@/hooks/useHooks";
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
import { Button } from "@opal/components";
import { ContentAction } from "@opal/layouts";
import { Content } from "@opal/layouts";
import InputSearch from "@/refresh-components/inputs/InputSearch";
import Card from "@/refresh-components/cards/Card";
import Text from "@/refresh-components/texts/Text";
@@ -17,6 +17,7 @@ import type {
HookPointMeta,
HookResponse,
} from "@/refresh-pages/admin/HooksPage/interfaces";
import { markdown } from "@opal/utils";
// ---------------------------------------------------------------------------
// Main component
@@ -145,37 +146,39 @@ export default function HooksContent() {
gap={0}
className="hover:border-border-02"
>
<ContentAction
sizePreset="main-ui"
variant="section"
paddingVariant="sm"
icon={UnconnectedIcon}
title={spec.display_name}
iconClassName="text-text-04"
description={spec.description}
bottomChildren={
spec.docs_url ? (
<div className="w-full flex flex-row">
<div className="flex-1 p-2">
<Content
sizePreset="main-ui"
variant="section"
icon={UnconnectedIcon}
title={spec.display_name}
description={spec.description}
/>
{spec.docs_url && (
<a
href={spec.docs_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 w-fit font-secondary-body text-text-03"
className="pl-6 flex items-center gap-1"
>
<span className="underline">Documentation</span>
<span className="underline font-secondary-body text-text-03">
Documentation
</span>
<SvgExternalLink size={12} className="shrink-0" />
</a>
) : undefined
}
rightChildren={
<Button
prominence="tertiary"
rightIcon={SvgArrowExchange}
onClick={() => setConnectSpec(spec)}
>
Connect
</Button>
}
/>
)}
</div>
<Button
prominence="tertiary"
rightIcon={SvgArrowExchange}
onClick={() => setConnectSpec(spec)}
>
Connect
</Button>
</div>
</Card>
);
})}

View File

@@ -216,7 +216,7 @@ function ExistingProviderCard({
icon={getProviderIcon(provider.provider)}
title={provider.name}
description={getProviderDisplayName(provider.provider)}
sizePreset="main-content"
sizePreset="main-ui"
variant="section"
tag={isDefault ? { title: "Default", color: "blue" } : undefined}
rightChildren={
@@ -280,7 +280,7 @@ function NewProviderCard({
icon={getProviderIcon(provider.name)}
title={getProviderProductName(provider.name)}
description={getProviderDisplayName(provider.name)}
sizePreset="main-content"
sizePreset="main-ui"
variant="section"
rightChildren={
<Button
@@ -316,7 +316,7 @@ function NewCustomProviderCard({
icon={getProviderIcon("custom")}
title={getProviderProductName("custom")}
description={getProviderDisplayName("custom")}
sizePreset="main-content"
sizePreset="main-ui"
variant="section"
rightChildren={
<Button

View File

@@ -1,7 +1,6 @@
import { SvgArrowUpRight, SvgFilterPlus, SvgUserSync } from "@opal/icons";
import { ContentAction } from "@opal/layouts";
import { Button } from "@opal/components";
import { Hoverable } from "@opal/core";
import { Section } from "@/layouts/general-layouts";
import Card from "@/refresh-components/cards/Card";
import IconButton from "@/refresh-components/buttons/IconButton";
@@ -23,38 +22,33 @@ function StatCell({ value, label, onFilter }: StatCellProps) {
const display = value === null ? "\u2014" : value.toLocaleString();
return (
<Hoverable.Root group="stat" widthVariant="full">
<div
className={`relative flex flex-col items-start gap-0.5 w-full p-2 rounded-08 transition-colors ${
onFilter ? "cursor-pointer hover:bg-background-tint-02" : ""
}`}
onClick={onFilter}
>
<Text as="span" mainUiAction text04>
{display}
</Text>
<Text as="span" secondaryBody text03>
{label}
</Text>
{onFilter && (
<div className="absolute right-1 top-1">
<Hoverable.Item group="stat" variant="opacity-on-hover">
<IconButton
tertiary
icon={SvgFilterPlus}
tooltip="Add Filter"
toolTipPosition="left"
tooltipSize="sm"
onClick={(e) => {
e.stopPropagation();
onFilter();
}}
/>
</Hoverable.Item>
</div>
)}
</div>
</Hoverable.Root>
<div
className={`group/stat relative flex flex-col items-start gap-0.5 w-full p-2 rounded-08 transition-colors ${
onFilter ? "cursor-pointer hover:bg-background-tint-02" : ""
}`}
onClick={onFilter}
>
<Text as="span" mainUiAction text04>
{display}
</Text>
<Text as="span" secondaryBody text03>
{label}
</Text>
{onFilter && (
<IconButton
tertiary
icon={SvgFilterPlus}
tooltip="Add Filter"
toolTipPosition="left"
tooltipSize="sm"
className="absolute right-1 top-1 opacity-0 group-hover/stat:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
onFilter();
}}
/>
)}
</div>
);
}

View File

@@ -158,7 +158,7 @@ export default function AddMCPServerModal({
<InputLayouts.Vertical
name="description"
title="Description"
optional
suffix="optional"
>
<InputTextAreaField
name="description"

View File

@@ -11,7 +11,7 @@ import Separator from "@/refresh-components/Separator";
import { useCallback, useEffect, useMemo, useState } from "react";
import CopyIconButton from "@/refresh-components/buttons/CopyIconButton";
import { Button } from "@opal/components";
import { Disabled, Hoverable } from "@opal/core";
import { Disabled } from "@opal/core";
import { MethodSpec, ToolSnapshot } from "@/lib/tools/interfaces";
import {
validateToolDefinition,
@@ -243,40 +243,31 @@ function FormContent({
`Specify an OpenAPI schema that defines the APIs you want to make available as part of this action. Learn more about [OpenAPI actions](${DOCS_ADMINS_PATH}/actions/openapi).`
)}
>
<Hoverable.Root group="definitionField" widthVariant="full">
<div className="relative w-full">
{values.definition.trim() && (
<div className="absolute z-[100000] top-2 right-2 bg-background-tint-00">
<Hoverable.Item
group="definitionField"
variant="opacity-on-hover"
>
<div className="flex">
<CopyIconButton
prominence="tertiary"
size="sm"
getCopyText={() => values.definition}
tooltip="Copy definition"
/>
<Button
prominence="tertiary"
size="sm"
icon={SvgBracketCurly}
tooltip="Format definition"
onClick={handleFormat}
/>
</div>
</Hoverable.Item>
</div>
)}
<InputTextAreaField
name="definition"
rows={14}
placeholder="Enter your OpenAPI schema here"
className="font-main-ui-mono"
/>
</div>
</Hoverable.Root>
<div className="group/DefinitionTextAreaField relative w-full">
{values.definition.trim() && (
<div className="invisible group-hover/DefinitionTextAreaField:visible absolute z-[100000] top-2 right-2 bg-background-tint-00">
<CopyIconButton
prominence="tertiary"
size="sm"
getCopyText={() => values.definition}
tooltip="Copy definition"
/>
<Button
prominence="tertiary"
size="sm"
icon={SvgBracketCurly}
tooltip="Format definition"
onClick={handleFormat}
/>
</div>
)}
<InputTextAreaField
name="definition"
rows={14}
placeholder="Enter your OpenAPI schema here"
className="font-main-ui-mono"
/>
</div>
</InputLayouts.Vertical>
<Separator noPadding />

View File

@@ -127,10 +127,9 @@ export default function AgentCard({ agent }: AgentCardProps) {
{fullAgent && <AgentViewerModal agent={fullAgent} />}
</agentViewerModal.Provider>
<Interactive.Stateless
<Interactive.Simple
onClick={() => agentViewerModal.toggle(true)}
group="group/AgentCard"
variant="none"
>
<Card
padding={0}
@@ -232,7 +231,7 @@ export default function AgentCard({ agent }: AgentCardProps) {
</div>
</div>
</Card>
</Interactive.Stateless>
</Interactive.Simple>
</>
);
}

View File

@@ -29,13 +29,12 @@ export default function DocumentSetCard({
disabled={!disabled || !disabledTooltip}
>
<div className="max-w-[12rem]">
<Interactive.Stateless
<Interactive.Simple
onClick={
disabled || isSelected === undefined
? undefined
: () => onSelectToggle?.(!isSelected)
}
variant="none"
>
<Interactive.Container
data-testid={`document-set-card-${documentSet.id}`}
@@ -64,7 +63,7 @@ export default function DocumentSetCard({
/>
<Spacer horizontal rem={0.5} />
</Interactive.Container>
</Interactive.Stateless>
</Interactive.Simple>
</div>
</SimpleTooltip>
);

View File

@@ -6,7 +6,7 @@ import { UserFileStatus } from "@/app/app/projects/projectsService";
import { cn, isImageFile } from "@/lib/utils";
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
import { SvgFileText, SvgX } from "@opal/icons";
import { Interactive, Hoverable } from "@opal/core";
import { Interactive } from "@opal/core";
import { AttachmentItemLayout } from "@/layouts/general-layouts";
import Spacer from "@/refresh-components/Spacer";
@@ -21,39 +21,29 @@ function Removable({ onRemove, children }: RemovableProps) {
}
return (
<Hoverable.Root group="fileCard" widthVariant="fit">
<div className="relative">
<div
className={cn(
"absolute -left-2 -top-2 z-10",
"pointer-events-none focus-within:pointer-events-auto"
)}
>
<Hoverable.Item group="fileCard" variant="opacity-on-hover">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
title="Remove"
aria-label="Remove"
className={cn(
"h-4 w-4",
"flex items-center justify-center",
"rounded-04 border border-border text-[11px]",
"bg-background-neutral-inverted-01 text-text-inverted-05 shadow-sm",
"pointer-events-auto",
"hover:opacity-90"
)}
>
<SvgX className="h-3 w-3 stroke-text-inverted-03" />
</button>
</Hoverable.Item>
</div>
{children}
</div>
</Hoverable.Root>
<div className="relative group">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
title="Remove"
aria-label="Remove"
className={cn(
"absolute -left-2 -top-2 z-10 h-4 w-4",
"flex items-center justify-center",
"rounded-04 border border-border text-[11px]",
"bg-background-neutral-inverted-01 text-text-inverted-05 shadow-sm",
"opacity-0 group-hover:opacity-100 focus:opacity-100",
"pointer-events-none group-hover:pointer-events-auto focus:pointer-events-auto",
"transition-opacity duration-150 hover:opacity-90"
)}
>
<SvgX className="h-3 w-3 stroke-text-inverted-03" />
</button>
{children}
</div>
);
}

View File

@@ -80,7 +80,7 @@ export default function FeedbackModal({
<InputLayouts.Vertical
name="additional_feedback"
title="Provide Additional Details"
optional={feedbackType === "like"}
suffix={feedbackType === "like" ? "optional" : undefined}
>
<InputTextAreaField
name="additional_feedback"

View File

@@ -126,7 +126,7 @@ function BifrostModalInternals({
<InputLayouts.Vertical
name="api_key"
title="API Key"
optional={true}
suffix="optional"
subDescription={markdown(
"Paste your API key from [Bifrost](https://docs.getbifrost.ai/overview) to access your models."
)}

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