mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-02-23 18:55:45 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84d3aea847 | ||
|
|
00a404d3cd | ||
|
|
787cf90d96 |
@@ -0,0 +1,31 @@
|
||||
"""code interpreter server model
|
||||
|
||||
Revision ID: 7cb492013621
|
||||
Revises: 0bb4558f35df
|
||||
Create Date: 2026-02-22 18:54:54.007265
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "7cb492013621"
|
||||
down_revision = "0bb4558f35df"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"code_interpreter_server",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column(
|
||||
"server_enabled", sa.Boolean, nullable=False, server_default=sa.true()
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("code_interpreter_server")
|
||||
@@ -4978,3 +4978,12 @@ class ScimGroupMapping(Base):
|
||||
user_group: Mapped[UserGroup] = relationship(
|
||||
"UserGroup", foreign_keys=[user_group_id]
|
||||
)
|
||||
|
||||
|
||||
class CodeInterpreterServer(Base):
|
||||
"""Details about the code interpreter server"""
|
||||
|
||||
__tablename__ = "code_interpreter_server"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
server_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
|
||||
@@ -3,8 +3,8 @@ set -e
|
||||
|
||||
cleanup() {
|
||||
echo "Error occurred. Cleaning up..."
|
||||
docker stop onyx_postgres onyx_vespa onyx_redis onyx_minio onyx_code_interpreter 2>/dev/null || true
|
||||
docker rm onyx_postgres onyx_vespa onyx_redis onyx_minio onyx_code_interpreter 2>/dev/null || true
|
||||
docker stop onyx_postgres onyx_vespa onyx_redis onyx_minio 2>/dev/null || true
|
||||
docker rm onyx_postgres onyx_vespa onyx_redis onyx_minio 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Trap errors and output a message, then cleanup
|
||||
@@ -20,8 +20,8 @@ MINIO_VOLUME=${4:-""} # Default is empty if not provided
|
||||
|
||||
# Stop and remove the existing containers
|
||||
echo "Stopping and removing existing containers..."
|
||||
docker stop onyx_postgres onyx_vespa onyx_redis onyx_minio onyx_code_interpreter 2>/dev/null || true
|
||||
docker rm onyx_postgres onyx_vespa onyx_redis onyx_minio onyx_code_interpreter 2>/dev/null || true
|
||||
docker stop onyx_postgres onyx_vespa onyx_redis onyx_minio 2>/dev/null || true
|
||||
docker rm onyx_postgres onyx_vespa onyx_redis onyx_minio 2>/dev/null || true
|
||||
|
||||
# Start the PostgreSQL container with optional volume
|
||||
echo "Starting PostgreSQL container..."
|
||||
@@ -55,10 +55,6 @@ else
|
||||
docker run --detach --name onyx_minio --publish 9004:9000 --publish 9005:9001 -e MINIO_ROOT_USER=minioadmin -e MINIO_ROOT_PASSWORD=minioadmin minio/minio server /data --console-address ":9001"
|
||||
fi
|
||||
|
||||
# Start the Code Interpreter container
|
||||
echo "Starting Code Interpreter container..."
|
||||
docker run --detach --name onyx_code_interpreter --publish 8000:8000 --user root -v /var/run/docker.sock:/var/run/docker.sock onyxdotapp/code-interpreter:latest bash ./entrypoint.sh code-interpreter-api
|
||||
|
||||
# Ensure alembic runs in the correct directory (backend/)
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
|
||||
PARENT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
@@ -243,12 +243,12 @@ USAGE_LIMIT_CHUNKS_INDEXED_PAID = int(
|
||||
)
|
||||
|
||||
# Per-week API calls using API keys or Personal Access Tokens
|
||||
USAGE_LIMIT_API_CALLS_TRIAL = int(os.environ.get("USAGE_LIMIT_API_CALLS_TRIAL", "400"))
|
||||
USAGE_LIMIT_API_CALLS_TRIAL = int(os.environ.get("USAGE_LIMIT_API_CALLS_TRIAL", "0"))
|
||||
USAGE_LIMIT_API_CALLS_PAID = int(os.environ.get("USAGE_LIMIT_API_CALLS_PAID", "40000"))
|
||||
|
||||
# Per-week non-streaming API calls (more expensive, so lower limits)
|
||||
USAGE_LIMIT_NON_STREAMING_CALLS_TRIAL = int(
|
||||
os.environ.get("USAGE_LIMIT_NON_STREAMING_CALLS_TRIAL", "80")
|
||||
os.environ.get("USAGE_LIMIT_NON_STREAMING_CALLS_TRIAL", "0")
|
||||
)
|
||||
USAGE_LIMIT_NON_STREAMING_CALLS_PAID = int(
|
||||
os.environ.get("USAGE_LIMIT_NON_STREAMING_CALLS_PAID", "160")
|
||||
|
||||
@@ -487,7 +487,16 @@ services:
|
||||
|
||||
code-interpreter:
|
||||
image: onyxdotapp/code-interpreter:${CODE_INTERPRETER_IMAGE_TAG:-latest}
|
||||
command: ["bash", "./entrypoint.sh", "code-interpreter-api"]
|
||||
entrypoint: ["/bin/bash", "-c"]
|
||||
command: >
|
||||
"
|
||||
if [ \"$${CODE_INTERPRETER_BETA_ENABLED}\" = \"True\" ] || [ \"$${CODE_INTERPRETER_BETA_ENABLED}\" = \"true\" ]; then
|
||||
exec bash ./entrypoint.sh code-interpreter-api;
|
||||
else
|
||||
echo 'Skipping code interpreter';
|
||||
exec tail -f /dev/null;
|
||||
fi
|
||||
"
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- path: .env
|
||||
|
||||
@@ -69,4 +69,6 @@ services:
|
||||
inference_model_server:
|
||||
profiles: ["inference"]
|
||||
|
||||
code-interpreter: {}
|
||||
# Code interpreter is not needed in minimal mode.
|
||||
code-interpreter:
|
||||
profiles: ["code-interpreter"]
|
||||
|
||||
@@ -315,7 +315,16 @@ services:
|
||||
|
||||
code-interpreter:
|
||||
image: onyxdotapp/code-interpreter:${CODE_INTERPRETER_IMAGE_TAG:-latest}
|
||||
command: ["bash", "./entrypoint.sh", "code-interpreter-api"]
|
||||
entrypoint: ["/bin/bash", "-c"]
|
||||
command: >
|
||||
"
|
||||
if [ \"$${CODE_INTERPRETER_BETA_ENABLED}\" = \"True\" ] || [ \"$${CODE_INTERPRETER_BETA_ENABLED}\" = \"true\" ]; then
|
||||
exec bash ./entrypoint.sh code-interpreter-api;
|
||||
else
|
||||
echo 'Skipping code interpreter';
|
||||
exec tail -f /dev/null;
|
||||
fi
|
||||
"
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- path: .env
|
||||
|
||||
@@ -352,7 +352,16 @@ services:
|
||||
|
||||
code-interpreter:
|
||||
image: onyxdotapp/code-interpreter:${CODE_INTERPRETER_IMAGE_TAG:-latest}
|
||||
command: ["bash", "./entrypoint.sh", "code-interpreter-api"]
|
||||
entrypoint: ["/bin/bash", "-c"]
|
||||
command: >
|
||||
"
|
||||
if [ \"$${CODE_INTERPRETER_BETA_ENABLED}\" = \"True\" ] || [ \"$${CODE_INTERPRETER_BETA_ENABLED}\" = \"true\" ]; then
|
||||
exec bash ./entrypoint.sh code-interpreter-api;
|
||||
else
|
||||
echo 'Skipping code interpreter';
|
||||
exec tail -f /dev/null;
|
||||
fi
|
||||
"
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- path: .env
|
||||
|
||||
@@ -527,7 +527,16 @@ services:
|
||||
|
||||
code-interpreter:
|
||||
image: onyxdotapp/code-interpreter:${CODE_INTERPRETER_IMAGE_TAG:-latest}
|
||||
command: ["bash", "./entrypoint.sh", "code-interpreter-api"]
|
||||
entrypoint: ["/bin/bash", "-c"]
|
||||
command: >
|
||||
"
|
||||
if [ \"$${CODE_INTERPRETER_BETA_ENABLED}\" = \"True\" ] || [ \"$${CODE_INTERPRETER_BETA_ENABLED}\" = \"true\" ]; then
|
||||
exec bash ./entrypoint.sh code-interpreter-api;
|
||||
else
|
||||
echo 'Skipping code interpreter';
|
||||
exec tail -f /dev/null;
|
||||
fi
|
||||
"
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- path: .env
|
||||
|
||||
@@ -955,9 +955,9 @@ minio:
|
||||
tolerations: []
|
||||
affinity: {}
|
||||
|
||||
# Code Interpreter - Python code execution service
|
||||
# Code Interpreter - Python code execution service (beta feature)
|
||||
codeInterpreter:
|
||||
enabled: true
|
||||
enabled: false # Disabled by default (beta feature)
|
||||
|
||||
replicaCount: 1
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import Button from "@/refresh-components/buttons/Button";
|
||||
import CopyIconButton from "@/refresh-components/buttons/CopyIconButton";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { SvgEdit, SvgKey, SvgRefreshCw } from "@opal/icons";
|
||||
import { useCloudSubscription } from "@/hooks/useCloudSubscription";
|
||||
|
||||
function Main() {
|
||||
const {
|
||||
@@ -39,6 +40,8 @@ function Main() {
|
||||
error,
|
||||
} = useSWR<APIKey[]>("/api/admin/api-key", errorHandlingFetcher);
|
||||
|
||||
const canCreateKeys = useCloudSubscription();
|
||||
|
||||
const [fullApiKey, setFullApiKey] = useState<string | null>(null);
|
||||
const [keyIsGenerating, setKeyIsGenerating] = useState(false);
|
||||
const [showCreateUpdateForm, setShowCreateUpdateForm] = useState(false);
|
||||
@@ -70,12 +73,23 @@ function Main() {
|
||||
const introSection = (
|
||||
<div className="flex flex-col items-start gap-4">
|
||||
<Text as="p">
|
||||
API Keys allow you to access Onyx APIs programmatically. Click the
|
||||
button below to generate a new API Key.
|
||||
API Keys allow you to access Onyx APIs programmatically.
|
||||
{canCreateKeys
|
||||
? " Click the button below to generate a new API Key."
|
||||
: ""}
|
||||
</Text>
|
||||
<CreateButton onClick={() => setShowCreateUpdateForm(true)}>
|
||||
Create API Key
|
||||
</CreateButton>
|
||||
{canCreateKeys ? (
|
||||
<CreateButton onClick={() => setShowCreateUpdateForm(true)}>
|
||||
Create API Key
|
||||
</CreateButton>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 rounded-lg bg-background-tint-02 p-4">
|
||||
<Text as="p" text04>
|
||||
This feature requires an active paid subscription.
|
||||
</Text>
|
||||
<Button href="/admin/billing">Upgrade Plan</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -109,7 +123,7 @@ function Main() {
|
||||
title="New API Key"
|
||||
icon={SvgKey}
|
||||
onClose={() => setFullApiKey(null)}
|
||||
description="Make sure you copy your new API key. You won’t be able to see this key again."
|
||||
description="Make sure you copy your new API key. You won't be able to see this key again."
|
||||
/>
|
||||
<Modal.Body>
|
||||
<Text as="p" className="break-all flex-1">
|
||||
@@ -124,88 +138,94 @@ function Main() {
|
||||
|
||||
{introSection}
|
||||
|
||||
<Separator />
|
||||
{canCreateKeys && (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
<Title className="mt-6">Existing API Keys</Title>
|
||||
<Table className="overflow-visible">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>API Key</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Regenerate</TableHead>
|
||||
<TableHead>Delete</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredApiKeys.map((apiKey) => (
|
||||
<TableRow key={apiKey.api_key_id}>
|
||||
<TableCell>
|
||||
<Button
|
||||
internal
|
||||
onClick={() => handleEdit(apiKey)}
|
||||
leftIcon={SvgEdit}
|
||||
>
|
||||
{apiKey.api_key_name || <i>null</i>}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-64">
|
||||
{apiKey.api_key_display}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-64">
|
||||
{apiKey.api_key_role.toUpperCase()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
internal
|
||||
leftIcon={SvgRefreshCw}
|
||||
onClick={async () => {
|
||||
setKeyIsGenerating(true);
|
||||
const response = await regenerateApiKey(apiKey);
|
||||
setKeyIsGenerating(false);
|
||||
if (!response.ok) {
|
||||
const errorMsg = await response.text();
|
||||
toast.error(`Failed to regenerate API Key: ${errorMsg}`);
|
||||
return;
|
||||
}
|
||||
const newKey = (await response.json()) as APIKey;
|
||||
setFullApiKey(newKey.api_key);
|
||||
mutate("/api/admin/api-key");
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DeleteButton
|
||||
onClick={async () => {
|
||||
const response = await deleteApiKey(apiKey.api_key_id);
|
||||
if (!response.ok) {
|
||||
const errorMsg = await response.text();
|
||||
toast.error(`Failed to delete API Key: ${errorMsg}`);
|
||||
return;
|
||||
}
|
||||
mutate("/api/admin/api-key");
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<Title className="mt-6">Existing API Keys</Title>
|
||||
<Table className="overflow-visible">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>API Key</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Regenerate</TableHead>
|
||||
<TableHead>Delete</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredApiKeys.map((apiKey) => (
|
||||
<TableRow key={apiKey.api_key_id}>
|
||||
<TableCell>
|
||||
<Button
|
||||
internal
|
||||
onClick={() => handleEdit(apiKey)}
|
||||
leftIcon={SvgEdit}
|
||||
>
|
||||
{apiKey.api_key_name || <i>null</i>}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-64">
|
||||
{apiKey.api_key_display}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-64">
|
||||
{apiKey.api_key_role.toUpperCase()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
internal
|
||||
leftIcon={SvgRefreshCw}
|
||||
onClick={async () => {
|
||||
setKeyIsGenerating(true);
|
||||
const response = await regenerateApiKey(apiKey);
|
||||
setKeyIsGenerating(false);
|
||||
if (!response.ok) {
|
||||
const errorMsg = await response.text();
|
||||
toast.error(
|
||||
`Failed to regenerate API Key: ${errorMsg}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const newKey = (await response.json()) as APIKey;
|
||||
setFullApiKey(newKey.api_key);
|
||||
mutate("/api/admin/api-key");
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DeleteButton
|
||||
onClick={async () => {
|
||||
const response = await deleteApiKey(apiKey.api_key_id);
|
||||
if (!response.ok) {
|
||||
const errorMsg = await response.text();
|
||||
toast.error(`Failed to delete API Key: ${errorMsg}`);
|
||||
return;
|
||||
}
|
||||
mutate("/api/admin/api-key");
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{showCreateUpdateForm && (
|
||||
<OnyxApiKeyForm
|
||||
onCreateApiKey={(apiKey) => {
|
||||
setFullApiKey(apiKey.api_key);
|
||||
}}
|
||||
onClose={() => {
|
||||
setShowCreateUpdateForm(false);
|
||||
setSelectedApiKey(undefined);
|
||||
mutate("/api/admin/api-key");
|
||||
}}
|
||||
apiKey={selectedApiKey}
|
||||
/>
|
||||
{showCreateUpdateForm && (
|
||||
<OnyxApiKeyForm
|
||||
onCreateApiKey={(apiKey) => {
|
||||
setFullApiKey(apiKey.api_key);
|
||||
}}
|
||||
onClose={() => {
|
||||
setShowCreateUpdateForm(false);
|
||||
setSelectedApiKey(undefined);
|
||||
mutate("/api/admin/api-key");
|
||||
}}
|
||||
apiKey={selectedApiKey}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import Image from "next/image";
|
||||
import { useMemo, useState, useReducer } from "react";
|
||||
import { AdminPageTitle } from "@/components/admin/Title";
|
||||
import { InfoIcon } from "@/components/icons/icons";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import Separator from "@/refresh-components/Separator";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { Content } from "@opal/layouts";
|
||||
import useSWR from "swr";
|
||||
import { errorHandlingFetcher, FetchError } from "@/lib/fetcher";
|
||||
import { ThreeDotsLoader } from "@/components/Loading";
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
SvgOnyxLogo,
|
||||
SvgX,
|
||||
} from "@opal/icons";
|
||||
|
||||
import { WebProviderSetupModal } from "@/app/admin/configuration/web-search/WebProviderSetupModal";
|
||||
import {
|
||||
SEARCH_PROVIDERS_URL,
|
||||
@@ -402,36 +401,40 @@ export default function Page() {
|
||||
: undefined);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageTitle
|
||||
title="Web Search"
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgGlobe}
|
||||
includeDivider={false}
|
||||
title="Web Search"
|
||||
description="Search settings for external search across the internet."
|
||||
separator
|
||||
/>
|
||||
<Callout type="danger" title="Failed to load web search settings">
|
||||
{message}
|
||||
{detail && (
|
||||
<Text as="p" className="mt-2 text-text-03" mainContentBody text03>
|
||||
{detail}
|
||||
</Text>
|
||||
)}
|
||||
</Callout>
|
||||
</>
|
||||
<SettingsLayouts.Body>
|
||||
<Callout type="danger" title="Failed to load web search settings">
|
||||
{message}
|
||||
{detail && (
|
||||
<Text as="p" className="mt-2 text-text-03" mainContentBody text03>
|
||||
{detail}
|
||||
</Text>
|
||||
)}
|
||||
</Callout>
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<AdminPageTitle
|
||||
title="Web Search"
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgGlobe}
|
||||
includeDivider={false}
|
||||
title="Web Search"
|
||||
description="Search settings for external search across the internet."
|
||||
separator
|
||||
/>
|
||||
<div className="mt-8">
|
||||
<SettingsLayouts.Body>
|
||||
<ThreeDotsLoader />
|
||||
</div>
|
||||
</>
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -827,32 +830,22 @@ export default function Page() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<>
|
||||
<AdminPageTitle icon={SvgGlobe} title="Web Search" />
|
||||
<div className="pt-4 pb-4">
|
||||
<Text as="p" className="text-text-dark">
|
||||
Search settings for external search across the internet.
|
||||
</Text>
|
||||
</div>
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgGlobe}
|
||||
title="Web Search"
|
||||
description="Search settings for external search across the internet."
|
||||
separator
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex w-full flex-col gap-8 pb-6">
|
||||
<div className="flex w-full max-w-[960px] flex-col gap-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Text as="p" mainContentEmphasis text05>
|
||||
Search Engine
|
||||
</Text>
|
||||
<Text
|
||||
as="p"
|
||||
className="flex items-start gap-[2px] self-stretch text-text-03"
|
||||
secondaryBody
|
||||
text03
|
||||
>
|
||||
External search engine API used for web search result URLs,
|
||||
snippets, and metadata.
|
||||
</Text>
|
||||
</div>
|
||||
<SettingsLayouts.Body>
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<Content
|
||||
title="Search Engine"
|
||||
description="External search engine API used for web search result URLs, snippets, and metadata."
|
||||
sizePreset="main-content"
|
||||
variant="section"
|
||||
/>
|
||||
|
||||
{activationError && (
|
||||
<Callout type="danger" title="Unable to update default provider">
|
||||
@@ -974,14 +967,12 @@ export default function Page() {
|
||||
size: 16,
|
||||
isHighlighted,
|
||||
})}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Text as="p" mainUiAction text05>
|
||||
{label}
|
||||
</Text>
|
||||
<Text as="p" secondaryBody text03>
|
||||
{subtitle}
|
||||
</Text>
|
||||
</div>
|
||||
<Content
|
||||
title={label}
|
||||
description={subtitle}
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{isConfigured && (
|
||||
@@ -1045,20 +1036,13 @@ export default function Page() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full max-w-[960px] flex-col gap-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Text as="p" mainContentEmphasis text05>
|
||||
Web Crawler
|
||||
</Text>
|
||||
<Text
|
||||
as="p"
|
||||
className="flex items-start gap-[2px] self-stretch text-text-03"
|
||||
secondaryBody
|
||||
text03
|
||||
>
|
||||
Used to read the full contents of search result pages.
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<Content
|
||||
title="Web Crawler"
|
||||
description="Used to read the full contents of search result pages."
|
||||
sizePreset="main-content"
|
||||
variant="section"
|
||||
/>
|
||||
|
||||
{contentActivationError && (
|
||||
<Callout type="danger" title="Unable to update crawler">
|
||||
@@ -1173,14 +1157,12 @@ export default function Page() {
|
||||
size: 16,
|
||||
isHighlighted: isCurrentCrawler,
|
||||
})}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Text as="p" mainUiAction text05>
|
||||
{label}
|
||||
</Text>
|
||||
<Text as="p" secondaryBody text03>
|
||||
{subtitle}
|
||||
</Text>
|
||||
</div>
|
||||
<Content
|
||||
title={label}
|
||||
description={subtitle}
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{provider.provider_type !== "onyx_web_crawler" &&
|
||||
@@ -1244,8 +1226,8 @@ export default function Page() {
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
|
||||
<WebProviderSetupModal
|
||||
isOpen={selectedProviderType !== null}
|
||||
|
||||
25
web/src/hooks/useCloudSubscription.ts
Normal file
25
web/src/hooks/useCloudSubscription.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
|
||||
import { hasPaidSubscription } from "@/lib/billing/interfaces";
|
||||
import { useBillingInformation } from "@/hooks/useBillingInformation";
|
||||
|
||||
/**
|
||||
* Returns whether the current tenant has an active paid subscription on cloud.
|
||||
*
|
||||
* Self-hosted deployments always return true (no billing gate).
|
||||
* Cloud deployments check billing status via the billing API.
|
||||
* Returns true while loading to avoid flashing the upgrade prompt.
|
||||
*/
|
||||
export function useCloudSubscription(): boolean {
|
||||
const { data: billingData, isLoading } = useBillingInformation();
|
||||
|
||||
if (!NEXT_PUBLIC_CLOUD_ENABLED) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Treat loading as subscribed to avoid UI flash
|
||||
if (isLoading || billingData == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return hasPaidSubscription(billingData);
|
||||
}
|
||||
@@ -36,10 +36,11 @@
|
||||
import BackButton from "@/refresh-components/buttons/BackButton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Separator from "@/refresh-components/Separator";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { WithoutStyles } from "@/types";
|
||||
import { IconProps } from "@opal/types";
|
||||
import { IconFunctionComponent } from "@opal/types";
|
||||
import { HtmlHTMLAttributes, useEffect, useRef, useState } from "react";
|
||||
import { Content } from "@opal/layouts";
|
||||
import Spacer from "@/refresh-components/Spacer";
|
||||
|
||||
const widthClasses = {
|
||||
md: "w-[min(50rem,100%)]",
|
||||
@@ -163,7 +164,7 @@ function SettingsRoot({ width = "md", ...props }: SettingsRootProps) {
|
||||
* ```
|
||||
*/
|
||||
export interface SettingsHeaderProps {
|
||||
icon: React.FunctionComponent<IconProps>;
|
||||
icon: IconFunctionComponent;
|
||||
title: string;
|
||||
description?: string;
|
||||
children?: React.ReactNode;
|
||||
@@ -184,7 +185,10 @@ function SettingsHeader({
|
||||
}: SettingsHeaderProps) {
|
||||
const [showShadow, setShowShadow] = useState(false);
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const isSticky = !!rightChildren; //headers with actions are always sticky, others are not
|
||||
|
||||
// # NOTE (@Subash-Mohan)
|
||||
// Headers with actions are always sticky, others are not.
|
||||
const isSticky = !!rightChildren;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSticky) return;
|
||||
@@ -221,34 +225,35 @@ function SettingsHeader({
|
||||
<BackButton behaviorOverride={onBack} />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn("flex flex-col gap-6 px-4", backButton ? "pt-2" : "pt-4")}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-row justify-between items-center gap-4">
|
||||
<Icon className="stroke-text-04 h-[1.75rem] w-[1.75rem]" />
|
||||
{rightChildren}
|
||||
</div>
|
||||
<div className={cn("flex flex-col", separator ? "pb-6" : "pb-2")}>
|
||||
<div aria-label="admin-page-title">
|
||||
<Text as="p" headingH2>
|
||||
{title}
|
||||
</Text>
|
||||
</div>
|
||||
{description && (
|
||||
<Text secondaryBody text03>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Spacer vertical rem={1} />
|
||||
|
||||
<div className="flex flex-col gap-6 px-4">
|
||||
<div className="flex w-full justify-between">
|
||||
<div aria-label="admin-page-title">
|
||||
<Content
|
||||
icon={Icon}
|
||||
title={title}
|
||||
description={description}
|
||||
sizePreset="headline"
|
||||
variant="heading"
|
||||
/>
|
||||
</div>
|
||||
{rightChildren}
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
{separator && (
|
||||
|
||||
{separator ? (
|
||||
<>
|
||||
<Spacer vertical rem={1.5} />
|
||||
<Separator noPadding className="px-4" />
|
||||
</>
|
||||
) : (
|
||||
<Spacer vertical rem={0.5} />
|
||||
)}
|
||||
|
||||
{isSticky && (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -133,6 +133,19 @@ export function hasActiveSubscription(
|
||||
return data.status !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the response indicates an active *paid* subscription.
|
||||
* Returns true only for status === "active" (excludes trialing, past_due, etc.).
|
||||
*/
|
||||
export function hasPaidSubscription(
|
||||
data: BillingInformation | SubscriptionStatus
|
||||
): data is BillingInformation {
|
||||
if ("subscribed" in data) {
|
||||
return false;
|
||||
}
|
||||
return data.status === BillingStatus.ACTIVE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a license is valid and active.
|
||||
*/
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import Button from "@/refresh-components/buttons/Button";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { Route } from "next";
|
||||
import { Button } from "@opal/components";
|
||||
import { SvgArrowLeft } from "@opal/icons";
|
||||
|
||||
export interface BackButtonProps {
|
||||
@@ -18,8 +18,8 @@ export default function BackButton({
|
||||
|
||||
return (
|
||||
<Button
|
||||
leftIcon={SvgArrowLeft}
|
||||
tertiary
|
||||
icon={SvgArrowLeft}
|
||||
prominence="tertiary"
|
||||
onClick={() => {
|
||||
if (behaviorOverride) {
|
||||
behaviorOverride();
|
||||
|
||||
@@ -14,7 +14,7 @@ import Tabs from "@/refresh-components/Tabs";
|
||||
import FilterButton from "@/refresh-components/buttons/FilterButton";
|
||||
import Popover, { PopoverMenu } from "@/refresh-components/Popover";
|
||||
import LineItem from "@/refresh-components/buttons/LineItem";
|
||||
import Button from "@/refresh-components/buttons/Button";
|
||||
import { Button } from "@opal/components";
|
||||
import {
|
||||
SEARCH_TOOL_ID,
|
||||
IMAGE_GENERATION_TOOL_ID,
|
||||
@@ -428,11 +428,13 @@ export default function AgentsNavigationPage() {
|
||||
title="Agents & Assistants"
|
||||
description="Customize AI behavior and knowledge for you and your team's use cases."
|
||||
rightChildren={
|
||||
<div data-testid="AgentsPage/new-agent-button">
|
||||
<Button href="/app/agents/create" leftIcon={SvgPlus}>
|
||||
New Agent
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
href="/app/agents/create"
|
||||
icon={SvgPlus}
|
||||
aria-label="AgentsPage/new-agent-button"
|
||||
>
|
||||
New Agent
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
|
||||
@@ -65,6 +65,7 @@ import { Interactive } from "@opal/core";
|
||||
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
|
||||
import { useSettingsContext } from "@/providers/SettingsProvider";
|
||||
import SimpleTooltip from "@/refresh-components/SimpleTooltip";
|
||||
import { useCloudSubscription } from "@/hooks/useCloudSubscription";
|
||||
|
||||
interface PAT {
|
||||
id: number;
|
||||
@@ -937,6 +938,8 @@ function AccountsAccessSettings() {
|
||||
useState<CreatedTokenState | null>(null);
|
||||
const [tokenToDelete, setTokenToDelete] = useState<PAT | null>(null);
|
||||
|
||||
const canCreateTokens = useCloudSubscription();
|
||||
|
||||
const showPasswordSection = Boolean(user?.password_configured);
|
||||
const showTokensSection = authType !== null;
|
||||
|
||||
@@ -1245,93 +1248,104 @@ function AccountsAccessSettings() {
|
||||
{showTokensSection && (
|
||||
<Section gap={0.75}>
|
||||
<InputLayouts.Title title="Access Tokens" />
|
||||
<Card padding={0.25}>
|
||||
<Section gap={0}>
|
||||
{/* Header with search/empty state and create button */}
|
||||
<Section flexDirection="row" padding={0.25} gap={0.5}>
|
||||
{pats.length === 0 ? (
|
||||
<Section padding={0.5} alignItems="start">
|
||||
<Text as="span" text03 secondaryBody>
|
||||
{isLoading
|
||||
? "Loading tokens..."
|
||||
: "No access tokens created."}
|
||||
</Text>
|
||||
</Section>
|
||||
) : (
|
||||
<InputTypeIn
|
||||
placeholder="Search..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
leftSearchIcon
|
||||
variant="internal"
|
||||
/>
|
||||
)}
|
||||
<CreateButton
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
secondary={false}
|
||||
internal
|
||||
transient={showCreateModal}
|
||||
rightIcon
|
||||
>
|
||||
New Access Token
|
||||
</CreateButton>
|
||||
</Section>
|
||||
{canCreateTokens ? (
|
||||
<Card padding={0.25}>
|
||||
<Section gap={0}>
|
||||
<Section flexDirection="row" padding={0.25} gap={0.5}>
|
||||
{pats.length === 0 ? (
|
||||
<Section padding={0.5} alignItems="start">
|
||||
<Text text03 secondaryBody>
|
||||
{isLoading
|
||||
? "Loading tokens..."
|
||||
: "No access tokens created."}
|
||||
</Text>
|
||||
</Section>
|
||||
) : (
|
||||
<InputTypeIn
|
||||
placeholder="Search..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
leftSearchIcon
|
||||
variant="internal"
|
||||
/>
|
||||
)}
|
||||
<CreateButton
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
secondary={false}
|
||||
internal
|
||||
transient={showCreateModal}
|
||||
rightIcon
|
||||
>
|
||||
New Access Token
|
||||
</CreateButton>
|
||||
</Section>
|
||||
|
||||
{/* Token List */}
|
||||
<Section gap={0.25}>
|
||||
{filteredPats.map((pat) => {
|
||||
const now = new Date();
|
||||
const createdDate = new Date(pat.created_at);
|
||||
const daysSinceCreation = Math.floor(
|
||||
(now.getTime() - createdDate.getTime()) /
|
||||
(1000 * 60 * 60 * 24)
|
||||
);
|
||||
|
||||
let expiryText = "Never expires";
|
||||
if (pat.expires_at) {
|
||||
const expiresDate = new Date(pat.expires_at);
|
||||
const daysUntilExpiry = Math.ceil(
|
||||
(expiresDate.getTime() - now.getTime()) /
|
||||
<Section gap={0.25}>
|
||||
{filteredPats.map((pat) => {
|
||||
const now = new Date();
|
||||
const createdDate = new Date(pat.created_at);
|
||||
const daysSinceCreation = Math.floor(
|
||||
(now.getTime() - createdDate.getTime()) /
|
||||
(1000 * 60 * 60 * 24)
|
||||
);
|
||||
expiryText = `Expires in ${daysUntilExpiry} day${
|
||||
daysUntilExpiry === 1 ? "" : "s"
|
||||
}`;
|
||||
}
|
||||
|
||||
const middleText = `Created ${daysSinceCreation} day${
|
||||
daysSinceCreation === 1 ? "" : "s"
|
||||
} ago - ${expiryText}`;
|
||||
let expiryText = "Never expires";
|
||||
if (pat.expires_at) {
|
||||
const expiresDate = new Date(pat.expires_at);
|
||||
const daysUntilExpiry = Math.ceil(
|
||||
(expiresDate.getTime() - now.getTime()) /
|
||||
(1000 * 60 * 60 * 24)
|
||||
);
|
||||
expiryText = `Expires in ${daysUntilExpiry} day${
|
||||
daysUntilExpiry === 1 ? "" : "s"
|
||||
}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<Interactive.Container
|
||||
key={pat.id}
|
||||
heightVariant="fit"
|
||||
widthVariant="full"
|
||||
>
|
||||
<div className="w-full bg-background-tint-01">
|
||||
<AttachmentItemLayout
|
||||
icon={SvgKey}
|
||||
title={pat.name}
|
||||
description={pat.token_display}
|
||||
middleText={middleText}
|
||||
rightChildren={
|
||||
<OpalButton
|
||||
icon={SvgTrash}
|
||||
onClick={() => setTokenToDelete(pat)}
|
||||
prominence="tertiary"
|
||||
size="sm"
|
||||
aria-label={`Delete token ${pat.name}`}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Interactive.Container>
|
||||
);
|
||||
})}
|
||||
const middleText = `Created ${daysSinceCreation} day${
|
||||
daysSinceCreation === 1 ? "" : "s"
|
||||
} ago - ${expiryText}`;
|
||||
|
||||
return (
|
||||
<Interactive.Container
|
||||
key={pat.id}
|
||||
heightVariant="fit"
|
||||
widthVariant="full"
|
||||
>
|
||||
<div className="w-full bg-background-tint-01">
|
||||
<AttachmentItemLayout
|
||||
icon={SvgKey}
|
||||
title={pat.name}
|
||||
description={pat.token_display}
|
||||
middleText={middleText}
|
||||
rightChildren={
|
||||
<OpalButton
|
||||
icon={SvgTrash}
|
||||
onClick={() => setTokenToDelete(pat)}
|
||||
prominence="tertiary"
|
||||
size="sm"
|
||||
aria-label={`Delete token ${pat.name}`}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Interactive.Container>
|
||||
);
|
||||
})}
|
||||
</Section>
|
||||
</Section>
|
||||
</Section>
|
||||
</Card>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<Section flexDirection="row" justifyContent="between">
|
||||
<Text text03 secondaryBody>
|
||||
Access tokens require an active paid subscription.
|
||||
</Text>
|
||||
<Button secondary href="/admin/billing">
|
||||
Upgrade Plan
|
||||
</Button>
|
||||
</Section>
|
||||
</Card>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
@@ -136,7 +136,7 @@ async function verifyAdminPageNavigation(
|
||||
|
||||
try {
|
||||
await expect(page.locator('[aria-label="admin-page-title"]')).toHaveText(
|
||||
pageTitle,
|
||||
new RegExp(`^${pageTitle}`),
|
||||
{
|
||||
timeout: 10000,
|
||||
}
|
||||
|
||||
@@ -21,12 +21,12 @@ function getToolSwitch(page: Page, toolName: string): Locator {
|
||||
}
|
||||
|
||||
/**
|
||||
* Click a tool switch and wait for the PATCH response to complete.
|
||||
* Click a button and wait for the PATCH response to complete.
|
||||
* Uses waitForResponse set up *before* the click to avoid race conditions.
|
||||
*/
|
||||
async function clickToolSwitchAndWaitForSave(
|
||||
async function clickAndWaitForPatch(
|
||||
page: Page,
|
||||
switchLocator: Locator
|
||||
buttonLocator: Locator
|
||||
): Promise<void> {
|
||||
const patchPromise = page.waitForResponse(
|
||||
(r) =>
|
||||
@@ -34,7 +34,7 @@ async function clickToolSwitchAndWaitForSave(
|
||||
r.request().method() === "PATCH",
|
||||
{ timeout: 8000 }
|
||||
);
|
||||
await switchLocator.click();
|
||||
await buttonLocator.click();
|
||||
await patchPromise;
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ test.describe("Chat Preferences Admin Page", () => {
|
||||
}) => {
|
||||
// Verify page loads with expected content
|
||||
await expect(page.locator('[aria-label="admin-page-title"]')).toHaveText(
|
||||
"Chat Preferences"
|
||||
/^Chat Preferences/
|
||||
);
|
||||
await expect(page.getByText("Actions & Tools")).toBeVisible();
|
||||
});
|
||||
@@ -215,7 +215,7 @@ test.describe("Chat Preferences Admin Page", () => {
|
||||
);
|
||||
|
||||
// Toggle back to original state
|
||||
await clickToolSwitchAndWaitForSave(page, searchSwitch);
|
||||
await clickAndWaitForPatch(page, searchSwitch);
|
||||
});
|
||||
|
||||
test("should toggle Web Search tool on and off", async ({ page }) => {
|
||||
@@ -267,7 +267,7 @@ test.describe("Chat Preferences Admin Page", () => {
|
||||
);
|
||||
|
||||
// Toggle back to original state
|
||||
await clickToolSwitchAndWaitForSave(page, webSearchSwitch);
|
||||
await clickAndWaitForPatch(page, webSearchSwitch);
|
||||
});
|
||||
|
||||
test("should toggle Image Generation tool on and off", async ({ page }) => {
|
||||
@@ -321,7 +321,7 @@ test.describe("Chat Preferences Admin Page", () => {
|
||||
);
|
||||
|
||||
// Toggle back to original state
|
||||
await clickToolSwitchAndWaitForSave(page, imageGenSwitch);
|
||||
await clickAndWaitForPatch(page, imageGenSwitch);
|
||||
});
|
||||
|
||||
test("should edit and save system prompt", async ({ page }) => {
|
||||
@@ -339,30 +339,12 @@ test.describe("Chat Preferences Admin Page", () => {
|
||||
const textarea = modal.getByPlaceholder("Enter your system prompt...");
|
||||
await textarea.fill(testPrompt);
|
||||
|
||||
// Set up response listener before the click to avoid race conditions
|
||||
const patchRespPromise = page.waitForResponse(
|
||||
(r) =>
|
||||
r.url().includes("/api/admin/default-assistant") &&
|
||||
r.request().method() === "PATCH",
|
||||
{ timeout: 8000 }
|
||||
// Click Save and wait for PATCH to complete
|
||||
await clickAndWaitForPatch(
|
||||
page,
|
||||
modal.getByRole("button", { name: "Save" })
|
||||
);
|
||||
|
||||
// Click Save in the modal footer
|
||||
await modal.getByRole("button", { name: "Save" }).click();
|
||||
|
||||
// Wait for PATCH to complete
|
||||
const patchResp = await patchRespPromise;
|
||||
console.log(
|
||||
`[prompt] Save PATCH status=${patchResp.status()} body=${(
|
||||
await patchResp.text()
|
||||
).slice(0, 300)}`
|
||||
);
|
||||
|
||||
// Wait for success toast
|
||||
await expect(page.getByText("System prompt updated")).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Modal should close after save
|
||||
await expect(modal).not.toBeVisible();
|
||||
|
||||
@@ -396,11 +378,10 @@ test.describe("Chat Preferences Admin Page", () => {
|
||||
// If already empty, add some text first
|
||||
if (initialValue === "") {
|
||||
await textarea.fill("Temporary text");
|
||||
await modal.getByRole("button", { name: "Save" }).click();
|
||||
await expect(page.getByText("System prompt updated")).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
await page.waitForTimeout(500);
|
||||
await clickAndWaitForPatch(
|
||||
page,
|
||||
modal.getByRole("button", { name: "Save" })
|
||||
);
|
||||
// Reopen modal
|
||||
await page.getByText("Modify Prompt").click();
|
||||
await expect(modal).toBeVisible({ timeout: 5000 });
|
||||
@@ -409,28 +390,12 @@ test.describe("Chat Preferences Admin Page", () => {
|
||||
// Clear the textarea
|
||||
await textarea.fill("");
|
||||
|
||||
// Set up response listener before the click to avoid race conditions
|
||||
const patchRespPromise = page.waitForResponse(
|
||||
(r) =>
|
||||
r.url().includes("/api/admin/default-assistant") &&
|
||||
r.request().method() === "PATCH",
|
||||
{ timeout: 8000 }
|
||||
);
|
||||
|
||||
// Save
|
||||
await modal.getByRole("button", { name: "Save" }).click();
|
||||
|
||||
const patchResp = await patchRespPromise;
|
||||
console.log(
|
||||
`[prompt-empty] Save empty PATCH status=${patchResp.status()} body=${(
|
||||
await patchResp.text()
|
||||
).slice(0, 300)}`
|
||||
await clickAndWaitForPatch(
|
||||
page,
|
||||
modal.getByRole("button", { name: "Save" })
|
||||
);
|
||||
|
||||
await expect(page.getByText("System prompt updated")).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Refresh page to verify persistence
|
||||
await page.reload();
|
||||
await page.waitForLoadState("networkidle");
|
||||
@@ -450,10 +415,10 @@ test.describe("Chat Preferences Admin Page", () => {
|
||||
// Restore original value if it wasn't already empty
|
||||
if (initialValue !== "") {
|
||||
await textareaAfter.fill(initialValue);
|
||||
await modalAfter.getByRole("button", { name: "Save" }).click();
|
||||
await expect(page.getByText("System prompt updated")).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
await clickAndWaitForPatch(
|
||||
page,
|
||||
modalAfter.getByRole("button", { name: "Save" })
|
||||
);
|
||||
} else {
|
||||
await modalAfter.getByRole("button", { name: "Cancel" }).click();
|
||||
}
|
||||
@@ -475,27 +440,12 @@ test.describe("Chat Preferences Admin Page", () => {
|
||||
|
||||
await textarea.fill(longPrompt);
|
||||
|
||||
// Set up response listener before the click to avoid race conditions
|
||||
const patchRespPromise = page.waitForResponse(
|
||||
(r) =>
|
||||
r.url().includes("/api/admin/default-assistant") &&
|
||||
r.request().method() === "PATCH",
|
||||
{ timeout: 8000 }
|
||||
);
|
||||
|
||||
// Save
|
||||
await modal.getByRole("button", { name: "Save" }).click();
|
||||
const patchResp = await patchRespPromise;
|
||||
console.log(
|
||||
`[prompt-long] Save PATCH status=${patchResp.status()} body=${(
|
||||
await patchResp.text()
|
||||
).slice(0, 300)}`
|
||||
await clickAndWaitForPatch(
|
||||
page,
|
||||
modal.getByRole("button", { name: "Save" })
|
||||
);
|
||||
|
||||
await expect(page.getByText("System prompt updated")).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Verify persistence after reload
|
||||
await page.reload();
|
||||
await page.waitForLoadState("networkidle");
|
||||
@@ -513,10 +463,10 @@ test.describe("Chat Preferences Admin Page", () => {
|
||||
"Enter your system prompt..."
|
||||
);
|
||||
await restoreTextarea.fill(initialValue);
|
||||
await modalAfter.getByRole("button", { name: "Save" }).click();
|
||||
await expect(page.getByText("System prompt updated")).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
await clickAndWaitForPatch(
|
||||
page,
|
||||
modalAfter.getByRole("button", { name: "Save" })
|
||||
);
|
||||
} else {
|
||||
await modalAfter.getByRole("button", { name: "Cancel" }).click();
|
||||
}
|
||||
@@ -602,7 +552,7 @@ test.describe("Chat Preferences Admin Page", () => {
|
||||
const toolSwitch = getToolSwitch(page, toolName);
|
||||
const currentState = await toolSwitch.getAttribute("aria-checked");
|
||||
if (currentState === "true") {
|
||||
await clickToolSwitchAndWaitForSave(page, toolSwitch);
|
||||
await clickAndWaitForPatch(page, toolSwitch);
|
||||
const newState = await toolSwitch.getAttribute("aria-checked");
|
||||
console.log(`[toggle-all] Clicked ${toolName}, new state=${newState}`);
|
||||
}
|
||||
@@ -628,7 +578,7 @@ test.describe("Chat Preferences Admin Page", () => {
|
||||
const toolSwitch = getToolSwitch(page, toolName);
|
||||
const currentState = await toolSwitch.getAttribute("aria-checked");
|
||||
if (currentState === "false") {
|
||||
await clickToolSwitchAndWaitForSave(page, toolSwitch);
|
||||
await clickAndWaitForPatch(page, toolSwitch);
|
||||
const newState = await toolSwitch.getAttribute("aria-checked");
|
||||
console.log(`[toggle-all] Clicked ${toolName}, new state=${newState}`);
|
||||
}
|
||||
@@ -722,7 +672,7 @@ test.describe("Chat Preferences Admin Page", () => {
|
||||
const originalState = toolStates[toolName];
|
||||
|
||||
if (currentState !== originalState) {
|
||||
await clickToolSwitchAndWaitForSave(page, toolSwitch);
|
||||
await clickAndWaitForPatch(page, toolSwitch);
|
||||
needsSave = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ test.describe("Disable Default Assistant Setting @exclusive", () => {
|
||||
|
||||
// Wait for the page to fully render (page title signals form is loaded)
|
||||
await expect(page.locator('[aria-label="admin-page-title"]')).toHaveText(
|
||||
"Chat Preferences",
|
||||
/^Chat Preferences/,
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
|
||||
@@ -224,7 +224,7 @@ test.describe("Disable Default Assistant Setting @exclusive", () => {
|
||||
|
||||
// Verify the page title
|
||||
await expect(page.locator('[aria-label="admin-page-title"]')).toHaveText(
|
||||
"Chat Preferences"
|
||||
/^Chat Preferences/
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ test.describe("LLM Provider Setup @exclusive", () => {
|
||||
await loginAs(page, "admin");
|
||||
await page.goto(LLM_SETUP_URL);
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(page.getByLabel("admin-page-title")).toHaveText("LLM Setup");
|
||||
await expect(page.getByLabel("admin-page-title")).toHaveText(/^LLM Setup/);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
|
||||
@@ -107,7 +107,7 @@ test.describe("Default Assistant Tests", () => {
|
||||
|
||||
// Create a custom assistant to test non-default behavior
|
||||
await page.getByTestId("AppSidebar/more-agents").click();
|
||||
await page.getByTestId("AgentsPage/new-agent-button").click();
|
||||
await page.getByLabel("AgentsPage/new-agent-button").click();
|
||||
await page
|
||||
.locator('input[name="name"]')
|
||||
.waitFor({ state: "visible", timeout: 10000 });
|
||||
@@ -150,7 +150,7 @@ test.describe("Default Assistant Tests", () => {
|
||||
}) => {
|
||||
// Create a custom assistant
|
||||
await page.getByTestId("AppSidebar/more-agents").click();
|
||||
await page.getByTestId("AgentsPage/new-agent-button").click();
|
||||
await page.getByLabel("AgentsPage/new-agent-button").click();
|
||||
await page
|
||||
.locator('input[name="name"]')
|
||||
.waitFor({ state: "visible", timeout: 10000 });
|
||||
@@ -200,7 +200,7 @@ test.describe("Default Assistant Tests", () => {
|
||||
}) => {
|
||||
// Create a custom assistant with starter messages
|
||||
await page.getByTestId("AppSidebar/more-agents").click();
|
||||
await page.getByTestId("AgentsPage/new-agent-button").click();
|
||||
await page.getByLabel("AgentsPage/new-agent-button").click();
|
||||
await page
|
||||
.locator('input[name="name"]')
|
||||
.waitFor({ state: "visible", timeout: 10000 });
|
||||
@@ -253,7 +253,7 @@ test.describe("Default Assistant Tests", () => {
|
||||
// Wait for modal or assistant list to appear
|
||||
// The selector might be in a modal or dropdown.
|
||||
await page
|
||||
.getByTestId("AgentsPage/new-agent-button")
|
||||
.getByLabel("AgentsPage/new-agent-button")
|
||||
.waitFor({ state: "visible", timeout: 5000 });
|
||||
|
||||
// Look for default assistant by name - it should NOT be there
|
||||
@@ -280,7 +280,7 @@ test.describe("Default Assistant Tests", () => {
|
||||
}) => {
|
||||
// Create a custom assistant
|
||||
await page.getByTestId("AppSidebar/more-agents").click();
|
||||
await page.getByTestId("AgentsPage/new-agent-button").click();
|
||||
await page.getByLabel("AgentsPage/new-agent-button").click();
|
||||
await page
|
||||
.locator('input[name="name"]')
|
||||
.waitFor({ state: "visible", timeout: 10000 });
|
||||
|
||||
@@ -32,7 +32,7 @@ test("Chat workflow", async ({ page }) => {
|
||||
|
||||
// Test creation of a new assistant
|
||||
await page.getByTestId("AppSidebar/more-agents").click();
|
||||
await page.getByTestId("AgentsPage/new-agent-button").click();
|
||||
await page.getByLabel("AgentsPage/new-agent-button").click();
|
||||
await page.locator('input[name="name"]').click();
|
||||
await page.locator('input[name="name"]').fill("Test Assistant");
|
||||
await page.locator('textarea[name="description"]').click();
|
||||
|
||||
@@ -496,10 +496,7 @@ test.describe("Default Assistant MCP Integration", () => {
|
||||
await page.getByTestId("AppSidebar/more-agents").click();
|
||||
await page.waitForURL("**/app/agents");
|
||||
|
||||
await page
|
||||
.getByTestId("AgentsPage/new-agent-button")
|
||||
.getByRole("link", { name: "New Agent" })
|
||||
.click();
|
||||
await page.getByLabel("AgentsPage/new-agent-button").click();
|
||||
await page.waitForURL("**/app/agents/create");
|
||||
|
||||
const assistantName = `MCP Assistant ${Date.now()}`;
|
||||
|
||||
@@ -20,7 +20,7 @@ export async function createAssistant(page: Page, params: AssistantParams) {
|
||||
|
||||
// Open Assistants modal/list
|
||||
await page.getByTestId("AppSidebar/more-agents").click();
|
||||
await page.getByTestId("AgentsPage/new-agent-button").click();
|
||||
await page.getByLabel("AgentsPage/new-agent-button").click();
|
||||
|
||||
// Fill required fields
|
||||
await page.locator('input[name="name"]').fill(name);
|
||||
|
||||
Reference in New Issue
Block a user