mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-03-01 13:45:44 +00:00
Compare commits
18 Commits
experiment
...
embed_imag
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edb69df30c | ||
|
|
6c8d088789 | ||
|
|
cdf2bfeb46 | ||
|
|
98aea79433 | ||
|
|
d2deefd1f1 | ||
|
|
18b90d405d | ||
|
|
8394e8837b | ||
|
|
f06df891c4 | ||
|
|
d6d5e72c18 | ||
|
|
449f5d62f9 | ||
|
|
4d256c5666 | ||
|
|
2e53496f46 | ||
|
|
63a206706a | ||
|
|
28427b3e5f | ||
|
|
3cafcd8a5e | ||
|
|
f2c50b7bb5 | ||
|
|
6b28c6bbfc | ||
|
|
226e801665 |
@@ -18,6 +18,14 @@ inputs:
|
||||
description: "Optional NIGHTLY_LLM_API_BASE"
|
||||
required: false
|
||||
default: ""
|
||||
api-version:
|
||||
description: "Optional NIGHTLY_LLM_API_VERSION"
|
||||
required: false
|
||||
default: ""
|
||||
deployment-name:
|
||||
description: "Optional NIGHTLY_LLM_DEPLOYMENT_NAME"
|
||||
required: false
|
||||
default: ""
|
||||
custom-config-json:
|
||||
description: "Optional NIGHTLY_LLM_CUSTOM_CONFIG_JSON"
|
||||
required: false
|
||||
@@ -84,6 +92,8 @@ runs:
|
||||
NIGHTLY_LLM_PROVIDER: ${{ inputs.provider }}
|
||||
NIGHTLY_LLM_API_KEY: ${{ inputs.provider-api-key }}
|
||||
NIGHTLY_LLM_API_BASE: ${{ inputs.api-base }}
|
||||
NIGHTLY_LLM_API_VERSION: ${{ inputs.api-version }}
|
||||
NIGHTLY_LLM_DEPLOYMENT_NAME: ${{ inputs.deployment-name }}
|
||||
NIGHTLY_LLM_CUSTOM_CONFIG_JSON: ${{ inputs.custom-config-json }}
|
||||
NIGHTLY_LLM_STRICT: ${{ inputs.strict }}
|
||||
RUNS_ON_ECR_CACHE: ${{ inputs.runs-on-ecr-cache }}
|
||||
@@ -112,6 +122,8 @@ runs:
|
||||
-e NIGHTLY_LLM_MODELS="${MODELS}" \
|
||||
-e NIGHTLY_LLM_API_KEY="${NIGHTLY_LLM_API_KEY}" \
|
||||
-e NIGHTLY_LLM_API_BASE="${NIGHTLY_LLM_API_BASE}" \
|
||||
-e NIGHTLY_LLM_API_VERSION="${NIGHTLY_LLM_API_VERSION}" \
|
||||
-e NIGHTLY_LLM_DEPLOYMENT_NAME="${NIGHTLY_LLM_DEPLOYMENT_NAME}" \
|
||||
-e NIGHTLY_LLM_CUSTOM_CONFIG_JSON="${NIGHTLY_LLM_CUSTOM_CONFIG_JSON}" \
|
||||
-e NIGHTLY_LLM_STRICT="${NIGHTLY_LLM_STRICT}" \
|
||||
${RUNS_ON_ECR_CACHE}:nightly-llm-it-${RUN_ID} \
|
||||
|
||||
@@ -20,12 +20,19 @@ jobs:
|
||||
anthropic_models: ${{ vars.NIGHTLY_LLM_ANTHROPIC_MODELS }}
|
||||
bedrock_models: ${{ vars.NIGHTLY_LLM_BEDROCK_MODELS }}
|
||||
vertex_ai_models: ${{ vars.NIGHTLY_LLM_VERTEX_AI_MODELS }}
|
||||
azure_models: ${{ vars.NIGHTLY_LLM_AZURE_MODELS }}
|
||||
azure_api_base: ${{ vars.NIGHTLY_LLM_AZURE_API_BASE }}
|
||||
ollama_models: ${{ vars.NIGHTLY_LLM_OLLAMA_MODELS }}
|
||||
openrouter_models: ${{ vars.NIGHTLY_LLM_OPENROUTER_MODELS }}
|
||||
strict: true
|
||||
secrets:
|
||||
openai_api_key: ${{ secrets.OPENAI_API_KEY }}
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
bedrock_api_key: ${{ secrets.BEDROCK_API_KEY }}
|
||||
vertex_ai_custom_config_json: ${{ secrets.NIGHTLY_LLM_VERTEX_AI_CUSTOM_CONFIG_JSON }}
|
||||
azure_api_key: ${{ secrets.AZURE_API_KEY }}
|
||||
ollama_api_key: ${{ secrets.OLLAMA_API_KEY }}
|
||||
openrouter_api_key: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
|
||||
@@ -23,6 +23,26 @@ on:
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
azure_models:
|
||||
description: "Comma-separated models for azure"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
ollama_models:
|
||||
description: "Comma-separated models for ollama_chat"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
openrouter_models:
|
||||
description: "Comma-separated models for openrouter"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
azure_api_base:
|
||||
description: "API base for azure provider"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
strict:
|
||||
description: "Default NIGHTLY_LLM_STRICT passed to tests"
|
||||
required: false
|
||||
@@ -37,6 +57,12 @@ on:
|
||||
required: false
|
||||
vertex_ai_custom_config_json:
|
||||
required: false
|
||||
azure_api_key:
|
||||
required: false
|
||||
ollama_api_key:
|
||||
required: false
|
||||
openrouter_api_key:
|
||||
required: false
|
||||
DOCKER_USERNAME:
|
||||
required: true
|
||||
DOCKER_TOKEN:
|
||||
@@ -146,21 +172,57 @@ jobs:
|
||||
models: ${{ inputs.openai_models }}
|
||||
api_key_secret: openai_api_key
|
||||
custom_config_secret: ""
|
||||
api_base: ""
|
||||
api_version: ""
|
||||
deployment_name: ""
|
||||
required: true
|
||||
- provider: anthropic
|
||||
models: ${{ inputs.anthropic_models }}
|
||||
api_key_secret: anthropic_api_key
|
||||
custom_config_secret: ""
|
||||
api_base: ""
|
||||
api_version: ""
|
||||
deployment_name: ""
|
||||
required: true
|
||||
- provider: bedrock
|
||||
models: ${{ inputs.bedrock_models }}
|
||||
api_key_secret: bedrock_api_key
|
||||
custom_config_secret: ""
|
||||
api_base: ""
|
||||
api_version: ""
|
||||
deployment_name: ""
|
||||
required: false
|
||||
- provider: vertex_ai
|
||||
models: ${{ inputs.vertex_ai_models }}
|
||||
api_key_secret: ""
|
||||
custom_config_secret: vertex_ai_custom_config_json
|
||||
api_base: ""
|
||||
api_version: ""
|
||||
deployment_name: ""
|
||||
required: false
|
||||
- provider: azure
|
||||
models: ${{ inputs.azure_models }}
|
||||
api_key_secret: azure_api_key
|
||||
custom_config_secret: ""
|
||||
api_base: ${{ inputs.azure_api_base }}
|
||||
api_version: "2025-04-01-preview"
|
||||
deployment_name: ""
|
||||
required: false
|
||||
- provider: ollama_chat
|
||||
models: ${{ inputs.ollama_models }}
|
||||
api_key_secret: ollama_api_key
|
||||
custom_config_secret: ""
|
||||
api_base: "https://ollama.com"
|
||||
api_version: ""
|
||||
deployment_name: ""
|
||||
required: false
|
||||
- provider: openrouter
|
||||
models: ${{ inputs.openrouter_models }}
|
||||
api_key_secret: openrouter_api_key
|
||||
custom_config_secret: ""
|
||||
api_base: "https://openrouter.ai/api/v1"
|
||||
api_version: ""
|
||||
deployment_name: ""
|
||||
required: false
|
||||
runs-on:
|
||||
- runs-on
|
||||
@@ -183,6 +245,9 @@ jobs:
|
||||
models: ${{ matrix.models }}
|
||||
provider-api-key: ${{ matrix.api_key_secret && secrets[matrix.api_key_secret] || '' }}
|
||||
strict: ${{ inputs.strict && 'true' || 'false' }}
|
||||
api-base: ${{ matrix.api_base }}
|
||||
api-version: ${{ matrix.api_version }}
|
||||
deployment-name: ${{ matrix.deployment_name }}
|
||||
custom-config-json: ${{ matrix.custom_config_secret && secrets[matrix.custom_config_secret] || '' }}
|
||||
runs-on-ecr-cache: ${{ env.RUNS_ON_ECR_CACHE }}
|
||||
run-id: ${{ github.run_id }}
|
||||
|
||||
@@ -124,26 +124,3 @@ def send_message(
|
||||
"X-Accel-Buffering": "no", # Disable nginx buffering
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/cancel-message", tags=PUBLIC_API_TAGS)
|
||||
def cancel_message(
|
||||
session_id: UUID,
|
||||
user: User = Depends(current_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> dict[str, bool]:
|
||||
"""
|
||||
Cancel the current message/prompt operation for a session.
|
||||
|
||||
Sends a session/cancel notification to the ACP agent to stop the
|
||||
currently running operation. This follows the ACP protocol specification
|
||||
for cancellation.
|
||||
|
||||
Returns:
|
||||
{"cancelled": true} if cancel was sent successfully
|
||||
{"cancelled": false} if no active operation to cancel
|
||||
"""
|
||||
session_manager = SessionManager(db_session)
|
||||
cancelled = session_manager.cancel_message(session_id, user.id)
|
||||
|
||||
return {"cancelled": cancelled}
|
||||
|
||||
@@ -491,27 +491,6 @@ class SandboxManager(ABC):
|
||||
nextjs_port: The port the Next.js server should be listening on
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def cancel_message(
|
||||
self,
|
||||
sandbox_id: UUID,
|
||||
session_id: UUID,
|
||||
) -> bool:
|
||||
"""Cancel the current message/prompt operation for a session.
|
||||
|
||||
Sends a session/cancel notification to the ACP agent to stop the
|
||||
currently running operation. This is a non-blocking operation that
|
||||
signals the agent to stop but does not wait for confirmation.
|
||||
|
||||
Args:
|
||||
sandbox_id: The sandbox ID
|
||||
session_id: The session ID whose operation should be cancelled
|
||||
|
||||
Returns:
|
||||
True if cancel was sent, False if no active session/client found
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
# Singleton instance cache for the factory
|
||||
_sandbox_manager_instance: SandboxManager | None = None
|
||||
|
||||
@@ -358,15 +358,6 @@ class KubernetesSandboxManager(SandboxManager):
|
||||
self._agent_instructions_template_path = build_dir / "AGENTS.template.md"
|
||||
self._skills_path = Path(__file__).parent / "docker" / "skills"
|
||||
|
||||
# Track active ephemeral ACP clients for cancellation support.
|
||||
# Only populated during an active send_message() call — entries are
|
||||
# added at the start of send_message() and removed in its finally block.
|
||||
# Keyed by (sandbox_id, session_id) tuple.
|
||||
# Lock guards dict access and client lifecycle between send_message()
|
||||
# (streaming thread) and cancel_message() (cancel request thread).
|
||||
self._active_acp_clients: dict[tuple[UUID, UUID], ACPExecClient] = {}
|
||||
self._active_acp_clients_lock = threading.Lock()
|
||||
|
||||
logger.info(
|
||||
f"KubernetesSandboxManager initialized: "
|
||||
f"namespace={self._namespace}, image={self._image}"
|
||||
@@ -1414,8 +1405,8 @@ echo "Session workspace setup complete"
|
||||
) -> None:
|
||||
"""Clean up a session workspace (on session delete).
|
||||
|
||||
Executes kubectl exec to remove the session directory. ACP clients are
|
||||
ephemeral (created per message), so there's nothing to stop here.
|
||||
Removes the ACP session mapping and executes kubectl exec to remove
|
||||
the session directory. The shared ACP client persists for other sessions.
|
||||
|
||||
Args:
|
||||
sandbox_id: The sandbox ID
|
||||
@@ -1898,12 +1889,6 @@ echo "Session config regeneration complete"
|
||||
# Create an ephemeral ACP client for this message
|
||||
acp_client = self._create_ephemeral_acp_client(sandbox_id, session_path)
|
||||
|
||||
# Register as the active client for this session so cancel_message()
|
||||
# can find and cancel it from another thread.
|
||||
client_key = (sandbox_id, session_id)
|
||||
with self._active_acp_clients_lock:
|
||||
self._active_acp_clients[client_key] = acp_client
|
||||
|
||||
try:
|
||||
# Resume (or create) the ACP session from opencode's on-disk storage
|
||||
acp_session_id = acp_client.resume_or_create_session(cwd=session_path)
|
||||
@@ -1987,9 +1972,8 @@ echo "Session config regeneration complete"
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
# Always deregister and stop the ephemeral ACP client.
|
||||
with self._active_acp_clients_lock:
|
||||
self._active_acp_clients.pop(client_key, None)
|
||||
# Always stop the ephemeral ACP client to kill the opencode process.
|
||||
# This ensures no stale processes linger in the sandbox container.
|
||||
try:
|
||||
acp_client.stop()
|
||||
except Exception as e:
|
||||
@@ -2730,43 +2714,3 @@ fi
|
||||
except ApiException as e:
|
||||
logger.warning(f"Failed to get upload stats: {e}")
|
||||
return 0, 0
|
||||
|
||||
def cancel_message(
|
||||
self,
|
||||
sandbox_id: UUID,
|
||||
session_id: UUID,
|
||||
) -> bool:
|
||||
"""Cancel the current message/prompt operation for a session.
|
||||
|
||||
Sends a session/cancel notification to the ACP agent to stop the
|
||||
currently running operation.
|
||||
|
||||
Args:
|
||||
sandbox_id: The sandbox ID
|
||||
session_id: The session ID whose operation should be cancelled
|
||||
|
||||
Returns:
|
||||
True if cancel was sent, False if no active session/client found
|
||||
"""
|
||||
client_key = (sandbox_id, session_id)
|
||||
with self._active_acp_clients_lock:
|
||||
exec_client = self._active_acp_clients.get(client_key)
|
||||
|
||||
if exec_client is None or not exec_client.is_running:
|
||||
logger.debug(
|
||||
f"No active ACP client for sandbox {sandbox_id}, session {session_id}"
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
exec_client.cancel()
|
||||
logger.info(
|
||||
f"Sent cancel notification for sandbox {sandbox_id}, session {session_id}"
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to cancel operation for sandbox {sandbox_id}, "
|
||||
f"session {session_id}: {e}"
|
||||
)
|
||||
return False
|
||||
|
||||
@@ -1047,35 +1047,23 @@ class LocalSandboxManager(SandboxManager):
|
||||
if not self._is_path_allowed(session_path, target_path):
|
||||
raise ValueError("Path traversal not allowed")
|
||||
|
||||
# If directory doesn't exist, return empty list (no files yet)
|
||||
if not target_path.exists():
|
||||
return []
|
||||
|
||||
if not target_path.is_dir():
|
||||
raise ValueError(f"Not a directory: {path}")
|
||||
|
||||
entries = []
|
||||
try:
|
||||
for item in target_path.iterdir():
|
||||
try:
|
||||
stat = item.stat()
|
||||
is_file = item.is_file()
|
||||
mime_type = mimetypes.guess_type(str(item))[0] if is_file else None
|
||||
entries.append(
|
||||
FilesystemEntry(
|
||||
name=item.name,
|
||||
path=str(item.relative_to(session_path)),
|
||||
is_directory=item.is_dir(),
|
||||
size=stat.st_size if is_file else None,
|
||||
mime_type=mime_type,
|
||||
)
|
||||
)
|
||||
except (FileNotFoundError, OSError):
|
||||
# Skip files that were deleted during iteration (race condition)
|
||||
continue
|
||||
except FileNotFoundError:
|
||||
# Directory was deleted during iteration
|
||||
return []
|
||||
for item in target_path.iterdir():
|
||||
stat = item.stat()
|
||||
is_file = item.is_file()
|
||||
mime_type = mimetypes.guess_type(str(item))[0] if is_file else None
|
||||
entries.append(
|
||||
FilesystemEntry(
|
||||
name=item.name,
|
||||
path=str(item.relative_to(session_path)),
|
||||
is_directory=item.is_dir(),
|
||||
size=stat.st_size if is_file else None,
|
||||
mime_type=mime_type,
|
||||
)
|
||||
)
|
||||
|
||||
return sorted(entries, key=lambda e: (not e.is_directory, e.name.lower()))
|
||||
|
||||
@@ -1423,42 +1411,3 @@ class LocalSandboxManager(SandboxManager):
|
||||
f"sync_files called for local sandbox {sandbox_id}{source_info} - no-op"
|
||||
)
|
||||
return True
|
||||
|
||||
def cancel_message(
|
||||
self,
|
||||
sandbox_id: UUID,
|
||||
session_id: UUID,
|
||||
) -> bool:
|
||||
"""Cancel the current message/prompt operation for a session.
|
||||
|
||||
Sends a session/cancel notification to the ACP agent to stop the
|
||||
currently running operation.
|
||||
|
||||
Args:
|
||||
sandbox_id: The sandbox ID
|
||||
session_id: The session ID whose operation should be cancelled
|
||||
|
||||
Returns:
|
||||
True if cancel was sent, False if no active session/client found
|
||||
"""
|
||||
client_key = (sandbox_id, session_id)
|
||||
client = self._acp_clients.get(client_key)
|
||||
|
||||
if client is None or not client.is_running:
|
||||
logger.debug(
|
||||
f"No active ACP client for sandbox {sandbox_id}, session {session_id}"
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
client.cancel()
|
||||
logger.info(
|
||||
f"Sent cancel notification for sandbox {sandbox_id}, session {session_id}"
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to cancel operation for sandbox {sandbox_id}, "
|
||||
f"session {session_id}: {e}"
|
||||
)
|
||||
return False
|
||||
|
||||
@@ -1550,46 +1550,6 @@ class SessionManager:
|
||||
return "error"
|
||||
return "unknown"
|
||||
|
||||
def cancel_message(
|
||||
self,
|
||||
session_id: UUID,
|
||||
user_id: UUID,
|
||||
) -> bool:
|
||||
"""Cancel the current message/prompt operation for a session.
|
||||
|
||||
Sends a session/cancel notification to the ACP agent to stop the
|
||||
currently running operation. This follows the ACP protocol:
|
||||
https://agentclientprotocol.com
|
||||
|
||||
Args:
|
||||
session_id: The session UUID
|
||||
user_id: The user ID (for ownership verification)
|
||||
|
||||
Returns:
|
||||
True if cancel was sent, False if session/sandbox not found or cancel failed
|
||||
"""
|
||||
# Verify session ownership
|
||||
session = get_build_session(session_id, user_id, self._db_session)
|
||||
if session is None:
|
||||
logger.warning(f"Cancel request for non-existent session {session_id}")
|
||||
return False
|
||||
|
||||
# Get the user's sandbox
|
||||
sandbox = get_sandbox_by_user_id(self._db_session, user_id)
|
||||
if sandbox is None:
|
||||
logger.warning(f"Cancel request but no sandbox for user {user_id}")
|
||||
return False
|
||||
|
||||
# Send cancel to the sandbox manager
|
||||
result = self._sandbox_manager.cancel_message(sandbox.id, session_id)
|
||||
|
||||
if result:
|
||||
logger.info(f"Cancelled message operation for session {session_id}")
|
||||
else:
|
||||
logger.debug(f"No active operation to cancel for session {session_id}")
|
||||
|
||||
return result
|
||||
|
||||
# =========================================================================
|
||||
# Artifact Operations
|
||||
# =========================================================================
|
||||
|
||||
@@ -11,6 +11,7 @@ SQLAlchemy connection pool metrics are registered separately via
|
||||
"""
|
||||
|
||||
from prometheus_fastapi_instrumentator import Instrumentator
|
||||
from prometheus_fastapi_instrumentator.metrics import default as default_metrics
|
||||
from sqlalchemy.exc import TimeoutError as SATimeoutError
|
||||
from starlette.applications import Starlette
|
||||
|
||||
@@ -60,6 +61,14 @@ def setup_prometheus_metrics(app: Starlette) -> None:
|
||||
excluded_handlers=_EXCLUDED_HANDLERS,
|
||||
)
|
||||
|
||||
# Explicitly create the default metrics (http_requests_total,
|
||||
# http_request_duration_seconds, etc.) and add them first. The library
|
||||
# skips creating defaults when ANY custom instrumentations are registered
|
||||
# via .add(), so we must include them ourselves.
|
||||
default_callback = default_metrics(latency_lowr_buckets=_LATENCY_BUCKETS)
|
||||
if default_callback:
|
||||
instrumentator.add(default_callback)
|
||||
|
||||
instrumentator.add(slow_request_callback)
|
||||
instrumentator.add(per_tenant_request_callback)
|
||||
|
||||
|
||||
@@ -257,7 +257,7 @@ exceptiongroup==1.3.0
|
||||
# via
|
||||
# braintrust
|
||||
# fastmcp
|
||||
fastapi==0.128.0
|
||||
fastapi==0.133.1
|
||||
# via
|
||||
# fastapi-limiter
|
||||
# fastapi-users
|
||||
@@ -1155,6 +1155,7 @@ typing-inspect==0.9.0
|
||||
# via dataclasses-json
|
||||
typing-inspection==0.4.2
|
||||
# via
|
||||
# fastapi
|
||||
# mcp
|
||||
# pydantic
|
||||
# pydantic-settings
|
||||
|
||||
@@ -125,7 +125,7 @@ executing==2.2.1
|
||||
# via stack-data
|
||||
faker==40.1.2
|
||||
# via onyx
|
||||
fastapi==0.128.0
|
||||
fastapi==0.133.1
|
||||
# via
|
||||
# onyx
|
||||
# onyx-devtools
|
||||
@@ -619,6 +619,7 @@ typing-extensions==4.15.0
|
||||
# typing-inspection
|
||||
typing-inspection==0.4.2
|
||||
# via
|
||||
# fastapi
|
||||
# mcp
|
||||
# pydantic
|
||||
# pydantic-settings
|
||||
|
||||
@@ -90,7 +90,7 @@ docstring-parser==0.17.0
|
||||
# via google-cloud-aiplatform
|
||||
durationpy==0.10
|
||||
# via kubernetes
|
||||
fastapi==0.128.0
|
||||
fastapi==0.133.1
|
||||
# via onyx
|
||||
fastavro==1.12.1
|
||||
# via cohere
|
||||
@@ -398,6 +398,7 @@ typing-extensions==4.15.0
|
||||
# typing-inspection
|
||||
typing-inspection==0.4.2
|
||||
# via
|
||||
# fastapi
|
||||
# mcp
|
||||
# pydantic
|
||||
# pydantic-settings
|
||||
|
||||
@@ -108,7 +108,7 @@ durationpy==0.10
|
||||
# via kubernetes
|
||||
einops==0.8.1
|
||||
# via onyx
|
||||
fastapi==0.128.0
|
||||
fastapi==0.133.1
|
||||
# via
|
||||
# onyx
|
||||
# sentry-sdk
|
||||
@@ -525,6 +525,7 @@ typing-extensions==4.15.0
|
||||
# typing-inspection
|
||||
typing-inspection==0.4.2
|
||||
# via
|
||||
# fastapi
|
||||
# mcp
|
||||
# pydantic
|
||||
# pydantic-settings
|
||||
|
||||
@@ -23,6 +23,8 @@ _ENV_PROVIDER = "NIGHTLY_LLM_PROVIDER"
|
||||
_ENV_MODELS = "NIGHTLY_LLM_MODELS"
|
||||
_ENV_API_KEY = "NIGHTLY_LLM_API_KEY"
|
||||
_ENV_API_BASE = "NIGHTLY_LLM_API_BASE"
|
||||
_ENV_API_VERSION = "NIGHTLY_LLM_API_VERSION"
|
||||
_ENV_DEPLOYMENT_NAME = "NIGHTLY_LLM_DEPLOYMENT_NAME"
|
||||
_ENV_CUSTOM_CONFIG_JSON = "NIGHTLY_LLM_CUSTOM_CONFIG_JSON"
|
||||
_ENV_STRICT = "NIGHTLY_LLM_STRICT"
|
||||
|
||||
@@ -34,6 +36,8 @@ class NightlyProviderConfig(BaseModel):
|
||||
model_names: list[str]
|
||||
api_key: str | None
|
||||
api_base: str | None
|
||||
api_version: str | None
|
||||
deployment_name: str | None
|
||||
custom_config: dict[str, str] | None
|
||||
strict: bool
|
||||
|
||||
@@ -66,6 +70,8 @@ def _load_provider_config() -> NightlyProviderConfig:
|
||||
model_names = _parse_models_env(_ENV_MODELS)
|
||||
api_key = os.environ.get(_ENV_API_KEY) or None
|
||||
api_base = os.environ.get(_ENV_API_BASE) or None
|
||||
api_version = os.environ.get(_ENV_API_VERSION) or None
|
||||
deployment_name = os.environ.get(_ENV_DEPLOYMENT_NAME) or None
|
||||
strict = _env_true(_ENV_STRICT, default=False)
|
||||
|
||||
custom_config: dict[str, str] | None = None
|
||||
@@ -84,6 +90,8 @@ def _load_provider_config() -> NightlyProviderConfig:
|
||||
model_names=model_names,
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
api_version=api_version,
|
||||
deployment_name=deployment_name,
|
||||
custom_config=custom_config,
|
||||
strict=strict,
|
||||
)
|
||||
@@ -124,6 +132,22 @@ def _validate_provider_config(config: NightlyProviderConfig) -> None:
|
||||
message=(f"{_ENV_API_BASE} is required for provider '{config.provider}'"),
|
||||
)
|
||||
|
||||
if config.provider == "azure":
|
||||
if not config.api_base:
|
||||
_skip_or_fail(
|
||||
strict=config.strict,
|
||||
message=(
|
||||
f"{_ENV_API_BASE} is required for provider '{config.provider}'"
|
||||
),
|
||||
)
|
||||
if not config.api_version:
|
||||
_skip_or_fail(
|
||||
strict=config.strict,
|
||||
message=(
|
||||
f"{_ENV_API_VERSION} is required for provider '{config.provider}'"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _assert_integration_mode_enabled() -> None:
|
||||
assert (
|
||||
@@ -162,6 +186,8 @@ def _create_provider_payload(
|
||||
model_name: str,
|
||||
api_key: str | None,
|
||||
api_base: str | None,
|
||||
api_version: str | None,
|
||||
deployment_name: str | None,
|
||||
custom_config: dict[str, str] | None,
|
||||
) -> dict:
|
||||
return {
|
||||
@@ -169,6 +195,8 @@ def _create_provider_payload(
|
||||
"provider": provider,
|
||||
"api_key": api_key,
|
||||
"api_base": api_base,
|
||||
"api_version": api_version,
|
||||
"deployment_name": deployment_name,
|
||||
"custom_config": custom_config,
|
||||
"default_model_name": model_name,
|
||||
"is_public": True,
|
||||
@@ -270,6 +298,8 @@ def _create_and_test_provider_for_model(
|
||||
model_name=model_name,
|
||||
api_key=config.api_key,
|
||||
api_base=resolved_api_base,
|
||||
api_version=config.api_version,
|
||||
deployment_name=config.deployment_name,
|
||||
custom_config=config.custom_config,
|
||||
)
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ def test_setup_attaches_instrumentator_to_app() -> None:
|
||||
inprogress_labels=True,
|
||||
excluded_handlers=["/health", "/metrics", "/openapi.json"],
|
||||
)
|
||||
assert mock_instance.add.call_count == 2
|
||||
assert mock_instance.add.call_count == 3
|
||||
mock_instance.instrument.assert_called_once_with(
|
||||
app,
|
||||
latency_lowr_buckets=(
|
||||
|
||||
@@ -163,3 +163,16 @@ Add clear comments:
|
||||
- Any TODOs you add in the code must be accompanied by either the name/username
|
||||
of the owner of that TODO, or an issue number for an issue referencing that
|
||||
piece of work.
|
||||
- Avoid module-level logic that runs on import, which leads to import-time side
|
||||
effects. Essentially every piece of meaningful logic should exist within some
|
||||
function that has to be explicitly invoked. Acceptable exceptions to this may
|
||||
include loading environment variables or setting up loggers.
|
||||
- If you find yourself needing something like this, you may want that logic to
|
||||
exist in a file dedicated for manual execution (contains `if __name__ ==
|
||||
"__main__":`) which should not be imported by anything else.
|
||||
- Related to the above, do not conflate Python scripts you intend to run from
|
||||
the command line (contains `if __name__ == "__main__":`) with modules you
|
||||
intend to import from elsewhere. If for some unlikely reason they have to be
|
||||
the same file, any logic specific to executing the file (including imports)
|
||||
should be contained in the `if __name__ == "__main__":` block.
|
||||
- Generally these executable files exist in `backend/scripts/`.
|
||||
|
||||
@@ -10,7 +10,7 @@ requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"aioboto3==15.1.0",
|
||||
"cohere==5.6.1",
|
||||
"fastapi==0.128.0",
|
||||
"fastapi==0.133.1",
|
||||
"google-cloud-aiplatform==1.121.0",
|
||||
"google-genai==1.52.0",
|
||||
"litellm==1.81.6",
|
||||
|
||||
@@ -51,6 +51,7 @@ func NewRootCommand() *cobra.Command {
|
||||
cmd.AddCommand(NewRunCICommand())
|
||||
cmd.AddCommand(NewScreenshotDiffCommand())
|
||||
cmd.AddCommand(NewWebCommand())
|
||||
cmd.AddCommand(NewWhoisCommand())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
159
tools/ods/cmd/whois.go
Normal file
159
tools/ods/cmd/whois.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/onyx-dot-app/onyx/tools/ods/internal/kube"
|
||||
)
|
||||
|
||||
var safeIdentifier = regexp.MustCompile(`^[a-zA-Z0-9_\-]+$`)
|
||||
|
||||
// NewWhoisCommand creates the whois command for looking up users/tenants.
|
||||
func NewWhoisCommand() *cobra.Command {
|
||||
var ctx string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "whois <email-fragment or tenant-id>",
|
||||
Short: "Look up users and admins by email or tenant ID",
|
||||
Long: `Look up tenant and user information from the data plane PostgreSQL database.
|
||||
|
||||
Requires: AWS SSO login, kubectl access to the EKS cluster.
|
||||
|
||||
Two modes (auto-detected):
|
||||
|
||||
Email fragment:
|
||||
ods whois chris
|
||||
→ Searches user_tenant_mapping for emails matching '%chris%'
|
||||
|
||||
Tenant ID:
|
||||
ods whois tenant_abcd1234-...
|
||||
→ Lists all admin emails in that tenant
|
||||
|
||||
Cluster connection is configured via KUBE_CTX_* environment variables.
|
||||
Each variable is a space-separated tuple: "cluster region namespace"
|
||||
|
||||
export KUBE_CTX_DATA_PLANE="<cluster> <region> <namespace>"
|
||||
export KUBE_CTX_CONTROL_PLANE="<cluster> <region> <namespace>"
|
||||
etc...
|
||||
|
||||
Use -c to select which context (default: data_plane).`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runWhois(args[0], ctx)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&ctx, "context", "c", "data_plane", "cluster context name (maps to KUBE_CTX_<NAME> env var)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func clusterFromEnv(name string) *kube.Cluster {
|
||||
envKey := "KUBE_CTX_" + strings.ToUpper(name)
|
||||
val := os.Getenv(envKey)
|
||||
if val == "" {
|
||||
log.Fatalf("Environment variable %s is not set.\n\nSet it as a space-separated tuple:\n export %s=\"<cluster> <region> <namespace>\"", envKey, envKey)
|
||||
}
|
||||
|
||||
parts := strings.Fields(val)
|
||||
if len(parts) != 3 {
|
||||
log.Fatalf("%s must be a space-separated tuple of 3 values (cluster region namespace), got: %q", envKey, val)
|
||||
}
|
||||
|
||||
return &kube.Cluster{Name: parts[0], Region: parts[1], Namespace: parts[2]}
|
||||
}
|
||||
|
||||
// queryPod runs a SQL query via pginto on the given pod and returns cleaned output lines.
|
||||
func queryPod(c *kube.Cluster, pod, sql string) []string {
|
||||
raw, err := c.ExecOnPod(pod, "pginto", "-A", "-t", "-F", "\t", "-c", sql)
|
||||
if err != nil {
|
||||
log.Fatalf("Query failed: %v", err)
|
||||
}
|
||||
|
||||
var lines []string
|
||||
for _, line := range strings.Split(strings.TrimSpace(raw), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" && !strings.HasPrefix(line, "Connecting to ") {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func runWhois(query string, ctx string) {
|
||||
c := clusterFromEnv(ctx)
|
||||
|
||||
if err := c.EnsureContext(); err != nil {
|
||||
log.Fatalf("Failed to ensure cluster context: %v", err)
|
||||
}
|
||||
|
||||
log.Info("Finding api-server pod...")
|
||||
pod, err := c.FindPod("api-server")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to find api-server pod: %v", err)
|
||||
}
|
||||
log.Debugf("Using pod: %s", pod)
|
||||
|
||||
if strings.HasPrefix(query, "tenant_") {
|
||||
findAdminsByTenant(c, pod, query)
|
||||
} else {
|
||||
findByEmail(c, pod, query)
|
||||
}
|
||||
}
|
||||
|
||||
func findByEmail(c *kube.Cluster, pod, fragment string) {
|
||||
fragment = strings.NewReplacer("'", "", `"`, "", `;`, "", `\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(fragment)
|
||||
|
||||
sql := fmt.Sprintf(
|
||||
`SELECT email, tenant_id, active FROM public.user_tenant_mapping WHERE email LIKE '%%%s%%' ORDER BY email;`,
|
||||
fragment,
|
||||
)
|
||||
|
||||
log.Infof("Searching for emails matching '%%%s%%'...", fragment)
|
||||
lines := queryPod(c, pod, sql)
|
||||
if len(lines) == 0 {
|
||||
fmt.Println("No results found.")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
_, _ = fmt.Fprintln(w, "EMAIL\tTENANT ID\tACTIVE")
|
||||
_, _ = fmt.Fprintln(w, "-----\t---------\t------")
|
||||
for _, line := range lines {
|
||||
_, _ = fmt.Fprintln(w, line)
|
||||
}
|
||||
_ = w.Flush()
|
||||
}
|
||||
|
||||
func findAdminsByTenant(c *kube.Cluster, pod, tenantID string) {
|
||||
if !safeIdentifier.MatchString(tenantID) {
|
||||
log.Fatalf("Invalid tenant ID: %q (must be alphanumeric, hyphens, underscores only)", tenantID)
|
||||
}
|
||||
|
||||
sql := fmt.Sprintf(
|
||||
`SELECT email FROM "%s"."user" WHERE role = 'ADMIN' AND is_active = true AND email NOT LIKE 'api_key__%%' ORDER BY email;`,
|
||||
tenantID,
|
||||
)
|
||||
|
||||
log.Infof("Fetching admin emails for %s...", tenantID)
|
||||
lines := queryPod(c, pod, sql)
|
||||
if len(lines) == 0 {
|
||||
fmt.Println("No admin users found for this tenant.")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("EMAIL")
|
||||
fmt.Println("-----")
|
||||
for _, line := range lines {
|
||||
fmt.Println(line)
|
||||
}
|
||||
}
|
||||
90
tools/ods/internal/kube/kube.go
Normal file
90
tools/ods/internal/kube/kube.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package kube
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Cluster holds the connection info for a Kubernetes cluster.
|
||||
type Cluster struct {
|
||||
Name string
|
||||
Region string
|
||||
Namespace string
|
||||
}
|
||||
|
||||
// EnsureContext makes sure the cluster exists in kubeconfig, calling
|
||||
// aws eks update-kubeconfig only if the context is missing.
|
||||
func (c *Cluster) EnsureContext() error {
|
||||
// Check if context already exists in kubeconfig
|
||||
cmd := exec.Command("kubectl", "config", "get-contexts", c.Name, "--no-headers")
|
||||
if err := cmd.Run(); err == nil {
|
||||
log.Debugf("Context %s already exists, skipping aws eks update-kubeconfig", c.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Infof("Context %s not found, fetching kubeconfig from AWS...", c.Name)
|
||||
cmd = exec.Command("aws", "eks", "update-kubeconfig", "--region", c.Region, "--name", c.Name, "--alias", c.Name)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("aws eks update-kubeconfig failed: %w\n%s", err, string(out))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// kubectlArgs returns common kubectl flags to target this cluster without mutating global context.
|
||||
func (c *Cluster) kubectlArgs() []string {
|
||||
return []string{"--context", c.Name, "--namespace", c.Namespace}
|
||||
}
|
||||
|
||||
// FindPod returns the name of the first Running/Ready pod matching the given substring.
|
||||
func (c *Cluster) FindPod(substring string) (string, error) {
|
||||
args := append(c.kubectlArgs(), "get", "po",
|
||||
"--field-selector", "status.phase=Running",
|
||||
"--no-headers",
|
||||
"-o", "custom-columns=NAME:.metadata.name,READY:.status.conditions[?(@.type=='Ready')].status",
|
||||
)
|
||||
cmd := exec.Command("kubectl", args...)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
return "", fmt.Errorf("kubectl get po failed: %w\n%s", err, string(exitErr.Stderr))
|
||||
}
|
||||
return "", fmt.Errorf("kubectl get po failed: %w", err)
|
||||
}
|
||||
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
name, ready := fields[0], fields[1]
|
||||
if strings.Contains(name, substring) && ready == "True" {
|
||||
log.Debugf("Found pod: %s", name)
|
||||
return name, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no ready pod found matching %q", substring)
|
||||
}
|
||||
|
||||
// ExecOnPod runs a command on a pod and returns its stdout.
|
||||
func (c *Cluster) ExecOnPod(pod string, command ...string) (string, error) {
|
||||
args := append(c.kubectlArgs(), "exec", pod, "--")
|
||||
args = append(args, command...)
|
||||
log.Debugf("Running: kubectl %s", strings.Join(args, " "))
|
||||
|
||||
cmd := exec.Command("kubectl", args...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("kubectl exec failed: %w\n%s", err, stderr.String())
|
||||
}
|
||||
|
||||
return stdout.String(), nil
|
||||
}
|
||||
9
uv.lock
generated
9
uv.lock
generated
@@ -1688,17 +1688,18 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.128.0"
|
||||
version = "0.133.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "starlette" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/52/08/8c8508db6c7b9aae8f7175046af41baad690771c9bcde676419965e338c7/fastapi-0.128.0.tar.gz", hash = "sha256:1cc179e1cef10a6be60ffe429f79b829dce99d8de32d7acb7e6c8dfdf7f2645a", size = 365682, upload-time = "2025-12-27T15:21:13.714Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/22/6f/0eafed8349eea1fa462238b54a624c8b408cd1ba2795c8e64aa6c34f8ab7/fastapi-0.133.1.tar.gz", hash = "sha256:ed152a45912f102592976fde6cbce7dae1a8a1053da94202e51dd35d184fadd6", size = 378741, upload-time = "2026-02-25T18:18:17.398Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/05/5cbb59154b093548acd0f4c7c474a118eda06da25aa75c616b72d8fcd92a/fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d", size = 103094, upload-time = "2025-12-27T15:21:12.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/c9/a175a7779f3599dfa4adfc97a6ce0e157237b3d7941538604aadaf97bfb6/fastapi-0.133.1-py3-none-any.whl", hash = "sha256:658f34ba334605b1617a65adf2ea6461901bdb9af3a3080d63ff791ecf7dc2e2", size = 109029, upload-time = "2026-02-25T18:18:18.578Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4612,7 +4613,7 @@ requires-dist = [
|
||||
{ name = "einops", marker = "extra == 'model-server'", specifier = "==0.8.1" },
|
||||
{ name = "exa-py", marker = "extra == 'backend'", specifier = "==1.15.4" },
|
||||
{ name = "faker", marker = "extra == 'dev'", specifier = "==40.1.2" },
|
||||
{ name = "fastapi", specifier = "==0.128.0" },
|
||||
{ name = "fastapi", specifier = "==0.133.1" },
|
||||
{ name = "fastapi-limiter", marker = "extra == 'backend'", specifier = "==0.1.6" },
|
||||
{ name = "fastapi-users", marker = "extra == 'backend'", specifier = "==15.0.4" },
|
||||
{ name = "fastapi-users-db-sqlalchemy", marker = "extra == 'backend'", specifier = "==7.0.0" },
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import "@opal/components/buttons/Button/styles.css";
|
||||
import "@opal/components/tooltip.css";
|
||||
import {
|
||||
Interactive,
|
||||
type InteractiveBaseProps,
|
||||
type InteractiveContainerWidthVariant,
|
||||
} from "@opal/core";
|
||||
import type { SizeVariant } from "@opal/shared";
|
||||
import { Interactive, type InteractiveBaseProps } from "@opal/core";
|
||||
import type { SizeVariant, WidthVariant } from "@opal/shared";
|
||||
import type { TooltipSide } from "@opal/components";
|
||||
import type { IconFunctionComponent } from "@opal/types";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
@@ -91,7 +87,7 @@ type ButtonProps = InteractiveBaseProps &
|
||||
tooltip?: string;
|
||||
|
||||
/** Width preset. `"auto"` shrink-wraps, `"full"` stretches to parent width. */
|
||||
width?: InteractiveContainerWidthVariant;
|
||||
width?: WidthVariant;
|
||||
|
||||
/** Which side the tooltip appears on. */
|
||||
tooltipSide?: TooltipSide;
|
||||
|
||||
@@ -12,6 +12,5 @@ export {
|
||||
type InteractiveBaseProps,
|
||||
type InteractiveBaseVariantProps,
|
||||
type InteractiveContainerProps,
|
||||
type InteractiveContainerWidthVariant,
|
||||
type InteractiveContainerRoundingVariant,
|
||||
} from "@opal/core/interactive/components";
|
||||
|
||||
@@ -3,7 +3,12 @@ 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";
|
||||
import {
|
||||
sizeVariants,
|
||||
type SizeVariant,
|
||||
widthVariants,
|
||||
type WidthVariant,
|
||||
} from "@opal/shared";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -39,18 +44,6 @@ type InteractiveBaseVariantProps =
|
||||
selected?: never;
|
||||
};
|
||||
|
||||
/**
|
||||
* Width presets for `Interactive.Container`.
|
||||
*
|
||||
* - `"auto"` — Shrink-wraps to content width (default)
|
||||
* - `"full"` — Stretches to fill the parent's width (`w-full`)
|
||||
*/
|
||||
type InteractiveContainerWidthVariant = "auto" | "full";
|
||||
const interactiveContainerWidthVariants = {
|
||||
auto: "w-auto",
|
||||
full: "w-full",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Border-radius presets for `Interactive.Container`.
|
||||
*
|
||||
@@ -345,7 +338,7 @@ interface InteractiveContainerProps
|
||||
*
|
||||
* @default "auto"
|
||||
*/
|
||||
widthVariant?: InteractiveContainerWidthVariant;
|
||||
widthVariant?: WidthVariant;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -413,7 +406,7 @@ function InteractiveContainer({
|
||||
height,
|
||||
minWidth,
|
||||
padding,
|
||||
interactiveContainerWidthVariants[widthVariant],
|
||||
widthVariants[widthVariant],
|
||||
slotClassName
|
||||
),
|
||||
"data-border": border ? ("true" as const) : undefined,
|
||||
@@ -490,6 +483,5 @@ export {
|
||||
type InteractiveBaseVariantProps,
|
||||
type InteractiveBaseSelectVariantProps,
|
||||
type InteractiveContainerProps,
|
||||
type InteractiveContainerWidthVariant,
|
||||
type InteractiveContainerRoundingVariant,
|
||||
};
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
} from "@opal/layouts/Content/LabelLayout";
|
||||
import type { TagProps } from "@opal/components/Tag/components";
|
||||
import type { IconFunctionComponent } from "@opal/types";
|
||||
import { widthVariants, type WidthVariant } from "@opal/shared";
|
||||
import { cn } from "@opal/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared types
|
||||
@@ -43,6 +45,17 @@ interface ContentBaseProps {
|
||||
|
||||
/** Called when the user commits an edit. */
|
||||
onTitleChange?: (newTitle: string) => void;
|
||||
|
||||
/**
|
||||
* Width preset controlling the component's horizontal size.
|
||||
* Uses the shared `WidthVariant` scale from `@opal/shared`.
|
||||
*
|
||||
* - `"auto"` — Shrink-wraps to content width
|
||||
* - `"full"` — Stretches to fill the parent's width
|
||||
*
|
||||
* @default "auto"
|
||||
*/
|
||||
widthVariant?: WidthVariant;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -87,11 +100,20 @@ type ContentProps = HeadingContentProps | LabelContentProps | BodyContentProps;
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function Content(props: ContentProps) {
|
||||
const { sizePreset = "headline", variant = "heading", ...rest } = props;
|
||||
const {
|
||||
sizePreset = "headline",
|
||||
variant = "heading",
|
||||
widthVariant = "auto",
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const widthClass = widthVariants[widthVariant];
|
||||
|
||||
let layout: React.ReactNode = null;
|
||||
|
||||
// Heading layout: headline/section presets with heading/section variant
|
||||
if (sizePreset === "headline" || sizePreset === "section") {
|
||||
return (
|
||||
layout = (
|
||||
<HeadingLayout
|
||||
sizePreset={sizePreset}
|
||||
variant={variant as HeadingLayoutProps["variant"]}
|
||||
@@ -101,8 +123,8 @@ function Content(props: ContentProps) {
|
||||
}
|
||||
|
||||
// Label layout: main-content/main-ui/secondary with section variant
|
||||
if (variant === "section" || variant === "heading") {
|
||||
return (
|
||||
else if (variant === "section" || variant === "heading") {
|
||||
layout = (
|
||||
<LabelLayout
|
||||
sizePreset={sizePreset}
|
||||
{...(rest as Omit<LabelLayoutProps, "sizePreset">)}
|
||||
@@ -111,8 +133,8 @@ function Content(props: ContentProps) {
|
||||
}
|
||||
|
||||
// Body layout: main-content/main-ui/secondary with body variant
|
||||
if (variant === "body") {
|
||||
return (
|
||||
else if (variant === "body") {
|
||||
layout = (
|
||||
<BodyLayout
|
||||
sizePreset={sizePreset}
|
||||
{...(rest as Omit<
|
||||
@@ -123,7 +145,17 @@ function Content(props: ContentProps) {
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
// This case should NEVER be hit.
|
||||
if (!layout)
|
||||
throw new Error(
|
||||
`Content: no layout matched for sizePreset="${sizePreset}" variant="${variant}"`
|
||||
);
|
||||
|
||||
// "auto" → return layout directly (a block div with w-auto still
|
||||
// stretches to its parent, defeating shrink-to-content).
|
||||
if (widthVariant === "auto") return layout;
|
||||
|
||||
return <div className={widthClass}>{layout}</div>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -50,4 +50,31 @@ const sizeVariants = {
|
||||
/** Named size preset key. */
|
||||
type SizeVariant = keyof typeof sizeVariants;
|
||||
|
||||
export { sizeVariants, type SizeVariant };
|
||||
// ---------------------------------------------------------------------------
|
||||
// Width Variants
|
||||
//
|
||||
// A named scale of width presets that map to Tailwind width utility classes.
|
||||
//
|
||||
// Consumers:
|
||||
// - Interactive.Container (widthVariant)
|
||||
// - Button (width)
|
||||
// - Content (widthVariant)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Width-variant scale.
|
||||
*
|
||||
* | Key | Tailwind class |
|
||||
* |--------|----------------|
|
||||
* | `auto` | `w-auto` |
|
||||
* | `full` | `w-full` |
|
||||
*/
|
||||
const widthVariants = {
|
||||
auto: "w-auto",
|
||||
full: "w-full",
|
||||
} as const;
|
||||
|
||||
/** Named width preset key. */
|
||||
type WidthVariant = keyof typeof widthVariants;
|
||||
|
||||
export { sizeVariants, type SizeVariant, widthVariants, type WidthVariant };
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function Main() {
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgMcp}
|
||||
title="MCP Actions"
|
||||
description="Connect MCP (Model Context Protocol) servers to add custom actions and tools for your assistants."
|
||||
description="Connect MCP (Model Context Protocol) servers to add custom actions and tools for your agents."
|
||||
separator
|
||||
/>
|
||||
<SettingsLayouts.Body>
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function Main() {
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgActions}
|
||||
title="OpenAPI Actions"
|
||||
description="Connect OpenAPI servers to add custom actions and tools for your assistants."
|
||||
description="Connect OpenAPI servers to add custom actions and tools for your agents."
|
||||
separator
|
||||
/>
|
||||
<SettingsLayouts.Body>
|
||||
|
||||
@@ -170,7 +170,7 @@ export function PersonasTable({
|
||||
{deleteModalOpen && personaToDelete && (
|
||||
<ConfirmationModalLayout
|
||||
icon={SvgAlertCircle}
|
||||
title="Delete Assistant"
|
||||
title="Delete Agent"
|
||||
onClose={closeDeleteModal}
|
||||
submit={<Button onClick={handleDeletePersona}>Delete</Button>}
|
||||
>
|
||||
@@ -183,15 +183,15 @@ export function PersonasTable({
|
||||
const isDefault = personaToToggleDefault.is_default_persona;
|
||||
|
||||
const title = isDefault
|
||||
? "Remove Featured Assistant"
|
||||
: "Set Featured Assistant";
|
||||
? "Remove Featured Agent"
|
||||
: "Set Featured Agent";
|
||||
const buttonText = isDefault ? "Remove Feature" : "Set as Featured";
|
||||
const text = isDefault
|
||||
? `Are you sure you want to remove the featured status of ${personaToToggleDefault.name}?`
|
||||
: `Are you sure you want to set the featured status of ${personaToToggleDefault.name}?`;
|
||||
const additionalText = isDefault
|
||||
? `Removing "${personaToToggleDefault.name}" as a featured assistant will not affect its visibility or accessibility.`
|
||||
: `Setting "${personaToToggleDefault.name}" as a featured assistant will make it public and visible to all users. This action cannot be undone.`;
|
||||
? `Removing "${personaToToggleDefault.name}" as a featured agent will not affect its visibility or accessibility.`
|
||||
: `Setting "${personaToToggleDefault.name}" as a featured agent will make it public and visible to all users. This action cannot be undone.`;
|
||||
|
||||
return (
|
||||
<ConfirmationModalLayout
|
||||
@@ -217,7 +217,7 @@ export function PersonasTable({
|
||||
"Name",
|
||||
"Description",
|
||||
"Type",
|
||||
"Featured Assistant",
|
||||
"Featured Agent",
|
||||
"Is Visible",
|
||||
"Delete",
|
||||
]}
|
||||
|
||||
@@ -47,8 +47,8 @@ function MainContent({
|
||||
return (
|
||||
<div>
|
||||
<Text className="mb-2">
|
||||
Assistants are a way to build custom search/question-answering
|
||||
experiences for different use cases.
|
||||
Agents are a way to build custom search/question-answering experiences
|
||||
for different use cases.
|
||||
</Text>
|
||||
<Text className="mt-2">They allow you to customize:</Text>
|
||||
<div className="text-sm">
|
||||
@@ -63,21 +63,21 @@ function MainContent({
|
||||
<div>
|
||||
<Separator />
|
||||
|
||||
<Title>Create an Assistant</Title>
|
||||
<Title>Create an Agent</Title>
|
||||
<CreateButton href="/app/agents/create?admin=true">
|
||||
New Assistant
|
||||
New Agent
|
||||
</CreateButton>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Title>Existing Assistants</Title>
|
||||
<Title>Existing Agents</Title>
|
||||
{totalItems > 0 ? (
|
||||
<>
|
||||
<SubLabel>
|
||||
Assistants will be displayed as options on the Chat / Search
|
||||
interfaces in the order they are displayed below. Assistants
|
||||
marked as hidden will not be displayed. Editable assistants are
|
||||
shown at the top.
|
||||
Agents will be displayed as options on the Chat / Search
|
||||
interfaces in the order they are displayed below. Agents marked as
|
||||
hidden will not be displayed. Editable agents are shown at the
|
||||
top.
|
||||
</SubLabel>
|
||||
<PersonasTable
|
||||
personas={customPersonas}
|
||||
@@ -96,21 +96,21 @@ function MainContent({
|
||||
) : (
|
||||
<div className="mt-6 p-8 border border-border rounded-lg bg-background-weak text-center">
|
||||
<Text className="text-lg font-medium mb-2">
|
||||
No custom assistants yet
|
||||
No custom agents yet
|
||||
</Text>
|
||||
<Text className="text-subtle mb-3">
|
||||
Create your first assistant to:
|
||||
Create your first agent to:
|
||||
</Text>
|
||||
<ul className="text-subtle text-sm list-disc text-left inline-block mb-3">
|
||||
<li>Build department-specific knowledge bases</li>
|
||||
<li>Create specialized research assistants</li>
|
||||
<li>Create specialized research agents</li>
|
||||
<li>Set up compliance and policy advisors</li>
|
||||
</ul>
|
||||
<Text className="text-subtle text-sm mb-4">
|
||||
...and so much more!
|
||||
</Text>
|
||||
<CreateButton href="/app/agents/create?admin=true">
|
||||
Create Your First Assistant
|
||||
Create Your First Agent
|
||||
</CreateButton>
|
||||
</div>
|
||||
)}
|
||||
@@ -128,13 +128,13 @@ export default function Page() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageTitle icon={SvgOnyxOctagon} title="Assistants" />
|
||||
<AdminPageTitle icon={SvgOnyxOctagon} title="Agents" />
|
||||
|
||||
{isLoading && <ThreeDotsLoader />}
|
||||
|
||||
{error && (
|
||||
<ErrorCallout
|
||||
errorTitle="Failed to load assistants"
|
||||
errorTitle="Failed to load agents"
|
||||
errorMsg={
|
||||
error?.info?.message ||
|
||||
error?.info?.detail ||
|
||||
|
||||
@@ -156,7 +156,7 @@ export const SlackChannelConfigCreationForm = ({
|
||||
is: "assistant",
|
||||
then: (schema) =>
|
||||
schema.required(
|
||||
"A persona is required when using the'Assistant' knowledge source"
|
||||
"An agent is required when using the 'Agent' knowledge source"
|
||||
),
|
||||
}),
|
||||
standard_answer_categories: Yup.array(),
|
||||
|
||||
@@ -224,14 +224,14 @@ export function SlackChannelConfigFormFields({
|
||||
<RadioGroupItemField
|
||||
value="assistant"
|
||||
id="assistant"
|
||||
label="Search Assistant"
|
||||
label="Search Agent"
|
||||
sublabel="Control both the documents and the prompt to use for answering questions"
|
||||
/>
|
||||
<RadioGroupItemField
|
||||
value="non_search_assistant"
|
||||
id="non_search_assistant"
|
||||
label="Non-Search Assistant"
|
||||
sublabel="Chat with an assistant that does not use documents"
|
||||
label="Non-Search Agent"
|
||||
sublabel="Chat with an agent that does not use documents"
|
||||
/>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
@@ -327,15 +327,15 @@ export function SlackChannelConfigFormFields({
|
||||
<div className="mt-4">
|
||||
<SubLabel>
|
||||
<>
|
||||
Select the search-enabled assistant OnyxBot will use while
|
||||
answering questions in Slack.
|
||||
Select the search-enabled agent OnyxBot will use while answering
|
||||
questions in Slack.
|
||||
{syncEnabledAssistants.length > 0 && (
|
||||
<>
|
||||
<br />
|
||||
<span className="text-sm text-text-dark/80">
|
||||
Note: Some of your assistants have auto-synced connectors
|
||||
in their document sets. You cannot select these assistants
|
||||
as they will not be able to answer questions in Slack.{" "}
|
||||
Note: Some of your agents have auto-synced connectors in
|
||||
their document sets. You cannot select these agents as
|
||||
they will not be able to answer questions in Slack.{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
@@ -349,7 +349,7 @@ export function SlackChannelConfigFormFields({
|
||||
{viewSyncEnabledAssistants
|
||||
? "Hide un-selectable "
|
||||
: "View all "}
|
||||
assistants
|
||||
agents
|
||||
</button>
|
||||
</span>
|
||||
</>
|
||||
@@ -367,7 +367,7 @@ export function SlackChannelConfigFormFields({
|
||||
{viewSyncEnabledAssistants && syncEnabledAssistants.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<p className="text-sm text-text-dark/80">
|
||||
Un-selectable assistants:
|
||||
Un-selectable agents:
|
||||
</p>
|
||||
<div className="mb-3 mt-2 flex gap-2 flex-wrap text-sm">
|
||||
{syncEnabledAssistants.map(
|
||||
@@ -394,15 +394,15 @@ export function SlackChannelConfigFormFields({
|
||||
<div className="mt-4">
|
||||
<SubLabel>
|
||||
<>
|
||||
Select the non-search assistant OnyxBot will use while answering
|
||||
Select the non-search agent OnyxBot will use while answering
|
||||
questions in Slack.
|
||||
{syncEnabledAssistants.length > 0 && (
|
||||
<>
|
||||
<br />
|
||||
<span className="text-sm text-text-dark/80">
|
||||
Note: Some of your assistants have auto-synced connectors
|
||||
in their document sets. You cannot select these assistants
|
||||
as they will not be able to answer questions in Slack.{" "}
|
||||
Note: Some of your agents have auto-synced connectors in
|
||||
their document sets. You cannot select these agents as
|
||||
they will not be able to answer questions in Slack.{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
@@ -416,7 +416,7 @@ export function SlackChannelConfigFormFields({
|
||||
{viewSyncEnabledAssistants
|
||||
? "Hide un-selectable "
|
||||
: "View all "}
|
||||
assistants
|
||||
agents
|
||||
</button>
|
||||
</span>
|
||||
</>
|
||||
@@ -524,7 +524,7 @@ export function SlackChannelConfigFormFields({
|
||||
name="is_ephemeral"
|
||||
label="Respond to user in a private (ephemeral) message"
|
||||
tooltip="If set, OnyxBot will respond only to the user in a private (ephemeral) message. If you also
|
||||
chose 'Search' Assistant above, selecting this option will make documents that are private to the user
|
||||
chose 'Search' Agent above, selecting this option will make documents that are private to the user
|
||||
available for their queries."
|
||||
/>
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import CodeInterpreterPage from "@/refresh-pages/admin/CodeInterpreterPage";
|
||||
|
||||
export default function Page() {
|
||||
return <CodeInterpreterPage />;
|
||||
}
|
||||
@@ -39,10 +39,10 @@ export function AdvancedOptions({
|
||||
agents={agents}
|
||||
isLoading={agentsLoading}
|
||||
error={agentsError}
|
||||
label="Assistant Whitelist"
|
||||
subtext="Restrict this provider to specific assistants."
|
||||
label="Agent Whitelist"
|
||||
subtext="Restrict this provider to specific agents."
|
||||
disabled={formikProps.values.is_public}
|
||||
disabledMessage="This LLM Provider is public and available to all assistants."
|
||||
disabledMessage="This LLM Provider is public and available to all agents."
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -299,11 +299,11 @@ export default function Page({ params }: Props) {
|
||||
});
|
||||
refreshGuild();
|
||||
toast.success(
|
||||
personaId ? "Default assistant updated" : "Default assistant cleared"
|
||||
personaId ? "Default agent updated" : "Default agent cleared"
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to update assistant"
|
||||
err instanceof Error ? err.message : "Failed to update agent"
|
||||
);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
@@ -355,7 +355,7 @@ export default function Page({ params }: Props) {
|
||||
<InputSelect.Trigger placeholder="Select agent" />
|
||||
<InputSelect.Content>
|
||||
<InputSelect.Item value="default">
|
||||
Default Assistant
|
||||
Default Agent
|
||||
</InputSelect.Item>
|
||||
{personas.map((persona) => (
|
||||
<InputSelect.Item
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { FiDownload } from "react-icons/fi";
|
||||
import { memo, useState } from "react";
|
||||
import { SvgDownload } from "@opal/icons";
|
||||
import { ImageShape } from "@/app/app/services/streamingModels";
|
||||
import { FullImageModal } from "@/app/app/components/files/images/FullImageModal";
|
||||
import { buildImgUrl } from "@/app/app/components/files/images/utils";
|
||||
@@ -24,17 +24,22 @@ const SHAPE_CLASSES: Record<ImageShape, { container: string; image: string }> =
|
||||
},
|
||||
};
|
||||
|
||||
// Used to stop image flashing as images are loaded and response continues
|
||||
const loadedImages = new Set<string>();
|
||||
|
||||
interface InMessageImageProps {
|
||||
fileId: string;
|
||||
fileName?: string;
|
||||
shape?: ImageShape;
|
||||
}
|
||||
|
||||
export function InMessageImage({
|
||||
export const InMessageImage = memo(function InMessageImage({
|
||||
fileId,
|
||||
fileName,
|
||||
shape = DEFAULT_SHAPE,
|
||||
}: InMessageImageProps) {
|
||||
const [fullImageShowing, setFullImageShowing] = useState(false);
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
const [imageLoaded, setImageLoaded] = useState(loadedImages.has(fileId));
|
||||
|
||||
const normalizedShape = SHAPE_CLASSES[shape] ? shape : DEFAULT_SHAPE;
|
||||
const { container: shapeContainerClasses, image: shapeImageClasses } =
|
||||
@@ -49,7 +54,7 @@ export function InMessageImage({
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `image-${fileId}.png`; // You can adjust the filename/extension as needed
|
||||
a.download = fileName || `image-${fileId}.png`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
@@ -76,7 +81,10 @@ export function InMessageImage({
|
||||
width={1200}
|
||||
height={1200}
|
||||
alt="Chat Message Image"
|
||||
onLoad={() => setImageLoaded(true)}
|
||||
onLoad={() => {
|
||||
loadedImages.add(fileId);
|
||||
setImageLoaded(true);
|
||||
}}
|
||||
className={cn(
|
||||
"object-contain object-left overflow-hidden rounded-lg w-full h-full transition-opacity duration-300 cursor-pointer",
|
||||
shapeImageClasses,
|
||||
@@ -94,7 +102,7 @@ export function InMessageImage({
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
icon={FiDownload}
|
||||
icon={SvgDownload}
|
||||
tooltip="Download"
|
||||
onClick={handleDownload}
|
||||
/>
|
||||
@@ -102,4 +110,4 @@ export function InMessageImage({
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,3 +1,21 @@
|
||||
const CHAT_FILE_URL_REGEX = /\/api\/chat\/file\/([^/?#]+)/;
|
||||
const IMAGE_EXTENSIONS = /\.(png|jpe?g|gif|webp|svg|bmp|ico|tiff?)$/i;
|
||||
|
||||
export function buildImgUrl(fileId: string) {
|
||||
return `/api/chat/file/${fileId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* If `href` points to a chat file and `linkText` ends with an image extension,
|
||||
* returns the file ID. Otherwise returns null.
|
||||
*/
|
||||
export function extractChatImageFileId(
|
||||
href: string | undefined,
|
||||
linkText: string
|
||||
): string | null {
|
||||
if (!href) return null;
|
||||
const match = CHAT_FILE_URL_REGEX.exec(href);
|
||||
if (!match?.[1]) return null;
|
||||
if (!IMAGE_EXTENSIONS.test(linkText)) return null;
|
||||
return match[1];
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@ export interface RendererResult {
|
||||
|
||||
// Whether this renderer supports collapsible mode (collapse button shown only when true)
|
||||
supportsCollapsible?: boolean;
|
||||
/** Whether the step should remain collapsible even in single-step timelines */
|
||||
alwaysCollapsible?: boolean;
|
||||
/** Whether the result should be wrapped by timeline UI or rendered as-is */
|
||||
timelineLayout?: TimelineLayout;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
import { extractCodeText, preprocessLaTeX } from "@/app/app/message/codeUtils";
|
||||
import { CodeBlock } from "@/app/app/message/CodeBlock";
|
||||
import { transformLinkUri, cn } from "@/lib/utils";
|
||||
import { InMessageImage } from "@/app/app/components/files/images/InMessageImage";
|
||||
import { extractChatImageFileId } from "@/app/app/components/files/images/utils";
|
||||
|
||||
/**
|
||||
* Processes content for markdown rendering by handling code blocks and LaTeX
|
||||
@@ -58,17 +60,31 @@ export const useMarkdownComponents = (
|
||||
);
|
||||
|
||||
const anchorCallback = useCallback(
|
||||
(props: any) => (
|
||||
<MemoizedAnchor
|
||||
updatePresentingDocument={state?.setPresentingDocument || (() => {})}
|
||||
docs={state?.docs || []}
|
||||
userFiles={state?.userFiles || []}
|
||||
citations={state?.citations}
|
||||
href={props.href}
|
||||
>
|
||||
{props.children}
|
||||
</MemoizedAnchor>
|
||||
),
|
||||
(props: any) => {
|
||||
const imageFileId = extractChatImageFileId(
|
||||
props.href,
|
||||
String(props.children ?? "")
|
||||
);
|
||||
if (imageFileId) {
|
||||
return (
|
||||
<InMessageImage
|
||||
fileId={imageFileId}
|
||||
fileName={String(props.children ?? "")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<MemoizedAnchor
|
||||
updatePresentingDocument={state?.setPresentingDocument || (() => {})}
|
||||
docs={state?.docs || []}
|
||||
userFiles={state?.userFiles || []}
|
||||
citations={state?.citations}
|
||||
href={props.href}
|
||||
>
|
||||
{props.children}
|
||||
</MemoizedAnchor>
|
||||
);
|
||||
},
|
||||
[
|
||||
state?.docs,
|
||||
state?.userFiles,
|
||||
|
||||
@@ -50,7 +50,9 @@ export function TimelineStepComposer({
|
||||
header={result.status}
|
||||
isExpanded={result.isExpanded}
|
||||
onToggle={result.onToggle}
|
||||
collapsible={collapsible && !isSingleStep}
|
||||
collapsible={
|
||||
collapsible && (!isSingleStep || !!result.alwaysCollapsible)
|
||||
}
|
||||
supportsCollapsible={result.supportsCollapsible}
|
||||
isLastStep={index === results.length - 1 && isLastStep}
|
||||
isFirstStep={index === 0 && isFirstStep}
|
||||
|
||||
@@ -54,7 +54,7 @@ export function TimelineRow({
|
||||
isHover={isHover}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1">{children}</div>
|
||||
<div className="flex-1 min-w-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ export const PythonToolRenderer: MessageRenderer<PythonToolPacket, {}> = ({
|
||||
{stdout && (
|
||||
<div className="rounded-md bg-background-neutral-02 p-3">
|
||||
<div className="text-xs font-semibold mb-1 text-text-03">Output:</div>
|
||||
<pre className="text-sm whitespace-pre-wrap font-mono text-text-01">
|
||||
<pre className="text-sm whitespace-pre-wrap font-mono text-text-01 overflow-x-auto">
|
||||
{stdout}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -150,7 +150,7 @@ export const PythonToolRenderer: MessageRenderer<PythonToolPacket, {}> = ({
|
||||
<div className="text-xs font-semibold mb-1 text-status-error-05">
|
||||
Error:
|
||||
</div>
|
||||
<pre className="text-sm whitespace-pre-wrap font-mono text-status-error-05">
|
||||
<pre className="text-sm whitespace-pre-wrap font-mono text-status-error-05 overflow-x-auto">
|
||||
{stderr}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -181,6 +181,7 @@ export const PythonToolRenderer: MessageRenderer<PythonToolPacket, {}> = ({
|
||||
status,
|
||||
content,
|
||||
supportsCollapsible: true,
|
||||
alwaysCollapsible: true,
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -191,6 +192,7 @@ export const PythonToolRenderer: MessageRenderer<PythonToolPacket, {}> = ({
|
||||
icon: SvgTerminal,
|
||||
status,
|
||||
supportsCollapsible: true,
|
||||
alwaysCollapsible: true,
|
||||
content: (
|
||||
<FadingEdgeContainer
|
||||
direction="bottom"
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useRef, useEffect } from "react";
|
||||
import Logo from "@/refresh-components/Logo";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import TextChunk from "@/app/craft/components/TextChunk";
|
||||
import ThinkingCard from "@/app/craft/components/ThinkingCard";
|
||||
import ToolCallPill from "@/app/craft/components/ToolCallPill";
|
||||
@@ -69,8 +68,6 @@ interface BuildMessageListProps {
|
||||
messages: BuildMessage[];
|
||||
streamItems: StreamItem[];
|
||||
isStreaming?: boolean;
|
||||
/** Whether the user cancelled the current generation */
|
||||
userCancelled?: boolean;
|
||||
/** Whether auto-scroll is enabled (user is at bottom) */
|
||||
autoScrollEnabled?: boolean;
|
||||
/** Ref to the end marker div for scroll detection */
|
||||
@@ -88,7 +85,6 @@ export default function BuildMessageList({
|
||||
messages,
|
||||
streamItems,
|
||||
isStreaming = false,
|
||||
userCancelled = false,
|
||||
autoScrollEnabled = true,
|
||||
messagesEndRef: externalMessagesEndRef,
|
||||
}: BuildMessageListProps) {
|
||||
@@ -108,11 +104,8 @@ export default function BuildMessageList({
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
const lastMessageIsUser = lastMessage?.type === "user";
|
||||
// Show streaming area if we have stream items OR if we're waiting for a response to the latest user message
|
||||
// Also show if user cancelled (to display the cancellation message)
|
||||
const showStreamingArea =
|
||||
hasStreamItems ||
|
||||
(isStreaming && lastMessageIsUser) ||
|
||||
(userCancelled && lastMessageIsUser);
|
||||
hasStreamItems || (isStreaming && lastMessageIsUser);
|
||||
|
||||
// Check for active tools (for "Working..." state)
|
||||
const hasActiveTools = streamItems.some(
|
||||
@@ -213,28 +206,16 @@ export default function BuildMessageList({
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-3 min-w-0">
|
||||
{!hasStreamItems ? (
|
||||
// Loading state or cancelled - show blinking dot or cancelled message
|
||||
userCancelled ? (
|
||||
<Text as="p" secondaryBody text04>
|
||||
User has stopped generation
|
||||
</Text>
|
||||
) : (
|
||||
<BlinkingDot />
|
||||
)
|
||||
// Loading state - no content yet, show blinking dot like main chat
|
||||
<BlinkingDot />
|
||||
) : (
|
||||
<>
|
||||
{/* Render stream items in FIFO order */}
|
||||
{renderStreamItems(streamItems, true)}
|
||||
|
||||
{/* Show cancelled message or streaming indicator */}
|
||||
{userCancelled ? (
|
||||
<Text as="p" secondaryBody text04>
|
||||
User has stopped generation
|
||||
</Text>
|
||||
) : (
|
||||
isStreaming &&
|
||||
hasStreamItems &&
|
||||
!hasActiveTools && <BlinkingDot />
|
||||
{/* Streaming indicator when actively streaming text */}
|
||||
{isStreaming && hasStreamItems && !hasActiveTools && (
|
||||
<BlinkingDot />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -112,7 +112,7 @@ export default function BuildChatPanel({
|
||||
const nameBuildSession = useBuildSessionStore(
|
||||
(state) => state.nameBuildSession
|
||||
);
|
||||
const { streamMessage, abortStream } = useBuildStreaming();
|
||||
const { streamMessage } = useBuildStreaming();
|
||||
const isPreProvisioning = useIsPreProvisioning();
|
||||
const isPreProvisioningFailed = useIsPreProvisioningFailed();
|
||||
const preProvisionedSessionId = usePreProvisionedSessionId();
|
||||
@@ -431,7 +431,6 @@ export default function BuildChatPanel({
|
||||
messages={session?.messages ?? []}
|
||||
streamItems={session?.streamItems ?? []}
|
||||
isStreaming={isRunning}
|
||||
userCancelled={session?.status === "cancelled"}
|
||||
autoScrollEnabled={isAtBottom}
|
||||
/>
|
||||
)}
|
||||
@@ -484,7 +483,6 @@ export default function BuildChatPanel({
|
||||
<InputBar
|
||||
ref={inputBarRef}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={abortStream}
|
||||
isRunning={isRunning}
|
||||
placeholder="Continue the conversation..."
|
||||
/>
|
||||
|
||||
@@ -36,7 +36,6 @@ import {
|
||||
SvgPaperclip,
|
||||
SvgOrganization,
|
||||
SvgAlertCircle,
|
||||
SvgStop,
|
||||
} from "@opal/icons";
|
||||
|
||||
const MAX_INPUT_HEIGHT = 200;
|
||||
@@ -53,8 +52,6 @@ export interface InputBarProps {
|
||||
files: BuildFile[],
|
||||
demoDataEnabled: boolean
|
||||
) => void;
|
||||
/** Callback to stop the current generation. If provided and isRunning is true, shows a stop button. */
|
||||
onStop?: () => void;
|
||||
isRunning: boolean;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
@@ -156,7 +153,6 @@ const InputBar = memo(
|
||||
(
|
||||
{
|
||||
onSubmit,
|
||||
onStop,
|
||||
isRunning,
|
||||
disabled = false,
|
||||
placeholder = "Describe your task...",
|
||||
@@ -409,26 +405,18 @@ const InputBar = memo(
|
||||
|
||||
{/* Bottom right controls */}
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
{/* Submit button - shows Stop when running, Send otherwise */}
|
||||
{isRunning && onStop ? (
|
||||
<IconButton
|
||||
icon={SvgStop}
|
||||
onClick={() => onStop()}
|
||||
tooltip="Stop generation"
|
||||
/>
|
||||
) : (
|
||||
<IconButton
|
||||
icon={sandboxInitializing ? SvgLoader : SvgArrowUp}
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
tooltip={
|
||||
sandboxInitializing ? "Initializing sandbox..." : "Send"
|
||||
}
|
||||
iconClassName={
|
||||
sandboxInitializing ? "animate-spin" : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{/* Submit button */}
|
||||
<IconButton
|
||||
icon={sandboxInitializing ? SvgLoader : SvgArrowUp}
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
tooltip={
|
||||
sandboxInitializing ? "Initializing sandbox..." : "Send"
|
||||
}
|
||||
iconClassName={
|
||||
sandboxInitializing ? "animate-spin" : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
fetchSession,
|
||||
generateFollowupSuggestions,
|
||||
RateLimitError,
|
||||
cancelMessage,
|
||||
} from "@/app/craft/services/apiServices";
|
||||
|
||||
import { useBuildSessionStore } from "@/app/craft/hooks/useBuildSessionStore";
|
||||
@@ -41,7 +40,9 @@ export function useBuildStreaming() {
|
||||
const setAbortController = useBuildSessionStore(
|
||||
(state) => state.setAbortController
|
||||
);
|
||||
const abortSession = useBuildSessionStore((state) => state.abortSession);
|
||||
const abortCurrentSession = useBuildSessionStore(
|
||||
(state) => state.abortCurrentSession
|
||||
);
|
||||
const updateSessionData = useBuildSessionStore(
|
||||
(state) => state.updateSessionData
|
||||
);
|
||||
@@ -448,45 +449,11 @@ export function useBuildStreaming() {
|
||||
]
|
||||
);
|
||||
|
||||
/**
|
||||
* Abort the current streaming operation.
|
||||
* This both:
|
||||
* 1. Aborts the HTTP request (via AbortController)
|
||||
* 2. Sends a cancel notification to the ACP agent (via backend API)
|
||||
* 3. Updates session status to "cancelled" to update UI state
|
||||
*/
|
||||
const abortStream = useCallback(
|
||||
async (sessionId?: string) => {
|
||||
const targetSessionId =
|
||||
sessionId ?? useBuildSessionStore.getState().currentSessionId;
|
||||
|
||||
if (!targetSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// First, send cancel to the backend to stop the agent
|
||||
// This sends session/cancel notification per ACP protocol
|
||||
try {
|
||||
await cancelMessage(targetSessionId);
|
||||
} catch (err) {
|
||||
console.error("[Streaming] Failed to cancel agent operation:", err);
|
||||
}
|
||||
|
||||
// Update status first so the UI transitions atomically (stop button
|
||||
// disappears and cancellation message appears before the stream closes)
|
||||
updateSessionData(targetSessionId, { status: "cancelled" });
|
||||
|
||||
// Abort the HTTP request to stop receiving events
|
||||
abortSession(targetSessionId);
|
||||
},
|
||||
[abortSession, updateSessionData]
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
streamMessage,
|
||||
abortStream,
|
||||
abortStream: abortCurrentSession,
|
||||
}),
|
||||
[streamMessage, abortStream]
|
||||
[streamMessage, abortCurrentSession]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -358,30 +358,6 @@ export async function sendMessageStream(
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the current message/prompt operation for a session.
|
||||
* Sends a session/cancel notification to the ACP agent to stop
|
||||
* the currently running operation.
|
||||
*
|
||||
* This follows the ACP (Agent Client Protocol) specification for cancellation.
|
||||
*
|
||||
* @returns true if cancel was sent, false if no active operation
|
||||
*/
|
||||
export async function cancelMessage(sessionId: string): Promise<boolean> {
|
||||
const res = await fetch(`${API_BASE}/sessions/${sessionId}/cancel-message`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.error(`Failed to cancel message: ${res.status}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return data.cancelled ?? false;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Artifacts API
|
||||
// =============================================================================
|
||||
|
||||
@@ -125,8 +125,7 @@ export type SessionStatus =
|
||||
| "creating"
|
||||
| "running"
|
||||
| "active"
|
||||
| "failed"
|
||||
| "cancelled";
|
||||
| "failed";
|
||||
|
||||
export interface Session {
|
||||
id: string | null;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
:root {
|
||||
--app-page-main-content-width: 52.5rem;
|
||||
--block-width-form-input-min: 10rem;
|
||||
}
|
||||
|
||||
@@ -427,7 +427,7 @@ export const GroupDisplay = ({
|
||||
|
||||
<Separator />
|
||||
|
||||
<h2 className="text-xl font-bold mt-8 mb-2">Assistants</h2>
|
||||
<h2 className="text-xl font-bold mt-8 mb-2">Agents</h2>
|
||||
|
||||
<div>
|
||||
{userGroup.document_sets.length > 0 ? (
|
||||
@@ -445,7 +445,7 @@ export const GroupDisplay = ({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Text>No Assistants in this group...</Text>
|
||||
<Text>No Agents in this group...</Text>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -152,14 +152,14 @@ export function PersonaMessagesChart({
|
||||
} else if (selectedPersonaId === undefined) {
|
||||
content = (
|
||||
<div className="h-80 text-text-500 flex flex-col">
|
||||
<p className="m-auto">Select an assistant to view analytics</p>
|
||||
<p className="m-auto">Select an agent to view analytics</p>
|
||||
</div>
|
||||
);
|
||||
} else if (!personaMessagesData?.length) {
|
||||
content = (
|
||||
<div className="h-80 text-text-500 flex flex-col">
|
||||
<p className="m-auto">
|
||||
No data found for selected assistant in the specified time range
|
||||
No data found for selected agent in the specified time range
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -178,11 +178,9 @@ export function PersonaMessagesChart({
|
||||
|
||||
return (
|
||||
<CardSection className="mt-8">
|
||||
<Title>Assistant Analytics</Title>
|
||||
<Title>Agent Analytics</Title>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Text>
|
||||
Messages and unique users per day for the selected assistant
|
||||
</Text>
|
||||
<Text>Messages and unique users per day for the selected agent</Text>
|
||||
<div className="flex items-center gap-4">
|
||||
<Select
|
||||
value={selectedPersonaId?.toString() ?? ""}
|
||||
@@ -191,14 +189,14 @@ export function PersonaMessagesChart({
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="flex w-full max-w-xs">
|
||||
<SelectValue placeholder="Select an assistant to display" />
|
||||
<SelectValue placeholder="Select an agent to display" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<div className="flex items-center px-2 pb-2 sticky top-0 bg-background border-b">
|
||||
<Search className="h-4 w-4 mr-2 shrink-0 opacity-50" />
|
||||
<input
|
||||
className="flex h-8 w-full rounded-sm bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="Search assistants..."
|
||||
placeholder="Search agents..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
|
||||
@@ -146,7 +146,7 @@ export function AssistantStats({ assistantId }: { assistantId: number }) {
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<p className="text-base font-normal text-2xl">Assistant Analytics</p>
|
||||
<p className="text-base font-normal text-2xl">Agent Analytics</p>
|
||||
<AdminDateRangeSelector
|
||||
value={dateRange}
|
||||
onValueChange={setDateRange}
|
||||
|
||||
@@ -12,17 +12,17 @@ export default function NoAssistantModal() {
|
||||
return (
|
||||
<Modal open>
|
||||
<Modal.Content width="sm" height="sm">
|
||||
<Modal.Header icon={SvgUser} title="No Assistant Available" />
|
||||
<Modal.Header icon={SvgUser} title="No Agent Available" />
|
||||
<Modal.Body>
|
||||
<Text as="p">
|
||||
You currently have no assistant configured. To use this feature, you
|
||||
You currently have no agent configured. To use this feature, you
|
||||
need to take action.
|
||||
</Text>
|
||||
{isAdmin ? (
|
||||
<>
|
||||
<Text as="p">
|
||||
As an administrator, you can create a new assistant by visiting
|
||||
the admin panel.
|
||||
As an administrator, you can create a new agent by visiting the
|
||||
admin panel.
|
||||
</Text>
|
||||
<Button className="w-full" href="/admin/assistants">
|
||||
Go to Admin Panel
|
||||
@@ -30,8 +30,7 @@ export default function NoAssistantModal() {
|
||||
</>
|
||||
) : (
|
||||
<Text as="p">
|
||||
Please contact your administrator to configure an assistant for
|
||||
you.
|
||||
Please contact your administrator to configure an agent for you.
|
||||
</Text>
|
||||
)}
|
||||
</Modal.Body>
|
||||
|
||||
44
web/src/hooks/useCodeInterpreter.ts
Normal file
44
web/src/hooks/useCodeInterpreter.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import useSWR from "swr";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
|
||||
const HEALTH_ENDPOINT = "/api/admin/code-interpreter/health";
|
||||
const STATUS_ENDPOINT = "/api/admin/code-interpreter";
|
||||
|
||||
interface CodeInterpreterHealth {
|
||||
healthy: boolean;
|
||||
}
|
||||
|
||||
interface CodeInterpreterStatus {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export default function useCodeInterpreter() {
|
||||
const {
|
||||
data: healthData,
|
||||
error: healthError,
|
||||
isLoading: isHealthLoading,
|
||||
mutate: refetchHealth,
|
||||
} = useSWR<CodeInterpreterHealth>(HEALTH_ENDPOINT, errorHandlingFetcher, {
|
||||
refreshInterval: 30000,
|
||||
});
|
||||
|
||||
const {
|
||||
data: statusData,
|
||||
error: statusError,
|
||||
isLoading: isStatusLoading,
|
||||
mutate: refetchStatus,
|
||||
} = useSWR<CodeInterpreterStatus>(STATUS_ENDPOINT, errorHandlingFetcher);
|
||||
|
||||
function refetch() {
|
||||
refetchHealth();
|
||||
refetchStatus();
|
||||
}
|
||||
|
||||
return {
|
||||
isHealthy: healthData?.healthy ?? false,
|
||||
isEnabled: statusData?.enabled ?? false,
|
||||
isLoading: isHealthLoading || isStatusLoading,
|
||||
error: healthError || statusError,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
15
web/src/lib/admin/code-interpreter/svc.ts
Normal file
15
web/src/lib/admin/code-interpreter/svc.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
const UPDATE_ENDPOINT = "/api/admin/code-interpreter";
|
||||
|
||||
interface CodeInterpreterUpdateRequest {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export async function updateCodeInterpreter(
|
||||
request: CodeInterpreterUpdateRequest
|
||||
): Promise<Response> {
|
||||
return fetch(UPDATE_ENDPOINT, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
@@ -153,7 +153,7 @@ function InputSelectRoot({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("w-full relative")}>
|
||||
<div className="w-full min-w-[var(--block-width-form-input-min)] relative">
|
||||
<InputSelectContext.Provider value={contextValue}>
|
||||
<SelectPrimitive.Root
|
||||
{...(isControlled ? { value: currentValue } : { defaultValue })}
|
||||
|
||||
@@ -179,7 +179,7 @@ export default function ActionLineItem({
|
||||
)}
|
||||
|
||||
{isSearchToolAndNotInProject && (
|
||||
<IconButton
|
||||
<Button
|
||||
icon={
|
||||
isSearchToolWithNoConnectors ? SvgSettings : SvgChevronRight
|
||||
}
|
||||
@@ -188,11 +188,8 @@ export default function ActionLineItem({
|
||||
router.push("/admin/add-connector");
|
||||
else onSourceManagementOpen?.();
|
||||
})}
|
||||
internal
|
||||
className={cn(
|
||||
isSearchToolWithNoConnectors &&
|
||||
"invisible group-hover/LineItem:visible"
|
||||
)}
|
||||
prominence="tertiary"
|
||||
size="sm"
|
||||
tooltip={
|
||||
isSearchToolWithNoConnectors
|
||||
? "Add Connectors"
|
||||
|
||||
@@ -425,7 +425,7 @@ export default function AgentsNavigationPage() {
|
||||
>
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgOnyxOctagon}
|
||||
title="Agents & Assistants"
|
||||
title="Agents"
|
||||
description="Customize AI behavior and knowledge for you and your team's use cases."
|
||||
rightChildren={
|
||||
<Button
|
||||
|
||||
241
web/src/refresh-pages/admin/CodeInterpreterPage.tsx
Normal file
241
web/src/refresh-pages/admin/CodeInterpreterPage.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { Card, type CardProps } from "@/refresh-components/cards";
|
||||
import {
|
||||
SvgArrowExchange,
|
||||
SvgCheckCircle,
|
||||
SvgRefreshCw,
|
||||
SvgTerminal,
|
||||
SvgUnplug,
|
||||
SvgXOctagon,
|
||||
} from "@opal/icons";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import { Button } from "@opal/components";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
|
||||
import ConfirmationModalLayout from "@/refresh-components/layouts/ConfirmationModalLayout";
|
||||
import useCodeInterpreter from "@/hooks/useCodeInterpreter";
|
||||
import { updateCodeInterpreter } from "@/lib/admin/code-interpreter/svc";
|
||||
import { ContentAction } from "@opal/layouts";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
|
||||
interface CodeInterpreterCardProps {
|
||||
variant?: CardProps["variant"];
|
||||
title: string;
|
||||
middleText?: string;
|
||||
strikethrough?: boolean;
|
||||
rightContent: React.ReactNode;
|
||||
}
|
||||
|
||||
function CodeInterpreterCard({
|
||||
variant,
|
||||
title,
|
||||
middleText,
|
||||
strikethrough,
|
||||
rightContent,
|
||||
}: CodeInterpreterCardProps) {
|
||||
return (
|
||||
// TODO (@raunakab): Allow Content to accept strikethrough and middleText
|
||||
<Card variant={variant} padding={0.5}>
|
||||
<ContentAction
|
||||
icon={SvgTerminal}
|
||||
title={middleText ? `${title} ${middleText}` : title}
|
||||
description="Built-in Python runtime"
|
||||
variant="section"
|
||||
sizePreset="main-ui"
|
||||
rightChildren={rightContent}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckingStatus() {
|
||||
return (
|
||||
<Section
|
||||
flexDirection="row"
|
||||
justifyContent="end"
|
||||
alignItems="center"
|
||||
gap={0.25}
|
||||
padding={0.5}
|
||||
>
|
||||
<Text mainUiAction text03>
|
||||
Checking...
|
||||
</Text>
|
||||
<SimpleLoader />
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConnectionStatusProps {
|
||||
healthy: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function ConnectionStatus({ healthy, isLoading }: ConnectionStatusProps) {
|
||||
if (isLoading) {
|
||||
return <CheckingStatus />;
|
||||
}
|
||||
|
||||
const label = healthy ? "Connected" : "Connection Lost";
|
||||
const Icon = healthy ? SvgCheckCircle : SvgXOctagon;
|
||||
const iconColor = healthy ? "text-status-success-05" : "text-status-error-05";
|
||||
|
||||
return (
|
||||
<Section
|
||||
flexDirection="row"
|
||||
justifyContent="end"
|
||||
alignItems="center"
|
||||
gap={0.25}
|
||||
padding={0.5}
|
||||
>
|
||||
<Text mainUiAction text03>
|
||||
{label}
|
||||
</Text>
|
||||
<Icon size={16} className={iconColor} />
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
interface ActionButtonsProps {
|
||||
onDisconnect: () => void;
|
||||
onRefresh: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function ActionButtons({
|
||||
onDisconnect,
|
||||
onRefresh,
|
||||
disabled,
|
||||
}: ActionButtonsProps) {
|
||||
return (
|
||||
<Section
|
||||
flexDirection="row"
|
||||
justifyContent="end"
|
||||
alignItems="center"
|
||||
gap={0.25}
|
||||
padding={0.25}
|
||||
>
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
size="sm"
|
||||
icon={SvgUnplug}
|
||||
onClick={onDisconnect}
|
||||
tooltip="Disconnect"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
size="sm"
|
||||
icon={SvgRefreshCw}
|
||||
onClick={onRefresh}
|
||||
tooltip="Refresh"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CodeInterpreterPage() {
|
||||
const { isHealthy, isEnabled, isLoading, refetch } = useCodeInterpreter();
|
||||
const [showDisconnectModal, setShowDisconnectModal] = useState(false);
|
||||
const [isReconnecting, setIsReconnecting] = useState(false);
|
||||
|
||||
async function handleToggle(enabled: boolean) {
|
||||
const action = enabled ? "reconnect" : "disconnect";
|
||||
setIsReconnecting(enabled);
|
||||
try {
|
||||
const response = await updateCodeInterpreter({ enabled });
|
||||
if (!response.ok) {
|
||||
toast.error(`Failed to ${action} Code Interpreter`);
|
||||
return;
|
||||
}
|
||||
setShowDisconnectModal(false);
|
||||
refetch();
|
||||
} finally {
|
||||
setIsReconnecting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgTerminal}
|
||||
title="Code Interpreter"
|
||||
description="Safe and sandboxed Python runtime available to your LLM. See docs for more details."
|
||||
separator
|
||||
/>
|
||||
|
||||
<SettingsLayouts.Body>
|
||||
{isEnabled || isLoading ? (
|
||||
<CodeInterpreterCard
|
||||
title="Code Interpreter"
|
||||
variant={isHealthy ? "primary" : "secondary"}
|
||||
strikethrough={!isHealthy}
|
||||
rightContent={
|
||||
<Section
|
||||
flexDirection="column"
|
||||
justifyContent="center"
|
||||
alignItems="end"
|
||||
gap={0}
|
||||
padding={0}
|
||||
>
|
||||
<ConnectionStatus healthy={isHealthy} isLoading={isLoading} />
|
||||
<ActionButtons
|
||||
onDisconnect={() => setShowDisconnectModal(true)}
|
||||
onRefresh={refetch}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</Section>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<CodeInterpreterCard
|
||||
variant="secondary"
|
||||
title="Code Interpreter"
|
||||
middleText="(Disconnected)"
|
||||
strikethrough={true}
|
||||
rightContent={
|
||||
<Section flexDirection="row" alignItems="center" padding={0.5}>
|
||||
{isReconnecting ? (
|
||||
<CheckingStatus />
|
||||
) : (
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
rightIcon={SvgArrowExchange}
|
||||
onClick={() => handleToggle(true)}
|
||||
>
|
||||
Reconnect
|
||||
</Button>
|
||||
)}
|
||||
</Section>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SettingsLayouts.Body>
|
||||
|
||||
{showDisconnectModal && (
|
||||
<ConfirmationModalLayout
|
||||
icon={SvgUnplug}
|
||||
title="Disconnect Code Interpreter"
|
||||
onClose={() => setShowDisconnectModal(false)}
|
||||
submit={
|
||||
<Button variant="danger" onClick={() => handleToggle(false)}>
|
||||
Disconnect
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Text as="p" text03>
|
||||
All running sessions connected to{" "}
|
||||
<Text as="span" mainContentEmphasis text03>
|
||||
Code Interpreter
|
||||
</Text>{" "}
|
||||
will stop working. Note that this will not remove any data from your
|
||||
runtime. You can reconnect to this runtime later if needed.
|
||||
</Text>
|
||||
</ConfirmationModalLayout>
|
||||
)}
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
@@ -119,7 +119,7 @@ export default function NewTenantModal({
|
||||
: `Your request to join ${tenantInfo.number_of_users} other users of ${APP_DOMAIN} has been approved.`;
|
||||
|
||||
const description = isInvite
|
||||
? `By accepting this invitation, you will join the existing ${APP_DOMAIN} team and lose access to your current team. Note: you will lose access to your current assistants, prompts, chats, and connected sources.`
|
||||
? `By accepting this invitation, you will join the existing ${APP_DOMAIN} team and lose access to your current team. Note: you will lose access to your current agents, prompts, chats, and connected sources.`
|
||||
: `To finish joining your team, please reauthenticate with ${user?.email}.`;
|
||||
|
||||
return (
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
SvgPaintBrush,
|
||||
SvgDiscordMono,
|
||||
SvgWallet,
|
||||
SvgTerminal,
|
||||
} from "@opal/icons";
|
||||
import SvgMcp from "@opal/icons/mcp";
|
||||
import UserAvatarPopover from "@/sections/sidebar/UserAvatarPopover";
|
||||
@@ -91,7 +92,7 @@ const custom_assistants_items = (
|
||||
) => {
|
||||
const items = [
|
||||
{
|
||||
name: "Assistants",
|
||||
name: "Agents",
|
||||
icon: SvgOnyxOctagon,
|
||||
link: "/admin/assistants",
|
||||
},
|
||||
@@ -165,7 +166,7 @@ const collections = (
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: "Custom Assistants",
|
||||
name: "Custom Agents",
|
||||
items: custom_assistants_items(isCurator, enableEnterprise),
|
||||
},
|
||||
...(isCurator && enableEnterprise
|
||||
@@ -207,6 +208,11 @@ const collections = (
|
||||
icon: SvgImage,
|
||||
link: "/admin/configuration/image-generation",
|
||||
},
|
||||
{
|
||||
name: "Code Interpreter",
|
||||
icon: SvgTerminal,
|
||||
link: "/admin/configuration/code-interpreter",
|
||||
},
|
||||
...(!enableCloud && vectorDbEnabled
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@ interface LogoSectionProps {
|
||||
function LogoSection({ folded, onFoldClick }: LogoSectionProps) {
|
||||
const settings = useSettingsContext();
|
||||
const applicationName = settings.enterpriseSettings?.application_name;
|
||||
const logoDisplayStyle = settings.enterpriseSettings?.logo_display_style;
|
||||
|
||||
const logo = useCallback(
|
||||
(className?: string) => <Logo folded={folded} className={className} />,
|
||||
@@ -43,7 +44,7 @@ function LogoSection({ folded, onFoldClick }: LogoSectionProps) {
|
||||
>
|
||||
{folded === undefined ? (
|
||||
<div className="p-1">{logo()}</div>
|
||||
) : folded ? (
|
||||
) : folded && logoDisplayStyle !== "name_only" ? (
|
||||
<>
|
||||
<div className="group-hover/SidebarWrapper:hidden pt-1.5">
|
||||
{logo()}
|
||||
@@ -52,6 +53,8 @@ function LogoSection({ folded, onFoldClick }: LogoSectionProps) {
|
||||
{closeButton(false)}
|
||||
</div>
|
||||
</>
|
||||
) : folded ? (
|
||||
<div className="flex w-full justify-center">{closeButton(false)}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="p-1"> {logo()}</div>
|
||||
|
||||
@@ -29,12 +29,12 @@ const ADMIN_PAGES: AdminPageSnapshot[] = [
|
||||
pageTitle: "Add Connector",
|
||||
},
|
||||
{
|
||||
name: "Custom Assistants - Assistants",
|
||||
name: "Custom Agents - Agents",
|
||||
path: "assistants",
|
||||
pageTitle: "Assistants",
|
||||
pageTitle: "Agents",
|
||||
options: {
|
||||
paragraphText:
|
||||
"Assistants are a way to build custom search/question-answering experiences for different use cases.",
|
||||
"Agents are a way to build custom search/question-answering experiences for different use cases.",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -52,7 +52,7 @@ const ADMIN_PAGES: AdminPageSnapshot[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Custom Assistants - Slack Bots",
|
||||
name: "Custom Agents - Slack Bots",
|
||||
path: "bots",
|
||||
pageTitle: "Slack Bots",
|
||||
options: {
|
||||
@@ -61,7 +61,7 @@ const ADMIN_PAGES: AdminPageSnapshot[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Custom Assistants - Standard Answers",
|
||||
name: "Custom Agents - Standard Answers",
|
||||
path: "standard-answer",
|
||||
pageTitle: "Standard Answers",
|
||||
},
|
||||
@@ -101,12 +101,12 @@ const ADMIN_PAGES: AdminPageSnapshot[] = [
|
||||
pageTitle: "Search Settings",
|
||||
},
|
||||
{
|
||||
name: "Custom Assistants - MCP Actions",
|
||||
name: "Custom Agents - MCP Actions",
|
||||
path: "actions/mcp",
|
||||
pageTitle: "MCP Actions",
|
||||
},
|
||||
{
|
||||
name: "Custom Assistants - OpenAPI Actions",
|
||||
name: "Custom Agents - OpenAPI Actions",
|
||||
path: "actions/open-api",
|
||||
pageTitle: "OpenAPI Actions",
|
||||
},
|
||||
|
||||
268
web/tests/e2e/admin/code-interpreter/code_interpreter.spec.ts
Normal file
268
web/tests/e2e/admin/code-interpreter/code_interpreter.spec.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { loginAs } from "@tests/e2e/utils/auth";
|
||||
|
||||
const CODE_INTERPRETER_URL = "/admin/configuration/code-interpreter";
|
||||
const API_STATUS_URL = "**/api/admin/code-interpreter";
|
||||
const API_HEALTH_URL = "**/api/admin/code-interpreter/health";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Intercept the status (GET /) and health (GET /health) endpoints with the
|
||||
* given values so the page renders deterministically.
|
||||
*
|
||||
* Also handles PUT requests — by default they succeed (200). Pass
|
||||
* `putStatus` to simulate failures.
|
||||
*/
|
||||
async function mockCodeInterpreterApi(
|
||||
page: Page,
|
||||
opts: { enabled: boolean; healthy: boolean; putStatus?: number }
|
||||
) {
|
||||
const putStatus = opts.putStatus ?? 200;
|
||||
|
||||
await page.route(API_HEALTH_URL, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ healthy: opts.healthy }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(API_STATUS_URL, async (route) => {
|
||||
if (route.request().method() === "PUT") {
|
||||
await route.fulfill({
|
||||
status: putStatus,
|
||||
contentType: "application/json",
|
||||
body:
|
||||
putStatus >= 400
|
||||
? JSON.stringify({ detail: "Server Error" })
|
||||
: JSON.stringify(null),
|
||||
});
|
||||
} else {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ enabled: opts.enabled }),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The disconnect icon button is an icon-only opal Button whose tooltip text
|
||||
* is not exposed as an accessible name. Locate it by finding the first
|
||||
* icon-only button (no label span) inside the card area.
|
||||
*/
|
||||
function getDisconnectIconButton(page: Page) {
|
||||
return page
|
||||
.locator("button:has(.opal-button):not(:has(.opal-button-label))")
|
||||
.first();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Code Interpreter Admin Page", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.context().clearCookies();
|
||||
await loginAs(page, "admin");
|
||||
});
|
||||
|
||||
test("page loads with header and description", async ({ page }) => {
|
||||
await mockCodeInterpreterApi(page, { enabled: true, healthy: true });
|
||||
await page.goto(CODE_INTERPRETER_URL);
|
||||
|
||||
await expect(page.locator('[aria-label="admin-page-title"]')).toHaveText(
|
||||
/^Code Interpreter/,
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
|
||||
await expect(page.getByText("Built-in Python runtime")).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows Connected status when enabled and healthy", async ({ page }) => {
|
||||
await mockCodeInterpreterApi(page, { enabled: true, healthy: true });
|
||||
await page.goto(CODE_INTERPRETER_URL);
|
||||
|
||||
await expect(page.getByText("Connected")).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test("shows Connection Lost when enabled but unhealthy", async ({ page }) => {
|
||||
await mockCodeInterpreterApi(page, { enabled: true, healthy: false });
|
||||
await page.goto(CODE_INTERPRETER_URL);
|
||||
|
||||
await expect(page.getByText("Connection Lost")).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
test("shows Reconnect button when disabled", async ({ page }) => {
|
||||
await mockCodeInterpreterApi(page, { enabled: false, healthy: false });
|
||||
await page.goto(CODE_INTERPRETER_URL);
|
||||
|
||||
await expect(page.getByRole("button", { name: "Reconnect" })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(page.getByText("(Disconnected)")).toBeVisible();
|
||||
});
|
||||
|
||||
test("disconnect flow opens modal and sends PUT request", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockCodeInterpreterApi(page, { enabled: true, healthy: true });
|
||||
await page.goto(CODE_INTERPRETER_URL);
|
||||
|
||||
await expect(page.getByText("Connected")).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Click the disconnect icon button
|
||||
await getDisconnectIconButton(page).click();
|
||||
|
||||
// Modal should appear
|
||||
await expect(page.getByText("Disconnect Code Interpreter")).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("All running sessions connected to")
|
||||
).toBeVisible();
|
||||
|
||||
// Click the danger Disconnect button in the modal
|
||||
const modal = page.getByRole("dialog");
|
||||
await modal.getByRole("button", { name: "Disconnect" }).click();
|
||||
|
||||
// Modal should close after successful disconnect
|
||||
await expect(page.getByText("Disconnect Code Interpreter")).not.toBeVisible(
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
});
|
||||
|
||||
test("disconnect modal can be closed without disconnecting", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockCodeInterpreterApi(page, { enabled: true, healthy: true });
|
||||
await page.goto(CODE_INTERPRETER_URL);
|
||||
|
||||
await expect(page.getByText("Connected")).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Open modal
|
||||
await getDisconnectIconButton(page).click();
|
||||
await expect(page.getByText("Disconnect Code Interpreter")).toBeVisible();
|
||||
|
||||
// Close modal via Cancel button
|
||||
const modal = page.getByRole("dialog");
|
||||
await modal.getByRole("button", { name: "Cancel" }).click();
|
||||
|
||||
// Modal should be gone, page still shows Connected
|
||||
await expect(
|
||||
page.getByText("Disconnect Code Interpreter")
|
||||
).not.toBeVisible();
|
||||
await expect(page.getByText("Connected")).toBeVisible();
|
||||
});
|
||||
|
||||
test("reconnect flow sends PUT with enabled=true", async ({ page }) => {
|
||||
await mockCodeInterpreterApi(page, { enabled: false, healthy: false });
|
||||
await page.goto(CODE_INTERPRETER_URL);
|
||||
|
||||
await expect(page.getByRole("button", { name: "Reconnect" })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Intercept the PUT and verify the payload
|
||||
const putPromise = page.waitForRequest(
|
||||
(req) =>
|
||||
req.url().includes("/api/admin/code-interpreter") &&
|
||||
req.method() === "PUT"
|
||||
);
|
||||
|
||||
await page.getByRole("button", { name: "Reconnect" }).click();
|
||||
|
||||
const putReq = await putPromise;
|
||||
expect(putReq.postDataJSON()).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
test("shows Checking... while reconnect is in progress", async ({ page }) => {
|
||||
// Use a single route handler that delays PUT responses
|
||||
await page.route(API_HEALTH_URL, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ healthy: false }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(API_STATUS_URL, async (route) => {
|
||||
if (route.request().method() === "PUT") {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(null),
|
||||
});
|
||||
} else {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ enabled: false }),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto(CODE_INTERPRETER_URL);
|
||||
|
||||
await expect(page.getByRole("button", { name: "Reconnect" })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: "Reconnect" }).click();
|
||||
|
||||
// Should show Checking... while the request is in flight
|
||||
await expect(page.getByText("Checking...")).toBeVisible({ timeout: 3000 });
|
||||
});
|
||||
|
||||
test("shows error toast when disconnect fails", async ({ page }) => {
|
||||
await mockCodeInterpreterApi(page, {
|
||||
enabled: true,
|
||||
healthy: true,
|
||||
putStatus: 500,
|
||||
});
|
||||
await page.goto(CODE_INTERPRETER_URL);
|
||||
|
||||
await expect(page.getByText("Connected")).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Open modal and click disconnect
|
||||
await getDisconnectIconButton(page).click();
|
||||
const modal = page.getByRole("dialog");
|
||||
await modal.getByRole("button", { name: "Disconnect" }).click();
|
||||
|
||||
// Error toast should appear
|
||||
await expect(
|
||||
page.getByText("Failed to disconnect Code Interpreter")
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test("shows error toast when reconnect fails", async ({ page }) => {
|
||||
await mockCodeInterpreterApi(page, {
|
||||
enabled: false,
|
||||
healthy: false,
|
||||
putStatus: 500,
|
||||
});
|
||||
await page.goto(CODE_INTERPRETER_URL);
|
||||
|
||||
await expect(page.getByRole("button", { name: "Reconnect" })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: "Reconnect" }).click();
|
||||
|
||||
// Error toast should appear
|
||||
await expect(
|
||||
page.getByText("Failed to reconnect Code Interpreter")
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Reconnect button should reappear (not stuck in Checking...)
|
||||
await expect(page.getByRole("button", { name: "Reconnect" })).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -46,7 +46,7 @@ test.skip("User changes password and logs in with new password", async ({
|
||||
|
||||
// Verify successful login
|
||||
await expect(page).toHaveURL("http://localhost:3000/app");
|
||||
await expect(page.getByText("Explore Assistants")).toBeVisible();
|
||||
await expect(page.getByText("Explore Agents")).toBeVisible();
|
||||
});
|
||||
|
||||
test.use({ storageState: "admin2_auth.json" });
|
||||
@@ -115,5 +115,5 @@ test.skip("Admin resets own password and logs in with new password", async ({
|
||||
|
||||
// Verify successful login
|
||||
await expect(page).toHaveURL("http://localhost:3000/app");
|
||||
await expect(page.getByText("Explore Assistants")).toBeVisible();
|
||||
await expect(page.getByText("Explore Agents")).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ import { OnyxApiClient } from "@tests/e2e/utils/onyxApiClient";
|
||||
|
||||
// Tool-related test selectors now imported from shared utils
|
||||
|
||||
test.describe("Default Assistant Tests", () => {
|
||||
test.describe("Default Agent Tests", () => {
|
||||
let imageGenConfigId: string | null = null;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
@@ -69,7 +69,7 @@ test.describe("Default Assistant Tests", () => {
|
||||
});
|
||||
|
||||
test.describe("Greeting Message Display", () => {
|
||||
test("should display greeting message when opening new chat with default assistant", async ({
|
||||
test("should display greeting message when opening new chat with default agent", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Look for greeting message - should be one from the predefined list
|
||||
@@ -95,23 +95,21 @@ test.describe("Default Assistant Tests", () => {
|
||||
expect(GREETING_MESSAGES).toContain(greetingAfterReload?.trim());
|
||||
});
|
||||
|
||||
test("greeting should only appear for default assistant", async ({
|
||||
page,
|
||||
}) => {
|
||||
// First verify greeting appears for default assistant
|
||||
test("greeting should only appear for default agent", async ({ page }) => {
|
||||
// First verify greeting appears for default agent
|
||||
const greetingElement = await page.waitForSelector(
|
||||
'[data-testid="onyx-logo"]',
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
expect(greetingElement).toBeTruthy();
|
||||
|
||||
// Create a custom assistant to test non-default behavior
|
||||
// Create a custom agent to test non-default behavior
|
||||
await page.getByTestId("AppSidebar/more-agents").click();
|
||||
await page.getByLabel("AgentsPage/new-agent-button").click();
|
||||
await page
|
||||
.locator('input[name="name"]')
|
||||
.waitFor({ state: "visible", timeout: 10000 });
|
||||
await page.locator('input[name="name"]').fill("Custom Test Assistant");
|
||||
await page.locator('input[name="name"]').fill("Custom Test Agent");
|
||||
await page
|
||||
.locator('textarea[name="description"]')
|
||||
.fill("Test Description");
|
||||
@@ -120,17 +118,17 @@ test.describe("Default Assistant Tests", () => {
|
||||
.fill("Test Instructions");
|
||||
await page.getByRole("button", { name: "Create" }).click();
|
||||
|
||||
// Wait for assistant to be created and selected
|
||||
await verifyAssistantIsChosen(page, "Custom Test Assistant");
|
||||
// Wait for agent to be created and selected
|
||||
await verifyAssistantIsChosen(page, "Custom Test Agent");
|
||||
|
||||
// Greeting should NOT appear for custom assistant
|
||||
// Greeting should NOT appear for custom agent
|
||||
const customGreeting = await page.$('[data-testid="onyx-logo"]');
|
||||
expect(customGreeting).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Default Assistant Branding", () => {
|
||||
test("should display Onyx logo for default assistant", async ({ page }) => {
|
||||
test.describe("Default Agent Branding", () => {
|
||||
test("should display Onyx logo for default agent", async ({ page }) => {
|
||||
// Look for Onyx logo
|
||||
const logoElement = await page.waitForSelector(
|
||||
'[data-testid="onyx-logo"]',
|
||||
@@ -138,23 +136,23 @@ test.describe("Default Assistant Tests", () => {
|
||||
);
|
||||
expect(logoElement).toBeTruthy();
|
||||
|
||||
// Should NOT show assistant name for default assistant
|
||||
// Should NOT show agent name for default agent
|
||||
const assistantNameElement = await page.$(
|
||||
'[data-testid="assistant-name-display"]'
|
||||
);
|
||||
expect(assistantNameElement).toBeNull();
|
||||
});
|
||||
|
||||
test("custom assistants should show name and icon instead of logo", async ({
|
||||
test("custom agents should show name and icon instead of logo", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Create a custom assistant
|
||||
// Create a custom agent
|
||||
await page.getByTestId("AppSidebar/more-agents").click();
|
||||
await page.getByLabel("AgentsPage/new-agent-button").click();
|
||||
await page
|
||||
.locator('input[name="name"]')
|
||||
.waitFor({ state: "visible", timeout: 10000 });
|
||||
await page.locator('input[name="name"]').fill("Custom Assistant");
|
||||
await page.locator('input[name="name"]').fill("Custom Agent");
|
||||
await page
|
||||
.locator('textarea[name="description"]')
|
||||
.fill("Test Description");
|
||||
@@ -163,16 +161,16 @@ test.describe("Default Assistant Tests", () => {
|
||||
.fill("Test Instructions");
|
||||
await page.getByRole("button", { name: "Create" }).click();
|
||||
|
||||
// Wait for assistant to be created and selected
|
||||
await verifyAssistantIsChosen(page, "Custom Assistant");
|
||||
// Wait for agent to be created and selected
|
||||
await verifyAssistantIsChosen(page, "Custom Agent");
|
||||
|
||||
// Should show assistant name and icon, not Onyx logo
|
||||
// Should show agent name and icon, not Onyx logo
|
||||
const assistantNameElement = await page.waitForSelector(
|
||||
'[data-testid="assistant-name-display"]',
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
const nameText = await assistantNameElement.textContent();
|
||||
expect(nameText).toContain("Custom Assistant");
|
||||
expect(nameText).toContain("Custom Agent");
|
||||
|
||||
// Onyx logo should NOT be shown
|
||||
const logoElement = await page.$('[data-testid="onyx-logo"]');
|
||||
@@ -181,10 +179,8 @@ test.describe("Default Assistant Tests", () => {
|
||||
});
|
||||
|
||||
test.describe("Starter Messages", () => {
|
||||
test("default assistant should NOT have starter messages", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Check that starter messages container does not exist for default assistant
|
||||
test("default agent should NOT have starter messages", async ({ page }) => {
|
||||
// Check that starter messages container does not exist for default agent
|
||||
const starterMessagesContainer = await page.$(
|
||||
'[data-testid="starter-messages"]'
|
||||
);
|
||||
@@ -195,18 +191,14 @@ test.describe("Default Assistant Tests", () => {
|
||||
expect(starterButtons.length).toBe(0);
|
||||
});
|
||||
|
||||
test("custom assistants should display starter messages", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Create a custom assistant with starter messages
|
||||
test("custom agents should display starter messages", async ({ page }) => {
|
||||
// Create a custom agent with starter messages
|
||||
await page.getByTestId("AppSidebar/more-agents").click();
|
||||
await page.getByLabel("AgentsPage/new-agent-button").click();
|
||||
await page
|
||||
.locator('input[name="name"]')
|
||||
.waitFor({ state: "visible", timeout: 10000 });
|
||||
await page
|
||||
.locator('input[name="name"]')
|
||||
.fill("Test Assistant with Starters");
|
||||
await page.locator('input[name="name"]').fill("Test Agent with Starters");
|
||||
await page
|
||||
.locator('textarea[name="description"]')
|
||||
.fill("Test Description");
|
||||
@@ -219,9 +211,9 @@ test.describe("Default Assistant Tests", () => {
|
||||
await page.getByRole("button", { name: "Create" }).click();
|
||||
|
||||
// Wait for assistant to be created and selected
|
||||
await verifyAssistantIsChosen(page, "Test Assistant with Starters");
|
||||
await verifyAssistantIsChosen(page, "Test Agent with Starters");
|
||||
|
||||
// Starter messages container might exist but be empty for custom assistants
|
||||
// Starter messages container might exist but be empty for custom agents
|
||||
const starterMessagesContainer = await page.$(
|
||||
'[data-testid="starter-messages"]'
|
||||
);
|
||||
@@ -230,24 +222,22 @@ test.describe("Default Assistant Tests", () => {
|
||||
const starterButtons = await page.$$(
|
||||
'[data-testid^="starter-message-"]'
|
||||
);
|
||||
// Custom assistant without configured starter messages should have none
|
||||
// Custom agent without configured starter messages should have none
|
||||
expect(starterButtons.length).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Assistant Selection", () => {
|
||||
test("default assistant should be selected for new chats", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Verify the input placeholder indicates default assistant (Onyx)
|
||||
test.describe("Agent Selection", () => {
|
||||
test("default agent should be selected for new chats", async ({ page }) => {
|
||||
// Verify the input placeholder indicates default agent (Onyx)
|
||||
await verifyDefaultAssistantIsChosen(page);
|
||||
});
|
||||
|
||||
test("default assistant should NOT appear in assistant selector", async ({
|
||||
test("default agent should NOT appear in agent selector", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Open assistant selector
|
||||
// Open agent selector
|
||||
await page.getByTestId("AppSidebar/more-agents").click();
|
||||
|
||||
// Wait for modal or assistant list to appear
|
||||
@@ -256,13 +246,13 @@ test.describe("Default Assistant Tests", () => {
|
||||
.getByLabel("AgentsPage/new-agent-button")
|
||||
.waitFor({ state: "visible", timeout: 5000 });
|
||||
|
||||
// Look for default assistant by name - it should NOT be there
|
||||
// Look for default agent by name - it should NOT be there
|
||||
const assistantElements = await page.$$('[data-testid^="assistant-"]');
|
||||
const assistantTexts = await Promise.all(
|
||||
assistantElements.map((el) => el.textContent())
|
||||
);
|
||||
|
||||
// Check that "Assistant" (the default assistant name) is not in the list
|
||||
// Check that the default agent is not in the list
|
||||
const hasDefaultAssistant = assistantTexts.some(
|
||||
(text) =>
|
||||
text?.includes("Assistant") &&
|
||||
@@ -275,16 +265,16 @@ test.describe("Default Assistant Tests", () => {
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
|
||||
test("should be able to switch from default to custom assistant", async ({
|
||||
test("should be able to switch from default to custom agent", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Create a custom assistant
|
||||
// Create a custom agent
|
||||
await page.getByTestId("AppSidebar/more-agents").click();
|
||||
await page.getByLabel("AgentsPage/new-agent-button").click();
|
||||
await page
|
||||
.locator('input[name="name"]')
|
||||
.waitFor({ state: "visible", timeout: 10000 });
|
||||
await page.locator('input[name="name"]').fill("Switch Test Assistant");
|
||||
await page.locator('input[name="name"]').fill("Switch Test Agent");
|
||||
await page
|
||||
.locator('textarea[name="description"]')
|
||||
.fill("Test Description");
|
||||
@@ -293,13 +283,13 @@ test.describe("Default Assistant Tests", () => {
|
||||
.fill("Test Instructions");
|
||||
await page.getByRole("button", { name: "Create" }).click();
|
||||
|
||||
// Verify switched to custom assistant
|
||||
await verifyAssistantIsChosen(page, "Switch Test Assistant");
|
||||
// Verify switched to custom agent
|
||||
await verifyAssistantIsChosen(page, "Switch Test Agent");
|
||||
|
||||
// Start new chat to go back to default
|
||||
await startNewChat(page);
|
||||
|
||||
// Should be back to default assistant
|
||||
// Should be back to default agent
|
||||
await verifyDefaultAssistantIsChosen(page);
|
||||
});
|
||||
});
|
||||
@@ -379,7 +369,7 @@ test.describe("Default Assistant Tests", () => {
|
||||
);
|
||||
}
|
||||
|
||||
// Enable the tools in default assistant config via API
|
||||
// Enable the tools in default agent config via API
|
||||
// Get current tools to find their IDs
|
||||
const toolsListResp = await page.request.get(
|
||||
"http://localhost:3000/api/tool"
|
||||
@@ -542,7 +532,7 @@ test.describe("Default Assistant Tests", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("End-to-End Default Assistant Flow", () => {
|
||||
test.describe("End-to-End Default Agent Flow", () => {
|
||||
let imageGenConfigId: string | null = null;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
@@ -584,7 +574,7 @@ test.describe("End-to-End Default Assistant Flow", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("complete user journey with default assistant", async ({ page }) => {
|
||||
test("complete user journey with default agent", async ({ page }) => {
|
||||
// Clear cookies and log in as a random user
|
||||
await page.context().clearCookies();
|
||||
await loginAsRandomUser(page);
|
||||
@@ -611,7 +601,7 @@ test.describe("End-to-End Default Assistant Flow", () => {
|
||||
// Start a new chat
|
||||
await startNewChat(page);
|
||||
|
||||
// Verify we're back to default assistant with greeting
|
||||
// Verify we're back to default agent with greeting
|
||||
await expect(page.locator('[data-testid="onyx-logo"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user