Compare commits

..

24 Commits

Author SHA1 Message Date
yyh
d8dd5fe0d9 fix(api): normalize api extension mutation response 2026-05-18 22:41:39 +08:00
yyh
521af725e4 fix: named export 2026-05-18 21:59:22 +08:00
yyh
8208ac0306 Merge remote-tracking branch 'origin/main' into codex/dify-ui-form-system 2026-05-18 21:57:57 +08:00
e2fa7e229b [autofix.ci] apply automated fixes 2026-05-18 13:49:09 +00:00
yyh
cd04f1d962 refactor: drive api extension state with orpc 2026-05-18 21:45:57 +08:00
06f076e0ff fix: no model selected but params keep loading (#36342) 2026-05-18 10:19:52 +00:00
5b79f7e99d docs: fix docker README numbering and refresh stale references (#36303) 2026-05-18 10:17:49 +00:00
1cee1a25b6 fix(console): require admin/owner to set default builtin tool credential (#36264)
Signed-off-by: NeatGuyCoding <15627489+NeatGuyCoding@users.noreply.github.com>
2026-05-18 10:15:51 +00:00
c0f237bf35 feat(web): allow annotation reply score threshold below 0.8 (#36337) 2026-05-18 10:05:13 +00:00
75d7fc0526 ci: add hotfix cherry-pick provenance check (#36340) 2026-05-18 10:03:56 +00:00
c057b5c5ff chore: Filter model presets by supported parameters (#36339) 2026-05-18 10:03:46 +00:00
yyh
2a74ab1e9c fix(web): align checkbox list field semantics 2026-05-18 17:53:33 +08:00
yyh
4a27cd94c4 docs: document dify-ui form contract 2026-05-18 17:46:14 +08:00
yyh
88597c1b93 Merge branch 'main' into codex/dify-ui-form-system 2026-05-18 17:29:35 +08:00
yyh
261cbfff12 refactor: tighten api extension modal contract 2026-05-18 17:26:19 +08:00
yyh
0d0f5d68df docs: add dialog form story 2026-05-18 17:23:41 +08:00
yyh
02245a17c3 refactor: model api extension modal as form 2026-05-18 17:21:32 +08:00
yyh
933e327519 docs: add multi-field form story 2026-05-18 17:15:02 +08:00
yyh
362624adcd fix: align field styling with design system 2026-05-18 17:08:05 +08:00
yyh
c1779112c6 refactor: expose base ui form directly 2026-05-18 16:53:40 +08:00
yyh
f404b3eac1 refactor: label checkbox list with fieldset 2026-05-18 16:46:57 +08:00
yyh
78394689c2 refactor: compose checkbox group stories with dify-ui field 2026-05-18 16:42:37 +08:00
yyh
9abaa60cfc feat: add dify-ui form primitives 2026-05-18 16:42:11 +08:00
yyh
afa212ff80 docs: regroup form control stories 2026-05-18 16:39:52 +08:00
69 changed files with 2263 additions and 549 deletions

View File

@ -0,0 +1,73 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_SHA=${BASE_SHA:-}
HEAD_SHA=${HEAD_SHA:-}
MAIN_REF=${MAIN_REF:-origin/main}
REMEDIATION_HINT="Changes should be made from the main branch using git cherry-pick -x."
error() {
printf 'ERROR: %s\n' "$1" >&2
}
if [[ -z "$BASE_SHA" || -z "$HEAD_SHA" ]]; then
error "BASE_SHA and HEAD_SHA are required. $REMEDIATION_HINT"
exit 2
fi
if ! git rev-parse --verify "$BASE_SHA^{commit}" > /dev/null 2>&1; then
error "Base commit '$BASE_SHA' is not available in the local git checkout."
exit 2
fi
if ! git rev-parse --verify "$HEAD_SHA^{commit}" > /dev/null 2>&1; then
error "Head commit '$HEAD_SHA' is not available in the local git checkout."
exit 2
fi
if ! git rev-parse --verify "$MAIN_REF^{commit}" > /dev/null 2>&1; then
error "Main ref '$MAIN_REF' is not available in the local git checkout. $REMEDIATION_HINT"
exit 2
fi
failed=0
checked=0
while IFS= read -r commit_sha; do
[[ -n "$commit_sha" ]] || continue
checked=$((checked + 1))
subject=$(git log -1 --format=%s "$commit_sha")
source_sha=$(
git log -1 --format=%B "$commit_sha" \
| sed -nE 's/^\(cherry picked from commit ([0-9a-fA-F]{7,64})\)$/\1/p' \
| tail -n 1
)
if [[ -z "$source_sha" ]]; then
error "Commit $commit_sha ($subject) is missing cherry-pick provenance. $REMEDIATION_HINT"
failed=1
continue
fi
if ! git cat-file -e "$source_sha^{commit}" 2> /dev/null; then
error "Commit $commit_sha ($subject) references source $source_sha, but that commit is not available locally. $REMEDIATION_HINT"
failed=1
continue
fi
if ! git merge-base --is-ancestor "$source_sha" "$MAIN_REF"; then
error "Commit $commit_sha ($subject) references source $source_sha, but that source is not reachable from main ($MAIN_REF). $REMEDIATION_HINT"
failed=1
fi
done < <(git rev-list --reverse "$BASE_SHA..$HEAD_SHA")
if [[ "$failed" -ne 0 ]]; then
exit 1
fi
if [[ "$checked" -eq 0 ]]; then
echo "No PR commits to check."
else
echo "Verified $checked PR commit(s) include cherry-pick provenance from main."
fi

View File

@ -0,0 +1,49 @@
name: Hotfix Cherry-Pick Provenance
on:
pull_request:
branches:
- 'hotfix/**'
- 'lts/**'
types:
- opened
- edited
- reopened
- ready_for_review
- synchronize
permissions:
contents: read
concurrency:
group: hotfix-cherry-pick-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
jobs:
check-cherry-pick-provenance:
name: Require cherry-pick provenance
runs-on: depot-ubuntu-24.04
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Fetch PR base, PR head, and main
env:
BASE_REF: ${{ github.base_ref }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
git fetch --no-tags --prune origin \
"+refs/heads/main:refs/remotes/origin/main" \
"+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}" \
"+refs/pull/${PR_NUMBER}/head:refs/remotes/pull/${PR_NUMBER}/head"
- name: Load checker from main
run: git show origin/main:.github/scripts/check-hotfix-cherry-picks.sh > "$RUNNER_TEMP/check-hotfix-cherry-picks.sh"
- name: Check PR commits
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
MAIN_REF: origin/main
run: bash "$RUNNER_TEMP/check-hotfix-cherry-picks.sh"

View File

@ -70,6 +70,21 @@ def _serialize_api_based_extension(extension: APIBasedExtension) -> dict[str, An
return APIBasedExtensionResponse.model_validate(extension, from_attributes=True).model_dump(mode="json")
def _serialize_saved_api_based_extension(extension: APIBasedExtension, api_key: str) -> dict[str, Any]:
"""Serialize a saved extension with the plaintext key used for response masking only.
APIBasedExtensionService.save mutates the ORM object to hold the encrypted token before returning it. The response
contract, however, should match list/detail responses, where api_key is masked from the decrypted token.
"""
return APIBasedExtensionResponse(
id=extension.id,
name=extension.name,
api_endpoint=extension.api_endpoint,
api_key=api_key,
created_at=to_timestamp(extension.created_at),
).model_dump(mode="json")
@console_ns.route("/code-based-extension")
class CodeBasedExtensionAPI(Resource):
@console_ns.doc("get_code_based_extension")
@ -125,7 +140,7 @@ class APIBasedExtensionAPI(Resource):
api_key=payload.api_key,
)
return _serialize_api_based_extension(APIBasedExtensionService.save(extension_data))
return _serialize_saved_api_based_extension(APIBasedExtensionService.save(extension_data), payload.api_key), 201
@console_ns.route("/api-based-extension/<uuid:id>")
@ -160,14 +175,19 @@ class APIBasedExtensionDetailAPI(Resource):
extension_data_from_db = APIBasedExtensionService.get_with_tenant_id(current_tenant_id, api_based_extension_id)
payload = APIBasedExtensionPayload.model_validate(console_ns.payload or {})
api_key_for_response = extension_data_from_db.api_key
extension_data_from_db.name = payload.name
extension_data_from_db.api_endpoint = payload.api_endpoint
if payload.api_key != HIDDEN_VALUE:
extension_data_from_db.api_key = payload.api_key
api_key_for_response = payload.api_key
return _serialize_api_based_extension(APIBasedExtensionService.save(extension_data_from_db))
return _serialize_saved_api_based_extension(
APIBasedExtensionService.save(extension_data_from_db),
api_key_for_response,
)
@console_ns.doc("delete_api_based_extension")
@console_ns.doc(description="Delete API-based extension")

View File

@ -874,6 +874,7 @@ class ToolBuiltinProviderSetDefaultApi(Resource):
@console_ns.expect(console_ns.models[BuiltinProviderDefaultCredentialPayload.__name__])
@setup_required
@login_required
@is_admin_or_owner_required
@account_initialization_required
def post(self, provider):
_, current_tenant_id = current_account_with_tenant()

View File

@ -0,0 +1,126 @@
"""Integration tests for console API-based extension endpoints using testcontainers."""
from __future__ import annotations
from unittest.mock import patch
import pytest
from flask.testing import FlaskClient
from sqlalchemy.orm import Session
from constants import HIDDEN_VALUE
from libs.rsa import generate_key_pair
from models import Tenant
from tests.test_containers_integration_tests.controllers.console.helpers import (
authenticate_console_client,
create_console_account_and_tenant,
)
def _masked_api_key(api_key: str) -> str:
if len(api_key) <= 8:
return api_key[0] + "******" + api_key[-1]
return api_key[:3] + "******" + api_key[-3:]
@pytest.fixture
def api_extension_client(
db_session_with_containers: Session,
test_client_with_containers: FlaskClient,
) -> tuple[FlaskClient, dict[str, str], Tenant]:
account, tenant = create_console_account_and_tenant(db_session_with_containers)
tenant.encrypt_public_key = generate_key_pair(tenant.id)
db_session_with_containers.commit()
headers = authenticate_console_client(test_client_with_containers, account)
return test_client_with_containers, headers, tenant
@pytest.fixture(autouse=True)
def mock_api_based_extension_ping():
with patch("services.api_based_extension_service.APIBasedExtensionRequestor") as requestor:
requestor.return_value.request.return_value = {"result": "pong"}
yield requestor
def test_create_response_masks_plaintext_api_key(
api_extension_client: tuple[FlaskClient, dict[str, str], Tenant],
) -> None:
client, headers, _ = api_extension_client
api_key = "plain-secret-12345"
response = client.post(
"/console/api/api-based-extension",
headers=headers,
json={
"name": "Docs API",
"api_endpoint": "https://docs.example.com/hook",
"api_key": api_key,
},
)
assert response.status_code == 201
assert response.json is not None
assert response.json["api_key"] == _masked_api_key(api_key)
def test_update_response_masks_new_plaintext_api_key(
api_extension_client: tuple[FlaskClient, dict[str, str], Tenant],
) -> None:
client, headers, _ = api_extension_client
new_api_key = "new-secret-67890"
create_response = client.post(
"/console/api/api-based-extension",
headers=headers,
json={
"name": "Docs API",
"api_endpoint": "https://docs.example.com/hook",
"api_key": "old-secret-12345",
},
)
assert create_response.json is not None
update_response = client.post(
f"/console/api/api-based-extension/{create_response.json['id']}",
headers=headers,
json={
"name": "Docs API Updated",
"api_endpoint": "https://docs.example.com/v2",
"api_key": new_api_key,
},
)
assert update_response.status_code == 200
assert update_response.json is not None
assert update_response.json["api_key"] == _masked_api_key(new_api_key)
def test_update_response_masks_existing_plaintext_api_key_when_hidden_value_is_submitted(
api_extension_client: tuple[FlaskClient, dict[str, str], Tenant],
) -> None:
client, headers, _ = api_extension_client
existing_api_key = "old-secret-12345"
create_response = client.post(
"/console/api/api-based-extension",
headers=headers,
json={
"name": "Docs API",
"api_endpoint": "https://docs.example.com/hook",
"api_key": existing_api_key,
},
)
assert create_response.json is not None
update_response = client.post(
f"/console/api/api-based-extension/{create_response.json['id']}",
headers=headers,
json={
"name": "Docs API Updated",
"api_endpoint": "https://docs.example.com/v2",
"api_key": HIDDEN_VALUE,
},
)
assert update_response.status_code == 200
assert update_response.json is not None
assert update_response.json["api_key"] == _masked_api_key(existing_api_key)

View File

@ -44,6 +44,12 @@ def _make_extension(
return extension
def _masked_api_key(api_key: str) -> str:
if len(api_key) <= 8:
return api_key[0] + "******" + api_key[-1]
return api_key[:3] + "******" + api_key[-3:]
@pytest.fixture(autouse=True)
def _mock_console_guards(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
"""Bypass console decorators so handlers can run in isolation."""
@ -114,7 +120,7 @@ def test_api_based_extension_get_returns_tenant_extensions(app: Flask, monkeypat
def test_api_based_extension_post_creates_extension(app: Flask, monkeypatch: pytest.MonkeyPatch):
saved_extension = _make_extension(name="Docs API", api_key="saved-secret")
saved_extension = _make_extension(name="Docs API", api_key="encrypted-token-from-save")
save_mock = MagicMock(return_value=saved_extension)
monkeypatch.setattr("controllers.console.extension.APIBasedExtensionService.save", save_mock)
@ -125,7 +131,7 @@ def test_api_based_extension_post_creates_extension(app: Flask, monkeypatch: pyt
}
with app.test_request_context("/console/api/api-based-extension", method="POST", json=payload):
response = APIBasedExtensionAPI().post()
response, status = APIBasedExtensionAPI().post()
args, _ = save_mock.call_args
created_extension: APIBasedExtension = args[0]
@ -133,7 +139,9 @@ def test_api_based_extension_post_creates_extension(app: Flask, monkeypatch: pyt
assert created_extension.name == payload["name"]
assert created_extension.api_endpoint == payload["api_endpoint"]
assert created_extension.api_key == payload["api_key"]
assert status == 201
assert response["name"] == saved_extension.name
assert response["api_key"] == _masked_api_key(payload["api_key"])
save_mock.assert_called_once()
@ -183,6 +191,7 @@ def test_api_based_extension_detail_post_keeps_hidden_api_key(app: Flask, monkey
assert existing_extension.api_key == "keep-me"
save_mock.assert_called_once_with(existing_extension)
assert response["name"] == payload["name"]
assert response["api_key"] == _masked_api_key("keep-me")
def test_api_based_extension_detail_post_updates_api_key_when_provided(app: Flask, monkeypatch: pytest.MonkeyPatch):
@ -212,6 +221,7 @@ def test_api_based_extension_detail_post_updates_api_key_when_provided(app: Flas
assert existing_extension.api_key == "new-secret"
save_mock.assert_called_once_with(existing_extension)
assert response["name"] == payload["name"]
assert response["api_key"] == _masked_api_key(payload["api_key"])
def test_api_based_extension_detail_delete_removes_extension(app: Flask, monkeypatch: pytest.MonkeyPatch):

View File

@ -5,7 +5,7 @@ Welcome to the new `docker` directory for deploying Dify using Docker Compose. T
### What's Updated
- **Certbot Container**: `docker-compose.yaml` now contains `certbot` for managing SSL certificates. This container automatically renews certificates and ensures secure HTTPS connections.\
For more information, refer `docker/certbot/README.md`.
For more information, refer to `docker/certbot/README.md`.
- **Persistent Environment Variables**: Essential startup defaults are provided in `.env.example`, while local values are stored in `.env`, ensuring that your configurations persist across deployments.
@ -17,26 +17,26 @@ Welcome to the new `docker` directory for deploying Dify using Docker Compose. T
### How to Deploy Dify with `docker-compose.yaml`
1. **Prerequisites**: Ensure Docker and Docker Compose are installed on your system.
1. **Environment Setup**:
2. **Environment Setup**:
- Navigate to the `docker` directory.
- Copy `.env.example` to `.env`.
- Customize `.env` when you need to change essential startup defaults. Copy optional files from `envs/` without the `.example` suffix when you need advanced settings.
- **Optional (for advanced deployments)**:
If you maintain a full `.env` file copied from `.env.example`, you may use the environment synchronization tool to keep it aligned with the latest `.env.example` updates while preserving your custom settings.
See the [Environment Variables Synchronization](#environment-variables-synchronization) section below.
1. **Running the Services**:
3. **Running the Services**:
- Execute `docker compose up -d` from the `docker` directory to start the services.
- To specify a vector database, set the `VECTOR_STORE` variable in your `.env` file to your desired vector database service, such as `milvus`, `weaviate`, or `opensearch`.
- To specify a vector database, set the `VECTOR_STORE` variable in your `.env` file to your desired vector database service, such as `milvus`, `weaviate`, or `opensearch`. See `envs/vectorstores/` for the full list of supported options.
```bash
cp .env.example .env
docker compose up -d
```
1. **SSL Certificate Setup**:
- Refer `docker/certbot/README.md` to set up SSL certificates using Certbot.
1. **OpenTelemetry Collector Setup**:
- Change `ENABLE_OTEL` to `true` in `.env`.
- Configure `OTLP_BASE_ENDPOINT` properly.
4. **SSL Certificate Setup**:
- Refer to `docker/certbot/README.md` to set up SSL certificates using Certbot.
5. **OpenTelemetry Collector Setup**:
- Copy `envs/core-services/shared.env.example` to `envs/core-services/shared.env`.
- Set `ENABLE_OTEL=true` and configure `OTLP_BASE_ENDPOINT`. Tune the other `OTEL_*` knobs in the same file if needed.
### How to Deploy Middleware for Developing Dify
@ -44,7 +44,7 @@ Welcome to the new `docker` directory for deploying Dify using Docker Compose. T
- Use the `docker-compose.middleware.yaml` for setting up essential middleware services like databases and caches.
- Navigate to the `docker` directory.
- Ensure the `middleware.env` file is created by running `cp envs/middleware.env.example middleware.env` (refer to the `envs/middleware.env.example` file).
1. **Running Middleware Services**:
2. **Running Middleware Services**:
- Navigate to the `docker` directory.
- Execute `docker compose --env-file middleware.env -f docker-compose.middleware.yaml -p dify up -d` to start PostgreSQL/MySQL (per `DB_TYPE`) plus the bundled Weaviate instance.
@ -55,9 +55,9 @@ Welcome to the new `docker` directory for deploying Dify using Docker Compose. T
For users migrating from the `docker-legacy` setup:
1. **Review Changes**: Familiarize yourself with the new `.env` configuration and Docker Compose setup.
1. **Transfer Customizations**:
2. **Transfer Customizations**:
- If you have customized configurations such as `docker-compose.yaml`, `ssrf_proxy/squid.conf`, or `nginx/conf.d/default.conf`, you will need to reflect these changes in the `.env` file you create.
1. **Data Migration**:
3. **Data Migration**:
- Ensure that data from services like databases and caches is backed up and migrated appropriately to the new structure if necessary.
### Overview of `.env`, `.env.example`, and `envs/`
@ -80,49 +80,51 @@ The root `.env.example` file contains the essential startup settings. Optional a
1. **Common Variables**:
- `CONSOLE_API_URL`, `SERVICE_API_URL`: URLs for different API services.
- `APP_WEB_URL`: Frontend application URL.
- `FILES_URL`: Base URL for file downloads and previews.
- `CONSOLE_API_URL`, `CONSOLE_WEB_URL`, `SERVICE_API_URL`, `APP_API_URL`, `APP_WEB_URL`: URLs for the API and frontend services.
- `FILES_URL`, `INTERNAL_FILES_URL`: Public and internal base URLs for file downloads and previews.
- `ENDPOINT_URL_TEMPLATE`, `NEXT_PUBLIC_SOCKET_URL`, `TRIGGER_URL`: Additional service URLs.
See `.env.example` for the full list.
1. **Server Configuration**:
2. **Server Configuration**:
- `LOG_LEVEL`, `DEBUG`, `FLASK_DEBUG`: Logging and debug settings.
- `SECRET_KEY`: A key for signing sessions, JWTs, and file URLs. Leave it empty to let Dify generate a persistent key in the storage directory, or set a unique value yourself.
1. **Database Configuration**:
3. **Database Configuration**:
- `DB_USERNAME`, `DB_PASSWORD`, `DB_HOST`, `DB_PORT`, `DB_DATABASE`: PostgreSQL database credentials and connection details.
1. **Redis Configuration**:
4. **Redis Configuration**:
- `REDIS_HOST`, `REDIS_PORT`, `REDIS_PASSWORD`: Redis server connection settings.
- `REDIS_KEY_PREFIX`: Optional global namespace prefix for Redis keys, topics, streams, and Celery Redis transport artifacts.
1. **Celery Configuration**:
5. **Celery Configuration**:
- `CELERY_BROKER_URL`: Configuration for Celery message broker.
1. **Storage Configuration**:
6. **Storage Configuration**:
- `STORAGE_TYPE`, `OPENDAL_SCHEME`, `OPENDAL_FS_ROOT`: Default local file storage settings. Optional storage backends are configured from the files under `envs/`.
1. **Vector Database Configuration**:
7. **Vector Database Configuration**:
- `VECTOR_STORE`: Type of vector database (e.g., `weaviate`, `milvus`).
- `VECTOR_STORE`: Type of vector database (e.g., `weaviate`, `milvus`). See `envs/vectorstores/` for the full list of supported options.
- Specific settings for each vector store like `WEAVIATE_ENDPOINT`, `MILVUS_URI`.
1. **CORS Configuration**:
8. **CORS Configuration**:
- `WEB_API_CORS_ALLOW_ORIGINS`, `CONSOLE_CORS_ALLOW_ORIGINS`: Settings for cross-origin resource sharing.
1. **OpenTelemetry Configuration**:
9. **OpenTelemetry Configuration**:
- `ENABLE_OTEL`: Enable OpenTelemetry collector in api.
- `OTLP_BASE_ENDPOINT`: Endpoint for your OTLP exporter.
1. **Other Service-Specific Environment Variables**:
10. **Other Service-Specific Environment Variables**:
- Each service like `nginx`, `redis`, `db`, and vector databases have specific environment variables that are directly referenced in the `docker-compose.yaml`.
- Each service like `nginx`, `redis`, `db`, and vector databases have specific environment variables that are directly referenced in the `docker-compose.yaml`.
### Environment Variables Synchronization

View File

@ -4642,11 +4642,6 @@
"count": 3
}
},
"web/service/client.spec.ts": {
"next/no-assign-module-variable": {
"count": 1
}
},
"web/service/common.ts": {
"ts/no-explicit-any": {
"count": 29

View File

@ -29,6 +29,8 @@ import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent, DialogTrigger } from '@langgenius/dify-ui/dialog'
import { Drawer, DrawerPopup, DrawerTrigger } from '@langgenius/dify-ui/drawer'
import { FieldControl, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
import { Form } from '@langgenius/dify-ui/form'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import '@langgenius/dify-ui/styles.css' // once, in the app root
```
@ -37,18 +39,48 @@ Importing from `@langgenius/dify-ui` (no subpath) is intentionally not supported
## Primitives
| Category | Subpath | Notes |
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
| Overlay | `./alert-dialog`, `./autocomplete`, `./combobox`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./popover`, `./select`, `./toast`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
| Form | `./autocomplete`, `./combobox`, `./number-field`, `./slider`, `./switch` | Controlled / uncontrolled per Base UI defaults. |
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
| Media | `./avatar`, `./button` | Button exposes `cva` variants. |
| Category | Subpath | Notes |
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
| Overlay | `./alert-dialog`, `./autocomplete`, `./combobox`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./popover`, `./select`, `./toast`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
| Form | `./form`, `./field`, `./fieldset`, `./checkbox`, `./checkbox-group`, `./number-field`, `./select`, `./slider`, `./switch` | Native form boundary, field semantics, and controls. |
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
| Media | `./avatar`, `./button` | Button exposes `cva` variants. |
Utilities:
- `./cn``clsx` + `tailwind-merge` wrapper. Use this for conditional class composition.
- `./styles.css` — the one CSS entry that ships the design tokens, theme variables, and project utilities/components. Import it once from the app root.
## Form contract
Dify UI's form primitives are a Base UI composition layer for native form semantics, field accessibility, and design-system styling. They are intentionally not a form state-management framework. See the upstream [Base UI Form], [Base UI Field], and [Base UI Fieldset] docs for the underlying component contracts.
Use `Form` for the submit boundary. It renders a native `<form>`, preserves Enter-to-submit and submit-button behavior, and adds Base UI's `onFormSubmit`, `errors`, `actionsRef`, and `validationMode` APIs for structured values and consolidated field validation. Prefer it over a bare `<form>` when the form is composed with Dify UI fields.
Use `FieldRoot` for each named field. A field must have a stable `name`, a visible `FieldLabel`, and either a `FieldControl` or another control that participates in the same Base UI field context. `FieldLabel`, `FieldDescription`, and `FieldError` provide the label and message relationships that screen readers need, while the Dify wrapper adds the default Form Input Set styling from the design system.
Use `FieldsetRoot` and `FieldsetLegend` when one field is represented by a group of related controls, such as checkbox groups, radio groups, or multi-thumb sliders. Compose group controls with the Base UI pattern:
```tsx
<FieldRoot name="allowedNetworkProtocols">
<FieldsetRoot render={<CheckboxGroup />}>
<FieldsetLegend>Allowed network protocols</FieldsetLegend>
<FieldItem>
<FieldLabel className="flex items-center gap-2">
<Checkbox value="https" />
HTTPS
</FieldLabel>
</FieldItem>
</FieldsetRoot>
</FieldRoot>
```
`FieldsetRoot` provides the group semantics and legend relationship. It does not own the interactive state of the grouped control. Pass `disabled`, `value`, `defaultValue`, and change handlers to the actual group primitive (`CheckboxGroup`, radio group, slider root, etc.) instead of relying on the fieldset wrapper to manage them.
For complex business forms, keep state ownership outside these primitives. TanStack Form, zod, server validation, dialog reset behavior, and schema-driven rendering belong to the feature layer in `web/`; they should pass `name`, `invalid`, `dirty`, `touched`, `value`, `onValueChange`, and errors into these primitives rather than replacing the field semantics.
Migration rule for `web/`: if a UI has a save/submit action, do not leave it as unrelated `Input` and `Button` pieces. Give it a real submit boundary with `Form` or a native `<form>`, attach visible field names through `FieldLabel`, expose helper/error text through `FieldDescription` / `FieldError`, and keep non-submit buttons as `type="button"`.
## Tailwind CSS v4 integration
This package uses Tailwind CSS v4's CSS-first configuration model. Consumers should import Tailwind from their own root stylesheet, then import this package's CSS entry:
@ -138,6 +170,9 @@ See `[AGENTS.md](./AGENTS.md)` for:
- Application state (`jotai`, `zustand`), data fetching (`ky`, `@tanstack/react-query`, `@orpc/*`), i18n (`next-i18next` / `react-i18next`), and routing (`next`) all live in `web/`. This package has zero dependencies on them and must stay that way so it can eventually be consumed by other apps or extracted.
- Business components (chat, workflow, dataset views, etc.). Those belong in `web/app/components/...`.
[Base UI Field]: https://base-ui.com/react/components/field
[Base UI Fieldset]: https://base-ui.com/react/components/fieldset
[Base UI Form]: https://base-ui.com/react/components/form
[Base UI Portal]: https://base-ui.com/react/overview/quick-start#portals
[Base UI]: https://base-ui.com/react
[Overlay & portal contract]: #overlay--portal-contract

View File

@ -53,6 +53,18 @@
"types": "./src/dropdown-menu/index.tsx",
"import": "./src/dropdown-menu/index.tsx"
},
"./field": {
"types": "./src/field/index.tsx",
"import": "./src/field/index.tsx"
},
"./fieldset": {
"types": "./src/fieldset/index.tsx",
"import": "./src/fieldset/index.tsx"
},
"./form": {
"types": "./src/form/index.tsx",
"import": "./src/form/index.tsx"
},
"./meter": {
"types": "./src/meter/index.tsx",
"import": "./src/meter/index.tsx"

View File

@ -1,8 +1,8 @@
import { Field } from '@base-ui/react/field'
import { Fieldset } from '@base-ui/react/fieldset'
import { useState } from 'react'
import { render } from 'vitest-browser-react'
import { Checkbox } from '../../checkbox'
import { FieldItem, FieldLabel, FieldRoot } from '../../field'
import { FieldsetLegend, FieldsetRoot } from '../../fieldset'
import { CheckboxGroup } from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
@ -43,26 +43,26 @@ describe('CheckboxGroup', () => {
})
})
it('should compose with Base UI Field and Fieldset without losing labels', async () => {
it('should compose with Dify UI Field and Fieldset without losing labels', async () => {
const onValueChange = vi.fn()
const screen = await render(
<Field.Root name="features">
<Fieldset.Root render={<CheckboxGroup value={['search']} onValueChange={onValueChange} />}>
<Fieldset.Legend>Features</Fieldset.Legend>
<Field.Item>
<Field.Label>
<FieldRoot name="features">
<FieldsetRoot render={<CheckboxGroup value={['search']} onValueChange={onValueChange} />}>
<FieldsetLegend>Features</FieldsetLegend>
<FieldItem>
<FieldLabel>
<Checkbox value="search" />
Search
</Field.Label>
</Field.Item>
<Field.Item>
<Field.Label>
</FieldLabel>
</FieldItem>
<FieldItem>
<FieldLabel>
<Checkbox value="analytics" />
Analytics
</Field.Label>
</Field.Item>
</Fieldset.Root>
</Field.Root>,
</FieldLabel>
</FieldItem>
</FieldsetRoot>
</FieldRoot>,
)
const analytics = screen.getByRole('checkbox', { name: 'Analytics' })

View File

@ -1,12 +1,17 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { Field } from '@base-ui/react/field'
import { Fieldset } from '@base-ui/react/fieldset'
import { useId, useState } from 'react'
import { CheckboxGroup } from '.'
import { Checkbox } from '../checkbox'
import {
FieldDescription,
FieldItem,
FieldLabel,
FieldRoot,
} from '../field'
import { FieldsetLegend, FieldsetRoot } from '../fieldset'
const meta = {
title: 'Base/UI/CheckboxGroup',
title: 'Base/Form/CheckboxGroup',
component: CheckboxGroup,
parameters: {
layout: 'centered',
@ -75,11 +80,11 @@ function DynamicFormFieldDemo() {
const [selected, setSelected] = useState<string[]>(['markdown'])
return (
<Field.Root name="allowed_file_types" className="flex w-80 flex-col gap-2">
<Field.Description className="body-xs-regular text-text-tertiary">
<FieldRoot name="allowed_file_types" className="flex w-80 flex-col gap-2">
<FieldDescription className="body-xs-regular text-text-tertiary">
This mirrors Dify dynamic form fields where checkbox options are controlled by schema and persisted as a string array.
</Field.Description>
<Fieldset.Root
</FieldDescription>
<FieldsetRoot
render={(
<CheckboxGroup
value={selected}
@ -88,19 +93,19 @@ function DynamicFormFieldDemo() {
/>
)}
>
<Fieldset.Legend className="system-sm-medium text-text-secondary">
<FieldsetLegend className="system-sm-medium text-text-secondary">
Allowed file types
</Fieldset.Legend>
</FieldsetLegend>
{options.map(option => (
<Field.Item key={option.value}>
<Field.Label className="flex items-center gap-2 system-sm-medium text-text-secondary">
<FieldItem key={option.value}>
<FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary">
<Checkbox value={option.value} />
{option.label}
</Field.Label>
</Field.Item>
</FieldLabel>
</FieldItem>
))}
</Fieldset.Root>
</Field.Root>
</FieldsetRoot>
</FieldRoot>
)
}

View File

@ -7,7 +7,7 @@ import {
} from '.'
const meta = {
title: 'Base/UI/Checkbox',
title: 'Base/Form/Checkbox',
component: Checkbox,
parameters: {
layout: 'centered',

View File

@ -9,6 +9,8 @@ import {
DialogTrigger,
} from '.'
import { Button } from '../button'
import { FieldControl, FieldDescription, FieldError, FieldLabel, FieldRoot } from '../field'
import { Form } from '../form'
const triggerButtonClassName = 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs hover:bg-state-base-hover'
@ -139,6 +141,89 @@ export const Controlled: Story = {
render: () => <ControlledDemo />,
}
type ApiExtensionFormValues = {
name: string
endpoint: string
apiKey: string
}
const FormDialogDemo = () => {
const [open, setOpen] = useState(false)
return (
<Dialog open={open} onOpenChange={setOpen} disablePointerDismissal>
<DialogTrigger
render={<button type="button" className={triggerButtonClassName} />}
>
Configure API extension
</DialogTrigger>
<DialogContent backdropProps={{ forceRender: true }} className="w-160">
<DialogCloseButton />
<div className="grid gap-2 pr-8">
<DialogTitle className="text-lg leading-7 font-semibold text-text-primary">
Configure API extension
</DialogTitle>
<DialogDescription className="text-sm leading-5 text-text-secondary">
Save the endpoint and credentials used by this workspace integration.
</DialogDescription>
</div>
<Form<ApiExtensionFormValues>
className="grid gap-4 pt-5"
onFormSubmit={() => setOpen(false)}
>
<FieldRoot name="name">
<FieldLabel>Name</FieldLabel>
<FieldControl required placeholder="Production API" />
<FieldError match="valueMissing">Name is required.</FieldError>
</FieldRoot>
<FieldRoot name="endpoint">
<FieldLabel>Endpoint</FieldLabel>
<FieldControl type="url" required placeholder="https://api.example.com" />
<FieldDescription>
<a
href="https://docs.dify.ai/use-dify/workspace/api-extension/api-extension"
target="_blank"
rel="noopener noreferrer"
className="inline-flex w-fit items-center text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
>
View API extension docs
</a>
</FieldDescription>
<FieldError match="valueMissing">Endpoint is required.</FieldError>
<FieldError match="typeMismatch">Enter a valid URL.</FieldError>
</FieldRoot>
<FieldRoot
name="apiKey"
validate={(value) => {
if (typeof value === 'string' && value.length > 0 && value.length < 5)
return 'API key must be at least 5 characters.'
return null
}}
>
<FieldLabel>API key</FieldLabel>
<FieldControl required placeholder="sk-..." />
<FieldError match="valueMissing">API key is required.</FieldError>
<FieldError match="customError" />
</FieldRoot>
<div className="mt-2 flex items-center justify-end gap-2">
<Button type="button" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" variant="primary">
Save
</Button>
</div>
</Form>
</DialogContent>
</Dialog>
)
}
export const FormDialog: Story = {
render: () => <FormDialogDemo />,
}
export const ScrollingContent: Story = {
render: () => (
<Dialog>

View File

@ -0,0 +1,126 @@
import { render } from 'vitest-browser-react'
import { Checkbox } from '../../checkbox'
import { CheckboxGroup } from '../../checkbox-group'
import { FieldsetLegend, FieldsetRoot } from '../../fieldset'
import { Form } from '../../form'
import {
FieldControl,
FieldDescription,
FieldError,
FieldItem,
FieldLabel,
FieldRoot,
} from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
describe('Field primitives', () => {
it('should associate label, description, and error with the control', async () => {
const onFormSubmit = vi.fn()
const screen = await render(
<Form aria-label="profile form" onFormSubmit={onFormSubmit}>
<FieldRoot name="email">
<FieldLabel>Email</FieldLabel>
<FieldControl type="email" required />
<FieldDescription>Used for account notifications.</FieldDescription>
<FieldError match="valueMissing">Email is required.</FieldError>
</FieldRoot>
<button type="submit">Save</button>
</Form>,
)
const input = screen.getByRole('textbox', { name: 'Email' })
const label = asHTMLElement(screen.getByText('Email').element())
const description = asHTMLElement(screen.getByText('Used for account notifications.').element())
await expect.element(input).toHaveAccessibleDescription('Used for account notifications.')
expect(label.tagName).toBe('LABEL')
expect(label).toHaveAttribute('for', asHTMLElement(input.element()).id)
expect(asHTMLElement(input.element()).getAttribute('aria-describedby')?.split(' ')).toContain(description.id)
await expect.element(input).toHaveClass('rounded-lg', 'system-sm-regular')
await expect.element(screen.getByText('Email')).toHaveClass('py-1', 'system-sm-medium')
await expect.element(screen.getByText('Used for account notifications.')).toHaveClass('py-0.5', 'body-xs-regular')
asHTMLElement(screen.getByRole('button', { name: 'Save' }).element()).click()
await vi.waitFor(async () => {
const error = asHTMLElement(screen.getByText('Email is required.').element())
await expect.element(screen.getByText('Email is required.')).toBeInTheDocument()
await expect.element(input).toHaveAttribute('aria-invalid', 'true')
await expect.element(input).toHaveClass('data-invalid:border-components-input-border-destructive')
expect(asHTMLElement(input.element()).getAttribute('aria-describedby')?.split(' ')).toEqual(
expect.arrayContaining([description.id, error.id]),
)
})
expect(onFormSubmit).not.toHaveBeenCalled()
})
it('should submit valid field values through Base UI Form', async () => {
const onFormSubmit = vi.fn()
const screen = await render(
<Form aria-label="settings form" onFormSubmit={onFormSubmit}>
<FieldRoot name="apiKey">
<FieldLabel>API key</FieldLabel>
<FieldControl defaultValue="sk-test" required />
</FieldRoot>
<button type="submit">Save</button>
</Form>,
)
asHTMLElement(screen.getByRole('button', { name: 'Save' }).element()).click()
expect(onFormSubmit).toHaveBeenCalledTimes(1)
expect(onFormSubmit.mock.calls[0]?.[0]).toMatchObject({ apiKey: 'sk-test' })
})
it('should support external invalid state without requiring FieldControl', async () => {
const screen = await render(
<FieldRoot name="features" invalid>
<FieldsetRoot render={<CheckboxGroup value={['search']} />}>
<FieldsetLegend>Features</FieldsetLegend>
<FieldItem>
<FieldLabel className="flex items-center gap-2">
<Checkbox value="search" />
Search
</FieldLabel>
</FieldItem>
<FieldError match>Choose at least one feature.</FieldError>
</FieldsetRoot>
</FieldRoot>,
)
await expect.element(screen.getByRole('group', { name: 'Features' })).toBeInTheDocument()
await expect.element(screen.getByRole('checkbox', { name: 'Search' })).toHaveAttribute('aria-checked', 'true')
await expect.element(screen.getByText('Choose at least one feature.')).toHaveClass('text-text-destructive', 'body-xs-regular')
})
it('should apply design-system control sizes when requested', async () => {
const screen = await render(
<>
<FieldRoot name="name">
<FieldLabel>Name</FieldLabel>
<FieldControl size="large" />
</FieldRoot>
<FieldRoot name="alias">
<FieldLabel>Alias</FieldLabel>
<FieldControl size="small" />
</FieldRoot>
</>,
)
await expect.element(screen.getByRole('textbox', { name: 'Name' })).toHaveClass('rounded-[10px]', 'py-[7px]', 'system-md-regular')
await expect.element(screen.getByRole('textbox', { name: 'Alias' })).toHaveClass('rounded-md', 'py-[3px]', 'system-xs-regular')
})
it('should expose the design-system read-only state', async () => {
const screen = await render(
<FieldRoot name="token">
<FieldLabel>Token</FieldLabel>
<FieldControl readOnly defaultValue="readonly-token" />
</FieldRoot>,
)
await expect.element(screen.getByRole('textbox', { name: 'Token' })).toHaveAttribute('readonly')
await expect.element(screen.getByRole('textbox', { name: 'Token' })).toHaveClass('read-only:cursor-default', 'read-only:focus:border-transparent')
})
})

View File

@ -0,0 +1,111 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { Button } from '../button'
import {
FieldControl,
FieldDescription,
FieldError,
FieldLabel,
FieldRoot,
} from './index'
const meta = {
title: 'Base/Form/Field',
component: FieldRoot,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Field primitives built on Base UI Field. Use FieldRoot with FieldLabel, FieldControl, FieldDescription, and FieldError for one named form field. External form libraries can control invalid, dirty, and touched on FieldRoot.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof FieldRoot>
export default meta
type Story = StoryObj<typeof meta>
export const TextField: Story = {
render: () => (
<form className="grid w-96 gap-4">
<FieldRoot name="endpoint">
<FieldLabel>Endpoint</FieldLabel>
<FieldControl type="url" required placeholder="https://api.example.com" />
<FieldDescription>Used as the base URL for extension requests.</FieldDescription>
<FieldError match="valueMissing">Endpoint is required.</FieldError>
<FieldError match="typeMismatch">Enter a valid URL.</FieldError>
</FieldRoot>
<div className="flex justify-end">
<Button type="submit" variant="primary">Save</Button>
</div>
</form>
),
}
export const MultipleFields: Story = {
render: () => (
<form className="grid w-96 gap-4">
<FieldRoot name="name">
<FieldLabel>Name</FieldLabel>
<FieldControl required placeholder="Production API" />
<FieldError match="valueMissing">Name is required.</FieldError>
</FieldRoot>
<FieldRoot name="endpoint">
<FieldLabel>Endpoint</FieldLabel>
<FieldControl type="url" required placeholder="https://api.example.com" />
<FieldDescription>Used as the base URL for extension requests.</FieldDescription>
<FieldError match="valueMissing">Endpoint is required.</FieldError>
<FieldError match="typeMismatch">Enter a valid URL.</FieldError>
</FieldRoot>
<FieldRoot name="apiKey">
<FieldLabel>API key</FieldLabel>
<FieldControl required placeholder="sk-..." />
<FieldDescription>Stored with the extension configuration.</FieldDescription>
<FieldError match="valueMissing">API key is required.</FieldError>
</FieldRoot>
<div className="flex justify-end">
<Button type="submit" variant="primary">Save</Button>
</div>
</form>
),
}
export const ExternalInvalidState: Story = {
render: () => (
<FieldRoot name="apiKey" invalid className="w-96">
<FieldLabel>API key</FieldLabel>
<FieldControl defaultValue="expired-key" />
<FieldError match>API key has expired.</FieldError>
</FieldRoot>
),
}
export const Sizes: Story = {
render: () => (
<div className="grid w-96 gap-4">
<FieldRoot name="smallEndpoint">
<FieldLabel>Small</FieldLabel>
<FieldControl size="small" placeholder="Small input" />
</FieldRoot>
<FieldRoot name="regularEndpoint">
<FieldLabel>Regular</FieldLabel>
<FieldControl placeholder="Regular input" />
</FieldRoot>
<FieldRoot name="largeEndpoint">
<FieldLabel>Large</FieldLabel>
<FieldControl size="large" placeholder="Large input" />
</FieldRoot>
</div>
),
}
export const ReadOnly: Story = {
render: () => (
<FieldRoot name="readonlyEndpoint" className="w-96">
<FieldLabel>Endpoint</FieldLabel>
<FieldControl readOnly defaultValue="https://api.example.com" />
<FieldDescription>This value is managed by the workspace owner.</FieldDescription>
</FieldRoot>
),
}

View File

@ -0,0 +1,154 @@
'use client'
import type { Field as BaseFieldNS } from '@base-ui/react/field'
import type { VariantProps } from 'class-variance-authority'
import { Field as BaseField } from '@base-ui/react/field'
import { cva } from 'class-variance-authority'
import { cn } from '../cn'
export type FieldRootProps
= Omit<BaseFieldNS.Root.Props, 'className'>
& {
className?: string
}
export type FieldRootActions = BaseFieldNS.Root.Actions
export function FieldRoot({
className,
...props
}: FieldRootProps) {
return (
<BaseField.Root
className={cn('group/field grid min-w-0 gap-1', className)}
{...props}
/>
)
}
export type FieldItemProps
= Omit<BaseFieldNS.Item.Props, 'className'>
& {
className?: string
}
export function FieldItem({
className,
...props
}: FieldItemProps) {
return (
<BaseField.Item
className={cn('grid min-w-0 gap-1', className)}
{...props}
/>
)
}
export type FieldLabelProps
= Omit<BaseFieldNS.Label.Props, 'className'>
& {
className?: string
}
export function FieldLabel({
className,
...props
}: FieldLabelProps) {
return (
<BaseField.Label
className={cn('w-fit py-1 text-text-secondary system-sm-medium data-disabled:cursor-not-allowed', className)}
{...props}
/>
)
}
const fieldControlVariants = cva(
[
'w-full appearance-none border border-transparent bg-components-input-bg-normal text-components-input-text-filled caret-primary-600 outline-hidden transition-[background-color,border-color,box-shadow]',
'placeholder:text-components-input-text-placeholder',
'hover:border-components-input-border-hover hover:bg-components-input-bg-hover',
'focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs',
'data-invalid:border-components-input-border-destructive data-invalid:bg-components-input-bg-destructive',
'read-only:cursor-default read-only:shadow-none read-only:hover:border-transparent read-only:hover:bg-components-input-bg-normal read-only:focus:border-transparent read-only:focus:bg-components-input-bg-normal read-only:focus:shadow-none',
'disabled:cursor-not-allowed disabled:border-transparent disabled:bg-components-input-bg-disabled disabled:text-components-input-text-filled-disabled',
'disabled:hover:border-transparent disabled:hover:bg-components-input-bg-disabled',
'motion-reduce:transition-none',
],
{
variants: {
size: {
small: 'rounded-md px-2 py-[3px] system-xs-regular',
medium: 'rounded-lg px-3 py-[7px] system-sm-regular',
large: 'rounded-[10px] px-4 py-[7px] system-md-regular',
},
},
defaultVariants: {
size: 'medium',
},
},
)
export type FieldControlSize = NonNullable<VariantProps<typeof fieldControlVariants>['size']>
export type FieldControlProps
= Omit<BaseFieldNS.Control.Props, 'className' | 'size'>
& VariantProps<typeof fieldControlVariants>
& {
className?: string
}
export type FieldControlChangeEventDetails = BaseFieldNS.Control.ChangeEventDetails
export function FieldControl({
className,
size = 'medium',
...props
}: FieldControlProps) {
return (
<BaseField.Control
className={cn(fieldControlVariants({ size }), className)}
{...props}
/>
)
}
export type FieldDescriptionProps
= Omit<BaseFieldNS.Description.Props, 'className'>
& {
className?: string
}
export function FieldDescription({
className,
...props
}: FieldDescriptionProps) {
return (
<BaseField.Description
className={cn('py-0.5 text-text-tertiary body-xs-regular', className)}
{...props}
/>
)
}
export type FieldErrorProps
= Omit<BaseFieldNS.Error.Props, 'className'>
& {
className?: string
}
export function FieldError({
className,
...props
}: FieldErrorProps) {
return (
<BaseField.Error
className={cn('py-0.5 text-text-destructive body-xs-regular', className)}
{...props}
/>
)
}
export type FieldValidityProps = BaseFieldNS.Validity.Props
export type FieldValidityState = BaseFieldNS.Validity.State
export const FieldValidity = BaseField.Validity

View File

@ -0,0 +1,21 @@
import { render } from 'vitest-browser-react'
import {
FieldsetLegend,
FieldsetRoot,
} from '../index'
describe('Fieldset primitives', () => {
it('should apply reset design-system classes', async () => {
const screen = await render(
<FieldsetRoot className="custom-root">
<FieldsetLegend className="custom-legend">Permissions</FieldsetLegend>
</FieldsetRoot>,
)
const legend = screen.getByText('Permissions').element() as HTMLElement
const fieldset = legend.closest('fieldset') as HTMLElement
await expect.element(fieldset).toHaveClass('m-0', 'min-w-0', 'border-0', 'p-0', 'custom-root')
await expect.element(legend).toHaveClass('mb-1', 'py-1', 'system-sm-medium', 'text-text-secondary', 'custom-legend')
})
})

View File

@ -0,0 +1,56 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { Checkbox } from '../checkbox'
import { CheckboxGroup } from '../checkbox-group'
import { FieldItem, FieldLabel, FieldRoot } from '../field'
import {
FieldsetLegend,
FieldsetRoot,
} from './index'
const meta = {
title: 'Base/Form/Fieldset',
component: FieldsetRoot,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Fieldset primitives built on Base UI Fieldset. Use FieldsetRoot and FieldsetLegend when one field is represented by a group of related controls such as checkbox groups, radio groups, or multi-thumb sliders. Fieldset provides group semantics and labeling; pass interactive state such as disabled and value to the actual group primitive.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof FieldsetRoot>
export default meta
type Story = StoryObj<typeof meta>
export const CheckboxGroupField: Story = {
render: () => (
<FieldRoot name="scopes" className="w-80">
<FieldsetRoot render={<CheckboxGroup defaultValue={['read']} />}>
<FieldsetLegend>Scopes</FieldsetLegend>
<div className="grid gap-2">
<FieldItem>
<FieldLabel className="flex items-center gap-2">
<Checkbox value="read" />
Read
</FieldLabel>
</FieldItem>
<FieldItem>
<FieldLabel className="flex items-center gap-2">
<Checkbox value="write" />
Write
</FieldLabel>
</FieldItem>
<FieldItem>
<FieldLabel className="flex items-center gap-2">
<Checkbox value="admin" />
Admin
</FieldLabel>
</FieldItem>
</div>
</FieldsetRoot>
</FieldRoot>
),
}

View File

@ -0,0 +1,41 @@
'use client'
import type { Fieldset as BaseFieldsetNS } from '@base-ui/react/fieldset'
import { Fieldset as BaseFieldset } from '@base-ui/react/fieldset'
import { cn } from '../cn'
export type FieldsetRootProps
= Omit<BaseFieldsetNS.Root.Props, 'className'>
& {
className?: string
}
export function FieldsetRoot({
className,
...props
}: FieldsetRootProps) {
return (
<BaseFieldset.Root
className={cn('m-0 min-w-0 border-0 p-0', className)}
{...props}
/>
)
}
export type FieldsetLegendProps
= Omit<BaseFieldsetNS.Legend.Props, 'className'>
& {
className?: string
}
export function FieldsetLegend({
className,
...props
}: FieldsetLegendProps) {
return (
<BaseFieldset.Legend
className={cn('mb-1 py-1 text-text-secondary system-sm-medium', className)}
{...props}
/>
)
}

View File

@ -0,0 +1,53 @@
import { render } from 'vitest-browser-react'
import { FieldControl, FieldLabel, FieldRoot } from '../../field'
import { Form } from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
describe('Form primitive', () => {
it('should render a native named form and merge custom class names', async () => {
const screen = await render(
<Form aria-label="profile form" className="custom-form">
<FieldRoot name="name">
<FieldLabel>Name</FieldLabel>
<FieldControl defaultValue="Ada" />
</FieldRoot>
</Form>,
)
await expect.element(screen.getByRole('form', { name: 'profile form' })).toHaveClass('custom-form')
})
it('should call onFormSubmit with submitted values', async () => {
const onFormSubmit = vi.fn()
const screen = await render(
<Form aria-label="api form" onFormSubmit={onFormSubmit}>
<FieldRoot name="endpoint">
<FieldLabel>Endpoint</FieldLabel>
<FieldControl defaultValue="https://api.example.com" />
</FieldRoot>
<button type="submit">Save</button>
</Form>,
)
asHTMLElement(screen.getByRole('button', { name: 'Save' }).element()).click()
expect(onFormSubmit).toHaveBeenCalledTimes(1)
expect(onFormSubmit.mock.calls[0]?.[0]).toMatchObject({
endpoint: 'https://api.example.com',
})
})
it('should expose externally supplied errors through FieldError consumers', async () => {
const screen = await render(
<Form aria-label="server form" errors={{ token: 'Token has expired.' }}>
<FieldRoot name="token">
<FieldLabel>Token</FieldLabel>
<FieldControl defaultValue="expired" />
</FieldRoot>
</Form>,
)
await expect.element(screen.getByRole('textbox', { name: 'Token' })).toHaveAttribute('aria-invalid', 'true')
})
})

View File

@ -0,0 +1,70 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { Button } from '../button'
import { Checkbox } from '../checkbox'
import { CheckboxGroup } from '../checkbox-group'
import {
FieldControl,
FieldDescription,
FieldError,
FieldItem,
FieldLabel,
FieldRoot,
} from '../field'
import { FieldsetLegend, FieldsetRoot } from '../fieldset'
import { Form } from './index'
const meta = {
title: 'Base/Form/Form',
component: Form,
parameters: {
layout: 'centered',
},
} satisfies Meta<typeof Form>
export default meta
type Story = StoryObj<typeof meta>
export const Basic: Story = {
render: () => (
<Form className="grid w-96 gap-4" onFormSubmit={() => undefined}>
<FieldRoot name="name">
<FieldLabel>Name</FieldLabel>
<FieldControl required placeholder="Enter a name" />
<FieldError match="valueMissing">Name is required.</FieldError>
</FieldRoot>
<FieldRoot name="email">
<FieldLabel>Email</FieldLabel>
<FieldControl type="email" required placeholder="name@example.com" />
<FieldDescription>Used for account notifications.</FieldDescription>
<FieldError match="valueMissing">Email is required.</FieldError>
<FieldError match="typeMismatch">Enter a valid email address.</FieldError>
</FieldRoot>
<FieldRoot name="features">
<FieldsetRoot render={<CheckboxGroup defaultValue={['search']} />}>
<FieldsetLegend>Features</FieldsetLegend>
<div className="grid gap-2">
<FieldItem>
<FieldLabel className="flex items-center gap-2">
<Checkbox value="search" />
Search
</FieldLabel>
</FieldItem>
<FieldItem>
<FieldLabel className="flex items-center gap-2">
<Checkbox value="analytics" />
Analytics
</FieldLabel>
</FieldItem>
</div>
</FieldsetRoot>
</FieldRoot>
<div className="flex justify-end">
<Button type="submit" variant="primary">Save</Button>
</div>
</Form>
),
}

View File

@ -0,0 +1,11 @@
'use client'
import type { Form as BaseFormNS } from '@base-ui/react/form'
import { Form as BaseForm } from '@base-ui/react/form'
export const Form = BaseForm
export type FormProps = BaseFormNS.Props
export type FormActions = BaseFormNS.Actions
export type FormValidationMode = BaseFormNS.ValidationMode
export type FormSubmitEventDetails = BaseFormNS.SubmitEventDetails

View File

@ -108,7 +108,7 @@ const DemoField = ({
}
const meta = {
title: 'Base/UI/NumberField',
title: 'Base/Form/NumberField',
component: NumberField,
parameters: {
layout: 'centered',

View File

@ -16,7 +16,7 @@ import {
const triggerWidth = 'w-64'
const meta = {
title: 'Base/UI/Select',
title: 'Base/Form/Select',
component: Select,
parameters: {
layout: 'centered',

View File

@ -4,7 +4,7 @@ import { useState } from 'react'
import { Slider } from '.'
const meta = {
title: 'Base/UI/Slider',
title: 'Base/Form/Slider',
component: Slider,
parameters: {
layout: 'centered',

View File

@ -4,7 +4,7 @@ import { useState, useTransition } from 'react'
import { Switch, SwitchSkeleton } from '.'
const meta = {
title: 'Base/UI/Switch',
title: 'Base/Form/Switch',
component: Switch,
parameters: {
layout: 'centered',

View File

@ -9,6 +9,9 @@ export default defineConfig({
resolve: {
tsconfigPaths: true,
},
optimizeDeps: {
include: ['@base-ui/react/form'],
},
test: {
globals: true,
setupFiles: ['./vitest.setup.ts'],

View File

@ -84,7 +84,7 @@ vi.mock('@/app/components/base/features/new-feature-panel/moderation/form-genera
}))
vi.mock('@/app/components/header/account-setting/api-based-extension-page/selector', () => ({
default: ({
ApiBasedExtensionSelector: ({
onChange,
value,
}: {

View File

@ -13,7 +13,7 @@ import AppIcon from '@/app/components/base/app-icon'
import EmojiPicker from '@/app/components/base/emoji-picker'
import FormGeneration from '@/app/components/base/features/new-feature-panel/moderation/form-generation'
import { BookOpen01 } from '@/app/components/base/icons/src/vender/line/education'
import ApiBasedExtensionSelector from '@/app/components/header/account-setting/api-based-extension-page/selector'
import { ApiBasedExtensionSelector } from '@/app/components/header/account-setting/api-based-extension-page/selector'
import { useDocLink, useLocale } from '@/context/i18n'
import { useCodeBasedExtensions } from '@/service/use-common'
import {

View File

@ -21,6 +21,7 @@ describe('checkbox list component', () => {
)
expect(screen.getByText('Test Title'))!.toBeInTheDocument()
expect(screen.getByText('Test Description'))!.toBeInTheDocument()
expect(screen.getByRole('group', { name: 'Test Title' }))!.toHaveAccessibleDescription('Test Description')
options.forEach((option) => {
expect(screen.getByText(option.label))!.toBeInTheDocument()
})
@ -231,6 +232,7 @@ describe('checkbox list component', () => {
/>,
)
expect(screen.getByText('Test Label'))!.toBeInTheDocument()
expect(screen.getByRole('group', { name: 'Test Label' }))!.toBeInTheDocument()
})
it('renders without showSelectAll, showCount, showSearch', () => {

View File

@ -3,7 +3,9 @@ import { Button } from '@langgenius/dify-ui/button'
import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { CheckboxGroup } from '@langgenius/dify-ui/checkbox-group'
import { cn } from '@langgenius/dify-ui/cn'
import { useId, useMemo, useState } from 'react'
import { FieldDescription, FieldItem, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
import SearchInput from '@/app/components/base/search-input'
@ -16,6 +18,7 @@ type CheckboxListOption = {
}
type CheckboxListProps = {
name?: string
title?: string
label?: string
description?: string
@ -31,6 +34,7 @@ type CheckboxListProps = {
}
export const CheckboxList = ({
name,
title = '',
label,
description,
@ -45,7 +49,6 @@ export const CheckboxList = ({
maxHeight,
}: CheckboxListProps) => {
const { t } = useTranslation()
const groupLabelId = useId()
const [searchQuery, setSearchQuery] = useState('')
const filteredOptions = useMemo(() => {
@ -66,116 +69,129 @@ export const CheckboxList = ({
)
return (
<div className={cn('flex w-full flex-col gap-1', containerClassName)}>
{label && (
<div id={groupLabelId} className="system-sm-medium text-text-secondary">
{label}
</div>
)}
{description && (
<div className="body-xs-regular text-text-tertiary">
{description}
</div>
)}
<CheckboxGroup
aria-labelledby={label ? groupLabelId : undefined}
value={value}
onValueChange={nextValue => onChange?.(nextValue)}
allValues={selectableOptionValues}
disabled={disabled}
className="rounded-lg border border-components-panel-border bg-components-panel-bg"
<FieldRoot name={name} className={cn('flex w-full flex-col gap-1', containerClassName)}>
<FieldsetRoot
render={(
<CheckboxGroup
aria-label={!label && title ? title : undefined}
value={value}
onValueChange={nextValue => onChange?.(nextValue)}
allValues={selectableOptionValues}
disabled={disabled}
className="flex flex-col gap-1"
/>
)}
>
{(showSelectAll || title || showSearch) && (
<div className="relative flex items-center gap-2 border-b border-divider-subtle px-3 py-2">
{!searchQuery && showSelectAll && (
<label className={cn('flex shrink-0 items-center', !disabled && 'cursor-pointer')}>
<Checkbox
parent
disabled={disabled}
{label && (
<FieldsetLegend className="mb-0">
{label}
</FieldsetLegend>
)}
{description && (
<FieldDescription className="body-xs-regular text-text-tertiary">
{description}
</FieldDescription>
)}
<div className="rounded-lg border border-components-panel-border bg-components-panel-bg">
{(showSelectAll || title || showSearch) && (
<div className="relative flex items-center gap-2 border-b border-divider-subtle px-3 py-2">
{!searchQuery && showSelectAll && (
<FieldItem disabled={disabled} className="shrink-0 gap-0">
<FieldLabel className={cn('flex items-center p-0', !disabled && 'cursor-pointer')}>
<Checkbox
parent
disabled={disabled}
/>
<span className="sr-only">{t('operation.selectAll', { ns: 'common' })}</span>
</FieldLabel>
</FieldItem>
)}
{!searchQuery
? (
<div className="flex min-w-0 flex-1 items-center gap-1">
{title && (
<span className="truncate system-xs-semibold-uppercase leading-5 text-text-secondary">
{title}
</span>
)}
{showCount && selectedCount > 0 && (
<Badge uppercase>
{t('operation.selectCount', { ns: 'common', count: selectedCount })}
</Badge>
)}
</div>
)
: (
<div className="flex-1 system-sm-medium-uppercase leading-6 text-text-secondary">
{
filteredOptions.length > 0
? t('operation.searchCount', { ns: 'common', count: filteredOptions.length, content: title })
: t('operation.noSearchCount', { ns: 'common', content: title })
}
</div>
)}
{showSearch && (
<SearchInput
value={searchQuery}
onChange={setSearchQuery}
placeholder={t('placeholder.search', { ns: 'common' })}
className="w-40"
/>
<span className="sr-only">{t('operation.selectAll', { ns: 'common' })}</span>
</label>
)}
{!searchQuery
)}
</div>
)}
<div
className="p-1"
style={maxHeight ? { maxHeight, overflowY: 'auto' } : {}}
data-testid="options-container"
>
{!filteredOptions.length
? (
<div className="flex min-w-0 flex-1 items-center gap-1">
{title && (
<span className="truncate system-xs-semibold-uppercase leading-5 text-text-secondary">
{title}
</span>
)}
{showCount && selectedCount > 0 && (
<Badge uppercase>
{t('operation.selectCount', { ns: 'common', count: selectedCount })}
</Badge>
)}
<div className="px-3 py-6 text-center text-sm text-text-tertiary">
{searchQuery
? (
<div className="flex flex-col items-center justify-center gap-2">
<img alt="search menu" src={SearchMenu.src} width={32} />
<span className="system-sm-regular text-text-secondary">{t('operation.noSearchResults', { ns: 'common', content: title })}</span>
<Button variant="secondary-accent" size="small" onClick={() => setSearchQuery('')}>{t('operation.resetKeywords', { ns: 'common' })}</Button>
</div>
)
: t('noData', { ns: 'common' })}
</div>
)
: (
<div className="flex-1 system-sm-medium-uppercase leading-6 text-text-secondary">
{
filteredOptions.length > 0
? t('operation.searchCount', { ns: 'common', count: filteredOptions.length, content: title })
: t('operation.noSearchCount', { ns: 'common', content: title })
}
</div>
)}
{showSearch && (
<SearchInput
value={searchQuery}
onChange={setSearchQuery}
placeholder={t('placeholder.search', { ns: 'common' })}
className="w-40"
/>
)}
</div>
)}
<div
className="p-1"
style={maxHeight ? { maxHeight, overflowY: 'auto' } : {}}
data-testid="options-container"
>
{!filteredOptions.length
? (
<div className="px-3 py-6 text-center text-sm text-text-tertiary">
{searchQuery
? (
<div className="flex flex-col items-center justify-center gap-2">
<img alt="search menu" src={SearchMenu.src} width={32} />
<span className="system-sm-regular text-text-secondary">{t('operation.noSearchResults', { ns: 'common', content: title })}</span>
<Button variant="secondary-accent" size="small" onClick={() => setSearchQuery('')}>{t('operation.resetKeywords', { ns: 'common' })}</Button>
</div>
)
: t('noData', { ns: 'common' })}
</div>
)
: (
filteredOptions.map(option => (
<label
key={option.value}
data-testid="option-item"
className={cn(
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-state-base-hover',
(option.disabled || disabled) && 'cursor-not-allowed opacity-50',
)}
>
<Checkbox
value={option.value}
filteredOptions.map(option => (
<FieldItem
key={option.value}
disabled={option.disabled || disabled}
/>
<span
className="flex-1 truncate system-sm-medium text-text-secondary"
title={option.label}
className="gap-0"
>
{option.label}
</span>
</label>
))
)}
<FieldLabel
data-testid="option-item"
className={cn(
'flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-state-base-hover',
(option.disabled || disabled) && 'cursor-not-allowed opacity-50',
)}
>
<Checkbox
value={option.value}
disabled={option.disabled || disabled}
/>
<span
className="flex-1 truncate system-sm-medium text-text-secondary"
title={option.label}
>
{option.label}
</span>
</FieldLabel>
</FieldItem>
))
)}
</div>
</div>
</CheckboxGroup>
</div>
</FieldsetRoot>
</FieldRoot>
)
}

View File

@ -40,7 +40,7 @@ vi.mock('../score-slider', () => ({
<input
role="slider"
type="range"
min={80}
min={0}
max={100}
value={value}
onChange={e => onChange(Number((e.target as HTMLInputElement).value))}
@ -272,7 +272,7 @@ describe('ConfigParamModal', () => {
)
const slider = screen.getByRole('slider')
expect(slider).toHaveAttribute('min', '80')
expect(slider).toHaveAttribute('min', '0')
expect(slider).toHaveAttribute('max', '100')
expect(slider).toHaveValue('90')
})
@ -375,7 +375,7 @@ describe('ConfigParamModal', () => {
it('should use ANNOTATION_DEFAULT score_threshold when config has no score_threshold', () => {
const configWithoutThreshold = {
...defaultAnnotationConfig,
score_threshold: 0,
score_threshold: undefined as unknown as number,
}
render(
<ConfigParamModal
@ -390,6 +390,35 @@ describe('ConfigParamModal', () => {
expect(screen.getByRole('slider')).toHaveValue('90')
})
it('should preserve zero score threshold instead of falling back to default', async () => {
const onSave = vi.fn().mockResolvedValue(undefined)
render(
<ConfigParamModal
appId="test-app"
isShow={true}
onHide={vi.fn()}
onSave={onSave}
annotationConfig={{
...defaultAnnotationConfig,
score_threshold: 0,
}}
/>,
)
expect(screen.getByRole('slider')).toHaveValue('0')
const buttons = screen.getAllByRole('button')
const saveBtn = buttons.find(b => b.textContent?.includes('initSetup'))
fireEvent.click(saveBtn!)
await waitFor(() => {
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ embedding_provider_name: 'openai' }),
0,
)
})
})
it('should set loading state while saving', async () => {
let resolveOnSave: () => void
const onSave = vi.fn().mockImplementation(() => new Promise<void>((resolve) => {

View File

@ -175,6 +175,22 @@ describe('AnnotationReply', () => {
expect(screen.getByText('text-embedding-ada-002')).toBeInTheDocument()
})
it('should show zero score threshold when enabled', () => {
renderWithProvider({}, {
annotationReply: {
enabled: true,
score_threshold: 0,
embedding_model: {
embedding_provider_name: 'openai',
embedding_model_name: 'text-embedding-ada-002',
},
},
})
expect(screen.getByText('0')).toBeInTheDocument()
expect(screen.getByText('text-embedding-ada-002')).toBeInTheDocument()
})
it('should show dash when score threshold is not set', () => {
renderWithProvider({}, {
annotationReply: {

View File

@ -1,6 +1,6 @@
import type { AnnotationReplyConfig } from '@/models/debug'
import { act, renderHook } from '@testing-library/react'
import { queryAnnotationJobStatus } from '@/service/annotation'
import { queryAnnotationJobStatus, updateAnnotationStatus } from '@/service/annotation'
import { sleep } from '@/utils'
import useAnnotationConfig from '../use-annotation-config'
@ -162,6 +162,35 @@ describe('useAnnotationConfig', () => {
expect(updatedConfig.score_threshold).toBe(0.85)
})
it('should preserve zero score threshold when enabling annotation', async () => {
const zeroScoreConfig = { ...defaultConfig, score_threshold: 0 }
const setAnnotationConfig = vi.fn()
const { result } = renderHook(() => useAnnotationConfig({
appId: 'test-app',
annotationConfig: zeroScoreConfig,
setAnnotationConfig,
}))
await act(async () => {
await result.current.handleEnableAnnotation({
embedding_provider_name: 'openai',
embedding_model_name: 'text-embedding-3-small',
}, 0)
})
expect(updateAnnotationStatus).toHaveBeenCalledWith(
'test-app',
'enable',
{
embedding_provider_name: 'openai',
embedding_model_name: 'text-embedding-3-small',
},
0,
)
const updatedConfig = setAnnotationConfig.mock.calls[0]![0]
expect(updatedConfig.score_threshold).toBe(0)
})
it('should set score and embedding model together', () => {
const setAnnotationConfig = vi.fn()
const { result } = renderHook(() => useAnnotationConfig({

View File

@ -75,7 +75,7 @@ const ConfigParamModal: FC<Props> = ({ isShow, onHide: doHide, onSave, isInit, a
<Item title={t('feature.annotation.scoreThreshold.title', { ns: 'appDebug' })} tooltip={t('feature.annotation.scoreThreshold.description', { ns: 'appDebug' })}>
<ScoreSlider
className="mt-1"
value={(annotationConfig.score_threshold || ANNOTATION_DEFAULT.score_threshold) * 100}
value={(annotationConfig.score_threshold ?? ANNOTATION_DEFAULT.score_threshold) * 100}
onChange={(val) => {
setAnnotationConfig({
...annotationConfig,

View File

@ -100,7 +100,7 @@ const AnnotationReply = ({
<div className="flex items-center gap-4 pt-0.5">
<div className="">
<div className="mb-0.5 system-2xs-medium-uppercase text-text-tertiary">{t('feature.annotation.scoreThreshold.title', { ns: 'appDebug' })}</div>
<div className="system-xs-regular text-text-secondary">{annotationReply.score_threshold || '-'}</div>
<div className="system-xs-regular text-text-secondary">{annotationReply.score_threshold ?? '-'}</div>
</div>
<div className="h-[27px] w-px rotate-12 bg-divider-subtle"></div>
<div className="">

View File

@ -17,7 +17,7 @@ describe('ScoreSlider', () => {
it('should display easy match and accurate match labels', () => {
render(<ScoreSlider value={90} onChange={vi.fn()} />)
expect(screen.getByText('0.8')).toBeInTheDocument()
expect(screen.getByText('0.0')).toBeInTheDocument()
expect(screen.getByText('1.0')).toBeInTheDocument()
expect(screen.getByText(/feature\.annotation\.scoreThreshold\.easyMatch/)).toBeInTheDocument()
expect(screen.getByText(/feature\.annotation\.scoreThreshold\.accurateMatch/)).toBeInTheDocument()
@ -36,4 +36,11 @@ describe('ScoreSlider', () => {
expect(getSliderInput()).toHaveValue('95')
expect(screen.getByText('0.95')).toBeInTheDocument()
})
it('should allow zero as the minimum score threshold', () => {
render(<ScoreSlider value={0} onChange={vi.fn()} />)
expect(getSliderInput()).toHaveValue('0')
expect(screen.getByText('0.00')).toBeInTheDocument()
})
})

View File

@ -17,13 +17,16 @@ const clamp = (value: number, min: number, max: number) => {
return Math.min(Math.max(value, min), max)
}
const SCORE_MIN = 0
const SCORE_MAX = 100
const ScoreSlider: FC<Props> = ({
className,
value,
onChange,
}) => {
const { t } = useTranslation()
const safeValue = clamp(value, 80, 100)
const safeValue = clamp(value, SCORE_MIN, SCORE_MAX)
return (
<div className={className}>
@ -31,8 +34,8 @@ const ScoreSlider: FC<Props> = ({
<Slider
className="w-full"
value={safeValue}
min={80}
max={100}
min={SCORE_MIN}
max={SCORE_MAX}
step={1}
onValueChange={onChange}
aria-label={t('feature.annotation.scoreThreshold.title', { ns: 'appDebug' })}
@ -40,7 +43,7 @@ const ScoreSlider: FC<Props> = ({
<div
className="pointer-events-none absolute top-[-16px] system-sm-semibold text-text-primary"
style={{
left: `calc(4px + ${(safeValue - 80) / 20} * (100% - 8px))`,
left: `calc(4px + ${safeValue / SCORE_MAX} * (100% - 8px))`,
transform: 'translateX(-50%)',
}}
>
@ -49,7 +52,7 @@ const ScoreSlider: FC<Props> = ({
</div>
<div className="mt-[10px] flex items-center justify-between system-xs-semibold-uppercase">
<div className="flex space-x-1 text-util-colors-cyan-cyan-500">
<div>0.8</div>
<div>0.0</div>
<div>·</div>
<div>{t('feature.annotation.scoreThreshold.easyMatch', { ns: 'appDebug' })}</div>
</div>

View File

@ -53,7 +53,7 @@ const useAnnotationConfig = ({
setAnnotationConfig(produce(annotationConfig, (draft: AnnotationReplyConfig) => {
draft.enabled = true
draft.embedding_model = embeddingModel
if (!draft.score_threshold)
if (draft.score_threshold === undefined || draft.score_threshold === null)
draft.score_threshold = ANNOTATION_DEFAULT.score_threshold
}))
}

View File

@ -53,7 +53,7 @@ vi.mock('@/app/components/header/account-setting/constants', () => ({
}))
vi.mock('@/app/components/header/account-setting/api-based-extension-page/selector', () => ({
default: ({ onChange }: { value: string, onChange: (v: string) => void }) => (
ApiBasedExtensionSelector: ({ onChange }: { value: string, onChange: (v: string) => void }) => (
<div data-testid="api-selector">
<button data-testid="select-api" onClick={() => onChange('api-ext-1')}>Select API</button>
</div>

View File

@ -8,7 +8,7 @@ import { toast } from '@langgenius/dify-ui/toast'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import ApiBasedExtensionSelector from '@/app/components/header/account-setting/api-based-extension-page/selector'
import { ApiBasedExtensionSelector } from '@/app/components/header/account-setting/api-based-extension-page/selector'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { CustomConfigurationStatusEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useDocLink, useLocale } from '@/context/i18n'

View File

@ -305,6 +305,7 @@ const BaseField = ({
{
formItemType === FormTypeEnum.checkbox /* && multiple */ && (
<CheckboxList
name={field.name}
title={name}
value={value}
onChange={v => field.handleChange(v)}

View File

@ -66,11 +66,33 @@ vi.mock('@/service/use-datasource', () => ({
}))
vi.mock('@/service/use-common', () => ({
useApiBasedExtensions: vi.fn(() => ({ data: [], isPending: false })),
useMembers: vi.fn(() => ({ data: { accounts: [] }, refetch: vi.fn() })),
useProviderContext: vi.fn(),
}))
vi.mock('@/service/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/client')>()
return {
...actual,
consoleQuery: new Proxy(actual.consoleQuery, {
get(target, prop, receiver) {
if (prop === 'apiBasedExtension') {
return {
get: {
queryOptions: () => ({
queryKey: ['console', 'api-based-extension'],
queryFn: () => Promise.resolve([]),
}),
},
}
}
return Reflect.get(target, prop, receiver)
},
}),
}
})
vi.mock('@/app/components/billing/billing-page', () => ({
__esModule: true,
default: () => <div data-testid="billing-page" />,

View File

@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react'
import Empty from '../empty'
import { Empty } from '../empty'
describe('Empty State', () => {
describe('Rendering', () => {

View File

@ -1,33 +1,66 @@
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { addApiBasedExtension, updateApiBasedExtension } from '@/service/common'
import { useApiBasedExtensions } from '@/service/use-common'
import ApiBasedExtensionPage from '../index'
import { ApiBasedExtensionPage } from '../index'
vi.mock('@/service/use-common', () => ({
useApiBasedExtensions: vi.fn(),
const {
mockApiBasedExtensionsQuery,
mockCreateApiBasedExtension,
mockUpdateApiBasedExtension,
mockDeleteApiBasedExtension,
} = vi.hoisted(() => ({
mockApiBasedExtensionsQuery: vi.fn(),
mockCreateApiBasedExtension: vi.fn(),
mockUpdateApiBasedExtension: vi.fn(),
mockDeleteApiBasedExtension: vi.fn(),
}))
vi.mock('@/service/common', () => ({
addApiBasedExtension: vi.fn(),
updateApiBasedExtension: vi.fn(),
vi.mock('@/service/client', () => ({
consoleQuery: {
apiBasedExtension: {
get: {
queryOptions: () => ({}),
},
post: {
mutationOptions: () => ({ mutationFn: mockCreateApiBasedExtension }),
},
byId: {
post: {
mutationOptions: () => ({ mutationFn: mockUpdateApiBasedExtension }),
},
delete: {
mutationOptions: () => ({ mutationFn: mockDeleteApiBasedExtension }),
},
},
},
},
}))
vi.mock('@tanstack/react-query', () => ({
useQuery: vi.fn(() => mockApiBasedExtensionsQuery()),
useMutation: vi.fn((options: { mutationFn: (variables: unknown) => Promise<unknown> }) => ({
isPending: false,
mutate: (variables: unknown, mutationOptions?: { onSuccess?: (data: unknown) => void }) => {
options.mutationFn(variables).then(data => mutationOptions?.onSuccess?.(data))
},
})),
}))
describe('ApiBasedExtensionPage', () => {
const mockRefetch = vi.fn<() => void>()
beforeEach(() => {
vi.clearAllMocks()
mockApiBasedExtensionsQuery.mockReturnValue({
data: [],
isPending: false,
})
})
describe('Rendering', () => {
it('should render empty state when no data exists', () => {
// Arrange
vi.mocked(useApiBasedExtensions).mockReturnValue({
mockApiBasedExtensionsQuery.mockReturnValue({
data: [],
isPending: false,
refetch: mockRefetch,
} as unknown as ReturnType<typeof useApiBasedExtensions>)
})
// Act
render(<ApiBasedExtensionPage />)
@ -44,11 +77,10 @@ describe('ApiBasedExtensionPage', () => {
{ id: '2', name: 'Extension 2', api_endpoint: 'url2', api_key: 'key2' },
]
vi.mocked(useApiBasedExtensions).mockReturnValue({
mockApiBasedExtensionsQuery.mockReturnValue({
data: mockData,
isPending: false,
refetch: mockRefetch,
} as unknown as ReturnType<typeof useApiBasedExtensions>)
})
// Act
render(<ApiBasedExtensionPage />)
@ -63,11 +95,10 @@ describe('ApiBasedExtensionPage', () => {
it('should handle loading state', () => {
// Arrange
vi.mocked(useApiBasedExtensions).mockReturnValue({
data: null,
mockApiBasedExtensionsQuery.mockReturnValue({
data: undefined,
isPending: true,
refetch: mockRefetch,
} as unknown as ReturnType<typeof useApiBasedExtensions>)
})
// Act
render(<ApiBasedExtensionPage />)
@ -112,11 +143,10 @@ describe('ApiBasedExtensionPage', () => {
describe('User Interactions', () => {
it('should open modal when clicking add button', () => {
// Arrange
vi.mocked(useApiBasedExtensions).mockReturnValue({
mockApiBasedExtensionsQuery.mockReturnValue({
data: [],
isPending: false,
refetch: mockRefetch,
} as unknown as ReturnType<typeof useApiBasedExtensions>)
})
// Act
render(<ApiBasedExtensionPage />)
@ -126,19 +156,18 @@ describe('ApiBasedExtensionPage', () => {
expect(screen.getByRole('dialog', { name: 'common.apiBasedExtension.modal.title' })).toBeInTheDocument()
})
it('should call refetch when add modal saves successfully', async () => {
it('should close add modal when create mutation succeeds', async () => {
// Arrange
vi.mocked(addApiBasedExtension).mockResolvedValue({
mockCreateApiBasedExtension.mockResolvedValue({
id: 'new-id',
name: 'New Ext',
api_endpoint: 'https://api.test',
api_key: 'secret-key',
})
vi.mocked(useApiBasedExtensions).mockReturnValue({
mockApiBasedExtensionsQuery.mockReturnValue({
data: [],
isPending: false,
refetch: mockRefetch,
} as unknown as ReturnType<typeof useApiBasedExtensions>)
})
// Act
render(<ApiBasedExtensionPage />)
@ -150,19 +179,25 @@ describe('ApiBasedExtensionPage', () => {
// Assert
await waitFor(() => {
expect(mockRefetch).toHaveBeenCalled()
expect(mockCreateApiBasedExtension).toHaveBeenCalledWith({
body: {
name: 'New Ext',
api_endpoint: 'https://api.test',
api_key: 'secret-key',
},
})
expect(screen.queryByRole('dialog', { name: 'common.apiBasedExtension.modal.title' })).not.toBeInTheDocument()
})
})
it('should call refetch when an item is updated', async () => {
it('should close edit modal when update mutation succeeds', async () => {
// Arrange
const extension: ApiBasedExtensionResponse = { id: '1', name: 'Extension 1', api_endpoint: 'url1', api_key: 'long-api-key' }
vi.mocked(updateApiBasedExtension).mockResolvedValue({ ...extension, name: 'Updated' })
vi.mocked(useApiBasedExtensions).mockReturnValue({
mockUpdateApiBasedExtension.mockResolvedValue({ ...extension, name: 'Updated' })
mockApiBasedExtensionsQuery.mockReturnValue({
data: [extension],
isPending: false,
refetch: mockRefetch,
} as unknown as ReturnType<typeof useApiBasedExtensions>)
})
render(<ApiBasedExtensionPage />)
@ -172,7 +207,17 @@ describe('ApiBasedExtensionPage', () => {
// Assert
await waitFor(() => {
expect(mockRefetch).toHaveBeenCalled()
expect(mockUpdateApiBasedExtension).toHaveBeenCalledWith({
params: {
id: '1',
},
body: {
name: 'Extension 1',
api_endpoint: 'url1',
api_key: '[__HIDDEN__]',
},
})
expect(screen.queryByRole('dialog', { name: 'common.apiBasedExtension.modal.editTitle' })).not.toBeInTheDocument()
})
})
})

View File

@ -2,11 +2,31 @@ import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-
import type { TFunction } from 'i18next'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import * as reactI18next from 'react-i18next'
import { deleteApiBasedExtension } from '@/service/common'
import Item from '../item'
import { Item } from '../item'
vi.mock('@/service/common', () => ({
deleteApiBasedExtension: vi.fn(),
const { mockDeleteApiBasedExtension } = vi.hoisted(() => ({
mockDeleteApiBasedExtension: vi.fn(),
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
apiBasedExtension: {
byId: {
delete: {
mutationOptions: () => ({ mutationFn: mockDeleteApiBasedExtension }),
},
},
},
},
}))
vi.mock('@tanstack/react-query', () => ({
useMutation: vi.fn((options: { mutationFn: (variables: unknown) => Promise<unknown> }) => ({
isPending: false,
mutate: (variables: unknown, mutationOptions?: { onSuccess?: (data: unknown) => void }) => {
options.mutationFn(variables).then(data => mutationOptions?.onSuccess?.(data))
},
})),
}))
describe('Item Component', () => {
@ -16,7 +36,6 @@ describe('Item Component', () => {
api_endpoint: 'https://api.example.com',
api_key: 'test-api-key',
}
const mockOnUpdate = vi.fn()
const mockOnEdit = vi.fn()
beforeEach(() => {
@ -26,7 +45,7 @@ describe('Item Component', () => {
describe('Rendering', () => {
it('should render extension data correctly', () => {
// Act
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
render(<Item apiBasedExtension={mockData} onEdit={mockOnEdit} />)
// Assert
// Assert
@ -44,7 +63,7 @@ describe('Item Component', () => {
}
// Act
render(<Item data={minimalData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
render(<Item apiBasedExtension={minimalData} onEdit={mockOnEdit} />)
// Assert
// Assert
@ -56,7 +75,7 @@ describe('Item Component', () => {
describe('Modal Interactions', () => {
it('should request editing with the current extension when clicking edit button', () => {
// Act
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
render(<Item apiBasedExtension={mockData} onEdit={mockOnEdit} />)
fireEvent.click(screen.getByText('common.operation.edit'))
// Assert
@ -67,7 +86,7 @@ describe('Item Component', () => {
describe('Deletion', () => {
it('should show delete confirmation dialog when clicking delete button', () => {
// Act
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
render(<Item apiBasedExtension={mockData} onEdit={mockOnEdit} />)
fireEvent.click(screen.getByText('common.operation.delete'))
// Assert
@ -75,10 +94,10 @@ describe('Item Component', () => {
expect(screen.getByText(/common\.operation\.delete.*Test Extension.*\?/i))!.toBeInTheDocument()
})
it('should call delete API and triggers onUpdate when confirming deletion', async () => {
it('should call delete mutation when confirming deletion', async () => {
// Arrange
vi.mocked(deleteApiBasedExtension).mockResolvedValue({ result: 'success' })
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
mockDeleteApiBasedExtension.mockResolvedValue({})
render(<Item apiBasedExtension={mockData} onEdit={mockOnEdit} />)
// Act
fireEvent.click(screen.getByText('common.operation.delete'))
@ -90,15 +109,18 @@ describe('Item Component', () => {
// Assert
await waitFor(() => {
expect(deleteApiBasedExtension).toHaveBeenCalledWith('/api-based-extension/1')
expect(mockOnUpdate).toHaveBeenCalledTimes(1)
expect(mockDeleteApiBasedExtension).toHaveBeenCalledWith({
params: {
id: '1',
},
})
})
})
it('should hide delete confirmation dialog after successful deletion', async () => {
// Arrange
vi.mocked(deleteApiBasedExtension).mockResolvedValue({ result: 'success' })
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
mockDeleteApiBasedExtension.mockResolvedValue({})
render(<Item apiBasedExtension={mockData} onEdit={mockOnEdit} />)
// Act
fireEvent.click(screen.getByText('common.operation.delete'))
@ -116,7 +138,7 @@ describe('Item Component', () => {
it('should close delete confirmation when clicking cancel button', async () => {
// Act
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
render(<Item apiBasedExtension={mockData} onEdit={mockOnEdit} />)
fireEvent.click(screen.getByText('common.operation.delete'))
fireEvent.click(screen.getByText('common.operation.cancel'))
@ -128,13 +150,12 @@ describe('Item Component', () => {
it('should not call delete API when canceling deletion', () => {
// Act
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
render(<Item apiBasedExtension={mockData} onEdit={mockOnEdit} />)
fireEvent.click(screen.getByText('common.operation.delete'))
fireEvent.click(screen.getByText('common.operation.cancel'))
// Assert
expect(deleteApiBasedExtension).not.toHaveBeenCalled()
expect(mockOnUpdate).not.toHaveBeenCalled()
expect(mockDeleteApiBasedExtension).not.toHaveBeenCalled()
})
})
@ -157,7 +178,7 @@ describe('Item Component', () => {
} as unknown as ReturnType<typeof reactI18next.useTranslation>)
// Act
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
render(<Item apiBasedExtension={mockData} onEdit={mockOnEdit} />)
const allButtons = screen.getAllByRole('button')
const editBtn = screen.getByText('operation.edit')
const deleteBtn = allButtons.find(btn => btn !== editBtn)

View File

@ -1,13 +1,14 @@
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
import type { TFunction } from 'i18next'
import type { ComponentProps, ReactElement } from 'react'
import type { ReactElement } from 'react'
import { fireEvent, render as RTLRender, screen, waitFor } from '@testing-library/react'
import * as reactI18next from 'react-i18next'
import { useDocLink } from '@/context/i18n'
import { addApiBasedExtension, updateApiBasedExtension } from '@/service/common'
import ApiBasedExtensionModal from '../modal'
import { ApiBasedExtensionModal } from '../modal'
const { mockToast } = vi.hoisted(() => {
const { mockCreateApiBasedExtension, mockUpdateApiBasedExtension, mockToast } = vi.hoisted(() => {
const mockCreateApiBasedExtension = vi.fn()
const mockUpdateApiBasedExtension = vi.fn()
const mockToast = Object.assign(vi.fn(), {
success: vi.fn(),
error: vi.fn(),
@ -17,16 +18,35 @@ const { mockToast } = vi.hoisted(() => {
update: vi.fn(),
promise: vi.fn(),
})
return { mockToast }
return { mockCreateApiBasedExtension, mockUpdateApiBasedExtension, mockToast }
})
vi.mock('@/context/i18n', () => ({
useDocLink: vi.fn(),
}))
vi.mock('@/service/common', () => ({
addApiBasedExtension: vi.fn(),
updateApiBasedExtension: vi.fn(),
vi.mock('@/service/client', () => ({
consoleQuery: {
apiBasedExtension: {
post: {
mutationOptions: () => ({ mutationFn: mockCreateApiBasedExtension }),
},
byId: {
post: {
mutationOptions: () => ({ mutationFn: mockUpdateApiBasedExtension }),
},
},
},
},
}))
vi.mock('@tanstack/react-query', () => ({
useMutation: vi.fn((options: { mutationFn: (variables: unknown) => Promise<unknown> }) => ({
isPending: false,
mutate: (variables: unknown, mutationOptions?: { onSuccess?: (data: unknown) => void }) => {
options.mutationFn(variables).then(data => mutationOptions?.onSuccess?.(data))
},
})),
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
@ -35,7 +55,7 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
describe('ApiBasedExtensionModal', () => {
const mockOnOpenChange = vi.fn()
const mockOnSave = vi.fn()
const mockOnSaved = vi.fn()
const mockDocLink = vi.fn((path?: string) => `https://docs.dify.ai${path || ''}`)
const mockExtension = (overrides: Partial<ApiBasedExtensionResponse> = {}): ApiBasedExtensionResponse => ({
id: '1',
@ -46,15 +66,34 @@ describe('ApiBasedExtensionModal', () => {
})
const render = (ui: ReactElement) => RTLRender(ui)
const renderModal = (props: Partial<ComponentProps<typeof ApiBasedExtensionModal>> = {}) => render(
<ApiBasedExtensionModal
open
extension={{}}
onOpenChange={mockOnOpenChange}
onSave={mockOnSave}
{...props}
/>,
)
const renderModal = (props: {
open?: boolean
} | {
mode: 'edit'
apiBasedExtension: ApiBasedExtensionResponse
open?: boolean
} = {}) => {
if ('mode' in props) {
return render(
<ApiBasedExtensionModal
open={props.open ?? true}
mode="edit"
apiBasedExtension={props.apiBasedExtension}
onOpenChange={mockOnOpenChange}
onSaved={mockOnSaved}
/>,
)
}
return render(
<ApiBasedExtensionModal
open={props.open ?? true}
mode="create"
onOpenChange={mockOnOpenChange}
onSaved={mockOnSaved}
/>,
)
}
const expectCloseRequested = () => {
const calls = mockOnOpenChange.mock.calls
expect(calls[calls.length - 1]?.[0]).toBe(false)
@ -73,9 +112,9 @@ describe('ApiBasedExtensionModal', () => {
// Assert
expect(screen.getByRole('dialog', { name: 'common.apiBasedExtension.modal.title' })).toBeInTheDocument()
expect(screen.getByText('common.apiBasedExtension.modal.title')).toBeInTheDocument()
expect(screen.getByPlaceholderText('common.apiBasedExtension.modal.name.placeholder')).toBeInTheDocument()
expect(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiEndpoint.placeholder')).toBeInTheDocument()
expect(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiKey.placeholder')).toBeInTheDocument()
expect(screen.getByRole('textbox', { name: 'common.apiBasedExtension.modal.name.title' })).toHaveAttribute('required')
expect(screen.getByRole('textbox', { name: 'common.apiBasedExtension.modal.apiEndpoint.title' })).toHaveAccessibleDescription('common.apiBasedExtension.link')
expect(screen.getByRole('textbox', { name: 'common.apiBasedExtension.modal.apiKey.title' })).toHaveAttribute('required')
})
it('should render correctly for editing an existing extension', () => {
@ -83,7 +122,7 @@ describe('ApiBasedExtensionModal', () => {
const data = mockExtension()
// Act
renderModal({ extension: data })
renderModal({ mode: 'edit', apiBasedExtension: data })
// Assert
expect(screen.getByText('common.apiBasedExtension.modal.editTitle')).toBeInTheDocument()
@ -102,7 +141,7 @@ describe('ApiBasedExtensionModal', () => {
})
describe('Form Submissions', () => {
it('should call addApiBasedExtension on save for new extension', async () => {
it('should call create mutation on save for new extension', async () => {
// Arrange
const newExtension = mockExtension({
id: 'new-id',
@ -110,7 +149,7 @@ describe('ApiBasedExtensionModal', () => {
api_endpoint: 'https://api.test',
api_key: 'secret-key',
})
vi.mocked(addApiBasedExtension).mockResolvedValue(newExtension)
mockCreateApiBasedExtension.mockResolvedValue(newExtension)
renderModal()
// Act
@ -121,23 +160,22 @@ describe('ApiBasedExtensionModal', () => {
// Assert
await waitFor(() => {
expect(addApiBasedExtension).toHaveBeenCalledWith({
url: '/api-based-extension',
expect(mockCreateApiBasedExtension).toHaveBeenCalledWith({
body: {
name: 'New Ext',
api_endpoint: 'https://api.test',
api_key: 'secret-key',
},
})
expect(mockOnSave).toHaveBeenCalledWith(newExtension)
expect(mockOnSaved).toHaveBeenCalled()
})
})
it('should call updateApiBasedExtension on save for existing extension', async () => {
it('should call update mutation on save for existing extension', async () => {
// Arrange
const data = mockExtension({ api_key: 'long-secret-key' })
vi.mocked(updateApiBasedExtension).mockResolvedValue({ ...data, name: 'Updated' })
renderModal({ extension: data })
mockUpdateApiBasedExtension.mockResolvedValue({ ...data, name: 'Updated' })
renderModal({ mode: 'edit', apiBasedExtension: data })
// Act
fireEvent.change(screen.getByDisplayValue('Existing'), { target: { value: 'Updated' } })
@ -145,8 +183,10 @@ describe('ApiBasedExtensionModal', () => {
// Assert
await waitFor(() => {
expect(updateApiBasedExtension).toHaveBeenCalledWith({
url: '/api-based-extension/1',
expect(mockUpdateApiBasedExtension).toHaveBeenCalledWith({
params: {
id: '1',
},
body: {
name: 'Updated',
api_endpoint: 'url',
@ -154,15 +194,15 @@ describe('ApiBasedExtensionModal', () => {
},
})
expect(mockToast.success).toHaveBeenCalledWith('common.actionMsg.modifiedSuccessfully')
expect(mockOnSave).toHaveBeenCalled()
expect(mockOnSaved).toHaveBeenCalled()
})
})
it('should call updateApiBasedExtension with new api_key when key is changed', async () => {
it('should call update mutation with new api_key when key is changed', async () => {
// Arrange
const data = mockExtension({ api_key: 'old-key' })
vi.mocked(updateApiBasedExtension).mockResolvedValue({ ...data, api_key: 'new-longer-key' })
renderModal({ extension: data })
mockUpdateApiBasedExtension.mockResolvedValue({ ...data, api_key: 'new-longer-key' })
renderModal({ mode: 'edit', apiBasedExtension: data })
// Act
fireEvent.change(screen.getByDisplayValue('old-key'), { target: { value: 'new-longer-key' } })
@ -170,8 +210,10 @@ describe('ApiBasedExtensionModal', () => {
// Assert
await waitFor(() => {
expect(updateApiBasedExtension).toHaveBeenCalledWith({
url: '/api-based-extension/1',
expect(mockUpdateApiBasedExtension).toHaveBeenCalledWith({
params: {
id: '1',
},
body: {
name: 'Existing',
api_endpoint: 'url',
@ -194,29 +236,16 @@ describe('ApiBasedExtensionModal', () => {
fireEvent.click(screen.getByText('common.operation.save'))
// Assert
expect(mockToast.error).toHaveBeenCalledWith('common.apiBasedExtension.modal.apiKey.lengthError')
expect(addApiBasedExtension).not.toHaveBeenCalled()
await waitFor(() => {
expect(screen.getByText('common.apiBasedExtension.modal.apiKey.lengthError')).toBeInTheDocument()
expect(screen.getByRole('textbox', { name: 'common.apiBasedExtension.modal.apiKey.title' })).toHaveAttribute('aria-invalid', 'true')
})
expect(mockToast.error).not.toHaveBeenCalled()
expect(mockCreateApiBasedExtension).not.toHaveBeenCalled()
})
})
describe('Interactions', () => {
it('should work when onSave is not provided', async () => {
// Arrange
vi.mocked(addApiBasedExtension).mockResolvedValue(mockExtension({ id: 'new-id' }))
renderModal({ onSave: undefined })
// Act
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.name.placeholder'), { target: { value: 'New Ext' } })
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiEndpoint.placeholder'), { target: { value: 'https://api.test' } })
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiKey.placeholder'), { target: { value: 'secret-key' } })
fireEvent.click(screen.getByText('common.operation.save'))
// Assert
await waitFor(() => {
expect(addApiBasedExtension).toHaveBeenCalled()
})
})
it('should request closing when clicking cancel button', () => {
// Arrange
renderModal()
@ -294,7 +323,7 @@ describe('ApiBasedExtensionModal', () => {
} as unknown as ReturnType<typeof reactI18next.useTranslation>)
// Act
const { container } = renderModal({ onSave: undefined })
const { container } = renderModal()
// Assert
const inputs = container.querySelectorAll('input')

View File

@ -1,23 +1,50 @@
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
import type { UseQueryResult } from '@tanstack/react-query'
import type { ModalContextState } from '@/context/modal-context'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useModalContext } from '@/context/modal-context'
import { addApiBasedExtension } from '@/service/common'
import { useApiBasedExtensions } from '@/service/use-common'
import ApiBasedExtensionSelector from '../selector'
import { ApiBasedExtensionSelector } from '../selector'
const {
mockApiBasedExtensionsQuery,
mockCreateApiBasedExtension,
mockUpdateApiBasedExtension,
} = vi.hoisted(() => ({
mockApiBasedExtensionsQuery: vi.fn(),
mockCreateApiBasedExtension: vi.fn(),
mockUpdateApiBasedExtension: vi.fn(),
}))
vi.mock('@/context/modal-context', () => ({
useModalContext: vi.fn(),
}))
vi.mock('@/service/use-common', () => ({
useApiBasedExtensions: vi.fn(),
vi.mock('@/service/client', () => ({
consoleQuery: {
apiBasedExtension: {
get: {
queryOptions: () => ({}),
},
post: {
mutationOptions: () => ({ mutationFn: mockCreateApiBasedExtension }),
},
byId: {
post: {
mutationOptions: () => ({ mutationFn: mockUpdateApiBasedExtension }),
},
},
},
},
}))
vi.mock('@/service/common', () => ({
addApiBasedExtension: vi.fn(),
vi.mock('@tanstack/react-query', () => ({
useQuery: vi.fn(() => mockApiBasedExtensionsQuery()),
useMutation: vi.fn((options: { mutationFn: (variables: unknown) => Promise<unknown> }) => ({
isPending: false,
mutate: (variables: unknown, mutationOptions?: { onSuccess?: (data: unknown) => void }) => {
options.mutationFn(variables).then(data => mutationOptions?.onSuccess?.(data))
},
})),
}))
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
@ -25,7 +52,6 @@ vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/bas
describe('ApiBasedExtensionSelector', () => {
const mockOnChange = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()
const mockRefetch = vi.fn()
const mockData: ApiBasedExtensionResponse[] = [
{ id: '1', name: 'Extension 1', api_endpoint: 'https://api1.test', api_key: 'key1' },
@ -37,12 +63,11 @@ describe('ApiBasedExtensionSelector', () => {
vi.mocked(useModalContext).mockReturnValue({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
} as unknown as ModalContextState)
vi.mocked(useApiBasedExtensions).mockReturnValue({
mockApiBasedExtensionsQuery.mockReturnValue({
data: mockData,
refetch: mockRefetch,
isPending: false,
isError: false,
} as unknown as UseQueryResult<ApiBasedExtensionResponse[], Error>)
})
})
describe('Rendering', () => {
@ -105,9 +130,9 @@ describe('ApiBasedExtensionSelector', () => {
})
})
it('should open add modal when clicking add button and refetches on save', async () => {
it('should open add modal when clicking add button and close it after save', async () => {
// Arrange
vi.mocked(addApiBasedExtension).mockResolvedValue({
mockCreateApiBasedExtension.mockResolvedValue({
id: 'new-id',
name: 'New Ext',
api_endpoint: 'https://api.test',
@ -127,7 +152,14 @@ describe('ApiBasedExtensionSelector', () => {
// Assert
await waitFor(() => {
expect(mockRefetch).toHaveBeenCalled()
expect(mockCreateApiBasedExtension).toHaveBeenCalledWith({
body: {
name: 'New Ext',
api_endpoint: 'https://api.test',
api_key: 'secret-key',
},
})
expect(screen.queryByRole('dialog', { name: 'common.apiBasedExtension.modal.title' })).not.toBeInTheDocument()
})
})
})

View File

@ -5,7 +5,7 @@ import {
import { useTranslation } from 'react-i18next'
import { useDocLink } from '@/context/i18n'
const Empty = () => {
export function Empty() {
const { t } = useTranslation()
const docLink = useDocLink()
@ -27,5 +27,3 @@ const Empty = () => {
</div>
)
}
export default Empty

View File

@ -1,36 +1,37 @@
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
import { Button } from '@langgenius/dify-ui/button'
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useApiBasedExtensions } from '@/service/use-common'
import Empty from './empty'
import Item from './item'
import ApiBasedExtensionModal from './modal'
import { consoleQuery } from '@/service/client'
import { Empty } from './empty'
import { Item } from './item'
import { ApiBasedExtensionModal } from './modal'
type ApiBasedExtensionDialogState = {
extension: Partial<ApiBasedExtensionResponse>
onSave: () => void
mode: 'create'
} | {
mode: 'edit'
apiBasedExtension: ApiBasedExtensionResponse
} | null
const ApiBasedExtensionPage = () => {
export function ApiBasedExtensionPage() {
const { t } = useTranslation()
const { data, refetch: mutate, isPending: isLoading } = useApiBasedExtensions()
const { data: apiBasedExtensions = [], isPending: isLoading } = useQuery(consoleQuery.apiBasedExtension.get.queryOptions())
const [dialogState, setDialogState] = useState<ApiBasedExtensionDialogState>(null)
const handleOpenApiBasedExtensionModal = () => {
setDialogState({
extension: {},
onSave: () => mutate(),
mode: 'create',
})
}
const handleEditApiBasedExtension = (extension: ApiBasedExtensionResponse) => {
const handleEditApiBasedExtension = (apiBasedExtension: ApiBasedExtensionResponse) => {
setDialogState({
extension,
onSave: () => mutate(),
mode: 'edit',
apiBasedExtension,
})
}
const handleSaveApiBasedExtension = () => {
dialogState?.onSave()
const handleApiBasedExtensionSaved = () => {
setDialogState(null)
}
const handleApiBasedExtensionModalOpenChange = (open: boolean) => {
@ -41,18 +42,17 @@ const ApiBasedExtensionPage = () => {
return (
<div>
{
!isLoading && !data?.length && (
!isLoading && !apiBasedExtensions.length && (
<Empty />
)
}
{
!isLoading && !!data?.length && (
data.map(item => (
!isLoading && !!apiBasedExtensions.length && (
apiBasedExtensions.map(item => (
<Item
key={item.id}
data={item}
apiBasedExtension={item}
onEdit={handleEditApiBasedExtension}
onUpdate={() => mutate()}
/>
))
)
@ -66,17 +66,26 @@ const ApiBasedExtensionPage = () => {
{t('apiBasedExtension.add', { ns: 'common' })}
</Button>
{
dialogState && (
dialogState?.mode === 'create' && (
<ApiBasedExtensionModal
open
extension={dialogState.extension}
mode="create"
onOpenChange={handleApiBasedExtensionModalOpenChange}
onSave={handleSaveApiBasedExtension}
onSaved={handleApiBasedExtensionSaved}
/>
)
}
{
dialogState?.mode === 'edit' && (
<ApiBasedExtensionModal
open
mode="edit"
apiBasedExtension={dialogState.apiBasedExtension}
onOpenChange={handleApiBasedExtensionModalOpenChange}
onSaved={handleApiBasedExtensionSaved}
/>
)
}
</div>
)
}
export default ApiBasedExtensionPage

View File

@ -8,38 +8,43 @@ import {
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { useMutation } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { deleteApiBasedExtension } from '@/service/common'
import { consoleQuery } from '@/service/client'
type ItemProps = {
data: ApiBasedExtensionResponse
onEdit: (extension: ApiBasedExtensionResponse) => void
onUpdate: () => void
apiBasedExtension: ApiBasedExtensionResponse
onEdit: (apiBasedExtension: ApiBasedExtensionResponse) => void
}
const Item = ({
data,
export function Item({
apiBasedExtension,
onEdit,
onUpdate,
}: ItemProps) => {
}: ItemProps) {
const { t } = useTranslation()
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const deleteApiBasedExtensionMutation = useMutation(consoleQuery.apiBasedExtension.byId.delete.mutationOptions())
const handleOpenApiBasedExtensionModal = () => {
onEdit(data)
onEdit(apiBasedExtension)
}
const handleDeleteApiBasedExtension = async () => {
await deleteApiBasedExtension(`/api-based-extension/${data.id}`)
setShowDeleteConfirm(false)
onUpdate()
const handleDeleteApiBasedExtension = () => {
deleteApiBasedExtensionMutation.mutate({
params: {
id: apiBasedExtension.id,
},
}, {
onSuccess: () => {
setShowDeleteConfirm(false)
},
})
}
return (
<div className="group mb-2 flex items-center rounded-xl border-[0.5px] border-transparent bg-components-input-bg-normal px-4 py-2 focus-within:border-components-input-border-active focus-within:shadow-xs hover:border-components-input-border-active hover:shadow-xs">
<div className="min-w-0 grow">
<div className="mb-0.5 text-[13px] font-medium text-text-secondary">{data.name}</div>
<div className="truncate text-xs text-text-tertiary">{data.api_endpoint}</div>
<div className="mb-0.5 text-[13px] font-medium text-text-secondary">{apiBasedExtension.name}</div>
<div className="truncate text-xs text-text-tertiary">{apiBasedExtension.api_endpoint}</div>
</div>
<div className="pointer-events-none flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100">
<Button
@ -59,12 +64,15 @@ const Item = ({
<AlertDialogContent backdropProps={{ forceRender: true }}>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{`${t('operation.delete', { ns: 'common' })} \u201C${data.name}\u201D?`}
{`${t('operation.delete', { ns: 'common' })} \u201C${apiBasedExtension.name}\u201D?`}
</AlertDialogTitle>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={handleDeleteApiBasedExtension}>
<AlertDialogConfirmButton
disabled={deleteApiBasedExtensionMutation.isPending}
onClick={handleDeleteApiBasedExtension}
>
{t('operation.delete', { ns: 'common' }) || ''}
</AlertDialogConfirmButton>
</AlertDialogActions>
@ -73,5 +81,3 @@ const Item = ({
</div>
)
}
export default Item

View File

@ -4,64 +4,67 @@ import type {
} from '@dify/contracts/api/console/api-based-extension/types.gen'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { FieldControl, FieldDescription, FieldError, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
import { Form } from '@langgenius/dify-ui/form'
import { toast } from '@langgenius/dify-ui/toast'
import { useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { useDocLink } from '@/context/i18n'
import { addApiBasedExtension, updateApiBasedExtension } from '@/service/common'
type ApiBasedExtensionField = 'name' | 'api_endpoint' | 'api_key'
import { consoleQuery } from '@/service/client'
type ApiBasedExtensionModalProps = {
open: boolean
extension: Partial<ApiBasedExtensionResponse>
onOpenChange: (open: boolean) => void
onSave?: (newData: ApiBasedExtensionResponse) => void
}
const ApiBasedExtensionModal = ({ open, extension, onOpenChange, onSave }: ApiBasedExtensionModalProps) => {
onSaved: () => void
} & ({
mode: 'create'
} | {
mode: 'edit'
apiBasedExtension: ApiBasedExtensionResponse
})
export function ApiBasedExtensionModal(props: ApiBasedExtensionModalProps) {
const { open, mode, onOpenChange, onSaved } = props
const { t } = useTranslation()
const docLink = useDocLink()
const [localData, setLocalData] = useState(extension)
const [loading, setLoading] = useState(false)
const handleDataChange = (field: ApiBasedExtensionField, value: string) => {
setLocalData({ ...localData, [field]: value })
}
const handleSave = async () => {
setLoading(true)
if (localData.api_key && localData.api_key.length < 5) {
toast.error(t('apiBasedExtension.modal.apiKey.lengthError', { ns: 'common' }))
setLoading(false)
const createApiBasedExtensionMutation = useMutation(consoleQuery.apiBasedExtension.post.mutationOptions())
const updateApiBasedExtensionMutation = useMutation(consoleQuery.apiBasedExtension.byId.post.mutationOptions())
const editingApiBasedExtension = mode === 'edit' ? props.apiBasedExtension : null
const isSaving = createApiBasedExtensionMutation.isPending || updateApiBasedExtensionMutation.isPending
const nameLabel = t('apiBasedExtension.modal.name.title', { ns: 'common' })
const apiEndpointLabel = t('apiBasedExtension.modal.apiEndpoint.title', { ns: 'common' })
const apiKeyLabel = t('apiBasedExtension.modal.apiKey.title', { ns: 'common' })
const handleSubmit = (formValues: ApiBasedExtensionPayload) => {
const body: ApiBasedExtensionPayload = {
name: formValues.name,
api_endpoint: formValues.api_endpoint,
api_key: formValues.api_key,
}
if (editingApiBasedExtension) {
updateApiBasedExtensionMutation.mutate({
params: {
id: editingApiBasedExtension.id,
},
body: {
...body,
api_key: editingApiBasedExtension.api_key === body.api_key ? '[__HIDDEN__]' : body.api_key,
},
}, {
onSuccess: () => {
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
onSaved()
},
})
return
}
try {
const payload: ApiBasedExtensionPayload = {
name: localData.name || '',
api_endpoint: localData.api_endpoint || '',
api_key: localData.api_key || '',
}
let res = {} as ApiBasedExtensionResponse
if (!extension.id) {
res = await addApiBasedExtension({
url: '/api-based-extension',
body: payload,
})
}
else {
res = await updateApiBasedExtension({
url: `/api-based-extension/${extension.id}`,
body: {
...payload,
api_key: extension.api_key === localData.api_key ? '[__HIDDEN__]' : payload.api_key,
},
})
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
}
if (onSave)
onSave(res)
}
finally {
setLoading(false)
}
createApiBasedExtensionMutation.mutate({
body,
}, {
onSuccess: onSaved,
})
}
return (
@ -73,42 +76,71 @@ const ApiBasedExtensionModal = ({ open, extension, onOpenChange, onSave }: ApiBa
<DialogCloseButton />
<DialogTitle className="mb-2 pr-8 text-xl font-semibold text-text-primary">
{extension.name
{mode === 'edit'
? t('apiBasedExtension.modal.editTitle', { ns: 'common' })
: t('apiBasedExtension.modal.title', { ns: 'common' })}
</DialogTitle>
<div className="py-2">
<div className="text-sm leading-9 font-medium text-text-primary">
{t('apiBasedExtension.modal.name.title', { ns: 'common' })}
<Form<ApiBasedExtensionPayload> className="grid gap-4 pt-2" onFormSubmit={handleSubmit}>
<FieldRoot name="name">
<FieldLabel>{nameLabel}</FieldLabel>
<FieldControl
required
defaultValue={editingApiBasedExtension?.name || ''}
placeholder={t('apiBasedExtension.modal.name.placeholder', { ns: 'common' }) || ''}
/>
<FieldError match="valueMissing">{t('errorMsg.fieldRequired', { ns: 'common', field: nameLabel })}</FieldError>
</FieldRoot>
<FieldRoot name="api_endpoint">
<FieldLabel>{apiEndpointLabel}</FieldLabel>
<FieldControl
required
defaultValue={editingApiBasedExtension?.api_endpoint || ''}
placeholder={t('apiBasedExtension.modal.apiEndpoint.placeholder', { ns: 'common' }) || ''}
/>
<FieldDescription>
<a
href={docLink('/use-dify/workspace/api-extension/api-extension')}
target="_blank"
rel="noopener noreferrer"
className="inline-flex w-fit items-center text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
>
<span className="mr-1 i-custom-vender-line-education-book-open-01 h-3 w-3" aria-hidden="true" />
{t('apiBasedExtension.link', { ns: 'common' })}
</a>
</FieldDescription>
<FieldError match="valueMissing">{t('errorMsg.fieldRequired', { ns: 'common', field: apiEndpointLabel })}</FieldError>
</FieldRoot>
<FieldRoot
name="api_key"
validate={(value) => {
if (typeof value === 'string' && value.length > 0 && value.length < 5)
return t('apiBasedExtension.modal.apiKey.lengthError', { ns: 'common' })
return null
}}
>
<FieldLabel>{apiKeyLabel}</FieldLabel>
<FieldControl
required
defaultValue={editingApiBasedExtension?.api_key || ''}
placeholder={t('apiBasedExtension.modal.apiKey.placeholder', { ns: 'common' }) || ''}
/>
<FieldError match="valueMissing">{t('errorMsg.fieldRequired', { ns: 'common', field: apiKeyLabel })}</FieldError>
<FieldError match="customError" />
</FieldRoot>
<div className="mt-2 flex items-center justify-end gap-2">
<Button type="button" onClick={() => onOpenChange(false)}>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button type="submit" variant="primary" disabled={isSaving}>
{t('operation.save', { ns: 'common' })}
</Button>
</div>
<input value={localData.name || ''} onChange={e => handleDataChange('name', e.target.value)} className="block h-9 w-full appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-text-primary outline-hidden" placeholder={t('apiBasedExtension.modal.name.placeholder', { ns: 'common' }) || ''} />
</div>
<div className="py-2">
<div className="flex h-9 items-center justify-between text-sm font-medium text-text-primary">
{t('apiBasedExtension.modal.apiEndpoint.title', { ns: 'common' })}
<a href={docLink('/use-dify/workspace/api-extension/api-extension')} target="_blank" rel="noopener noreferrer" className="flex items-center text-xs font-normal text-text-accent">
<span className="mr-1 i-custom-vender-line-education-book-open-01 h-3 w-3" aria-hidden="true" />
{t('apiBasedExtension.link', { ns: 'common' })}
</a>
</div>
<input value={localData.api_endpoint || ''} onChange={e => handleDataChange('api_endpoint', e.target.value)} className="block h-9 w-full appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-text-primary outline-hidden" placeholder={t('apiBasedExtension.modal.apiEndpoint.placeholder', { ns: 'common' }) || ''} />
</div>
<div className="py-2">
<div className="text-sm leading-9 font-medium text-text-primary">
{t('apiBasedExtension.modal.apiKey.title', { ns: 'common' })}
</div>
<input value={localData.api_key || ''} onChange={e => handleDataChange('api_key', e.target.value)} className="block h-9 w-full appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-text-primary outline-hidden" placeholder={t('apiBasedExtension.modal.apiKey.placeholder', { ns: 'common' }) || ''} />
</div>
<div className="mt-6 flex items-center justify-end gap-2">
<Button onClick={() => onOpenChange(false)}>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button variant="primary" disabled={!localData.name || !localData.api_endpoint || !localData.api_key || loading} onClick={handleSave}>
{t('operation.save', { ns: 'common' })}
</Button>
</div>
</Form>
</DialogContent>
</Dialog>
)
}
export default ApiBasedExtensionModal

View File

@ -1,36 +1,36 @@
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useModalContext } from '@/context/modal-context'
import { useApiBasedExtensions } from '@/service/use-common'
import ApiBasedExtensionModal from './modal'
import { consoleQuery } from '@/service/client'
import { ApiBasedExtensionModal } from './modal'
type ApiBasedExtensionSelectorProps = {
value: string
onChange: (value: string) => void
}
const ApiBasedExtensionSelector = ({
export function ApiBasedExtensionSelector({
value,
onChange,
}: ApiBasedExtensionSelectorProps) => {
}: ApiBasedExtensionSelectorProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [addModalOpen, setAddModalOpen] = useState(false)
const {
setShowAccountSettingModal,
} = useModalContext()
const { data, refetch: mutate } = useApiBasedExtensions()
const { data: apiBasedExtensions = [] } = useQuery(consoleQuery.apiBasedExtension.get.queryOptions())
const handleSelect = (id: string) => {
onChange(id)
setOpen(false)
}
const currentItem = data?.find(item => item.id === value)
const currentItem = apiBasedExtensions.find(item => item.id === value)
const handleSaveApiBasedExtension = () => {
mutate()
const handleApiBasedExtensionSaved = () => {
setAddModalOpen(false)
}
const handleAddModalOpenChange = (nextOpen: boolean) => {
@ -96,12 +96,12 @@ const ApiBasedExtensionSelector = ({
</div>
<div className="max-h-[250px] overflow-y-auto">
{
data?.map(item => (
apiBasedExtensions.map(item => (
<button
type="button"
key={item.id}
className="w-full cursor-pointer rounded-md border-none bg-transparent px-3 py-1.5 text-left hover:bg-state-base-hover"
onClick={() => handleSelect(item.id!)}
onClick={() => handleSelect(item.id)}
>
<div className="text-sm text-text-primary">{item.name}</div>
<div className="text-xs text-text-tertiary">{item.api_endpoint}</div>
@ -131,14 +131,12 @@ const ApiBasedExtensionSelector = ({
addModalOpen && (
<ApiBasedExtensionModal
open
extension={{}}
mode="create"
onOpenChange={handleAddModalOpenChange}
onSave={handleSaveApiBasedExtension}
onSaved={handleApiBasedExtensionSaved}
/>
)
}
</>
)
}
export default ApiBasedExtensionSelector

View File

@ -16,7 +16,7 @@ import MenuDialog from '@/app/components/header/account-setting/menu-dialog'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import ApiBasedExtensionPage from './api-based-extension-page'
import { ApiBasedExtensionPage } from './api-based-extension-page'
import DataSourcePage from './data-source-page-new'
import LanguagePage from './language-page'
import MembersPage from './members-page'

View File

@ -13,6 +13,7 @@ let parameterRules: Array<Record<string, unknown>> | undefined = [
},
]
let isRulesLoading = false
let isRulesPending = false
let currentProvider: Record<string, unknown> | undefined = { provider: 'openai', label: { en_US: 'OpenAI' } }
let currentModel: Record<string, unknown> | undefined = {
model: 'gpt-3.5-turbo',
@ -49,7 +50,7 @@ vi.mock('@/service/use-common', () => ({
data: parameterRules,
},
isLoading: isRulesLoading,
isPending: isRulesLoading,
isPending: isRulesPending,
}),
}))
@ -92,9 +93,21 @@ vi.mock('../../model-selector', () => ({
}))
vi.mock('../presets-parameter', () => ({
default: ({ onSelect }: { onSelect: (id: number) => void }) => (
<button onClick={() => onSelect(1)}>Preset 1</button>
),
default: ({ onSelect, supportedParameterNames }: { onSelect: (id: number) => void, supportedParameterNames?: string[] }) => {
if (supportedParameterNames && !supportedParameterNames.includes('temperature'))
return null
return <button onClick={() => onSelect(1)}>Preset 1</button>
},
}))
vi.mock('../presets-parameter-utils', () => ({
getSupportedPresetConfig: (_toneId: number, supportedParameterNames?: string[]) => {
if (supportedParameterNames && !supportedParameterNames.includes('temperature'))
return {}
return { temperature: 0.8 }
},
}))
vi.mock('../trigger', () => ({
@ -126,6 +139,7 @@ describe('ModelParameterModal', () => {
beforeEach(() => {
vi.clearAllMocks()
isRulesLoading = false
isRulesPending = false
parameterRules = [
{
name: 'temperature',
@ -194,7 +208,28 @@ describe('ModelParameterModal', () => {
render(<ModelParameterModal {...defaultProps} />)
fireEvent.click(screen.getByText('Open Settings'))
fireEvent.click(screen.getByText('Preset 1'))
expect(defaultProps.onCompletionParamsChange).toHaveBeenCalled()
expect(defaultProps.onCompletionParamsChange).toHaveBeenCalledWith({
...defaultProps.completionParams,
temperature: 0.8,
})
})
it('should not render preset control when visible parameters do not support preset keys', () => {
parameterRules = [
{
name: 'max_tokens',
label: { en_US: 'Max Tokens' },
type: 'int',
default: 256,
min: 1,
max: 4096,
},
]
render(<ModelParameterModal {...defaultProps} />)
fireEvent.click(screen.getByText('Open Settings'))
expect(screen.queryByText('Preset 1')).not.toBeInTheDocument()
})
it('should call setModel when model selector picks another model', () => {
@ -219,11 +254,29 @@ describe('ModelParameterModal', () => {
it('should render loading state when parameter rules are loading', () => {
isRulesLoading = true
isRulesPending = true
render(<ModelParameterModal {...defaultProps} />)
fireEvent.click(screen.getByText('Open Settings'))
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('should not render parameter loading when model is not configured and parameter rules query is pending but disabled', () => {
isRulesPending = true
parameterRules = []
render(
<ModelParameterModal
{...defaultProps}
provider=""
modelId=""
/>,
)
fireEvent.click(screen.getByText('Open Settings'))
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
})
it('should not open content when readonly is true', () => {
render(<ModelParameterModal {...defaultProps} readonly />)
fireEvent.click(screen.getByText('Open Settings'))
@ -299,6 +352,7 @@ describe('ModelParameterModal', () => {
it('should render the empty loading fallback when rules resolve to an empty list', () => {
parameterRules = []
isRulesLoading = true
isRulesPending = true
render(<ModelParameterModal {...defaultProps} />)
fireEvent.click(screen.getByText('Open Settings'))

View File

@ -1,6 +1,7 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { vi } from 'vitest'
import PresetsParameter from '../presets-parameter'
import { getSupportedPresetConfig } from '../presets-parameter-utils'
describe('PresetsParameter', () => {
beforeEach(() => {
@ -47,4 +48,22 @@ describe('PresetsParameter', () => {
expect(onSelect).toHaveBeenCalledWith(3)
})
it('should render presets when at least one preset parameter is supported', () => {
render(<PresetsParameter onSelect={vi.fn()} supportedParameterNames={['temperature']} />)
expect(screen.getByRole('button', { name: /common\.modelProvider\.loadPresets/i })).toBeInTheDocument()
})
it('should not render presets when no preset parameters are supported', () => {
render(<PresetsParameter onSelect={vi.fn()} supportedParameterNames={['max_tokens']} />)
expect(screen.queryByRole('button', { name: /common\.modelProvider\.loadPresets/i })).not.toBeInTheDocument()
})
it('should return only supported preset config keys', () => {
expect(getSupportedPresetConfig(1, ['temperature'])).toEqual({
temperature: 0.8,
})
})
})

View File

@ -24,7 +24,7 @@ import { useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ArrowNarrowLeft } from '@/app/components/base/icons/src/vender/line/arrows'
import Loading from '@/app/components/base/loading'
import { PROVIDER_WITH_PRESET_TONE, STOP_PARAMETER_RULE, TONE_LIST } from '@/config'
import { PROVIDER_WITH_PRESET_TONE, STOP_PARAMETER_RULE } from '@/config'
import { useModelParameterRules } from '@/service/use-common'
import {
useTextGenerationCurrentProviderAndModelAndModelList,
@ -32,6 +32,7 @@ import {
import ModelSelector from '../model-selector'
import ParameterItem from './parameter-item'
import PresetsParameter from './presets-parameter'
import { getSupportedPresetConfig } from './presets-parameter-utils'
import Trigger from './trigger'
export type ModelParameterModalProps = {
@ -75,10 +76,9 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
const settingsIconRef = useRef<HTMLDivElement>(null)
const {
data: parameterRulesData,
isPending,
isLoading,
} = useModelParameterRules(provider, modelId)
const isRulesLoading = isPending || isLoading
const isRulesLoading = !!provider && !!modelId && isLoading
const {
currentProvider,
currentModel,
@ -90,6 +90,9 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
const parameterRules: ModelParameterRule[] = useMemo(() => {
return parameterRulesData?.data || []
}, [parameterRulesData])
const supportedPresetParameterNames = useMemo(() => {
return parameterRules.map(parameterRule => parameterRule.name)
}, [parameterRules])
const handleParamChange = (key: string, value: ParameterValue) => {
onCompletionParamsChange({
@ -125,13 +128,10 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
}
const handleSelectPresetParameter = (toneId: number) => {
const tone = TONE_LIST.find(tone => tone.id === toneId)
if (tone) {
onCompletionParamsChange({
...completionParams,
...tone.config,
})
}
onCompletionParamsChange({
...completionParams,
...getSupportedPresetConfig(toneId, supportedPresetParameterNames),
})
}
return (
@ -199,7 +199,10 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
<div className="flex flex-1 items-center system-sm-semibold-uppercase text-text-secondary">{t('modelProvider.parameters', { ns: 'common' })}</div>
{
PROVIDER_WITH_PRESET_TONE.includes(provider) && (
<PresetsParameter onSelect={handleSelectPresetParameter} />
<PresetsParameter
onSelect={handleSelectPresetParameter}
supportedParameterNames={supportedPresetParameterNames}
/>
)
}
</div>

View File

@ -0,0 +1,19 @@
import { TONE_LIST } from '@/config'
export const getSupportedPresetConfig = (toneId: number, supportedParameterNames?: string[]) => {
const tone = TONE_LIST.find(tone => tone.id === toneId)
if (!tone?.config)
return {}
if (!supportedParameterNames)
return { ...tone.config }
const supportedParameterNameSet = new Set(supportedParameterNames)
return Object.entries(tone.config).reduce<Record<string, number>>((acc, [key, value]) => {
if (supportedParameterNameSet.has(key))
acc[key] = value
return acc
}, {})
}

View File

@ -12,6 +12,8 @@ import { Scales02 } from '@/app/components/base/icons/src/vender/solid/FinanceAn
import { Target04 } from '@/app/components/base/icons/src/vender/solid/general'
import { TONE_LIST } from '@/config'
const PRESET_TONE_LIST = TONE_LIST.slice(0, 3)
const toneI18nKeyMap = {
Creative: 'model.tone.Creative',
Balanced: 'model.tone.Balanced',
@ -27,10 +29,18 @@ const TONE_ICONS: Record<number, ReactNode> = {
type PresetsParameterProps = {
onSelect: (toneId: number) => void
supportedParameterNames?: string[]
}
function PresetsParameter({ onSelect }: PresetsParameterProps) {
function PresetsParameter({ onSelect, supportedParameterNames }: PresetsParameterProps) {
const { t } = useTranslation()
const supportedParameterNameSet = supportedParameterNames ? new Set(supportedParameterNames) : undefined
const visiblePresetTones = supportedParameterNameSet
? PRESET_TONE_LIST.filter(tone => Object.keys(tone.config ?? {}).some(key => supportedParameterNameSet.has(key)))
: PRESET_TONE_LIST
if (!visiblePresetTones.length)
return null
return (
<DropdownMenu>
@ -47,7 +57,7 @@ function PresetsParameter({ onSelect }: PresetsParameterProps) {
<span className="ml-0.5 i-ri-arrow-down-s-line h-3.5 w-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent>
{TONE_LIST.slice(0, 3).map(tone => (
{visiblePresetTones.map(tone => (
<DropdownMenuItem key={tone.id} onClick={() => onSelect(tone.id)}>
{TONE_ICONS[tone.id]}
{t(toneI18nKeyMap[tone.name], { ns: 'common' })}

View File

@ -75,14 +75,58 @@ vi.mock('@/config', () => ({
// Mock PresetsParameter component
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter', () => ({
default: ({ onSelect }: { onSelect: (toneId: number) => void }) => (
<div data-testid="presets-parameter">
<button data-testid="preset-creative" onClick={() => onSelect(1)}>Creative</button>
<button data-testid="preset-balanced" onClick={() => onSelect(2)}>Balanced</button>
<button data-testid="preset-precise" onClick={() => onSelect(3)}>Precise</button>
<button data-testid="preset-custom" onClick={() => onSelect(4)}>Custom</button>
</div>
),
default: ({ onSelect, supportedParameterNames }: { onSelect: (toneId: number) => void, supportedParameterNames?: string[] }) => {
const hasSupportedParameter = !supportedParameterNames || supportedParameterNames.some(name => ['temperature', 'top_p', 'presence_penalty', 'frequency_penalty'].includes(name))
if (!hasSupportedParameter)
return null
return (
<div data-testid="presets-parameter">
<button data-testid="preset-creative" onClick={() => onSelect(1)}>Creative</button>
<button data-testid="preset-balanced" onClick={() => onSelect(2)}>Balanced</button>
<button data-testid="preset-precise" onClick={() => onSelect(3)}>Precise</button>
<button data-testid="preset-custom" onClick={() => onSelect(4)}>Custom</button>
</div>
)
},
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter-utils', () => ({
getSupportedPresetConfig: (toneId: number, supportedParameterNames?: string[]) => {
const toneConfigMap: Record<number, Record<string, number> | undefined> = {
1: {
temperature: 0.8,
top_p: 0.9,
presence_penalty: 0.1,
frequency_penalty: 0.1,
},
2: {
temperature: 0.5,
top_p: 0.85,
presence_penalty: 0.2,
frequency_penalty: 0.3,
},
3: {
temperature: 0.2,
top_p: 0.75,
presence_penalty: 0.5,
frequency_penalty: 0.5,
},
}
const toneConfig = toneConfigMap[toneId]
if (!toneConfig)
return {}
if (!supportedParameterNames)
return toneConfig
return Object.entries(toneConfig).reduce<Record<string, number>>((acc, [key, value]) => {
if (supportedParameterNames.includes(key))
acc[key] = value
return acc
}, {})
},
}))
// Mock ParameterItem component
@ -148,10 +192,12 @@ const createDefaultProps = (overrides: Partial<{
const setupModelParameterRulesMock = (config: {
data?: ModelParameterRule[]
isPending?: boolean
isLoading?: boolean
} = {}) => {
mockUseModelParameterRules.mockReturnValue({
data: config.data ? { data: config.data } : undefined,
isPending: config.isPending ?? false,
isLoading: config.isLoading ?? config.isPending ?? false,
})
}
@ -188,6 +234,19 @@ describe('LLMParamsPanel', () => {
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('should not render loading state when model is not configured and parameter rules query is pending but disabled', () => {
// Arrange
setupModelParameterRulesMock({ isPending: true, isLoading: false })
const props = createDefaultProps({ provider: '', modelId: '' })
// Act
render(<LLMParamsPanel {...props} />)
// Assert
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(screen.getByText('common.modelProvider.parameters')).toBeInTheDocument()
})
it('should render parameters header', () => {
// Arrange
setupModelParameterRulesMock({ data: [], isPending: false })
@ -202,7 +261,7 @@ describe('LLMParamsPanel', () => {
it('should render PresetsParameter for openai provider', () => {
// Arrange
setupModelParameterRulesMock({ data: [], isPending: false })
setupModelParameterRulesMock({ data: [createParameterRule({ name: 'temperature' })], isPending: false })
const props = createDefaultProps({ provider: 'langgenius/openai/openai' })
// Act
@ -214,7 +273,7 @@ describe('LLMParamsPanel', () => {
it('should render PresetsParameter for azure_openai provider', () => {
// Arrange
setupModelParameterRulesMock({ data: [], isPending: false })
setupModelParameterRulesMock({ data: [createParameterRule({ name: 'temperature' })], isPending: false })
const props = createDefaultProps({ provider: 'langgenius/azure_openai/azure_openai' })
// Act
@ -224,6 +283,18 @@ describe('LLMParamsPanel', () => {
expect(screen.getByTestId('presets-parameter')).toBeInTheDocument()
})
it('should not render PresetsParameter when no visible parameter supports presets', () => {
// Arrange
setupModelParameterRulesMock({ data: [createParameterRule({ name: 'max_tokens', type: 'int' })], isPending: false })
const props = createDefaultProps({ provider: 'langgenius/openai/openai' })
// Act
render(<LLMParamsPanel {...props} />)
// Assert
expect(screen.queryByTestId('presets-parameter')).not.toBeInTheDocument()
})
it('should not render PresetsParameter for non-preset providers', () => {
// Arrange
setupModelParameterRulesMock({ data: [], isPending: false })
@ -360,7 +431,15 @@ describe('LLMParamsPanel', () => {
it('should apply Creative preset config', () => {
// Arrange
const onCompletionParamsChange = vi.fn()
setupModelParameterRulesMock({ data: [], isPending: false })
setupModelParameterRulesMock({
data: [
createParameterRule({ name: 'temperature' }),
createParameterRule({ name: 'top_p' }),
createParameterRule({ name: 'presence_penalty' }),
createParameterRule({ name: 'frequency_penalty' }),
],
isPending: false,
})
const props = createDefaultProps({
provider: 'langgenius/openai/openai',
onCompletionParamsChange,
@ -384,7 +463,15 @@ describe('LLMParamsPanel', () => {
it('should apply Balanced preset config', () => {
// Arrange
const onCompletionParamsChange = vi.fn()
setupModelParameterRulesMock({ data: [], isPending: false })
setupModelParameterRulesMock({
data: [
createParameterRule({ name: 'temperature' }),
createParameterRule({ name: 'top_p' }),
createParameterRule({ name: 'presence_penalty' }),
createParameterRule({ name: 'frequency_penalty' }),
],
isPending: false,
})
const props = createDefaultProps({
provider: 'langgenius/openai/openai',
onCompletionParamsChange,
@ -407,7 +494,15 @@ describe('LLMParamsPanel', () => {
it('should apply Precise preset config', () => {
// Arrange
const onCompletionParamsChange = vi.fn()
setupModelParameterRulesMock({ data: [], isPending: false })
setupModelParameterRulesMock({
data: [
createParameterRule({ name: 'temperature' }),
createParameterRule({ name: 'top_p' }),
createParameterRule({ name: 'presence_penalty' }),
createParameterRule({ name: 'frequency_penalty' }),
],
isPending: false,
})
const props = createDefaultProps({
provider: 'langgenius/openai/openai',
onCompletionParamsChange,
@ -430,7 +525,7 @@ describe('LLMParamsPanel', () => {
it('should apply empty config for Custom preset (spreads undefined)', () => {
// Arrange
const onCompletionParamsChange = vi.fn()
setupModelParameterRulesMock({ data: [], isPending: false })
setupModelParameterRulesMock({ data: [createParameterRule({ name: 'temperature' })], isPending: false })
const props = createDefaultProps({
provider: 'langgenius/openai/openai',
onCompletionParamsChange,
@ -444,6 +539,27 @@ describe('LLMParamsPanel', () => {
// Assert - Custom preset has no config, so only existing params are kept
expect(onCompletionParamsChange).toHaveBeenCalledWith({ existing: 'value' })
})
it('should apply only preset config keys supported by visible parameters', () => {
// Arrange
const onCompletionParamsChange = vi.fn()
setupModelParameterRulesMock({ data: [createParameterRule({ name: 'temperature' })], isPending: false })
const props = createDefaultProps({
provider: 'langgenius/openai/openai',
onCompletionParamsChange,
completionParams: { existing: 'value' },
})
// Act
render(<LLMParamsPanel {...props} />)
fireEvent.click(screen.getByTestId('preset-creative'))
// Assert
expect(onCompletionParamsChange).toHaveBeenCalledWith({
existing: 'value',
temperature: 0.8,
})
})
})
describe('handleParamChange', () => {

View File

@ -10,7 +10,8 @@ import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import ParameterItem from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item'
import PresetsParameter from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter'
import { PROVIDER_WITH_PRESET_TONE, STOP_PARAMETER_RULE, TONE_LIST } from '@/config'
import { getSupportedPresetConfig } from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter-utils'
import { PROVIDER_WITH_PRESET_TONE, STOP_PARAMETER_RULE } from '@/config'
import { useModelParameterRules } from '@/service/use-common'
type Props = {
@ -29,20 +30,21 @@ const LLMParamsPanel = ({
onCompletionParamsChange,
}: Props) => {
const { t } = useTranslation()
const { data: parameterRulesData, isPending: isLoading } = useModelParameterRules(provider, modelId)
const { data: parameterRulesData, isLoading } = useModelParameterRules(provider, modelId)
const isRulesLoading = !!provider && !!modelId && isLoading
const parameterRules: ModelParameterRule[] = useMemo(() => {
return parameterRulesData?.data || []
}, [parameterRulesData])
const supportedPresetParameterNames = useMemo(() => {
return parameterRules.map(parameterRule => parameterRule.name)
}, [parameterRules])
const handleSelectPresetParameter = (toneId: number) => {
const tone = TONE_LIST.find(tone => tone.id === toneId)
if (tone) {
onCompletionParamsChange({
...completionParams,
...tone.config,
})
}
onCompletionParamsChange({
...completionParams,
...getSupportedPresetConfig(toneId, supportedPresetParameterNames),
})
}
const handleParamChange = (key: string, value: ParameterValue) => {
onCompletionParamsChange({
@ -65,7 +67,7 @@ const LLMParamsPanel = ({
}
}
if (isLoading) {
if (isRulesLoading) {
return (
<div className="mt-5"><Loading /></div>
)
@ -77,7 +79,10 @@ const LLMParamsPanel = ({
<div className={cn('flex h-6 items-center system-sm-semibold text-text-secondary')}>{t('modelProvider.parameters', { ns: 'common' })}</div>
{
PROVIDER_WITH_PRESET_TONE.includes(provider) && (
<PresetsParameter onSelect={handleSelectPresetParameter} />
<PresetsParameter
onSelect={handleSelectPresetParameter}
supportedParameterNames={supportedPresetParameterNames}
/>
)
}
</div>

View File

@ -305,6 +305,7 @@ const FormInputItem: FC<Props> = ({
)}
{isCheckbox && isConstant && (
<CheckboxList
name={variable}
title={schema.label?.[language] || schema.label?.en_US || variable}
value={checkboxListValue}
onChange={handleCheckboxListChange}

View File

@ -0,0 +1,28 @@
import { AnnotationEnableStatus } from '@/app/components/app/annotation/type'
import { updateAnnotationStatus } from './annotation'
import { post } from './base'
vi.mock('./base', () => ({
post: vi.fn(),
}))
describe('annotation service', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should preserve zero score threshold when updating annotation status', () => {
updateAnnotationStatus('app-1', AnnotationEnableStatus.enable, {
embedding_model_name: 'model',
embedding_provider_name: 'provider',
}, 0)
expect(post).toHaveBeenCalledWith('apps/app-1/annotation-reply/enable', {
body: {
embedding_model_name: 'model',
embedding_provider_name: 'provider',
score_threshold: 0,
},
})
})
})

View File

@ -7,7 +7,7 @@ export const fetchAnnotationConfig = (appId: string) => {
}
export const updateAnnotationStatus = (appId: string, action: AnnotationEnableStatus, embeddingModel?: EmbeddingModelConfig, score?: number) => {
let body: any = {
score_threshold: score || ANNOTATION_DEFAULT.score_threshold,
score_threshold: score ?? ANNOTATION_DEFAULT.score_threshold,
}
if (embeddingModel) {
body = {

View File

@ -1,3 +1,4 @@
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
import type { MutationFunctionContext } from '@tanstack/react-query'
import type { Tag } from '@/contract/console/tags'
import { QueryClient } from '@tanstack/react-query'
@ -7,7 +8,6 @@ const loadGetBaseURL = async (isClientValue: boolean) => {
vi.resetModules()
vi.doMock('@/utils/client', () => ({ isClient: isClientValue, isServer: !isClientValue }))
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
// eslint-disable-next-line next/no-assign-module-variable
const module = await import('./client')
warnSpy.mockClear()
return { getBaseURL: module.getBaseURL, warnSpy }
@ -35,6 +35,14 @@ const createTag = (overrides: Partial<Tag> = {}): Tag => ({
...overrides,
})
const createApiBasedExtension = (overrides: Partial<ApiBasedExtensionResponse> = {}): ApiBasedExtensionResponse => ({
id: 'extension-1',
name: 'Weather',
api_endpoint: 'https://api.example.com/weather',
api_key: 'secret-key',
...overrides,
})
// Scenario: base URL selection and warnings.
describe('getBaseURL', () => {
beforeEach(() => {
@ -258,3 +266,94 @@ describe('consoleQuery tag mutation defaults', () => {
expect(queryClient.getQueryData(knowledgeListKey)).toEqual([knowledgeTag])
})
})
// Scenario: oRPC mutation defaults own shared API Extension cache behavior.
describe('consoleQuery apiBasedExtension mutation defaults', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('should add created API Extension to the list query cache', async () => {
const consoleQuery = await loadConsoleQuery()
const queryClient = new QueryClient()
const listKey = consoleQuery.apiBasedExtension.get.queryKey()
const existingExtension = createApiBasedExtension({ id: 'extension-1', name: 'Existing' })
const createdExtension = createApiBasedExtension({ id: 'extension-2', name: 'Created' })
queryClient.setQueryData(listKey, [existingExtension])
const mutationOptions = consoleQuery.apiBasedExtension.post.mutationOptions()
await mutationOptions.onSuccess?.(
createdExtension,
{
body: {
name: createdExtension.name,
api_endpoint: createdExtension.api_endpoint,
api_key: createdExtension.api_key,
},
},
undefined,
createMutationContext(queryClient),
)
expect(queryClient.getQueryData(listKey)).toEqual([createdExtension, existingExtension])
})
it('should update matching API Extension in the list query cache', async () => {
const consoleQuery = await loadConsoleQuery()
const queryClient = new QueryClient()
const listKey = consoleQuery.apiBasedExtension.get.queryKey()
const targetExtension = createApiBasedExtension({ id: 'extension-1', name: 'Before' })
const otherExtension = createApiBasedExtension({ id: 'extension-2', name: 'Other' })
const updatedExtension = createApiBasedExtension({ ...targetExtension, name: 'After' })
queryClient.setQueryData(listKey, [targetExtension, otherExtension])
const mutationOptions = consoleQuery.apiBasedExtension.byId.post.mutationOptions()
await mutationOptions.onSuccess?.(
updatedExtension,
{
params: {
id: targetExtension.id,
},
body: {
name: 'Ignored Client Name',
api_endpoint: targetExtension.api_endpoint,
api_key: '[__HIDDEN__]',
},
},
undefined,
createMutationContext(queryClient),
)
expect(queryClient.getQueryData(listKey)).toEqual([updatedExtension, otherExtension])
})
it('should remove deleted API Extension from the list query cache', async () => {
const consoleQuery = await loadConsoleQuery()
const queryClient = new QueryClient()
const listKey = consoleQuery.apiBasedExtension.get.queryKey()
const deletedExtension = createApiBasedExtension({ id: 'extension-1', name: 'Delete me' })
const remainingExtension = createApiBasedExtension({ id: 'extension-2', name: 'Keep me' })
queryClient.setQueryData(listKey, [deletedExtension, remainingExtension])
const mutationOptions = consoleQuery.apiBasedExtension.byId.delete.mutationOptions()
await mutationOptions.onSuccess?.(
{},
{
params: {
id: deletedExtension.id,
},
},
undefined,
createMutationContext(queryClient),
)
expect(queryClient.getQueryData(listKey)).toEqual([remainingExtension])
})
})

View File

@ -1,3 +1,4 @@
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
import type { ContractRouterClient } from '@orpc/contract'
import type { JsonifiedClient } from '@orpc/openapi-client'
import type { Tag } from '@/contract/console/tags'
@ -89,6 +90,45 @@ export const consoleClient: JsonifiedClient<ContractRouterClient<typeof consoleR
export const consoleQuery = createTanstackQueryUtils(consoleClient, {
path: ['console'],
experimental_defaults: {
apiBasedExtension: {
post: {
mutationOptions: {
onSuccess: (createdExtension, _variables, _onMutateResult, context) => {
context.client.setQueryData(
consoleQuery.apiBasedExtension.get.queryKey(),
(oldExtensions: ApiBasedExtensionResponse[] | undefined) =>
oldExtensions ? [createdExtension, ...oldExtensions] : oldExtensions,
)
},
},
},
byId: {
post: {
mutationOptions: {
onSuccess: (updatedExtension, variables, _onMutateResult, context) => {
context.client.setQueryData(
consoleQuery.apiBasedExtension.get.queryKey(),
(oldExtensions: ApiBasedExtensionResponse[] | undefined) =>
oldExtensions?.map(extension => extension.id === variables.params.id
? updatedExtension
: extension),
)
},
},
},
delete: {
mutationOptions: {
onSuccess: (_data, variables, _onMutateResult, context) => {
context.client.setQueryData(
consoleQuery.apiBasedExtension.get.queryKey(),
(oldExtensions: ApiBasedExtensionResponse[] | undefined) =>
oldExtensions?.filter(extension => extension.id !== variables.params.id),
)
},
},
},
},
},
tags: {
create: {
mutationOptions: {

View File

@ -1,8 +1,3 @@
import type {
ApiBasedExtensionListResponse,
ApiBasedExtensionPayload,
ApiBasedExtensionResponse,
} from '@dify/contracts/api/console/api-based-extension/types.gen'
import type {
DefaultModelResponse,
Model,
@ -275,26 +270,6 @@ export const fetchDataSourceNotionBinding = (url: string): Promise<{ result: str
return get<{ result: string }>(url)
}
export const fetchApiBasedExtensionList = (url: string): Promise<ApiBasedExtensionListResponse> => {
return get<ApiBasedExtensionListResponse>(url)
}
export const fetchApiBasedExtensionDetail = (url: string): Promise<ApiBasedExtensionResponse> => {
return get<ApiBasedExtensionResponse>(url)
}
export const addApiBasedExtension = ({ url, body }: { url: string, body: ApiBasedExtensionPayload }): Promise<ApiBasedExtensionResponse> => {
return post<ApiBasedExtensionResponse>(url, { body })
}
export const updateApiBasedExtension = ({ url, body }: { url: string, body: ApiBasedExtensionPayload }): Promise<ApiBasedExtensionResponse> => {
return post<ApiBasedExtensionResponse>(url, { body })
}
export const deleteApiBasedExtension = (url: string): Promise<{ result: string }> => {
return del<{ result: string }>(url)
}
export const fetchCodeBasedExtensionList = (url: string): Promise<CodeBasedExtension> => {
return get<CodeBasedExtension>(url)
}

View File

@ -23,7 +23,6 @@ import type { RETRIEVE_METHOD } from '@/types/app'
import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { IS_DEV } from '@/config'
import { get, post } from './base'
import { consoleClient } from './client'
/**
* True iff `err` is a 401 Response thrown by `service/base.ts`.
@ -52,7 +51,6 @@ export const commonQueryKeys = {
accountIntegrates: [NAME_SPACE, 'account-integrates'] as const,
pluginProviders: [NAME_SPACE, 'plugin-providers'] as const,
notionConnection: [NAME_SPACE, 'notion-connection'] as const,
apiBasedExtensions: [NAME_SPACE, 'api-based-extensions'] as const,
codeBasedExtensions: (module?: string) => [NAME_SPACE, 'code-based-extensions', module] as const,
invitationCheck: (params?: { workspace_id?: string, email?: string, token?: string }) => [
NAME_SPACE,
@ -319,13 +317,6 @@ export const useCodeBasedExtensions = (module: string) => {
})
}
export const useApiBasedExtensions = () => {
return useQuery({
queryKey: commonQueryKeys.apiBasedExtensions,
queryFn: () => consoleClient.apiBasedExtension.get(),
})
}
export const useInvitationCheck = (params?: { workspace_id?: string, email?: string, token?: string }, enabled?: boolean) => {
return useQuery({
queryKey: commonQueryKeys.invitationCheck(params),