mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-02-27 04:35:50 +00:00
Compare commits
15 Commits
fix-action
...
csv_render
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80cf389774 | ||
|
|
e775aaacb7 | ||
|
|
e5b08b3d92 | ||
|
|
7c91304ba2 | ||
|
|
68a292b500 | ||
|
|
e553b80030 | ||
|
|
f3949f8e09 | ||
|
|
c7c064e296 | ||
|
|
68b91a8862 | ||
|
|
c23e5a196d | ||
|
|
093223c6c4 | ||
|
|
89517111d4 | ||
|
|
883d4b4ceb | ||
|
|
f3672b6819 | ||
|
|
921f5d9e96 |
@@ -1,29 +0,0 @@
|
||||
"""code interpreter seed
|
||||
|
||||
Revision ID: 07b98176f1de
|
||||
Revises: 7cb492013621
|
||||
Create Date: 2026-02-23 15:55:07.606784
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "07b98176f1de"
|
||||
down_revision = "7cb492013621"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Seed the single instance of code_interpreter_server
|
||||
# NOTE: There should only exist at most and at minimum 1 code_interpreter_server row
|
||||
op.execute(
|
||||
sa.text("INSERT INTO code_interpreter_server (server_enabled) VALUES (true)")
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(sa.text("DELETE FROM code_interpreter_server"))
|
||||
@@ -5,10 +5,8 @@ from uuid import UUID
|
||||
|
||||
import httpx
|
||||
import sqlalchemy as sa
|
||||
from celery import Celery
|
||||
from celery import shared_task
|
||||
from celery import Task
|
||||
from redis import Redis
|
||||
from redis.lock import Lock as RedisLock
|
||||
from retry import retry
|
||||
from sqlalchemy import select
|
||||
@@ -26,14 +24,12 @@ from onyx.configs.constants import CELERY_GENERIC_BEAT_LOCK_TIMEOUT
|
||||
from onyx.configs.constants import CELERY_USER_FILE_PROCESSING_LOCK_TIMEOUT
|
||||
from onyx.configs.constants import CELERY_USER_FILE_PROCESSING_TASK_EXPIRES
|
||||
from onyx.configs.constants import CELERY_USER_FILE_PROJECT_SYNC_LOCK_TIMEOUT
|
||||
from onyx.configs.constants import CELERY_USER_FILE_PROJECT_SYNC_TASK_EXPIRES
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.configs.constants import OnyxCeleryPriority
|
||||
from onyx.configs.constants import OnyxCeleryQueues
|
||||
from onyx.configs.constants import OnyxCeleryTask
|
||||
from onyx.configs.constants import OnyxRedisLocks
|
||||
from onyx.configs.constants import USER_FILE_PROCESSING_MAX_QUEUE_DEPTH
|
||||
from onyx.configs.constants import USER_FILE_PROJECT_SYNC_MAX_QUEUE_DEPTH
|
||||
from onyx.connectors.file.connector import LocalFileConnector
|
||||
from onyx.connectors.models import Document
|
||||
from onyx.connectors.models import HierarchyNode
|
||||
@@ -79,58 +75,10 @@ def _user_file_project_sync_lock_key(user_file_id: str | UUID) -> str:
|
||||
return f"{OnyxRedisLocks.USER_FILE_PROJECT_SYNC_LOCK_PREFIX}:{user_file_id}"
|
||||
|
||||
|
||||
def _user_file_project_sync_queued_key(user_file_id: str | UUID) -> str:
|
||||
return f"{OnyxRedisLocks.USER_FILE_PROJECT_SYNC_QUEUED_PREFIX}:{user_file_id}"
|
||||
|
||||
|
||||
def _user_file_delete_lock_key(user_file_id: str | UUID) -> str:
|
||||
return f"{OnyxRedisLocks.USER_FILE_DELETE_LOCK_PREFIX}:{user_file_id}"
|
||||
|
||||
|
||||
def get_user_file_project_sync_queue_depth(celery_app: Celery) -> int:
|
||||
redis_celery: Redis = celery_app.broker_connection().channel().client # type: ignore
|
||||
return celery_get_queue_length(
|
||||
OnyxCeleryQueues.USER_FILE_PROJECT_SYNC, redis_celery
|
||||
)
|
||||
|
||||
|
||||
def enqueue_user_file_project_sync_task(
|
||||
*,
|
||||
celery_app: Celery,
|
||||
redis_client: Redis,
|
||||
user_file_id: str | UUID,
|
||||
tenant_id: str,
|
||||
priority: OnyxCeleryPriority = OnyxCeleryPriority.HIGH,
|
||||
) -> bool:
|
||||
"""Enqueue a project-sync task if no matching queued task already exists."""
|
||||
queued_key = _user_file_project_sync_queued_key(user_file_id)
|
||||
|
||||
# NX+EX gives us atomic dedupe and a self-healing TTL.
|
||||
queued_guard_set = redis_client.set(
|
||||
queued_key,
|
||||
1,
|
||||
nx=True,
|
||||
ex=CELERY_USER_FILE_PROJECT_SYNC_TASK_EXPIRES,
|
||||
)
|
||||
if not queued_guard_set:
|
||||
return False
|
||||
|
||||
try:
|
||||
celery_app.send_task(
|
||||
OnyxCeleryTask.PROCESS_SINGLE_USER_FILE_PROJECT_SYNC,
|
||||
kwargs={"user_file_id": str(user_file_id), "tenant_id": tenant_id},
|
||||
queue=OnyxCeleryQueues.USER_FILE_PROJECT_SYNC,
|
||||
priority=priority,
|
||||
expires=CELERY_USER_FILE_PROJECT_SYNC_TASK_EXPIRES,
|
||||
)
|
||||
except Exception:
|
||||
# Roll back the queued guard if task publish fails.
|
||||
redis_client.delete(queued_key)
|
||||
raise
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@retry(tries=3, delay=1, backoff=2, jitter=(0.0, 1.0))
|
||||
def _visit_chunks(
|
||||
*,
|
||||
@@ -684,8 +632,8 @@ def process_single_user_file_delete(
|
||||
ignore_result=True,
|
||||
)
|
||||
def check_for_user_file_project_sync(self: Task, *, tenant_id: str) -> None:
|
||||
"""Scan for user files needing project sync and enqueue per-file tasks."""
|
||||
task_logger.info("Starting")
|
||||
"""Scan for user files with PROJECT_SYNC status and enqueue per-file tasks."""
|
||||
task_logger.info("check_for_user_file_project_sync - Starting")
|
||||
|
||||
redis_client = get_redis_client(tenant_id=tenant_id)
|
||||
lock: RedisLock = redis_client.lock(
|
||||
@@ -697,16 +645,7 @@ def check_for_user_file_project_sync(self: Task, *, tenant_id: str) -> None:
|
||||
return None
|
||||
|
||||
enqueued = 0
|
||||
skipped_guard = 0
|
||||
try:
|
||||
queue_depth = get_user_file_project_sync_queue_depth(self.app)
|
||||
if queue_depth > USER_FILE_PROJECT_SYNC_MAX_QUEUE_DEPTH:
|
||||
task_logger.warning(
|
||||
f"Queue depth {queue_depth} exceeds "
|
||||
f"{USER_FILE_PROJECT_SYNC_MAX_QUEUE_DEPTH}, skipping enqueue for tenant={tenant_id}"
|
||||
)
|
||||
return None
|
||||
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
user_file_ids = (
|
||||
db_session.execute(
|
||||
@@ -722,23 +661,19 @@ def check_for_user_file_project_sync(self: Task, *, tenant_id: str) -> None:
|
||||
)
|
||||
|
||||
for user_file_id in user_file_ids:
|
||||
if not enqueue_user_file_project_sync_task(
|
||||
celery_app=self.app,
|
||||
redis_client=redis_client,
|
||||
user_file_id=user_file_id,
|
||||
tenant_id=tenant_id,
|
||||
self.app.send_task(
|
||||
OnyxCeleryTask.PROCESS_SINGLE_USER_FILE_PROJECT_SYNC,
|
||||
kwargs={"user_file_id": str(user_file_id), "tenant_id": tenant_id},
|
||||
queue=OnyxCeleryQueues.USER_FILE_PROJECT_SYNC,
|
||||
priority=OnyxCeleryPriority.HIGH,
|
||||
):
|
||||
skipped_guard += 1
|
||||
continue
|
||||
)
|
||||
enqueued += 1
|
||||
finally:
|
||||
if lock.owned():
|
||||
lock.release()
|
||||
|
||||
task_logger.info(
|
||||
f"Enqueued {enqueued} "
|
||||
f"Skipped guard {skipped_guard} tasks for tenant={tenant_id}"
|
||||
f"check_for_user_file_project_sync - Enqueued {enqueued} tasks for tenant={tenant_id}"
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -757,8 +692,6 @@ def process_single_user_file_project_sync(
|
||||
)
|
||||
|
||||
redis_client = get_redis_client(tenant_id=tenant_id)
|
||||
redis_client.delete(_user_file_project_sync_queued_key(user_file_id))
|
||||
|
||||
file_lock: RedisLock = redis_client.lock(
|
||||
_user_file_project_sync_lock_key(user_file_id),
|
||||
timeout=CELERY_USER_FILE_PROJECT_SYNC_LOCK_TIMEOUT,
|
||||
|
||||
@@ -30,7 +30,6 @@ from onyx.configs.constants import DocumentSource
|
||||
from onyx.configs.constants import MessageType
|
||||
from onyx.context.search.models import SearchDoc
|
||||
from onyx.context.search.models import SearchDocsResponse
|
||||
from onyx.db.engine.sql_engine import get_session_with_current_tenant
|
||||
from onyx.db.memory import add_memory
|
||||
from onyx.db.memory import update_memory_at_index
|
||||
from onyx.db.memory import UserMemoryContext
|
||||
@@ -657,12 +656,7 @@ def run_llm_loop(
|
||||
fallback_extraction_attempted: bool = False
|
||||
citation_mapping: dict[int, str] = {} # Maps citation_num -> document_id/URL
|
||||
|
||||
# Fetch this in a short-lived session so the long-running stream loop does
|
||||
# not pin a connection just to keep read state alive.
|
||||
with get_session_with_current_tenant() as prompt_db_session:
|
||||
default_base_system_prompt: str = get_default_base_system_prompt(
|
||||
prompt_db_session
|
||||
)
|
||||
default_base_system_prompt: str = get_default_base_system_prompt(db_session)
|
||||
system_prompt = None
|
||||
custom_agent_prompt_msg = None
|
||||
|
||||
|
||||
@@ -856,11 +856,6 @@ def handle_stream_message_objects(
|
||||
reserved_tokens=reserved_token_count,
|
||||
)
|
||||
|
||||
# Release any read transaction before entering the long-running LLM stream.
|
||||
# Without this, the request-scoped session can keep a connection checked out
|
||||
# for the full stream duration.
|
||||
db_session.commit()
|
||||
|
||||
# The stream generator can resume on a different worker thread after early yields.
|
||||
# Set this right before launching the LLM loop so run_in_background copies the right context.
|
||||
if new_msg_req.mock_llm_response is not None:
|
||||
|
||||
@@ -167,14 +167,6 @@ CELERY_USER_FILE_PROCESSING_TASK_EXPIRES = 60 # 1 minute (in seconds)
|
||||
# beat generator stops adding more. Prevents unbounded queue growth when workers
|
||||
# fall behind.
|
||||
USER_FILE_PROCESSING_MAX_QUEUE_DEPTH = 500
|
||||
# How long a queued user-file-project-sync task remains valid.
|
||||
# Should be short enough to discard stale queue entries under load while still
|
||||
# allowing workers enough time to pick up new tasks.
|
||||
CELERY_USER_FILE_PROJECT_SYNC_TASK_EXPIRES = 60 # 1 minute (in seconds)
|
||||
|
||||
# Max queue depth before user-file-project-sync producers stop enqueuing.
|
||||
# This applies backpressure when workers are falling behind.
|
||||
USER_FILE_PROJECT_SYNC_MAX_QUEUE_DEPTH = 500
|
||||
|
||||
CELERY_USER_FILE_PROJECT_SYNC_LOCK_TIMEOUT = 5 * 60 # 5 minutes (in seconds)
|
||||
|
||||
@@ -467,7 +459,6 @@ class OnyxRedisLocks:
|
||||
USER_FILE_QUEUED_PREFIX = "da_lock:user_file_queued"
|
||||
USER_FILE_PROJECT_SYNC_BEAT_LOCK = "da_lock:check_user_file_project_sync_beat"
|
||||
USER_FILE_PROJECT_SYNC_LOCK_PREFIX = "da_lock:user_file_project_sync"
|
||||
USER_FILE_PROJECT_SYNC_QUEUED_PREFIX = "da_lock:user_file_project_sync_queued"
|
||||
USER_FILE_DELETE_BEAT_LOCK = "da_lock:check_user_file_delete_beat"
|
||||
USER_FILE_DELETE_LOCK_PREFIX = "da_lock:user_file_delete"
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ from typing import cast
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import Field
|
||||
from pydantic import field_validator
|
||||
from pydantic import model_validator
|
||||
|
||||
from onyx.access.models import ExternalAccess
|
||||
@@ -168,14 +167,6 @@ class DocumentBase(BaseModel):
|
||||
# list of strings.
|
||||
metadata: dict[str, str | list[str]]
|
||||
|
||||
@field_validator("metadata", mode="before")
|
||||
@classmethod
|
||||
def _coerce_metadata_values(cls, v: dict[str, Any]) -> dict[str, str | list[str]]:
|
||||
return {
|
||||
key: [str(item) for item in val] if isinstance(val, list) else str(val)
|
||||
for key, val in v.items()
|
||||
}
|
||||
|
||||
# UTC time
|
||||
doc_updated_at: datetime | None = None
|
||||
chunk_count: int | None = None
|
||||
|
||||
@@ -68,7 +68,6 @@ from onyx.server.features.build.db.sandbox import create_sandbox__no_commit
|
||||
from onyx.server.features.build.db.sandbox import get_running_sandbox_count_by_tenant
|
||||
from onyx.server.features.build.db.sandbox import get_sandbox_by_session_id
|
||||
from onyx.server.features.build.db.sandbox import get_sandbox_by_user_id
|
||||
from onyx.server.features.build.db.sandbox import get_snapshots_for_session
|
||||
from onyx.server.features.build.db.sandbox import update_sandbox_heartbeat
|
||||
from onyx.server.features.build.db.sandbox import update_sandbox_status__no_commit
|
||||
from onyx.server.features.build.sandbox import get_sandbox_manager
|
||||
@@ -1036,23 +1035,6 @@ class SessionManager:
|
||||
# workspace cleanup fails (e.g., if pod is already terminated)
|
||||
logger.warning(f"Failed to cleanup session workspace {session_id}: {e}")
|
||||
|
||||
# Delete snapshot files from S3 before removing DB records
|
||||
snapshots = get_snapshots_for_session(self._db_session, session_id)
|
||||
if snapshots:
|
||||
from onyx.file_store.file_store import get_default_file_store
|
||||
from onyx.server.features.build.sandbox.manager.snapshot_manager import (
|
||||
SnapshotManager,
|
||||
)
|
||||
|
||||
snapshot_manager = SnapshotManager(get_default_file_store())
|
||||
for snapshot in snapshots:
|
||||
try:
|
||||
snapshot_manager.delete_snapshot(snapshot.storage_path)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to delete snapshot file {snapshot.storage_path}: {e}"
|
||||
)
|
||||
|
||||
# Delete session (uses flush, caller commits)
|
||||
return delete_build_session__no_commit(session_id, user_id, self._db_session)
|
||||
|
||||
|
||||
@@ -12,18 +12,11 @@ from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.auth.users import current_user
|
||||
from onyx.background.celery.tasks.user_file_processing.tasks import (
|
||||
enqueue_user_file_project_sync_task,
|
||||
)
|
||||
from onyx.background.celery.tasks.user_file_processing.tasks import (
|
||||
get_user_file_project_sync_queue_depth,
|
||||
)
|
||||
from onyx.background.celery.versioned_apps.client import app as client_app
|
||||
from onyx.configs.constants import OnyxCeleryPriority
|
||||
from onyx.configs.constants import OnyxCeleryQueues
|
||||
from onyx.configs.constants import OnyxCeleryTask
|
||||
from onyx.configs.constants import PUBLIC_API_TAGS
|
||||
from onyx.configs.constants import USER_FILE_PROJECT_SYNC_MAX_QUEUE_DEPTH
|
||||
from onyx.db.engine.sql_engine import get_session
|
||||
from onyx.db.enums import UserFileStatus
|
||||
from onyx.db.models import ChatSession
|
||||
@@ -34,7 +27,6 @@ from onyx.db.models import UserProject
|
||||
from onyx.db.persona import get_personas_by_ids
|
||||
from onyx.db.projects import get_project_token_count
|
||||
from onyx.db.projects import upload_files_to_user_files_with_indexing
|
||||
from onyx.redis.redis_pool import get_redis_client
|
||||
from onyx.server.features.projects.models import CategorizedFilesSnapshot
|
||||
from onyx.server.features.projects.models import ChatSessionRequest
|
||||
from onyx.server.features.projects.models import TokenCountResponse
|
||||
@@ -55,33 +47,6 @@ class UserFileDeleteResult(BaseModel):
|
||||
assistant_names: list[str] = []
|
||||
|
||||
|
||||
def _trigger_user_file_project_sync(user_file_id: UUID, tenant_id: str) -> None:
|
||||
queue_depth = get_user_file_project_sync_queue_depth(client_app)
|
||||
if queue_depth > USER_FILE_PROJECT_SYNC_MAX_QUEUE_DEPTH:
|
||||
logger.warning(
|
||||
f"Skipping immediate project sync for user_file_id={user_file_id} due to "
|
||||
f"queue depth {queue_depth}>{USER_FILE_PROJECT_SYNC_MAX_QUEUE_DEPTH}. "
|
||||
"It will be picked up by beat later."
|
||||
)
|
||||
return
|
||||
|
||||
redis_client = get_redis_client(tenant_id=tenant_id)
|
||||
enqueued = enqueue_user_file_project_sync_task(
|
||||
celery_app=client_app,
|
||||
redis_client=redis_client,
|
||||
user_file_id=user_file_id,
|
||||
tenant_id=tenant_id,
|
||||
priority=OnyxCeleryPriority.HIGHEST,
|
||||
)
|
||||
if not enqueued:
|
||||
logger.info(
|
||||
f"Skipped duplicate project sync enqueue for user_file_id={user_file_id}"
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(f"Triggered project sync for user_file_id={user_file_id}")
|
||||
|
||||
|
||||
@router.get("", tags=PUBLIC_API_TAGS)
|
||||
def get_projects(
|
||||
user: User = Depends(current_user),
|
||||
@@ -224,7 +189,15 @@ def unlink_user_file_from_project(
|
||||
db_session.commit()
|
||||
|
||||
tenant_id = get_current_tenant_id()
|
||||
_trigger_user_file_project_sync(user_file.id, tenant_id)
|
||||
task = client_app.send_task(
|
||||
OnyxCeleryTask.PROCESS_SINGLE_USER_FILE_PROJECT_SYNC,
|
||||
kwargs={"user_file_id": user_file.id, "tenant_id": tenant_id},
|
||||
queue=OnyxCeleryQueues.USER_FILE_PROJECT_SYNC,
|
||||
priority=OnyxCeleryPriority.HIGHEST,
|
||||
)
|
||||
logger.info(
|
||||
f"Triggered project sync for user_file_id={user_file.id} with task_id={task.id}"
|
||||
)
|
||||
|
||||
return Response(status_code=204)
|
||||
|
||||
@@ -268,7 +241,15 @@ def link_user_file_to_project(
|
||||
db_session.commit()
|
||||
|
||||
tenant_id = get_current_tenant_id()
|
||||
_trigger_user_file_project_sync(user_file.id, tenant_id)
|
||||
task = client_app.send_task(
|
||||
OnyxCeleryTask.PROCESS_SINGLE_USER_FILE_PROJECT_SYNC,
|
||||
kwargs={"user_file_id": user_file.id, "tenant_id": tenant_id},
|
||||
queue=OnyxCeleryQueues.USER_FILE_PROJECT_SYNC,
|
||||
priority=OnyxCeleryPriority.HIGHEST,
|
||||
)
|
||||
logger.info(
|
||||
f"Triggered project sync for user_file_id={user_file.id} with task_id={task.id}"
|
||||
)
|
||||
|
||||
return UserFileSnapshot.from_model(user_file)
|
||||
|
||||
|
||||
@@ -587,7 +587,6 @@ def handle_send_chat_message(
|
||||
request.headers
|
||||
),
|
||||
mcp_headers=chat_message_req.mcp_headers,
|
||||
additional_context=chat_message_req.additional_context,
|
||||
external_state_container=state_container,
|
||||
)
|
||||
result = gather_stream_full(packets, state_container)
|
||||
@@ -610,7 +609,6 @@ def handle_send_chat_message(
|
||||
request.headers
|
||||
),
|
||||
mcp_headers=chat_message_req.mcp_headers,
|
||||
additional_context=chat_message_req.additional_context,
|
||||
external_state_container=state_container,
|
||||
):
|
||||
yield get_json_line(obj.model_dump())
|
||||
|
||||
@@ -125,11 +125,6 @@ class SendMessageRequest(BaseModel):
|
||||
# - No CitationInfo packets are emitted during streaming
|
||||
include_citations: bool = True
|
||||
|
||||
# Additional context injected into the LLM call but NOT stored in the DB
|
||||
# (not shown in chat history). Used e.g. by the Chrome extension to pass
|
||||
# the current tab URL when "Read this tab" is enabled.
|
||||
additional_context: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_chat_session_id_or_info(self) -> "SendMessageRequest":
|
||||
# If neither is provided, default to creating a new chat session using the
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import json
|
||||
from collections.abc import Generator
|
||||
from typing import Literal
|
||||
from typing import TypedDict
|
||||
from typing import Union
|
||||
|
||||
import requests
|
||||
from pydantic import BaseModel
|
||||
@@ -39,39 +36,6 @@ class ExecuteResponse(BaseModel):
|
||||
files: list[WorkspaceFile]
|
||||
|
||||
|
||||
class StreamOutputEvent(BaseModel):
|
||||
"""SSE 'output' event: a chunk of stdout or stderr"""
|
||||
|
||||
stream: Literal["stdout", "stderr"]
|
||||
data: str
|
||||
|
||||
|
||||
class StreamResultEvent(BaseModel):
|
||||
"""SSE 'result' event: final execution result"""
|
||||
|
||||
exit_code: int | None
|
||||
timed_out: bool
|
||||
duration_ms: int
|
||||
files: list[WorkspaceFile]
|
||||
|
||||
|
||||
class StreamErrorEvent(BaseModel):
|
||||
"""SSE 'error' event: execution-level error"""
|
||||
|
||||
message: str
|
||||
|
||||
|
||||
StreamEvent = Union[StreamOutputEvent, StreamResultEvent, StreamErrorEvent]
|
||||
|
||||
_SSE_EVENT_MAP: dict[
|
||||
str, type[StreamOutputEvent | StreamResultEvent | StreamErrorEvent]
|
||||
] = {
|
||||
"output": StreamOutputEvent,
|
||||
"result": StreamResultEvent,
|
||||
"error": StreamErrorEvent,
|
||||
}
|
||||
|
||||
|
||||
class CodeInterpreterClient:
|
||||
"""Client for Code Interpreter service"""
|
||||
|
||||
@@ -81,23 +45,6 @@ class CodeInterpreterClient:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.session = requests.Session()
|
||||
|
||||
def _build_payload(
|
||||
self,
|
||||
code: str,
|
||||
stdin: str | None,
|
||||
timeout_ms: int,
|
||||
files: list[FileInput] | None,
|
||||
) -> dict:
|
||||
payload: dict = {
|
||||
"code": code,
|
||||
"timeout_ms": timeout_ms,
|
||||
}
|
||||
if stdin is not None:
|
||||
payload["stdin"] = stdin
|
||||
if files:
|
||||
payload["files"] = files
|
||||
return payload
|
||||
|
||||
def execute(
|
||||
self,
|
||||
code: str,
|
||||
@@ -105,110 +52,25 @@ class CodeInterpreterClient:
|
||||
timeout_ms: int = 30000,
|
||||
files: list[FileInput] | None = None,
|
||||
) -> ExecuteResponse:
|
||||
"""Execute Python code (batch)"""
|
||||
"""Execute Python code"""
|
||||
url = f"{self.base_url}/v1/execute"
|
||||
payload = self._build_payload(code, stdin, timeout_ms, files)
|
||||
|
||||
payload = {
|
||||
"code": code,
|
||||
"timeout_ms": timeout_ms,
|
||||
}
|
||||
|
||||
if stdin is not None:
|
||||
payload["stdin"] = stdin
|
||||
|
||||
if files:
|
||||
payload["files"] = files
|
||||
|
||||
response = self.session.post(url, json=payload, timeout=timeout_ms / 1000 + 10)
|
||||
response.raise_for_status()
|
||||
|
||||
return ExecuteResponse(**response.json())
|
||||
|
||||
def execute_streaming(
|
||||
self,
|
||||
code: str,
|
||||
stdin: str | None = None,
|
||||
timeout_ms: int = 30000,
|
||||
files: list[FileInput] | None = None,
|
||||
) -> Generator[StreamEvent, None, None]:
|
||||
"""Execute Python code with streaming SSE output.
|
||||
|
||||
Yields StreamEvent objects (StreamOutputEvent, StreamResultEvent,
|
||||
StreamErrorEvent) as execution progresses. Falls back to batch
|
||||
execution if the streaming endpoint is not available (older
|
||||
code-interpreter versions).
|
||||
"""
|
||||
url = f"{self.base_url}/v1/execute/stream"
|
||||
payload = self._build_payload(code, stdin, timeout_ms, files)
|
||||
|
||||
response = self.session.post(
|
||||
url,
|
||||
json=payload,
|
||||
stream=True,
|
||||
timeout=timeout_ms / 1000 + 10,
|
||||
)
|
||||
|
||||
if response.status_code == 404:
|
||||
logger.info(
|
||||
"Streaming endpoint not available, " "falling back to batch execution"
|
||||
)
|
||||
response.close()
|
||||
yield from self._batch_as_stream(code, stdin, timeout_ms, files)
|
||||
return
|
||||
|
||||
response.raise_for_status()
|
||||
yield from self._parse_sse(response)
|
||||
|
||||
def _parse_sse(
|
||||
self, response: requests.Response
|
||||
) -> Generator[StreamEvent, None, None]:
|
||||
"""Parse SSE streaming response into StreamEvent objects.
|
||||
|
||||
Expected format per event:
|
||||
event: <type>
|
||||
data: <json>
|
||||
<blank line>
|
||||
"""
|
||||
event_type: str | None = None
|
||||
data_lines: list[str] = []
|
||||
|
||||
for line in response.iter_lines(decode_unicode=True):
|
||||
if line is None:
|
||||
continue
|
||||
|
||||
if line == "":
|
||||
# Blank line marks end of an SSE event
|
||||
if event_type is not None and data_lines:
|
||||
data = "\n".join(data_lines)
|
||||
model_cls = _SSE_EVENT_MAP.get(event_type)
|
||||
if model_cls is not None:
|
||||
yield model_cls(**json.loads(data))
|
||||
else:
|
||||
logger.warning(f"Unknown SSE event type: {event_type}")
|
||||
event_type = None
|
||||
data_lines = []
|
||||
elif line.startswith("event:"):
|
||||
event_type = line[len("event:") :].strip()
|
||||
elif line.startswith("data:"):
|
||||
data_lines.append(line[len("data:") :].strip())
|
||||
|
||||
if event_type is not None or data_lines:
|
||||
logger.warning(
|
||||
f"SSE stream ended with incomplete event: "
|
||||
f"event_type={event_type}, data_lines={data_lines}"
|
||||
)
|
||||
|
||||
def _batch_as_stream(
|
||||
self,
|
||||
code: str,
|
||||
stdin: str | None,
|
||||
timeout_ms: int,
|
||||
files: list[FileInput] | None,
|
||||
) -> Generator[StreamEvent, None, None]:
|
||||
"""Execute via batch endpoint and yield results as stream events."""
|
||||
result = self.execute(code, stdin, timeout_ms, files)
|
||||
|
||||
if result.stdout:
|
||||
yield StreamOutputEvent(stream="stdout", data=result.stdout)
|
||||
if result.stderr:
|
||||
yield StreamOutputEvent(stream="stderr", data=result.stderr)
|
||||
yield StreamResultEvent(
|
||||
exit_code=result.exit_code,
|
||||
timed_out=result.timed_out,
|
||||
duration_ms=result.duration_ms,
|
||||
files=result.files,
|
||||
)
|
||||
|
||||
def upload_file(self, file_content: bytes, filename: str) -> str:
|
||||
"""Upload file to Code Interpreter and return file_id"""
|
||||
url = f"{self.base_url}/v1/files"
|
||||
|
||||
@@ -28,15 +28,6 @@ from onyx.tools.tool_implementations.python.code_interpreter_client import (
|
||||
CodeInterpreterClient,
|
||||
)
|
||||
from onyx.tools.tool_implementations.python.code_interpreter_client import FileInput
|
||||
from onyx.tools.tool_implementations.python.code_interpreter_client import (
|
||||
StreamErrorEvent,
|
||||
)
|
||||
from onyx.tools.tool_implementations.python.code_interpreter_client import (
|
||||
StreamOutputEvent,
|
||||
)
|
||||
from onyx.tools.tool_implementations.python.code_interpreter_client import (
|
||||
StreamResultEvent,
|
||||
)
|
||||
from onyx.utils.logger import setup_logger
|
||||
|
||||
|
||||
@@ -190,50 +181,19 @@ class PythonTool(Tool[PythonToolOverrideKwargs]):
|
||||
try:
|
||||
logger.debug(f"Executing code: {code}")
|
||||
|
||||
# Execute code with streaming (falls back to batch if unavailable)
|
||||
stdout_parts: list[str] = []
|
||||
stderr_parts: list[str] = []
|
||||
result_event: StreamResultEvent | None = None
|
||||
|
||||
for event in client.execute_streaming(
|
||||
# Execute code with timeout
|
||||
response = client.execute(
|
||||
code=code,
|
||||
timeout_ms=CODE_INTERPRETER_DEFAULT_TIMEOUT_MS,
|
||||
files=files_to_stage or None,
|
||||
):
|
||||
if isinstance(event, StreamOutputEvent):
|
||||
if event.stream == "stdout":
|
||||
stdout_parts.append(event.data)
|
||||
else:
|
||||
stderr_parts.append(event.data)
|
||||
# Emit incremental delta to frontend
|
||||
self.emitter.emit(
|
||||
Packet(
|
||||
placement=placement,
|
||||
obj=PythonToolDelta(
|
||||
stdout=event.data if event.stream == "stdout" else "",
|
||||
stderr=event.data if event.stream == "stderr" else "",
|
||||
),
|
||||
)
|
||||
)
|
||||
elif isinstance(event, StreamResultEvent):
|
||||
result_event = event
|
||||
elif isinstance(event, StreamErrorEvent):
|
||||
raise RuntimeError(f"Code interpreter error: {event.message}")
|
||||
|
||||
if result_event is None:
|
||||
raise RuntimeError(
|
||||
"Code interpreter stream ended without a result event"
|
||||
)
|
||||
|
||||
full_stdout = "".join(stdout_parts)
|
||||
full_stderr = "".join(stderr_parts)
|
||||
)
|
||||
|
||||
# Truncate output for LLM consumption
|
||||
truncated_stdout = _truncate_output(
|
||||
full_stdout, CODE_INTERPRETER_MAX_OUTPUT_LENGTH, "stdout"
|
||||
response.stdout, CODE_INTERPRETER_MAX_OUTPUT_LENGTH, "stdout"
|
||||
)
|
||||
truncated_stderr = _truncate_output(
|
||||
full_stderr, CODE_INTERPRETER_MAX_OUTPUT_LENGTH, "stderr"
|
||||
response.stderr, CODE_INTERPRETER_MAX_OUTPUT_LENGTH, "stderr"
|
||||
)
|
||||
|
||||
# Handle generated files
|
||||
@@ -242,7 +202,7 @@ class PythonTool(Tool[PythonToolOverrideKwargs]):
|
||||
file_ids_to_cleanup: list[str] = []
|
||||
file_store = get_default_file_store()
|
||||
|
||||
for workspace_file in result_event.files:
|
||||
for workspace_file in response.files:
|
||||
if workspace_file.kind != "file" or not workspace_file.file_id:
|
||||
continue
|
||||
|
||||
@@ -298,23 +258,26 @@ class PythonTool(Tool[PythonToolOverrideKwargs]):
|
||||
f"Failed to delete Code Interpreter staged file {file_mapping['file_id']}: {e}"
|
||||
)
|
||||
|
||||
# Emit file_ids once files are processed
|
||||
if generated_file_ids:
|
||||
self.emitter.emit(
|
||||
Packet(
|
||||
placement=placement,
|
||||
obj=PythonToolDelta(file_ids=generated_file_ids),
|
||||
)
|
||||
# Emit delta with stdout/stderr and generated files
|
||||
self.emitter.emit(
|
||||
Packet(
|
||||
placement=placement,
|
||||
obj=PythonToolDelta(
|
||||
stdout=truncated_stdout,
|
||||
stderr=truncated_stderr,
|
||||
file_ids=generated_file_ids,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# Build result
|
||||
result = LlmPythonExecutionResult(
|
||||
stdout=truncated_stdout,
|
||||
stderr=truncated_stderr,
|
||||
exit_code=result_event.exit_code,
|
||||
timed_out=result_event.timed_out,
|
||||
exit_code=response.exit_code,
|
||||
timed_out=response.timed_out,
|
||||
generated_files=generated_files,
|
||||
error=None if result_event.exit_code == 0 else truncated_stderr,
|
||||
error=None if response.exit_code == 0 else truncated_stderr,
|
||||
)
|
||||
|
||||
# Serialize result for LLM
|
||||
|
||||
@@ -9,7 +9,6 @@ from collections.abc import AsyncGenerator
|
||||
from collections.abc import Generator
|
||||
from contextlib import asynccontextmanager
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from dotenv import load_dotenv
|
||||
@@ -47,15 +46,11 @@ def mock_current_admin_user() -> MagicMock:
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def client() -> Generator[TestClient, None, None]:
|
||||
# Initialize TestClient with the FastAPI app using a no-op test lifespan.
|
||||
# Patch out prometheus metrics setup to avoid "Duplicated timeseries in
|
||||
# CollectorRegistry" errors when multiple tests each create a new app
|
||||
# (prometheus registers metrics globally and rejects duplicate names).
|
||||
# Initialize TestClient with the FastAPI app using a no-op test lifespan
|
||||
get_app = fetch_versioned_implementation(
|
||||
module="onyx.main", attribute="get_application"
|
||||
)
|
||||
with patch("onyx.main.setup_prometheus_metrics"):
|
||||
app: FastAPI = get_app(lifespan_override=test_lifespan)
|
||||
app: FastAPI = get_app(lifespan_override=test_lifespan)
|
||||
|
||||
# Override the database session dependency with a mock
|
||||
# (these tests don't actually need DB access)
|
||||
|
||||
@@ -990,27 +990,6 @@ class _MockCIHandler(BaseHTTPRequestHandler):
|
||||
self._respond_json(
|
||||
200, {"file_id": f"mock-ci-file-{self.server._file_counter}"}
|
||||
)
|
||||
elif self.path == "/v1/execute/stream":
|
||||
if self.server.streaming_enabled:
|
||||
self._respond_sse(
|
||||
[
|
||||
(
|
||||
"output",
|
||||
{"stream": "stdout", "data": "mock output\n"},
|
||||
),
|
||||
(
|
||||
"result",
|
||||
{
|
||||
"exit_code": 0,
|
||||
"timed_out": False,
|
||||
"duration_ms": 50,
|
||||
"files": [],
|
||||
},
|
||||
),
|
||||
]
|
||||
)
|
||||
else:
|
||||
self._respond_json(404, {"error": "not found"})
|
||||
elif self.path == "/v1/execute":
|
||||
self._respond_json(
|
||||
200,
|
||||
@@ -1048,17 +1027,6 @@ class _MockCIHandler(BaseHTTPRequestHandler):
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
def _respond_sse(self, events: list[tuple[str, dict[str, Any]]]) -> None:
|
||||
frames = []
|
||||
for event_type, data in events:
|
||||
frames.append(f"event: {event_type}\ndata: {json.dumps(data)}\n\n")
|
||||
payload = "".join(frames).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
def log_message(self, format: str, *args: Any) -> None: # noqa: A002
|
||||
pass
|
||||
|
||||
@@ -1070,7 +1038,6 @@ class MockCodeInterpreterServer(HTTPServer):
|
||||
super().__init__(("localhost", 0), _MockCIHandler)
|
||||
self.captured_requests: list[CapturedRequest] = []
|
||||
self._file_counter = 0
|
||||
self.streaming_enabled: bool = True
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
@@ -1201,19 +1168,17 @@ def test_code_interpreter_receives_chat_files(
|
||||
finally:
|
||||
ci_mod.CodeInterpreterClient.__init__.__defaults__ = original_defaults
|
||||
|
||||
# Verify: file uploaded, code executed via streaming, staged file cleaned up
|
||||
# Verify: file uploaded, code executed, staged file cleaned up
|
||||
assert len(mock_ci_server.get_requests(method="POST", path="/v1/files")) == 1
|
||||
assert (
|
||||
len(mock_ci_server.get_requests(method="POST", path="/v1/execute/stream")) == 1
|
||||
)
|
||||
assert len(mock_ci_server.get_requests(method="POST", path="/v1/execute")) == 1
|
||||
|
||||
delete_requests = mock_ci_server.get_requests(method="DELETE")
|
||||
assert len(delete_requests) == 1
|
||||
assert delete_requests[0].path.startswith("/v1/files/")
|
||||
|
||||
execute_body = mock_ci_server.get_requests(
|
||||
method="POST", path="/v1/execute/stream"
|
||||
)[0].json_body()
|
||||
execute_body = mock_ci_server.get_requests(method="POST", path="/v1/execute")[
|
||||
0
|
||||
].json_body()
|
||||
assert execute_body["code"] == code
|
||||
assert len(execute_body["files"]) == 1
|
||||
assert execute_body["files"][0]["path"] == "data.csv"
|
||||
@@ -1319,9 +1284,7 @@ def test_code_interpreter_replay_packets_include_code_and_output(
|
||||
db_session=db_session,
|
||||
)
|
||||
|
||||
assert (
|
||||
len(mock_ci_server.get_requests(method="POST", path="/v1/execute/stream")) == 1
|
||||
)
|
||||
assert len(mock_ci_server.get_requests(method="POST", path="/v1/execute")) == 1
|
||||
|
||||
# The response contains `packets` — a list of packet-lists, one per
|
||||
# assistant message. We should have exactly one assistant message.
|
||||
@@ -1350,76 +1313,3 @@ def test_code_interpreter_replay_packets_include_code_and_output(
|
||||
delta_obj = delta_packets[0].obj
|
||||
assert isinstance(delta_obj, PythonToolDelta)
|
||||
assert "mock output" in delta_obj.stdout
|
||||
|
||||
|
||||
def test_code_interpreter_streaming_fallback_to_batch(
|
||||
db_session: Session,
|
||||
mock_ci_server: MockCodeInterpreterServer,
|
||||
_attach_python_tool_to_default_persona: None,
|
||||
initialize_file_store: None, # noqa: ARG001
|
||||
) -> None:
|
||||
"""When the streaming endpoint is not available (older code-interpreter),
|
||||
execute_streaming should fall back to the batch /v1/execute endpoint."""
|
||||
mock_ci_server.captured_requests.clear()
|
||||
mock_ci_server._file_counter = 0
|
||||
mock_ci_server.streaming_enabled = False
|
||||
mock_url = mock_ci_server.url
|
||||
|
||||
user = create_test_user(db_session, "ci_fallback_test")
|
||||
chat_session = create_chat_session(db_session=db_session, user=user)
|
||||
|
||||
code = 'print("fallback test")'
|
||||
msg_req = SendMessageRequest(
|
||||
message="Print fallback test",
|
||||
chat_session_id=chat_session.id,
|
||||
stream=True,
|
||||
)
|
||||
|
||||
original_defaults = ci_mod.CodeInterpreterClient.__init__.__defaults__
|
||||
with (
|
||||
use_mock_llm() as mock_llm,
|
||||
patch(
|
||||
"onyx.tools.tool_implementations.python.python_tool.CODE_INTERPRETER_BASE_URL",
|
||||
mock_url,
|
||||
),
|
||||
patch(
|
||||
"onyx.tools.tool_implementations.python.code_interpreter_client.CODE_INTERPRETER_BASE_URL",
|
||||
mock_url,
|
||||
),
|
||||
):
|
||||
mock_llm.add_response(
|
||||
LLMToolCallResponse(
|
||||
tool_name="python",
|
||||
tool_call_id="call_fallback",
|
||||
tool_call_argument_tokens=[json.dumps({"code": code})],
|
||||
)
|
||||
)
|
||||
mock_llm.forward_till_end()
|
||||
|
||||
ci_mod.CodeInterpreterClient.__init__.__defaults__ = (mock_url,)
|
||||
try:
|
||||
packets = list(
|
||||
handle_stream_message_objects(
|
||||
new_msg_req=msg_req, user=user, db_session=db_session
|
||||
)
|
||||
)
|
||||
finally:
|
||||
ci_mod.CodeInterpreterClient.__init__.__defaults__ = original_defaults
|
||||
mock_ci_server.streaming_enabled = True
|
||||
|
||||
# Streaming was attempted first (returned 404), then fell back to batch
|
||||
assert (
|
||||
len(mock_ci_server.get_requests(method="POST", path="/v1/execute/stream")) == 1
|
||||
)
|
||||
assert len(mock_ci_server.get_requests(method="POST", path="/v1/execute")) == 1
|
||||
|
||||
# Verify output still made it through
|
||||
delta_packets = [
|
||||
p
|
||||
for p in packets
|
||||
if isinstance(p, Packet) and isinstance(p.obj, PythonToolDelta)
|
||||
]
|
||||
assert len(delta_packets) >= 1
|
||||
first_delta = delta_packets[0].obj
|
||||
assert isinstance(first_delta, PythonToolDelta)
|
||||
assert "mock output" in first_delta.stdout
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from onyx.background.celery.tasks.user_file_processing.tasks import (
|
||||
_user_file_project_sync_queued_key,
|
||||
)
|
||||
from onyx.background.celery.tasks.user_file_processing.tasks import (
|
||||
check_for_user_file_project_sync,
|
||||
)
|
||||
from onyx.background.celery.tasks.user_file_processing.tasks import (
|
||||
enqueue_user_file_project_sync_task,
|
||||
)
|
||||
from onyx.background.celery.tasks.user_file_processing.tasks import (
|
||||
process_single_user_file_project_sync,
|
||||
)
|
||||
from onyx.configs.constants import CELERY_USER_FILE_PROJECT_SYNC_TASK_EXPIRES
|
||||
from onyx.configs.constants import OnyxCeleryPriority
|
||||
from onyx.configs.constants import OnyxCeleryQueues
|
||||
from onyx.configs.constants import OnyxCeleryTask
|
||||
from onyx.configs.constants import USER_FILE_PROJECT_SYNC_MAX_QUEUE_DEPTH
|
||||
|
||||
|
||||
def _build_redis_mock_with_lock() -> tuple[MagicMock, MagicMock]:
|
||||
redis_client = MagicMock()
|
||||
lock = MagicMock()
|
||||
lock.acquire.return_value = True
|
||||
lock.owned.return_value = True
|
||||
redis_client.lock.return_value = lock
|
||||
return redis_client, lock
|
||||
|
||||
|
||||
@patch(
|
||||
"onyx.background.celery.tasks.user_file_processing.tasks."
|
||||
"get_user_file_project_sync_queue_depth"
|
||||
)
|
||||
@patch("onyx.background.celery.tasks.user_file_processing.tasks.get_redis_client")
|
||||
def test_check_for_user_file_project_sync_applies_queue_backpressure(
|
||||
mock_get_redis_client: MagicMock,
|
||||
mock_get_queue_depth: MagicMock,
|
||||
) -> None:
|
||||
redis_client, lock = _build_redis_mock_with_lock()
|
||||
mock_get_redis_client.return_value = redis_client
|
||||
mock_get_queue_depth.return_value = USER_FILE_PROJECT_SYNC_MAX_QUEUE_DEPTH + 1
|
||||
|
||||
task_app = MagicMock()
|
||||
with patch.object(check_for_user_file_project_sync, "app", task_app):
|
||||
check_for_user_file_project_sync.run(tenant_id="test-tenant")
|
||||
|
||||
task_app.send_task.assert_not_called()
|
||||
lock.release.assert_called_once()
|
||||
|
||||
|
||||
@patch(
|
||||
"onyx.background.celery.tasks.user_file_processing.tasks."
|
||||
"enqueue_user_file_project_sync_task"
|
||||
)
|
||||
@patch(
|
||||
"onyx.background.celery.tasks.user_file_processing.tasks."
|
||||
"get_user_file_project_sync_queue_depth"
|
||||
)
|
||||
@patch(
|
||||
"onyx.background.celery.tasks.user_file_processing.tasks."
|
||||
"get_session_with_current_tenant"
|
||||
)
|
||||
@patch("onyx.background.celery.tasks.user_file_processing.tasks.get_redis_client")
|
||||
def test_check_for_user_file_project_sync_skips_duplicates(
|
||||
mock_get_redis_client: MagicMock,
|
||||
mock_get_session: MagicMock,
|
||||
mock_get_queue_depth: MagicMock,
|
||||
mock_enqueue: MagicMock,
|
||||
) -> None:
|
||||
redis_client, lock = _build_redis_mock_with_lock()
|
||||
mock_get_redis_client.return_value = redis_client
|
||||
mock_get_queue_depth.return_value = 0
|
||||
|
||||
user_file_id_one = uuid4()
|
||||
user_file_id_two = uuid4()
|
||||
|
||||
session = MagicMock()
|
||||
session.execute.return_value.scalars.return_value.all.return_value = [
|
||||
user_file_id_one,
|
||||
user_file_id_two,
|
||||
]
|
||||
mock_get_session.return_value.__enter__.return_value = session
|
||||
mock_enqueue.side_effect = [True, False]
|
||||
|
||||
task_app = MagicMock()
|
||||
with patch.object(check_for_user_file_project_sync, "app", task_app):
|
||||
check_for_user_file_project_sync.run(tenant_id="test-tenant")
|
||||
|
||||
assert mock_enqueue.call_count == 2
|
||||
lock.release.assert_called_once()
|
||||
|
||||
|
||||
def test_enqueue_user_file_project_sync_task_sets_guard_and_expiry() -> None:
|
||||
redis_client = MagicMock()
|
||||
redis_client.set.return_value = True
|
||||
celery_app = MagicMock()
|
||||
user_file_id = str(uuid4())
|
||||
|
||||
enqueued = enqueue_user_file_project_sync_task(
|
||||
celery_app=celery_app,
|
||||
redis_client=redis_client,
|
||||
user_file_id=user_file_id,
|
||||
tenant_id="test-tenant",
|
||||
priority=OnyxCeleryPriority.HIGHEST,
|
||||
)
|
||||
|
||||
assert enqueued is True
|
||||
redis_client.set.assert_called_once_with(
|
||||
_user_file_project_sync_queued_key(user_file_id),
|
||||
1,
|
||||
nx=True,
|
||||
ex=CELERY_USER_FILE_PROJECT_SYNC_TASK_EXPIRES,
|
||||
)
|
||||
celery_app.send_task.assert_called_once_with(
|
||||
OnyxCeleryTask.PROCESS_SINGLE_USER_FILE_PROJECT_SYNC,
|
||||
kwargs={"user_file_id": user_file_id, "tenant_id": "test-tenant"},
|
||||
queue=OnyxCeleryQueues.USER_FILE_PROJECT_SYNC,
|
||||
priority=OnyxCeleryPriority.HIGHEST,
|
||||
expires=CELERY_USER_FILE_PROJECT_SYNC_TASK_EXPIRES,
|
||||
)
|
||||
|
||||
|
||||
def test_enqueue_user_file_project_sync_task_rolls_back_guard_on_publish_failure() -> (
|
||||
None
|
||||
):
|
||||
redis_client = MagicMock()
|
||||
redis_client.set.return_value = True
|
||||
celery_app = MagicMock()
|
||||
celery_app.send_task.side_effect = RuntimeError("publish failed")
|
||||
|
||||
user_file_id = str(uuid4())
|
||||
with pytest.raises(RuntimeError):
|
||||
enqueue_user_file_project_sync_task(
|
||||
celery_app=celery_app,
|
||||
redis_client=redis_client,
|
||||
user_file_id=user_file_id,
|
||||
tenant_id="test-tenant",
|
||||
)
|
||||
|
||||
redis_client.delete.assert_called_once_with(
|
||||
_user_file_project_sync_queued_key(user_file_id)
|
||||
)
|
||||
|
||||
|
||||
@patch("onyx.background.celery.tasks.user_file_processing.tasks.get_redis_client")
|
||||
def test_process_single_user_file_project_sync_clears_queued_guard_on_pickup(
|
||||
mock_get_redis_client: MagicMock,
|
||||
) -> None:
|
||||
redis_client = MagicMock()
|
||||
lock = MagicMock()
|
||||
lock.acquire.return_value = False
|
||||
redis_client.lock.return_value = lock
|
||||
mock_get_redis_client.return_value = redis_client
|
||||
|
||||
user_file_id = str(uuid4())
|
||||
process_single_user_file_project_sync.run(
|
||||
user_file_id=user_file_id,
|
||||
tenant_id="test-tenant",
|
||||
)
|
||||
|
||||
redis_client.delete.assert_called_once_with(
|
||||
_user_file_project_sync_queued_key(user_file_id)
|
||||
)
|
||||
@@ -1,95 +0,0 @@
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.connectors.models import Document
|
||||
from onyx.connectors.models import DocumentBase
|
||||
from onyx.connectors.models import TextSection
|
||||
|
||||
|
||||
def _minimal_doc_kwargs(metadata: dict) -> dict:
|
||||
return {
|
||||
"id": "test-doc",
|
||||
"sections": [TextSection(text="hello", link="http://example.com")],
|
||||
"source": DocumentSource.NOT_APPLICABLE,
|
||||
"semantic_identifier": "Test Doc",
|
||||
"metadata": metadata,
|
||||
}
|
||||
|
||||
|
||||
def test_int_values_coerced_to_str() -> None:
|
||||
doc = Document(**_minimal_doc_kwargs({"count": 42}))
|
||||
assert doc.metadata == {"count": "42"}
|
||||
|
||||
|
||||
def test_float_values_coerced_to_str() -> None:
|
||||
doc = Document(**_minimal_doc_kwargs({"score": 3.14}))
|
||||
assert doc.metadata == {"score": "3.14"}
|
||||
|
||||
|
||||
def test_bool_values_coerced_to_str() -> None:
|
||||
doc = Document(**_minimal_doc_kwargs({"active": True}))
|
||||
assert doc.metadata == {"active": "True"}
|
||||
|
||||
|
||||
def test_list_of_ints_coerced_to_list_of_str() -> None:
|
||||
doc = Document(**_minimal_doc_kwargs({"ids": [1, 2, 3]}))
|
||||
assert doc.metadata == {"ids": ["1", "2", "3"]}
|
||||
|
||||
|
||||
def test_list_of_mixed_types_coerced_to_list_of_str() -> None:
|
||||
doc = Document(**_minimal_doc_kwargs({"tags": ["a", 1, True, 2.5]}))
|
||||
assert doc.metadata == {"tags": ["a", "1", "True", "2.5"]}
|
||||
|
||||
|
||||
def test_list_of_dicts_coerced_to_list_of_str() -> None:
|
||||
raw = {"nested": [{"key": "val"}, {"key2": "val2"}]}
|
||||
doc = Document(**_minimal_doc_kwargs(raw))
|
||||
assert doc.metadata == {"nested": ["{'key': 'val'}", "{'key2': 'val2'}"]}
|
||||
|
||||
|
||||
def test_dict_value_coerced_to_str() -> None:
|
||||
raw = {"info": {"inner_key": "inner_val"}}
|
||||
doc = Document(**_minimal_doc_kwargs(raw))
|
||||
assert doc.metadata == {"info": "{'inner_key': 'inner_val'}"}
|
||||
|
||||
|
||||
def test_none_value_coerced_to_str() -> None:
|
||||
doc = Document(**_minimal_doc_kwargs({"empty": None}))
|
||||
assert doc.metadata == {"empty": "None"}
|
||||
|
||||
|
||||
def test_already_valid_str_values_unchanged() -> None:
|
||||
doc = Document(**_minimal_doc_kwargs({"key": "value"}))
|
||||
assert doc.metadata == {"key": "value"}
|
||||
|
||||
|
||||
def test_already_valid_list_of_str_unchanged() -> None:
|
||||
doc = Document(**_minimal_doc_kwargs({"tags": ["a", "b", "c"]}))
|
||||
assert doc.metadata == {"tags": ["a", "b", "c"]}
|
||||
|
||||
|
||||
def test_empty_metadata_unchanged() -> None:
|
||||
doc = Document(**_minimal_doc_kwargs({}))
|
||||
assert doc.metadata == {}
|
||||
|
||||
|
||||
def test_mixed_metadata_values() -> None:
|
||||
raw = {
|
||||
"str_val": "hello",
|
||||
"int_val": 99,
|
||||
"list_val": [1, "two", 3.0],
|
||||
"dict_val": {"nested": True},
|
||||
}
|
||||
doc = Document(**_minimal_doc_kwargs(raw))
|
||||
assert doc.metadata == {
|
||||
"str_val": "hello",
|
||||
"int_val": "99",
|
||||
"list_val": ["1", "two", "3.0"],
|
||||
"dict_val": "{'nested': True}",
|
||||
}
|
||||
|
||||
|
||||
def test_coercion_works_on_base_class() -> None:
|
||||
kwargs = _minimal_doc_kwargs({"count": 42})
|
||||
kwargs.pop("source")
|
||||
kwargs.pop("id")
|
||||
doc = DocumentBase(**kwargs)
|
||||
assert doc.metadata == {"count": "42"}
|
||||
@@ -1,173 +0,0 @@
|
||||
"""Unit tests for CodeInterpreterClient streaming-to-batch fallback.
|
||||
|
||||
When the streaming endpoint (/v1/execute/stream) returns 404 — e.g. because the
|
||||
code-interpreter service is an older version that doesn't support streaming — the
|
||||
client should transparently fall back to the batch endpoint (/v1/execute) and
|
||||
convert the batch response into the same stream-event interface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
from onyx.tools.tool_implementations.python.code_interpreter_client import (
|
||||
CodeInterpreterClient,
|
||||
)
|
||||
from onyx.tools.tool_implementations.python.code_interpreter_client import FileInput
|
||||
from onyx.tools.tool_implementations.python.code_interpreter_client import (
|
||||
StreamOutputEvent,
|
||||
)
|
||||
from onyx.tools.tool_implementations.python.code_interpreter_client import (
|
||||
StreamResultEvent,
|
||||
)
|
||||
|
||||
|
||||
def _make_batch_response(
|
||||
stdout: str = "",
|
||||
stderr: str = "",
|
||||
exit_code: int = 0,
|
||||
timed_out: bool = False,
|
||||
duration_ms: int = 50,
|
||||
) -> MagicMock:
|
||||
"""Build a mock ``requests.Response`` for the batch /v1/execute endpoint."""
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.raise_for_status = MagicMock()
|
||||
resp.json.return_value = {
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
"exit_code": exit_code,
|
||||
"timed_out": timed_out,
|
||||
"duration_ms": duration_ms,
|
||||
"files": [],
|
||||
}
|
||||
return resp
|
||||
|
||||
|
||||
def _make_404_response() -> MagicMock:
|
||||
"""Build a mock ``requests.Response`` that returns 404 (streaming not found)."""
|
||||
resp = MagicMock()
|
||||
resp.status_code = 404
|
||||
return resp
|
||||
|
||||
|
||||
def test_execute_streaming_fallback_to_batch_on_404() -> None:
|
||||
"""When /v1/execute/stream returns 404, the client should fall back to
|
||||
/v1/execute and yield equivalent StreamEvent objects."""
|
||||
|
||||
client = CodeInterpreterClient(base_url="http://fake:9000")
|
||||
|
||||
stream_resp = _make_404_response()
|
||||
batch_resp = _make_batch_response(
|
||||
stdout="hello world\n",
|
||||
stderr="a warning\n",
|
||||
)
|
||||
|
||||
urls_called: list[str] = []
|
||||
|
||||
def mock_post(url: str, **_kwargs: object) -> MagicMock:
|
||||
urls_called.append(url)
|
||||
if url.endswith("/v1/execute/stream"):
|
||||
return stream_resp
|
||||
if url.endswith("/v1/execute"):
|
||||
return batch_resp
|
||||
raise AssertionError(f"Unexpected URL: {url}")
|
||||
|
||||
with patch.object(client.session, "post", side_effect=mock_post):
|
||||
events = list(client.execute_streaming(code="print('hello world')"))
|
||||
|
||||
# Streaming endpoint was attempted first, then batch
|
||||
assert len(urls_called) == 2
|
||||
assert urls_called[0].endswith("/v1/execute/stream")
|
||||
assert urls_called[1].endswith("/v1/execute")
|
||||
|
||||
# The 404 response must be closed before making the batch call
|
||||
stream_resp.close.assert_called_once()
|
||||
|
||||
# _batch_as_stream yields: stdout event, stderr event, result event
|
||||
assert len(events) == 3
|
||||
|
||||
assert isinstance(events[0], StreamOutputEvent)
|
||||
assert events[0].stream == "stdout"
|
||||
assert events[0].data == "hello world\n"
|
||||
|
||||
assert isinstance(events[1], StreamOutputEvent)
|
||||
assert events[1].stream == "stderr"
|
||||
assert events[1].data == "a warning\n"
|
||||
|
||||
assert isinstance(events[2], StreamResultEvent)
|
||||
assert events[2].exit_code == 0
|
||||
assert not events[2].timed_out
|
||||
assert events[2].duration_ms == 50
|
||||
assert events[2].files == []
|
||||
|
||||
|
||||
def test_execute_streaming_fallback_stdout_only() -> None:
|
||||
"""Fallback with only stdout (no stderr) should yield two events:
|
||||
one StreamOutputEvent for stdout and one StreamResultEvent."""
|
||||
|
||||
client = CodeInterpreterClient(base_url="http://fake:9000")
|
||||
|
||||
stream_resp = _make_404_response()
|
||||
batch_resp = _make_batch_response(stdout="result: 42\n")
|
||||
|
||||
def mock_post(url: str, **_kwargs: object) -> MagicMock:
|
||||
if url.endswith("/v1/execute/stream"):
|
||||
return stream_resp
|
||||
if url.endswith("/v1/execute"):
|
||||
return batch_resp
|
||||
raise AssertionError(f"Unexpected URL: {url}")
|
||||
|
||||
with patch.object(client.session, "post", side_effect=mock_post):
|
||||
events = list(client.execute_streaming(code="print(42)"))
|
||||
|
||||
# No stderr → only stdout + result
|
||||
assert len(events) == 2
|
||||
|
||||
assert isinstance(events[0], StreamOutputEvent)
|
||||
assert events[0].stream == "stdout"
|
||||
assert events[0].data == "result: 42\n"
|
||||
|
||||
assert isinstance(events[1], StreamResultEvent)
|
||||
assert events[1].exit_code == 0
|
||||
|
||||
|
||||
def test_execute_streaming_fallback_preserves_files_param() -> None:
|
||||
"""When falling back, the files parameter must be forwarded to the
|
||||
batch endpoint so staged files are still available for execution."""
|
||||
|
||||
client = CodeInterpreterClient(base_url="http://fake:9000")
|
||||
|
||||
stream_resp = _make_404_response()
|
||||
batch_resp = _make_batch_response(stdout="ok\n")
|
||||
|
||||
captured_payloads: list[dict] = []
|
||||
|
||||
def mock_post(url: str, **kwargs: object) -> MagicMock:
|
||||
if "json" in kwargs:
|
||||
captured_payloads.append(kwargs["json"]) # type: ignore[arg-type]
|
||||
if url.endswith("/v1/execute/stream"):
|
||||
return stream_resp
|
||||
if url.endswith("/v1/execute"):
|
||||
return batch_resp
|
||||
raise AssertionError(f"Unexpected URL: {url}")
|
||||
|
||||
files_input: list[FileInput] = [{"path": "data.csv", "file_id": "file-abc123"}]
|
||||
|
||||
with patch.object(client.session, "post", side_effect=mock_post):
|
||||
events = list(
|
||||
client.execute_streaming(
|
||||
code="import pandas",
|
||||
files=files_input,
|
||||
)
|
||||
)
|
||||
|
||||
# Both the streaming attempt and the batch fallback should include files
|
||||
assert len(captured_payloads) == 2
|
||||
for payload in captured_payloads:
|
||||
assert payload["files"] == files_input
|
||||
assert payload["code"] == "import pandas"
|
||||
|
||||
# Should still yield valid events
|
||||
assert any(isinstance(e, StreamResultEvent) for e in events)
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Onyx",
|
||||
"version": "1.1",
|
||||
"version": "1.0",
|
||||
"description": "Onyx lets you research, create, and automate with LLMs powered by your team's unique knowledge",
|
||||
"permissions": [
|
||||
"sidePanel",
|
||||
|
||||
@@ -43,12 +43,8 @@ async function openSidePanel(tabId) {
|
||||
}
|
||||
}
|
||||
|
||||
function encodeUserPrompt(text) {
|
||||
return encodeURIComponent(text).replace(/\(/g, "%28").replace(/\)/g, "%29");
|
||||
}
|
||||
|
||||
async function sendToOnyx(info, tab) {
|
||||
const selectedText = encodeUserPrompt(info.selectionText);
|
||||
const selectedText = encodeURIComponent(info.selectionText);
|
||||
const currentUrl = encodeURIComponent(tab.url);
|
||||
|
||||
try {
|
||||
@@ -157,23 +153,6 @@ chrome.commands.onCommand.addListener(async (command) => {
|
||||
}
|
||||
});
|
||||
|
||||
async function sendActiveTabUrlToPanel() {
|
||||
try {
|
||||
const [tab] = await chrome.tabs.query({
|
||||
active: true,
|
||||
lastFocusedWindow: true,
|
||||
});
|
||||
if (tab?.url) {
|
||||
chrome.runtime.sendMessage({
|
||||
action: ACTIONS.TAB_URL_UPDATED,
|
||||
url: tab.url,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Onyx SW] Error sending tab URL:", error);
|
||||
}
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
||||
if (request.action === ACTIONS.GET_CURRENT_ONYX_DOMAIN) {
|
||||
chrome.storage.local.get(
|
||||
@@ -209,7 +188,7 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
||||
chrome.storage.local.get(
|
||||
{ [CHROME_SPECIFIC_STORAGE_KEYS.ONYX_DOMAIN]: DEFAULT_ONYX_DOMAIN },
|
||||
(result) => {
|
||||
const encodedText = encodeUserPrompt(selectedText);
|
||||
const encodedText = encodeURIComponent(selectedText);
|
||||
const onyxDomain = result[CHROME_SPECIFIC_STORAGE_KEYS.ONYX_DOMAIN];
|
||||
const url = `${onyxDomain}${SIDE_PANEL_PATH}?user-prompt=${encodedText}`;
|
||||
|
||||
@@ -243,15 +222,6 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (request.action === ACTIONS.TAB_READING_ENABLED) {
|
||||
chrome.storage.session.set({ tabReadingEnabled: true });
|
||||
sendActiveTabUrlToPanel();
|
||||
return false;
|
||||
}
|
||||
if (request.action === ACTIONS.TAB_READING_DISABLED) {
|
||||
chrome.storage.session.set({ tabReadingEnabled: false });
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
chrome.storage.onChanged.addListener((changes, namespace) => {
|
||||
@@ -303,40 +273,4 @@ chrome.omnibox.onInputChanged.addListener((text, suggest) => {
|
||||
}
|
||||
});
|
||||
|
||||
chrome.tabs.onActivated.addListener(async (activeInfo) => {
|
||||
const result = await chrome.storage.session.get({ tabReadingEnabled: false });
|
||||
if (!result.tabReadingEnabled) return;
|
||||
try {
|
||||
const tab = await chrome.tabs.get(activeInfo.tabId);
|
||||
if (tab.url) {
|
||||
chrome.runtime.sendMessage({
|
||||
action: ACTIONS.TAB_URL_UPDATED,
|
||||
url: tab.url,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Onyx SW] Error on tab activated:", error);
|
||||
}
|
||||
});
|
||||
|
||||
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
|
||||
if (!changeInfo.url) return;
|
||||
const result = await chrome.storage.session.get({ tabReadingEnabled: false });
|
||||
if (!result.tabReadingEnabled) return;
|
||||
try {
|
||||
const [activeTab] = await chrome.tabs.query({
|
||||
active: true,
|
||||
lastFocusedWindow: true,
|
||||
});
|
||||
if (activeTab?.id === tabId) {
|
||||
chrome.runtime.sendMessage({
|
||||
action: ACTIONS.TAB_URL_UPDATED,
|
||||
url: changeInfo.url,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Onyx SW] Error on tab updated:", error);
|
||||
}
|
||||
});
|
||||
|
||||
setupSidePanel();
|
||||
|
||||
@@ -132,7 +132,9 @@ import { getOnyxDomain } from "../utils/storage.js";
|
||||
return;
|
||||
}
|
||||
|
||||
setIframeSrc(items[CHROME_SPECIFIC_STORAGE_KEYS.ONYX_DOMAIN] + "/nrf");
|
||||
setIframeSrc(
|
||||
items[CHROME_SPECIFIC_STORAGE_KEYS.ONYX_DOMAIN] + "/chat/nrf",
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,15 +15,6 @@ import {
|
||||
let iframeLoadTimeout;
|
||||
let authRequired = false;
|
||||
|
||||
// Returns the origin of the Onyx app loaded in the iframe.
|
||||
// We derive the origin from iframe.src so postMessage payloads
|
||||
// (including tab URLs) are only delivered to the expected page.
|
||||
// Throws if iframe.src is not a valid URL — this is intentional:
|
||||
// postMessage must never fall back to the unsafe wildcard "*".
|
||||
function getIframeOrigin() {
|
||||
return new URL(iframe.src).origin;
|
||||
}
|
||||
|
||||
async function checkPendingInput() {
|
||||
try {
|
||||
const result = await chrome.storage.session.get("pendingInput");
|
||||
@@ -66,7 +57,7 @@ import {
|
||||
type: WEB_MESSAGE.PAGE_CHANGE,
|
||||
url: pageUrl,
|
||||
},
|
||||
getIframeOrigin(),
|
||||
"*",
|
||||
);
|
||||
currentUrl = pageUrl;
|
||||
}
|
||||
@@ -85,34 +76,15 @@ import {
|
||||
}
|
||||
|
||||
function handleMessage(event) {
|
||||
// Only trust messages from the Onyx app iframe.
|
||||
// Check both source identity and origin so that a cross-origin page
|
||||
// navigated to inside the iframe cannot send privileged extension
|
||||
// messages (e.g. TAB_READING_ENABLED) after iframe.src changes.
|
||||
// getIframeOrigin() throws if iframe.src is not yet a valid URL —
|
||||
// catching it here fails closed (message is rejected, not processed).
|
||||
if (event.source !== iframe.contentWindow) return;
|
||||
try {
|
||||
if (event.origin !== getIframeOrigin()) return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (event.data.type === CHROME_MESSAGE.ONYX_APP_LOADED) {
|
||||
clearTimeout(iframeLoadTimeout);
|
||||
iframeLoaded = true;
|
||||
showIframe();
|
||||
if (iframe.contentWindow) {
|
||||
iframe.contentWindow.postMessage(
|
||||
{ type: "PANEL_READY" },
|
||||
getIframeOrigin(),
|
||||
);
|
||||
iframe.contentWindow.postMessage({ type: "PANEL_READY" }, "*");
|
||||
}
|
||||
} else if (event.data.type === CHROME_MESSAGE.AUTH_REQUIRED) {
|
||||
authRequired = true;
|
||||
} else if (event.data.type === CHROME_MESSAGE.TAB_READING_ENABLED) {
|
||||
chrome.runtime.sendMessage({ action: ACTIONS.TAB_READING_ENABLED });
|
||||
} else if (event.data.type === CHROME_MESSAGE.TAB_READING_DISABLED) {
|
||||
chrome.runtime.sendMessage({ action: ACTIONS.TAB_READING_DISABLED });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,13 +117,6 @@ import {
|
||||
setIframeSrc(request.url, request.pageUrl);
|
||||
} else if (request.action === ACTIONS.UPDATE_PAGE_URL) {
|
||||
sendWebsiteToIframe(request.pageUrl);
|
||||
} else if (request.action === ACTIONS.TAB_URL_UPDATED) {
|
||||
if (iframe.contentWindow) {
|
||||
iframe.contentWindow.postMessage(
|
||||
{ type: CHROME_MESSAGE.TAB_URL_UPDATED, url: request.url },
|
||||
getIframeOrigin(),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ export const THEMES = {
|
||||
|
||||
export const DEFAULT_ONYX_DOMAIN = "http://localhost:3000";
|
||||
|
||||
export const SIDE_PANEL_PATH = "/nrf/side-panel";
|
||||
export const SIDE_PANEL_PATH = "/chat/nrf/side-panel";
|
||||
|
||||
export const ACTIONS = {
|
||||
GET_SELECTED_TEXT: "getSelectedText",
|
||||
@@ -17,9 +17,6 @@ export const ACTIONS = {
|
||||
OPEN_SIDE_PANEL_WITH_INPUT: "openSidePanelWithInput",
|
||||
OPEN_ONYX_WITH_INPUT: "openOnyxWithInput",
|
||||
CLOSE_SIDE_PANEL: "closeSidePanel",
|
||||
TAB_URL_UPDATED: "tabUrlUpdated",
|
||||
TAB_READING_ENABLED: "tabReadingEnabled",
|
||||
TAB_READING_DISABLED: "tabReadingDisabled",
|
||||
};
|
||||
|
||||
export const CHROME_SPECIFIC_STORAGE_KEYS = {
|
||||
@@ -39,9 +36,6 @@ export const CHROME_MESSAGE = {
|
||||
LOAD_NEW_CHAT_PAGE: "LOAD_NEW_CHAT_PAGE",
|
||||
LOAD_NEW_PAGE: "LOAD_NEW_PAGE",
|
||||
AUTH_REQUIRED: "AUTH_REQUIRED",
|
||||
TAB_READING_ENABLED: "TAB_READING_ENABLED",
|
||||
TAB_READING_DISABLED: "TAB_READING_DISABLED",
|
||||
TAB_URL_UPDATED: "TAB_URL_UPDATED",
|
||||
};
|
||||
|
||||
export const WEB_MESSAGE = {
|
||||
|
||||
@@ -3,9 +3,9 @@ import "@opal/components/tooltip.css";
|
||||
import {
|
||||
Interactive,
|
||||
type InteractiveBaseProps,
|
||||
type InteractiveContainerHeightVariant,
|
||||
type InteractiveContainerWidthVariant,
|
||||
} from "@opal/core";
|
||||
import type { SizeVariant } from "@opal/shared";
|
||||
import type { TooltipSide } from "@opal/components";
|
||||
import type { IconFunctionComponent } from "@opal/types";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
@@ -22,7 +22,7 @@ const iconVariants = {
|
||||
|
||||
function iconWrapper(
|
||||
Icon: IconFunctionComponent | undefined,
|
||||
size: SizeVariant,
|
||||
size: InteractiveContainerHeightVariant,
|
||||
includeSpacer: boolean
|
||||
) {
|
||||
const { padding: p, size: s } = iconVariants[size];
|
||||
@@ -75,11 +75,8 @@ type ButtonContentProps =
|
||||
|
||||
type ButtonProps = InteractiveBaseProps &
|
||||
ButtonContentProps & {
|
||||
/**
|
||||
* Size preset — controls gap, text size, and Container height/rounding.
|
||||
* Uses the shared `SizeVariant` scale from `@opal/shared`.
|
||||
*/
|
||||
size?: SizeVariant;
|
||||
/** Size preset — controls gap, text size, and Container height/rounding. */
|
||||
size?: InteractiveContainerHeightVariant;
|
||||
|
||||
/** HTML button type. When provided, Container renders a `<button>` element. */
|
||||
type?: "submit" | "button" | "reset";
|
||||
|
||||
@@ -4,6 +4,7 @@ export {
|
||||
type InteractiveBaseProps,
|
||||
type InteractiveBaseVariantProps,
|
||||
type InteractiveContainerProps,
|
||||
type InteractiveContainerHeightVariant,
|
||||
type InteractiveContainerWidthVariant,
|
||||
type InteractiveContainerRoundingVariant,
|
||||
} from "@opal/core/interactive/components";
|
||||
|
||||
@@ -3,7 +3,6 @@ import React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cn } from "@opal/utils";
|
||||
import type { WithoutStyles } from "@opal/types";
|
||||
import { sizeVariants, type SizeVariant } from "@opal/shared";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -39,6 +38,31 @@ type InteractiveBaseVariantProps =
|
||||
selected?: never;
|
||||
};
|
||||
|
||||
/**
|
||||
* Height presets for `Interactive.Container`.
|
||||
*
|
||||
* - `"lg"` — 2.25rem (36px), suitable for most buttons/items
|
||||
* - `"md"` — 1.75rem (28px), standard compact size
|
||||
* - `"sm"` — 1.5rem (24px), for denser UIs
|
||||
* - `"xs"` — 1.25rem (20px), for inline elements
|
||||
* - `"2xs"` — 1rem (16px), for micro elements
|
||||
* - `"fit"` — Shrink-wraps to content height (`h-fit`), for variable-height layouts
|
||||
*/
|
||||
type InteractiveContainerHeightVariant =
|
||||
keyof typeof interactiveContainerSizeVariants;
|
||||
const interactiveContainerSizeVariants = {
|
||||
lg: { height: "h-[2.25rem]", minWidth: "min-w-[2.25rem]", padding: "p-2" },
|
||||
md: { height: "h-[1.75rem]", minWidth: "min-w-[1.75rem]", padding: "p-1" },
|
||||
sm: { height: "h-[1.5rem]", minWidth: "min-w-[1.5rem]", padding: "p-1" },
|
||||
xs: {
|
||||
height: "h-[1.25rem]",
|
||||
minWidth: "min-w-[1.25rem]",
|
||||
padding: "p-0.5",
|
||||
},
|
||||
"2xs": { height: "h-[1rem]", minWidth: "min-w-[1rem]", padding: "p-0.5" },
|
||||
fit: { height: "h-fit", minWidth: "", padding: "p-0" },
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Width presets for `Interactive.Container`.
|
||||
*
|
||||
@@ -329,13 +353,18 @@ interface InteractiveContainerProps
|
||||
roundingVariant?: InteractiveContainerRoundingVariant;
|
||||
|
||||
/**
|
||||
* Size preset controlling the container's height, min-width, and padding.
|
||||
* Uses the shared `SizeVariant` scale from `@opal/shared`.
|
||||
* Height preset controlling the container's vertical size.
|
||||
*
|
||||
* - `"lg"` — 2.25rem (36px), typical button/item height
|
||||
* - `"md"` — 1.75rem (28px), standard compact size
|
||||
* - `"sm"` — 1.5rem (24px), for denser UIs
|
||||
* - `"xs"` — 1.25rem (20px), for inline elements
|
||||
* - `"2xs"` — 1rem (16px), for micro elements
|
||||
* - `"fit"` — Shrink-wraps to content height (`h-fit`)
|
||||
*
|
||||
* @default "lg"
|
||||
* @see {@link SizeVariant} for the full list of presets.
|
||||
*/
|
||||
heightVariant?: SizeVariant;
|
||||
heightVariant?: InteractiveContainerHeightVariant;
|
||||
|
||||
/**
|
||||
* Width preset controlling the container's horizontal size.
|
||||
@@ -404,7 +433,8 @@ function InteractiveContainer({
|
||||
target?: string;
|
||||
rel?: string;
|
||||
};
|
||||
const { height, minWidth, padding } = sizeVariants[heightVariant];
|
||||
const { height, minWidth, padding } =
|
||||
interactiveContainerSizeVariants[heightVariant];
|
||||
const sharedProps = {
|
||||
...rest,
|
||||
className: cn(
|
||||
@@ -490,6 +520,7 @@ export {
|
||||
type InteractiveBaseVariantProps,
|
||||
type InteractiveBaseSelectVariantProps,
|
||||
type InteractiveContainerProps,
|
||||
type InteractiveContainerHeightVariant,
|
||||
type InteractiveContainerWidthVariant,
|
||||
type InteractiveContainerRoundingVariant,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@opal/components/buttons/Button/components";
|
||||
import type { SizeVariant } from "@opal/shared";
|
||||
import type { InteractiveContainerHeightVariant } from "@opal/core";
|
||||
import SvgEdit from "@opal/icons/edit";
|
||||
import type { IconFunctionComponent } from "@opal/types";
|
||||
import { cn } from "@opal/utils";
|
||||
@@ -25,8 +25,8 @@ interface HeadingPresetConfig {
|
||||
titleFont: string;
|
||||
/** Title line-height — also used as icon container min-height (CSS value). */
|
||||
lineHeight: string;
|
||||
/** Button `size` prop for the edit button. Uses the shared `SizeVariant` scale. */
|
||||
editButtonSize: SizeVariant;
|
||||
/** Button `size` prop for the edit button. */
|
||||
editButtonSize: InteractiveContainerHeightVariant;
|
||||
/** Tailwind padding class for the edit button container. */
|
||||
editButtonPadding: string;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Button } from "@opal/components/buttons/Button/components";
|
||||
import { Tag, type TagProps } from "@opal/components/Tag/components";
|
||||
import type { SizeVariant } from "@opal/shared";
|
||||
import type { InteractiveContainerHeightVariant } from "@opal/core";
|
||||
import SvgAlertCircle from "@opal/icons/alert-circle";
|
||||
import SvgAlertTriangle from "@opal/icons/alert-triangle";
|
||||
import SvgEdit from "@opal/icons/edit";
|
||||
@@ -26,8 +26,7 @@ interface LabelPresetConfig {
|
||||
titleFont: string;
|
||||
lineHeight: string;
|
||||
gap: string;
|
||||
/** Button `size` prop for the edit button. Uses the shared `SizeVariant` scale. */
|
||||
editButtonSize: SizeVariant;
|
||||
editButtonSize: InteractiveContainerHeightVariant;
|
||||
editButtonPadding: string;
|
||||
optionalFont: string;
|
||||
/** Aux icon size = lineHeight − 2 × p-0.5. */
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
# ContentAction
|
||||
|
||||
**Import:** `import { ContentAction, type ContentActionProps } from "@opal/layouts";`
|
||||
|
||||
A row layout that pairs a [`Content`](../Content/README.md) block with optional right-side action children (buttons, badges, icons, etc.).
|
||||
|
||||
## Why ContentAction?
|
||||
|
||||
`Content` renders icon + title + description but has no slot for actions. When you need a settings row, card header, or list item with an action on the right you would typically wrap `Content` in a manual flex-row. `ContentAction` standardises that pattern and adds padding alignment with `Interactive.Container` and `Button` via the shared `SizeVariant` scale.
|
||||
|
||||
## Props
|
||||
|
||||
Inherits **all** props from [`Content`](../Content/README.md) (same discriminated-union API) plus:
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `rightChildren` | `ReactNode` | `undefined` | Content rendered on the right side. Wrapper stretches to the full height of the row. |
|
||||
| `paddingVariant` | `SizeVariant` | `"lg"` | Padding preset applied around the `Content` area. Uses the shared size scale from `@opal/shared`. |
|
||||
|
||||
### `paddingVariant` reference
|
||||
|
||||
| Value | Padding class | Effective padding |
|
||||
|---|---|---|
|
||||
| `lg` | `p-2` | 0.5rem (8px) |
|
||||
| `md` | `p-1` | 0.25rem (4px) |
|
||||
| `sm` | `p-1` | 0.25rem (4px) |
|
||||
| `xs` | `p-0.5` | 0.125rem (2px) |
|
||||
| `2xs` | `p-0.5` | 0.125rem (2px) |
|
||||
| `fit` | `p-0` | 0 |
|
||||
|
||||
These values are identical to the padding applied by `Interactive.Container` at each size, so `ContentAction` labels naturally align with adjacent buttons of the same size.
|
||||
|
||||
## Layout Structure
|
||||
|
||||
```
|
||||
[ Content (flex-1, padded) ][ rightChildren (shrink-0, full height) ]
|
||||
```
|
||||
|
||||
- The outer wrapper is `flex flex-row items-stretch w-full`.
|
||||
- `Content` sits inside a `flex-1 min-w-0` div with padding from `paddingVariant`.
|
||||
- `rightChildren` is wrapped in `flex items-stretch shrink-0` so it stretches vertically.
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Settings row with an edit button
|
||||
|
||||
```tsx
|
||||
import { ContentAction } from "@opal/layouts";
|
||||
import { Button } from "@opal/components";
|
||||
import SvgSettings from "@opal/icons/settings";
|
||||
|
||||
<ContentAction
|
||||
icon={SvgSettings}
|
||||
title="OpenAI"
|
||||
description="GPT"
|
||||
sizePreset="main-content"
|
||||
variant="section"
|
||||
tag={{ title: "Default", color: "blue" }}
|
||||
paddingVariant="lg"
|
||||
rightChildren={
|
||||
<Button icon={SvgSettings} prominence="tertiary" onClick={handleEdit} />
|
||||
}
|
||||
/>
|
||||
```
|
||||
|
||||
### Card header with connect action
|
||||
|
||||
```tsx
|
||||
import { ContentAction } from "@opal/layouts";
|
||||
import { Button } from "@opal/components";
|
||||
import { SvgArrowExchange, SvgCloud } from "@opal/icons";
|
||||
|
||||
<ContentAction
|
||||
icon={SvgCloud}
|
||||
title="Google Cloud Vertex AI"
|
||||
description="Gemini"
|
||||
sizePreset="main-content"
|
||||
variant="section"
|
||||
paddingVariant="md"
|
||||
rightChildren={
|
||||
<Button rightIcon={SvgArrowExchange} prominence="tertiary">
|
||||
Connect
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
```
|
||||
|
||||
### No right children (padding-only wrapper)
|
||||
|
||||
```tsx
|
||||
<ContentAction
|
||||
title="Section Header"
|
||||
sizePreset="main-content"
|
||||
variant="section"
|
||||
paddingVariant="lg"
|
||||
/>
|
||||
```
|
||||
|
||||
When `rightChildren` is omitted the component renders only the padded `Content` — useful for alignment consistency when some rows have actions and others don't.
|
||||
@@ -1,75 +0,0 @@
|
||||
import { Content, type ContentProps } from "@opal/layouts/Content/components";
|
||||
import { sizeVariants, type SizeVariant } from "@opal/shared";
|
||||
import { cn } from "@opal/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ContentActionProps = ContentProps & {
|
||||
/** Content rendered on the right side, stretched to full height. */
|
||||
rightChildren?: React.ReactNode;
|
||||
|
||||
/**
|
||||
* Padding applied around the `Content` area.
|
||||
* Uses the shared `SizeVariant` scale from `@opal/shared`.
|
||||
*
|
||||
* @default "lg"
|
||||
* @see {@link SizeVariant} for the full list of presets.
|
||||
*/
|
||||
paddingVariant?: SizeVariant;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ContentAction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A row layout that pairs a {@link Content} block with optional right-side
|
||||
* action children (e.g. buttons, badges).
|
||||
*
|
||||
* The `Content` area receives padding controlled by `paddingVariant`, using
|
||||
* the same size scale as `Interactive.Container` and `Button`. The
|
||||
* `rightChildren` wrapper stretches to the full height of the row.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { ContentAction } from "@opal/layouts";
|
||||
* import { Button } from "@opal/components";
|
||||
* import SvgSettings from "@opal/icons/settings";
|
||||
*
|
||||
* <ContentAction
|
||||
* icon={SvgSettings}
|
||||
* title="OpenAI"
|
||||
* description="GPT"
|
||||
* sizePreset="main-content"
|
||||
* variant="section"
|
||||
* paddingVariant="lg"
|
||||
* rightChildren={<Button icon={SvgSettings} prominence="tertiary" />}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
function ContentAction({
|
||||
rightChildren,
|
||||
paddingVariant = "lg",
|
||||
...contentProps
|
||||
}: ContentActionProps) {
|
||||
const { padding } = sizeVariants[paddingVariant];
|
||||
|
||||
return (
|
||||
<div className="flex flex-row items-stretch w-full">
|
||||
<div className={cn("flex-1 min-w-0", padding)}>
|
||||
<Content {...contentProps} />
|
||||
</div>
|
||||
{rightChildren && (
|
||||
<div className="flex items-stretch shrink-0">{rightChildren}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export { ContentAction, type ContentActionProps };
|
||||
@@ -1,93 +0,0 @@
|
||||
# @opal/layouts
|
||||
|
||||
**Import:** `import { Content, ContentAction } from "@opal/layouts";`
|
||||
|
||||
Layout primitives for composing icon + title + description rows. These components handle sizing, font selection, icon alignment, and optional inline editing — things that are tedious to get right by hand and easy to get wrong.
|
||||
|
||||
## Components
|
||||
|
||||
| Component | Description | Docs |
|
||||
|---|---|---|
|
||||
| [`Content`](./Content/README.md) | Icon + title + description row. Routes to an internal layout (`HeadingLayout`, `LabelLayout`, or `BodyLayout`) based on `sizePreset` and `variant`. | [Content README](./Content/README.md) |
|
||||
| [`ContentAction`](./ContentAction/README.md) | Wraps `Content` in a flex-row with an optional `rightChildren` slot for action buttons. Adds padding alignment via the shared `SizeVariant` scale. | [ContentAction README](./ContentAction/README.md) |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```tsx
|
||||
import { Content, ContentAction } from "@opal/layouts";
|
||||
import { Button } from "@opal/components";
|
||||
import SvgSettings from "@opal/icons/settings";
|
||||
|
||||
// Simple heading
|
||||
<Content
|
||||
icon={SvgSettings}
|
||||
title="Account Settings"
|
||||
description="Manage your preferences"
|
||||
sizePreset="headline"
|
||||
variant="heading"
|
||||
/>
|
||||
|
||||
// Label with tag
|
||||
<Content
|
||||
icon={SvgSettings}
|
||||
title="OpenAI"
|
||||
description="GPT"
|
||||
sizePreset="main-content"
|
||||
variant="section"
|
||||
tag={{ title: "Default", color: "blue" }}
|
||||
/>
|
||||
|
||||
// Row with action button
|
||||
<ContentAction
|
||||
icon={SvgSettings}
|
||||
title="Provider Name"
|
||||
description="Some description"
|
||||
sizePreset="main-content"
|
||||
variant="section"
|
||||
paddingVariant="lg"
|
||||
rightChildren={
|
||||
<Button icon={SvgSettings} prominence="tertiary" />
|
||||
}
|
||||
/>
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Two-axis design (`Content`)
|
||||
|
||||
`Content` uses a two-axis system:
|
||||
|
||||
- **`sizePreset`** — controls sizing tokens (icon size, padding, gap, font, line-height).
|
||||
- **`variant`** — controls structural layout (icon placement, description rendering).
|
||||
|
||||
Valid preset/variant combinations are enforced at the type level via a discriminated union. See the [Content README](./Content/README.md) for the full matrix.
|
||||
|
||||
### Shared size scale (`ContentAction`)
|
||||
|
||||
`ContentAction` uses the same `SizeVariant` scale (`lg`, `md`, `sm`, `xs`, `2xs`, `fit`) defined in `@opal/shared` that powers `Interactive.Container` and `Button`. This ensures that padding on content rows aligns with adjacent interactive elements at the same size.
|
||||
|
||||
## Exports
|
||||
|
||||
From `@opal/layouts`:
|
||||
|
||||
```ts
|
||||
// Components
|
||||
Content
|
||||
ContentAction
|
||||
|
||||
// Types
|
||||
ContentProps
|
||||
ContentActionProps
|
||||
SizePreset
|
||||
ContentVariant
|
||||
```
|
||||
|
||||
## Internal Layout Components
|
||||
|
||||
These are not exported — `Content` routes to them automatically:
|
||||
|
||||
| Layout | Used when | File |
|
||||
|---|---|---|
|
||||
| `HeadingLayout` | `sizePreset` is `headline` or `section` | `Content/HeadingLayout.tsx` |
|
||||
| `LabelLayout` | `sizePreset` is `main-content`, `main-ui`, or `secondary` with `variant="section"` | `Content/LabelLayout.tsx` |
|
||||
| `BodyLayout` | `variant="body"` | `Content/BodyLayout.tsx` |
|
||||
@@ -5,9 +5,3 @@ export {
|
||||
type SizePreset,
|
||||
type ContentVariant,
|
||||
} from "@opal/layouts/Content/components";
|
||||
|
||||
/* ContentAction */
|
||||
export {
|
||||
ContentAction,
|
||||
type ContentActionProps,
|
||||
} from "@opal/layouts/ContentAction/components";
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
/**
|
||||
* @opal/shared — Shared constants and types for the opal design system.
|
||||
*
|
||||
* This module holds design tokens that are referenced by multiple opal
|
||||
* packages (core, components, layouts). Centralising them here avoids
|
||||
* circular imports and gives every consumer a single source of truth.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Size Variants
|
||||
//
|
||||
// A named scale of size presets (lg → 2xs, plus fit) that map to Tailwind
|
||||
// utility classes for height, min-width, and padding.
|
||||
//
|
||||
// Consumers:
|
||||
// - Interactive.Container (height + min-width + padding)
|
||||
// - Button (icon sizing)
|
||||
// - ContentAction (padding only)
|
||||
// - Content (HeadingLayout / LabelLayout) (edit-button size)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Size-variant scale.
|
||||
*
|
||||
* Each entry maps a named preset to Tailwind utility classes for
|
||||
* `height`, `min-width`, and `padding`.
|
||||
*
|
||||
* | Key | Height | Padding |
|
||||
* |-------|---------------|----------|
|
||||
* | `lg` | 2.25rem (36px)| `p-2` |
|
||||
* | `md` | 1.75rem (28px)| `p-1` |
|
||||
* | `sm` | 1.5rem (24px) | `p-1` |
|
||||
* | `xs` | 1.25rem (20px)| `p-0.5` |
|
||||
* | `2xs` | 1rem (16px) | `p-0.5` |
|
||||
* | `fit` | h-fit | `p-0` |
|
||||
*/
|
||||
const sizeVariants = {
|
||||
lg: { height: "h-[2.25rem]", minWidth: "min-w-[2.25rem]", padding: "p-2" },
|
||||
md: { height: "h-[1.75rem]", minWidth: "min-w-[1.75rem]", padding: "p-1" },
|
||||
sm: { height: "h-[1.5rem]", minWidth: "min-w-[1.5rem]", padding: "p-1" },
|
||||
xs: {
|
||||
height: "h-[1.25rem]",
|
||||
minWidth: "min-w-[1.25rem]",
|
||||
padding: "p-0.5",
|
||||
},
|
||||
"2xs": { height: "h-[1rem]", minWidth: "min-w-[1rem]", padding: "p-0.5" },
|
||||
fit: { height: "h-fit", minWidth: "", padding: "p-0" },
|
||||
} as const;
|
||||
|
||||
/** Named size preset key. */
|
||||
type SizeVariant = keyof typeof sizeVariants;
|
||||
|
||||
export { sizeVariants, type SizeVariant };
|
||||
@@ -11,10 +11,8 @@ import IconButton from "@/refresh-components/buttons/IconButton";
|
||||
import Modal from "@/refresh-components/Modal";
|
||||
import { useFilters, useLlmManager } from "@/lib/hooks";
|
||||
import Dropzone from "react-dropzone";
|
||||
import { useSendMessageToParent, getPanelOrigin } from "@/lib/extension/utils";
|
||||
import { useSendMessageToParent } from "@/lib/extension/utils";
|
||||
import { useNRFPreferences } from "@/components/context/NRFPreferencesContext";
|
||||
import SidePanelHeader from "@/app/nrf/side-panel/SidePanelHeader";
|
||||
import { CHROME_MESSAGE } from "@/lib/extension/constants";
|
||||
import { SettingsPanel } from "@/app/components/nrf/SettingsPanel";
|
||||
import LoginPage from "@/app/auth/login/LoginPage";
|
||||
import { sendSetDefaultNewTabMessage } from "@/lib/extension/utils";
|
||||
@@ -35,9 +33,16 @@ import ChatScrollContainer from "@/sections/chat/ChatScrollContainer";
|
||||
import WelcomeMessage from "@/app/app/components/WelcomeMessage";
|
||||
import useChatSessions from "@/hooks/useChatSessions";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Logo from "@/refresh-components/Logo";
|
||||
import Spacer from "@/refresh-components/Spacer";
|
||||
import { useAppSidebarContext } from "@/providers/AppSidebarProvider";
|
||||
import { DEFAULT_CONTEXT_TOKENS } from "@/lib/constants";
|
||||
import { SvgUser, SvgMenu, SvgAlertTriangle } from "@opal/icons";
|
||||
import {
|
||||
SvgUser,
|
||||
SvgMenu,
|
||||
SvgExternalLink,
|
||||
SvgAlertTriangle,
|
||||
} from "@opal/icons";
|
||||
import { useAppBackground } from "@/providers/AppBackgroundProvider";
|
||||
import { MinimalOnyxDocument } from "@/lib/search/interfaces";
|
||||
import DocumentsSidebar from "@/sections/document-sidebar/DocumentsSidebar";
|
||||
@@ -62,6 +67,14 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const filterManager = useFilters();
|
||||
const { user, authTypeMetadata } = useUser();
|
||||
const { setFolded } = useAppSidebarContext();
|
||||
|
||||
// Hide sidebar when in side panel mode
|
||||
useEffect(() => {
|
||||
if (isSidePanel) {
|
||||
setFolded(true);
|
||||
}
|
||||
}, [isSidePanel, setFolded]);
|
||||
|
||||
// Chat sessions
|
||||
const { refreshChatSessions } = useChatSessions();
|
||||
@@ -116,8 +129,6 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
|
||||
// State
|
||||
const [message, setMessage] = useState("");
|
||||
const [settingsOpen, setSettingsOpen] = useState<boolean>(false);
|
||||
const [tabReadingEnabled, setTabReadingEnabled] = useState<boolean>(false);
|
||||
const [currentTabUrl, setCurrentTabUrl] = useState<string | null>(null);
|
||||
const [presentingDocument, setPresentingDocument] =
|
||||
useState<MinimalOnyxDocument | null>(null);
|
||||
|
||||
@@ -126,12 +137,6 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
|
||||
const updateCurrentDocumentSidebarVisible = useChatSessionStore(
|
||||
(state) => state.updateCurrentDocumentSidebarVisible
|
||||
);
|
||||
const setCurrentSession = useChatSessionStore(
|
||||
(state) => state.setCurrentSession
|
||||
);
|
||||
const currentSessionId = useChatSessionStore(
|
||||
(state) => state.currentSessionId
|
||||
);
|
||||
|
||||
// Memoized callback for closing document sidebar
|
||||
const handleDocumentSidebarClose = useCallback(() => {
|
||||
@@ -196,26 +201,6 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
|
||||
|
||||
useSendMessageToParent();
|
||||
|
||||
// Listen for tab URL updates from the Chrome extension
|
||||
useEffect(() => {
|
||||
if (!isSidePanel) return;
|
||||
|
||||
function handleExtensionMessage(event: MessageEvent) {
|
||||
// Only trust messages from the Chrome extension parent.
|
||||
// Checking the origin (chrome-extension://) prevents a non-extension
|
||||
// page that embeds NRFPage as an iframe from injecting arbitrary URLs
|
||||
// into the prompt context via TAB_URL_UPDATED.
|
||||
if (!event.origin.startsWith("chrome-extension://")) return;
|
||||
if (event.source !== window.parent) return;
|
||||
if (event.data?.type === CHROME_MESSAGE.TAB_URL_UPDATED) {
|
||||
setCurrentTabUrl(event.data.url as string);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", handleExtensionMessage);
|
||||
return () => window.removeEventListener("message", handleExtensionMessage);
|
||||
}, [isSidePanel]);
|
||||
|
||||
const toggleSettings = () => {
|
||||
setSettingsOpen((prev) => !prev);
|
||||
};
|
||||
@@ -283,16 +268,23 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
|
||||
[handleMessageSpecificFileUpload]
|
||||
);
|
||||
|
||||
// Handler for chat submission (used by query controller)
|
||||
const onChat = useCallback(
|
||||
(chatMessage: string) => {
|
||||
resetInputBar();
|
||||
onSubmit({
|
||||
message: chatMessage,
|
||||
currentMessageFiles: currentMessageFiles,
|
||||
deepResearch: deepResearchEnabled,
|
||||
});
|
||||
},
|
||||
[onSubmit, currentMessageFiles, deepResearchEnabled, resetInputBar]
|
||||
);
|
||||
|
||||
// Handle submit from AppInputBar - routes through query controller for search/chat classification
|
||||
const handleChatInputSubmit = useCallback(
|
||||
async (submittedMessage: string) => {
|
||||
if (!submittedMessage.trim()) return;
|
||||
|
||||
const additionalContext =
|
||||
tabReadingEnabled && currentTabUrl
|
||||
? `The user is currently viewing: ${currentTabUrl}. Use the open_url tool to read this page and use its content as additional context for your response.`
|
||||
: undefined;
|
||||
|
||||
// If we already have messages (chat session started), always use chat mode
|
||||
// (matches AppPage behavior where existing sessions bypass classification)
|
||||
if (hasMessages) {
|
||||
@@ -301,22 +293,9 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
|
||||
message: submittedMessage,
|
||||
currentMessageFiles: currentMessageFiles,
|
||||
deepResearch: deepResearchEnabled,
|
||||
additionalContext,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Build an onChat closure that captures additionalContext for this submission
|
||||
const onChat = (chatMessage: string) => {
|
||||
resetInputBar();
|
||||
onSubmit({
|
||||
message: chatMessage,
|
||||
currentMessageFiles: currentMessageFiles,
|
||||
deepResearch: deepResearchEnabled,
|
||||
additionalContext,
|
||||
});
|
||||
};
|
||||
|
||||
// Use submitQuery which will classify the query and either:
|
||||
// - Route to search (sets classification to "search" and shows SearchUI)
|
||||
// - Route to chat (calls onChat callback)
|
||||
@@ -329,8 +308,7 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
|
||||
deepResearchEnabled,
|
||||
resetInputBar,
|
||||
submitQuery,
|
||||
tabReadingEnabled,
|
||||
currentTabUrl,
|
||||
onChat,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -353,34 +331,9 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
|
||||
});
|
||||
}, [messageHistory, onSubmit, currentMessageFiles, deepResearchEnabled]);
|
||||
|
||||
// Start a new chat session in the side panel
|
||||
const handleNewChat = useCallback(() => {
|
||||
setCurrentSession(null);
|
||||
setTabReadingEnabled(false);
|
||||
setCurrentTabUrl(null);
|
||||
resetInputBar();
|
||||
// Notify the service worker so it stops sending tab URL updates
|
||||
window.parent.postMessage(
|
||||
{ type: CHROME_MESSAGE.TAB_READING_DISABLED },
|
||||
getPanelOrigin()
|
||||
);
|
||||
}, [setCurrentSession, resetInputBar]);
|
||||
|
||||
const handleToggleTabReading = useCallback(() => {
|
||||
const next = !tabReadingEnabled;
|
||||
setTabReadingEnabled(next);
|
||||
if (!next) {
|
||||
setCurrentTabUrl(null);
|
||||
}
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: next
|
||||
? CHROME_MESSAGE.TAB_READING_ENABLED
|
||||
: CHROME_MESSAGE.TAB_READING_DISABLED,
|
||||
},
|
||||
getPanelOrigin()
|
||||
);
|
||||
}, [tabReadingEnabled]);
|
||||
const handleOpenInOnyx = () => {
|
||||
window.open(`${window.location.origin}/app`, "_blank");
|
||||
};
|
||||
|
||||
// Handle search result document click
|
||||
const handleSearchDocumentClick = useCallback(
|
||||
@@ -409,10 +362,18 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
|
||||
|
||||
{/* Side panel header */}
|
||||
{isSidePanel && (
|
||||
<SidePanelHeader
|
||||
onNewChat={handleNewChat}
|
||||
chatSessionId={currentSessionId}
|
||||
/>
|
||||
<header className="flex items-center justify-between px-4 py-3 border-b border-border-01 bg-background">
|
||||
<div className="flex items-center gap-2">
|
||||
<Logo />
|
||||
</div>
|
||||
<Button
|
||||
tertiary
|
||||
rightIcon={SvgExternalLink}
|
||||
onClick={handleOpenInOnyx}
|
||||
>
|
||||
Open in Onyx
|
||||
</Button>
|
||||
</header>
|
||||
)}
|
||||
|
||||
{/* Settings button */}
|
||||
@@ -431,22 +392,18 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
|
||||
{({ getRootProps }) => (
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={cn(
|
||||
"flex-1 min-h-0 w-full flex flex-col items-center outline-none",
|
||||
isSidePanel && "px-3"
|
||||
)}
|
||||
className="h-full w-full flex flex-col items-center outline-none"
|
||||
>
|
||||
{/* Chat area with messages */}
|
||||
{hasMessages && resolvedAssistant && (
|
||||
<>
|
||||
{/* Fake header - pushes content below absolute settings button (non-side-panel only) */}
|
||||
{!isSidePanel && <Spacer rem={2} />}
|
||||
{/* Fake header */}
|
||||
<Spacer rem={2} />
|
||||
<ChatScrollContainer
|
||||
sessionId="nrf-session"
|
||||
anchorSelector={anchorSelector}
|
||||
autoScroll={autoScrollEnabled}
|
||||
isStreaming={isStreaming}
|
||||
hideScrollbar={isSidePanel}
|
||||
>
|
||||
<ChatUI
|
||||
liveAssistant={resolvedAssistant}
|
||||
@@ -475,11 +432,7 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
|
||||
{/* AppInputBar container - in normal flex flow like AppPage */}
|
||||
<div
|
||||
ref={inputRef}
|
||||
className={cn(
|
||||
"w-full flex flex-col",
|
||||
!isSidePanel &&
|
||||
"max-w-[var(--app-page-main-content-width)] px-4"
|
||||
)}
|
||||
className="w-full max-w-[var(--app-page-main-content-width)] flex flex-col px-4"
|
||||
>
|
||||
<AppInputBar
|
||||
ref={chatInputBarRef}
|
||||
@@ -502,13 +455,8 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
|
||||
disabled={
|
||||
!llmManager.isLoadingProviders && !llmManager.hasAnyProvider
|
||||
}
|
||||
{...(isSidePanel && {
|
||||
tabReadingEnabled,
|
||||
currentTabUrl,
|
||||
onToggleTabReading: handleToggleTabReading,
|
||||
})}
|
||||
/>
|
||||
<Spacer rem={isSidePanel ? 1 : 0.5} />
|
||||
<Spacer rem={0.5} />
|
||||
</div>
|
||||
|
||||
{/* Search results - shown when query is classified as search */}
|
||||
20
web/src/app/app/nrf/page.tsx
Normal file
20
web/src/app/app/nrf/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { unstable_noStore as noStore } from "next/cache";
|
||||
import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh";
|
||||
import { cookies } from "next/headers";
|
||||
import NRFPage from "./NRFPage";
|
||||
import { NRFPreferencesProvider } from "@/components/context/NRFPreferencesContext";
|
||||
import * as AppLayouts from "@/layouts/app-layouts";
|
||||
|
||||
export default async function Page() {
|
||||
noStore();
|
||||
const requestCookies = await cookies();
|
||||
|
||||
return (
|
||||
<AppLayouts.Root>
|
||||
<InstantSSRAutoRefresh />
|
||||
<NRFPreferencesProvider>
|
||||
<NRFPage />
|
||||
</NRFPreferencesProvider>
|
||||
</AppLayouts.Root>
|
||||
);
|
||||
}
|
||||
18
web/src/app/app/nrf/side-panel/page.tsx
Normal file
18
web/src/app/app/nrf/side-panel/page.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { unstable_noStore as noStore } from "next/cache";
|
||||
import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh";
|
||||
import NRFPage from "@/app/app/nrf/NRFPage";
|
||||
import { NRFPreferencesProvider } from "@/components/context/NRFPreferencesContext";
|
||||
import * as AppLayouts from "@/layouts/app-layouts";
|
||||
|
||||
export default async function Page() {
|
||||
noStore();
|
||||
|
||||
return (
|
||||
<AppLayouts.Root>
|
||||
<InstantSSRAutoRefresh />
|
||||
<NRFPreferencesProvider>
|
||||
<NRFPage isSidePanel />
|
||||
</NRFPreferencesProvider>
|
||||
</AppLayouts.Root>
|
||||
);
|
||||
}
|
||||
@@ -126,9 +126,6 @@ export interface SendMessageParams {
|
||||
temperature?: number;
|
||||
// Origin of the message for telemetry tracking
|
||||
origin?: MessageOrigin;
|
||||
// Additional context injected into the LLM call but not stored/shown in chat.
|
||||
// Used e.g. by Chrome extension "Read this tab" feature.
|
||||
additionalContext?: string;
|
||||
}
|
||||
|
||||
export async function* sendMessage({
|
||||
@@ -145,7 +142,6 @@ export async function* sendMessage({
|
||||
modelVersion,
|
||||
temperature,
|
||||
origin,
|
||||
additionalContext,
|
||||
}: SendMessageParams): AsyncGenerator<PacketType, void, unknown> {
|
||||
// Build payload for new send-chat-message API
|
||||
const payload = {
|
||||
@@ -167,7 +163,6 @@ export async function* sendMessage({
|
||||
: null,
|
||||
// Default to "unknown" for consistency with backend; callers should set explicitly
|
||||
origin: origin ?? "unknown",
|
||||
additional_context: additionalContext ?? null,
|
||||
};
|
||||
|
||||
const body = JSON.stringify(payload);
|
||||
|
||||
@@ -75,10 +75,11 @@ export function useOnboardingModal(): OnboardingModalController {
|
||||
level: existingPersona?.level,
|
||||
};
|
||||
|
||||
// Check if user has completed initial onboarding (only role required, not name)
|
||||
// Check if user has completed initial onboarding
|
||||
const hasUserInfo = useMemo(() => {
|
||||
return !!getBuildUserPersona()?.workArea;
|
||||
}, [user]);
|
||||
const existingPersona = getBuildUserPersona();
|
||||
return !!(user?.personalization?.name && existingPersona?.workArea);
|
||||
}, [user?.personalization?.name]);
|
||||
|
||||
// Check if all providers are configured (skip LLM step entirely if so)
|
||||
const allProvidersConfigured = useMemo(
|
||||
@@ -93,7 +94,7 @@ export function useOnboardingModal(): OnboardingModalController {
|
||||
);
|
||||
|
||||
// Auto-open initial onboarding modal on first load
|
||||
// Shows if: user info (role) missing OR (admin AND no providers configured)
|
||||
// Shows if: user info is missing OR (admin AND no providers configured)
|
||||
useEffect(() => {
|
||||
if (hasInitialized || isLoadingLlm || !user) return;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { unstable_noStore as noStore } from "next/cache";
|
||||
import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh";
|
||||
import NRFPage from "@/app/nrf/NRFPage";
|
||||
import NRFPage from "@/app/app/nrf/NRFPage";
|
||||
import { NRFPreferencesProvider } from "@/components/context/NRFPreferencesContext";
|
||||
import NRFChrome from "../NRFChrome";
|
||||
|
||||
|
||||
@@ -76,7 +76,6 @@ export default function NRFChrome() {
|
||||
|
||||
const showModeToggle =
|
||||
isPaidEnterpriseFeaturesEnabled &&
|
||||
settings.isSearchModeAvailable &&
|
||||
appFocus.isNewSession() &&
|
||||
!classification;
|
||||
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Logo from "@/refresh-components/Logo";
|
||||
import IconButton from "@/refresh-components/buttons/IconButton";
|
||||
import { SvgEditBig, SvgExternalLink } from "@opal/icons";
|
||||
|
||||
interface SidePanelHeaderProps {
|
||||
onNewChat: () => void;
|
||||
chatSessionId?: string | null;
|
||||
}
|
||||
|
||||
export default function SidePanelHeader({
|
||||
onNewChat,
|
||||
chatSessionId,
|
||||
}: SidePanelHeaderProps) {
|
||||
const handleOpenInOnyx = () => {
|
||||
const path = chatSessionId ? `/app?chatId=${chatSessionId}` : "/app";
|
||||
window.open(`${window.location.origin}${path}`, "_blank");
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="flex items-center justify-between px-4 py-3 border-b border-border-01 bg-background">
|
||||
<Logo />
|
||||
<div className="flex items-center gap-1">
|
||||
<IconButton
|
||||
icon={SvgEditBig}
|
||||
onClick={onNewChat}
|
||||
tertiary
|
||||
tooltip="New chat"
|
||||
/>
|
||||
<IconButton
|
||||
icon={SvgExternalLink}
|
||||
onClick={handleOpenInOnyx}
|
||||
tertiary
|
||||
tooltip="Open in Onyx"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { unstable_noStore as noStore } from "next/cache";
|
||||
import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh";
|
||||
import NRFPage from "@/app/nrf/NRFPage";
|
||||
import NRFPage from "@/app/app/nrf/NRFPage";
|
||||
import { NRFPreferencesProvider } from "@/components/context/NRFPreferencesContext";
|
||||
|
||||
/**
|
||||
|
||||
@@ -89,8 +89,6 @@ export interface OnSubmitProps {
|
||||
isSeededChat?: boolean;
|
||||
modelOverride?: LlmDescriptor;
|
||||
regenerationRequest?: RegenerationRequest | null;
|
||||
// Additional context injected into the LLM call but not stored/shown in chat.
|
||||
additionalContext?: string;
|
||||
}
|
||||
|
||||
interface RegenerationRequest {
|
||||
@@ -370,7 +368,6 @@ export default function useChatController({
|
||||
isSeededChat,
|
||||
modelOverride,
|
||||
regenerationRequest,
|
||||
additionalContext,
|
||||
}: OnSubmitProps) => {
|
||||
const projectId = params(SEARCH_PARAM_NAMES.PROJECT_ID);
|
||||
{
|
||||
@@ -728,7 +725,6 @@ export default function useChatController({
|
||||
: undefined,
|
||||
forcedToolId: effectiveForcedToolId,
|
||||
origin: messageOrigin,
|
||||
additionalContext,
|
||||
});
|
||||
|
||||
const delay = (ms: number) => {
|
||||
|
||||
@@ -17,9 +17,6 @@ export const CHROME_MESSAGE = {
|
||||
LOAD_NEW_CHAT_PAGE: "LOAD_NEW_CHAT_PAGE",
|
||||
LOAD_NEW_PAGE: "LOAD_NEW_PAGE",
|
||||
AUTH_REQUIRED: "AUTH_REQUIRED",
|
||||
TAB_READING_ENABLED: "TAB_READING_ENABLED",
|
||||
TAB_READING_DISABLED: "TAB_READING_DISABLED",
|
||||
TAB_URL_UPDATED: "TAB_URL_UPDATED",
|
||||
};
|
||||
|
||||
export const SUBMIT_MESSAGE_TYPES = {
|
||||
|
||||
@@ -3,14 +3,6 @@ import { CHROME_MESSAGE } from "./constants";
|
||||
|
||||
export type ExtensionContext = "new_tab" | "side_panel" | null;
|
||||
|
||||
// Returns the origin of the Chrome extension panel (our parent frame).
|
||||
// window.location.ancestorOrigins is Chrome-specific and only populated
|
||||
// when the page is loaded inside an iframe (e.g. the Chrome extension panel).
|
||||
// Falls back to "*" in regular browser contexts (no parent frame).
|
||||
export function getPanelOrigin(): string {
|
||||
return window.location.ancestorOrigins?.[0] ?? "*";
|
||||
}
|
||||
|
||||
export function getExtensionContext(): {
|
||||
isExtension: boolean;
|
||||
context: ExtensionContext;
|
||||
@@ -28,20 +20,17 @@ export function getExtensionContext(): {
|
||||
return { isExtension: false, context: null };
|
||||
}
|
||||
export function sendSetDefaultNewTabMessage(value: boolean) {
|
||||
if (typeof window !== "undefined" && window.parent !== window) {
|
||||
if (typeof window !== "undefined" && window.parent) {
|
||||
window.parent.postMessage(
|
||||
{ type: CHROME_MESSAGE.SET_DEFAULT_NEW_TAB, value },
|
||||
getPanelOrigin()
|
||||
"*"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const sendAuthRequiredMessage = () => {
|
||||
if (typeof window !== "undefined" && window.parent !== window) {
|
||||
window.parent.postMessage(
|
||||
{ type: CHROME_MESSAGE.AUTH_REQUIRED },
|
||||
getPanelOrigin()
|
||||
);
|
||||
if (typeof window !== "undefined" && window.parent) {
|
||||
window.parent.postMessage({ type: CHROME_MESSAGE.AUTH_REQUIRED }, "*");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -52,11 +41,8 @@ export const useSendAuthRequiredMessage = () => {
|
||||
};
|
||||
|
||||
export const sendMessageToParent = () => {
|
||||
if (typeof window !== "undefined" && window.parent !== window) {
|
||||
window.parent.postMessage(
|
||||
{ type: CHROME_MESSAGE.ONYX_APP_LOADED },
|
||||
getPanelOrigin()
|
||||
);
|
||||
if (typeof window !== "undefined" && window.parent) {
|
||||
window.parent.postMessage({ type: CHROME_MESSAGE.ONYX_APP_LOADED }, "*");
|
||||
}
|
||||
};
|
||||
export const useSendMessageToParent = () => {
|
||||
|
||||
@@ -7,26 +7,15 @@ interface LinguistLanguage {
|
||||
filenames?: string[];
|
||||
}
|
||||
|
||||
const allLanguages = Object.values(languages) as LinguistLanguage[];
|
||||
|
||||
// Collect extensions that linguist-languages assigns to "Markdown" so we can
|
||||
// exclude them from the code-language map
|
||||
const markdownExtensions = new Set(
|
||||
allLanguages
|
||||
.find((lang) => lang.name === "Markdown")
|
||||
?.extensions?.map((ext) => ext.toLowerCase()) ?? []
|
||||
);
|
||||
|
||||
// Build extension → language name and filename → language name maps at module load
|
||||
const extensionMap = new Map<string, string>();
|
||||
const filenameMap = new Map<string, string>();
|
||||
|
||||
for (const lang of allLanguages) {
|
||||
for (const lang of Object.values(languages) as LinguistLanguage[]) {
|
||||
if (lang.type !== "programming") continue;
|
||||
|
||||
const name = lang.name.toLowerCase();
|
||||
for (const ext of lang.extensions ?? []) {
|
||||
if (markdownExtensions.has(ext.toLowerCase())) continue;
|
||||
// First language to claim an extension wins
|
||||
if (!extensionMap.has(ext)) {
|
||||
extensionMap.set(ext, name);
|
||||
@@ -49,12 +38,3 @@ export function getCodeLanguage(name: string): string | null {
|
||||
const ext = lower.match(/\.[^.]+$/)?.[0];
|
||||
return (ext && extensionMap.get(ext)) ?? filenameMap.get(lower) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the file name has a Markdown extension (as defined by
|
||||
* linguist-languages) and should be rendered as rich text rather than code.
|
||||
*/
|
||||
export function isMarkdownFile(name: string): boolean {
|
||||
const ext = name.toLowerCase().match(/\.[^.]+$/)?.[0];
|
||||
return !!ext && markdownExtensions.has(ext);
|
||||
}
|
||||
|
||||
@@ -816,18 +816,18 @@ export default function ActionsPopover({
|
||||
|
||||
if (toolId === searchToolId) {
|
||||
if (wasDisabled) {
|
||||
// Enabling - restore previous sources or enable all (persisted to localStorage)
|
||||
// Enabling - restore previous sources or enable all (no persistence)
|
||||
const previous = previouslyEnabledSourcesRef.current;
|
||||
if (previous.length > 0) {
|
||||
enableSources(previous);
|
||||
setSelectedSources(previous);
|
||||
} else {
|
||||
enableSources(configuredSources);
|
||||
setSelectedSources(configuredSources);
|
||||
}
|
||||
previouslyEnabledSourcesRef.current = [];
|
||||
} else {
|
||||
// Disabling - store current sources then disable all (persisted to localStorage)
|
||||
// Disabling - store current sources then disable all (no persistence)
|
||||
previouslyEnabledSourcesRef.current = [...selectedSources];
|
||||
baseDisableAllSources();
|
||||
setSelectedSources([]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -9,7 +9,6 @@ import React, {
|
||||
useState,
|
||||
} from "react";
|
||||
import { ScrollContainerProvider } from "@/components/chat/ScrollContainerContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Size constants
|
||||
const DEFAULT_ANCHOR_OFFSET_PX = 16; // 1rem
|
||||
@@ -50,9 +49,6 @@ export interface ChatScrollContainerProps {
|
||||
|
||||
/** Session ID - resets scroll state when changed */
|
||||
sessionId?: string;
|
||||
|
||||
/** Hide the scrollbar (scroll still works, just invisible) */
|
||||
hideScrollbar?: boolean;
|
||||
}
|
||||
|
||||
// Build a CSS mask that fades content opacity at top/bottom edges
|
||||
@@ -73,7 +69,6 @@ const ChatScrollContainer = React.memo(
|
||||
isStreaming = false,
|
||||
onScrollButtonVisibilityChange,
|
||||
sessionId,
|
||||
hideScrollbar = false,
|
||||
}: ChatScrollContainerProps,
|
||||
ref: ForwardedRef<ChatScrollContainerHandle>
|
||||
) => {
|
||||
@@ -352,10 +347,7 @@ const ChatScrollContainer = React.memo(
|
||||
key={sessionId}
|
||||
ref={scrollContainerRef}
|
||||
data-testid="chat-scroll-container"
|
||||
className={cn(
|
||||
"flex flex-col flex-1 min-h-0 overflow-y-auto overflow-x-hidden",
|
||||
hideScrollbar ? "no-scrollbar" : "default-scrollbar"
|
||||
)}
|
||||
className="flex flex-col flex-1 min-h-0 overflow-y-auto overflow-x-hidden default-scrollbar"
|
||||
onScroll={handleScroll}
|
||||
style={{
|
||||
scrollbarGutter: "stable both-edges",
|
||||
|
||||
@@ -43,7 +43,6 @@ import {
|
||||
SvgCalendar,
|
||||
SvgFiles,
|
||||
SvgFileText,
|
||||
SvgGlobe,
|
||||
SvgHourglass,
|
||||
SvgPlus,
|
||||
SvgPlusCircle,
|
||||
@@ -129,10 +128,6 @@ export interface AppInputBarProps {
|
||||
toggleDeepResearch: () => void;
|
||||
disabled: boolean;
|
||||
ref?: React.Ref<AppInputBarHandle>;
|
||||
// Side panel tab reading
|
||||
tabReadingEnabled?: boolean;
|
||||
currentTabUrl?: string | null;
|
||||
onToggleTabReading?: () => void;
|
||||
}
|
||||
|
||||
const AppInputBar = React.memo(
|
||||
@@ -158,9 +153,6 @@ const AppInputBar = React.memo(
|
||||
setPresentingDocument,
|
||||
disabled,
|
||||
ref,
|
||||
tabReadingEnabled,
|
||||
currentTabUrl,
|
||||
onToggleTabReading,
|
||||
}: AppInputBarProps) => {
|
||||
// Internal message state - kept local to avoid parent re-renders on every keystroke
|
||||
const [message, setMessage] = useState(initialMessage);
|
||||
@@ -716,40 +708,17 @@ const AppInputBar = React.memo(
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
{onToggleTabReading ? (
|
||||
{showDeepResearch && (
|
||||
<Button
|
||||
icon={SvgGlobe}
|
||||
onClick={onToggleTabReading}
|
||||
icon={SvgHourglass}
|
||||
onClick={toggleDeepResearch}
|
||||
variant="select"
|
||||
selected={tabReadingEnabled}
|
||||
foldable={!tabReadingEnabled}
|
||||
selected={deepResearchEnabled}
|
||||
foldable={!deepResearchEnabled}
|
||||
disabled={disabled}
|
||||
>
|
||||
{tabReadingEnabled
|
||||
? currentTabUrl
|
||||
? (() => {
|
||||
try {
|
||||
return new URL(currentTabUrl).hostname;
|
||||
} catch {
|
||||
return currentTabUrl;
|
||||
}
|
||||
})()
|
||||
: "Reading tab..."
|
||||
: "Read this tab"}
|
||||
Deep Research
|
||||
</Button>
|
||||
) : (
|
||||
showDeepResearch && (
|
||||
<Button
|
||||
icon={SvgHourglass}
|
||||
onClick={toggleDeepResearch}
|
||||
variant="select"
|
||||
selected={deepResearchEnabled}
|
||||
foldable={!deepResearchEnabled}
|
||||
disabled={disabled}
|
||||
>
|
||||
Deep Research
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
|
||||
{selectedAssistant &&
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
CopyButton,
|
||||
DownloadButton,
|
||||
} from "@/sections/modals/PreviewModal/variants/shared";
|
||||
import TextSeparator from "@/refresh-components/TextSeparator";
|
||||
|
||||
interface CsvData {
|
||||
headers: string[];
|
||||
@@ -37,7 +36,7 @@ export const csvVariant: PreviewVariant = {
|
||||
headerDescription: (ctx) => {
|
||||
if (!ctx.fileContent) return "";
|
||||
const { rows } = parseCsv(ctx.fileContent);
|
||||
return `CSV - ${rows.length} rows • ${ctx.fileSize}`;
|
||||
return `CSV - ${rows.length} rows · ${ctx.fileSize}`;
|
||||
},
|
||||
|
||||
renderContent: (ctx) => {
|
||||
@@ -47,10 +46,15 @@ export const csvVariant: PreviewVariant = {
|
||||
<Section justifyContent="start" alignItems="start" padding={1}>
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-sticky">
|
||||
<TableRow noHover>
|
||||
<TableRow className="bg-background-tint-02">
|
||||
{headers.map((h: string, i: number) => (
|
||||
<TableHead key={i}>
|
||||
<Text as="p" className="line-clamp-2" text04 secondaryAction>
|
||||
<Text
|
||||
as="p"
|
||||
className="line-clamp-2 font-medium"
|
||||
text03
|
||||
mainUiBody
|
||||
>
|
||||
{h}
|
||||
</Text>
|
||||
</TableHead>
|
||||
@@ -59,33 +63,22 @@ export const csvVariant: PreviewVariant = {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row: string[], rIdx: number) => (
|
||||
<TableRow key={rIdx} noHover>
|
||||
<TableRow key={rIdx}>
|
||||
{headers.map((_: string, cIdx: number) => (
|
||||
<TableCell
|
||||
key={cIdx}
|
||||
className={cn(
|
||||
cIdx === 0 && "sticky left-0",
|
||||
"py-4 px-4 whitespace-normal break-words"
|
||||
cIdx === 0 && "sticky left-0 bg-background-tint-01",
|
||||
"py-0 px-4 whitespace-normal break-words"
|
||||
)}
|
||||
>
|
||||
<Text
|
||||
as="p"
|
||||
{...(cIdx === 0
|
||||
? { text04: true, secondaryAction: true }
|
||||
: { text03: true, secondaryBody: true })}
|
||||
>
|
||||
{row?.[cIdx] ?? ""}
|
||||
</Text>
|
||||
{row?.[cIdx] ?? ""}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<TextSeparator
|
||||
count={rows.length}
|
||||
text={rows.length === 1 ? "row" : "rows"}
|
||||
/>
|
||||
</Section>
|
||||
);
|
||||
},
|
||||
@@ -95,7 +88,7 @@ export const csvVariant: PreviewVariant = {
|
||||
const { headers, rows } = parseCsv(ctx.fileContent);
|
||||
return (
|
||||
<Text text03 mainUiBody className="select-none">
|
||||
{headers.length} {headers.length === 1 ? "column" : "columns"} •{" "}
|
||||
{headers.length} {headers.length === 1 ? "column" : "columns"} ·{" "}
|
||||
{rows.length} {rows.length === 1 ? "row" : "rows"}
|
||||
</Text>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import MinimalMarkdown from "@/components/chat/MinimalMarkdown";
|
||||
import ScrollIndicatorDiv from "@/refresh-components/ScrollIndicatorDiv";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import { isMarkdownFile } from "@/lib/languages";
|
||||
import { PreviewVariant } from "@/sections/modals/PreviewModal/interfaces";
|
||||
import {
|
||||
CopyButton,
|
||||
@@ -19,7 +18,14 @@ const MARKDOWN_MIMES = [
|
||||
export const markdownVariant: PreviewVariant = {
|
||||
matches: (name, mime) => {
|
||||
if (MARKDOWN_MIMES.some((m) => mime.startsWith(m))) return true;
|
||||
return isMarkdownFile(name || "");
|
||||
const lower = (name || "").toLowerCase();
|
||||
return (
|
||||
lower.endsWith(".md") ||
|
||||
lower.endsWith(".markdown") ||
|
||||
lower.endsWith(".txt") ||
|
||||
lower.endsWith(".rst") ||
|
||||
lower.endsWith(".org")
|
||||
);
|
||||
},
|
||||
width: "lg",
|
||||
height: "full",
|
||||
|
||||
Reference in New Issue
Block a user