Compare commits

...
Author SHA1 Message Date
CodingOnStar 0736ac1baf Merge remote-tracking branch 'origin/main' into fix/workflow-line 2026-04-23 19:23:39 +08:00
0c8dec3315 fix: update node handle opacity and pointer events behavior in components and tests (#35525)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
2026-04-23 09:24:19 +00:00
CodingOnStar bd1a0b8908 fix: update node handle opacity and pointer events behavior in components and tests 2026-04-23 17:14:49 +08:00
非法操作andGitHub 38e831c1b3 fix: improve note node (#35461) 2026-04-23 08:54:56 +00:00
JingyiandGitHub 1c5d62d98a test(e2e): add app detail navigation and redirect scenarios (#35502) 2026-04-23 07:37:42 +00:00
非法操作andGitHub 6b4736bf78 fix: improve collaboration (#35309) 2026-04-23 07:37:05 +00:00
yyhGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
c9503fd818 fix(web): three small UX fixes on /datasets and /plugins (#35514)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-23 06:46:54 +00:00
yyhandGitHub 91a1df96cb fix(web): restore "Copied" feedback state on copy buttons (#35513) 2026-04-23 06:40:52 +00:00
JingyiandGitHub 5b2c5da945 test(e2e): add publish app happy path scenario (#35503) 2026-04-23 05:05:31 +00:00
b59ecea346 test(auth): add sign-in smoke test and core validation (#35501)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 04:54:45 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
61c0948136 chore: add script to generate openapi v2 json and add in README #35474 (#35477)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-23 03:42:04 +00:00
f746c7bdf2 fix(plugin): handle file input reset and improve local installer close functionality (#35506)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
2026-04-23 03:03:20 +00:00
Coding On StarGitHubCodingOnStarautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2a3deee385 refactor: enhance node handle components with opacity transitions and add tests for visibility behavior (#35494)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-22 10:50:44 +00:00
github-actions[bot]GitHubclaude[bot] <41898282+claude[bot]@users.noreply.github.com>
4b6803ba06 chore(i18n): sync translations with en-US (#35492)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-04-22 09:43:21 +00:00
Coding On StarGitHubCodingOnStarautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
4c908c8f39 refactor: migrate base/select to dify-ui/select (#35487)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-22 09:35:57 +00:00
非法操作GitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
afec528f51 feat: improve follow-up settings (#35442)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-22 08:55:16 +00:00
CrazywoolaandGitHub 491061b8f4 fix(web): keep Add model dialog footer visible when form overflows (#35490) 2026-04-22 08:54:23 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
8b1533438f chore: update 3 api (#35481)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-22 08:53:53 +00:00
yyhandGitHub ba924fc97b chore: migrate type-check from tsc to tsgo across all workspaces (#35488) 2026-04-22 08:45:54 +00:00
Zakir JiwaniGitHubAsuka Minatoautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
712e522220 fix: bump pyrefly version (#33702)
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-22 00:27:50 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
33eebe8cfc chore(deps): bump lxml from 6.0.2 to 6.1.0 in /api (#35470)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-22 00:17:12 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>99
2e1b11bdb2 chore: port 2 api as deprecated (#35261)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: 99 <wh2099@pm.me>
2026-04-21 12:29:39 +00:00
205 changed files with 7093 additions and 3433 deletions
@@ -367,7 +367,7 @@ For each extraction:
┌────────────────────────────────────────┐
│ 1. Extract code │
│ 2. Run: pnpm lint:fix │
│ 3. Run: pnpm type-check:tsgo
│ 3. Run: pnpm type-check
│ 4. Run: pnpm test │
│ 5. Test functionality manually │
│ 6. PASS? → Next extraction │
@@ -127,7 +127,7 @@ For the current file being tested:
- [ ] Run full directory test: `pnpm test path/to/directory/`
- [ ] Check coverage report: `pnpm test:coverage`
- [ ] Run `pnpm lint:fix` on all test files
- [ ] Run `pnpm type-check:tsgo`
- [ ] Run `pnpm type-check`
## Common Issues to Watch
+1 -1
View File
@@ -30,7 +30,7 @@ The codebase is split into:
## Language Style
- **Python**: Keep type hints on functions and attributes, and implement relevant special methods (e.g., `__repr__`, `__str__`). Prefer `TypedDict` over `dict` or `Mapping` for type safety and better code documentation.
- **TypeScript**: Use the strict config, rely on ESLint (`pnpm lint:fix` preferred) plus `pnpm type-check:tsgo`, and avoid `any` types.
- **TypeScript**: Use the strict config, rely on ESLint (`pnpm lint:fix` preferred) plus `pnpm type-check`, and avoid `any` types.
## General Practices
-13
View File
@@ -139,19 +139,6 @@ Star Dify on GitHub and be instantly notified of new releases.
If you need to customize the configuration, please refer to the comments in our [.env.example](docker/.env.example) file and update the corresponding values in your `.env` file. Additionally, you might need to make adjustments to the `docker-compose.yaml` file itself, such as changing image versions, port mappings, or volume mounts, based on your specific deployment environment and requirements. After making any changes, please re-run `docker compose up -d`. You can find the full list of available environment variables [here](https://docs.dify.ai/getting-started/install-self-hosted/environments).
#### Customizing Suggested Questions
You can now customize the "Suggested Questions After Answer" feature to better fit your use case. For example, to generate longer, more technical questions:
```bash
# In your .env file
SUGGESTED_QUESTIONS_PROMPT='Please help me predict the five most likely technical follow-up questions a developer would ask. Focus on implementation details, best practices, and architecture considerations. Keep each question between 40-60 characters. Output must be JSON array: ["question1","question2","question3","question4","question5"]'
SUGGESTED_QUESTIONS_MAX_TOKENS=512
SUGGESTED_QUESTIONS_TEMPERATURE=0.3
```
See the [Suggested Questions Configuration Guide](docs/suggested-questions-configuration.md) for detailed examples and usage instructions.
### Metrics Monitoring with Grafana
Import the dashboard to Grafana, using Dify's PostgreSQL database as data source, to monitor metrics in granularity of apps, tenants, messages, and more.
-16
View File
@@ -709,22 +709,6 @@ SWAGGER_UI_PATH=/swagger-ui.html
# Set to false to export dataset IDs as plain text for easier cross-environment import
DSL_EXPORT_ENCRYPT_DATASET_ID=true
# Suggested Questions After Answer Configuration
# These environment variables allow customization of the suggested questions feature
#
# Custom prompt for generating suggested questions (optional)
# If not set, uses the default prompt that generates 3 questions under 20 characters each
# Example: "Please help me predict the five most likely technical follow-up questions a developer would ask. Focus on implementation details, best practices, and architecture considerations. Keep each question between 40-60 characters. Output must be JSON array: [\"question1\",\"question2\",\"question3\",\"question4\",\"question5\"]"
# SUGGESTED_QUESTIONS_PROMPT=
# Maximum number of tokens for suggested questions generation (default: 256)
# Adjust this value for longer questions or more questions
# SUGGESTED_QUESTIONS_MAX_TOKENS=256
# Temperature for suggested questions generation (default: 0.0)
# Higher values (0.5-1.0) produce more creative questions, lower values (0.0-0.3) produce more focused questions
# SUGGESTED_QUESTIONS_TEMPERATURE=0
# Tenant isolated task queue configuration
TENANT_ISOLATED_TASK_CONCURRENCY=1
+8
View File
@@ -101,3 +101,11 @@ The scripts resolve paths relative to their location, so you can run them from a
uv run ruff format ./ # Format code
uv run basedpyright . # Type checking
```
## Generate TS stub
```
uv run dev/generate_swagger_specs.py --output-dir openapi
```
use https://jsontotable.org/openapi-to-typescript to convert to typescript
+157 -44
View File
@@ -1,3 +1,11 @@
"""Console workspace endpoint controllers.
This module exposes workspace-scoped plugin endpoint management APIs. The
canonical write routes follow resource-oriented paths, while the historical
verb-based aliases stay available as deprecated resources so OpenAPI metadata
marks only the legacy paths as deprecated.
"""
from typing import Any
from flask import request
@@ -25,7 +33,12 @@ class EndpointIdPayload(BaseModel):
endpoint_id: str
class EndpointUpdatePayload(EndpointIdPayload):
class EndpointUpdatePayload(BaseModel):
settings: dict[str, Any]
name: str = Field(min_length=1)
class LegacyEndpointUpdatePayload(EndpointIdPayload):
settings: dict[str, Any]
name: str = Field(min_length=1)
@@ -76,6 +89,7 @@ register_schema_models(
EndpointCreatePayload,
EndpointIdPayload,
EndpointUpdatePayload,
LegacyEndpointUpdatePayload,
EndpointListQuery,
EndpointListForPluginQuery,
EndpointCreateResponse,
@@ -88,8 +102,60 @@ register_schema_models(
)
@console_ns.route("/workspaces/current/endpoints/create")
class EndpointCreateApi(Resource):
def _create_endpoint() -> dict[str, bool]:
"""Create a plugin endpoint for the current workspace."""
user, tenant_id = current_account_with_tenant()
args = EndpointCreatePayload.model_validate(console_ns.payload)
try:
return {
"success": EndpointService.create_endpoint(
tenant_id=tenant_id,
user_id=user.id,
plugin_unique_identifier=args.plugin_unique_identifier,
name=args.name,
settings=args.settings,
)
}
except PluginPermissionDeniedError as e:
raise ValueError(e.description) from e
def _update_endpoint(endpoint_id: str) -> dict[str, bool]:
"""Update a plugin endpoint identified by the canonical path parameter."""
user, tenant_id = current_account_with_tenant()
args = EndpointUpdatePayload.model_validate(console_ns.payload)
return {
"success": EndpointService.update_endpoint(
tenant_id=tenant_id,
user_id=user.id,
endpoint_id=endpoint_id,
name=args.name,
settings=args.settings,
)
}
def _delete_endpoint(endpoint_id: str) -> dict[str, bool]:
"""Delete a plugin endpoint identified by the canonical path parameter."""
user, tenant_id = current_account_with_tenant()
return {
"success": EndpointService.delete_endpoint(
tenant_id=tenant_id,
user_id=user.id,
endpoint_id=endpoint_id,
)
}
@console_ns.route("/workspaces/current/endpoints")
class EndpointCollectionApi(Resource):
"""Canonical collection resource for endpoint creation."""
@console_ns.doc("create_endpoint")
@console_ns.doc(description="Create a new plugin endpoint")
@console_ns.expect(console_ns.models[EndpointCreatePayload.__name__])
@@ -104,22 +170,33 @@ class EndpointCreateApi(Resource):
@is_admin_or_owner_required
@account_initialization_required
def post(self):
user, tenant_id = current_account_with_tenant()
return _create_endpoint()
args = EndpointCreatePayload.model_validate(console_ns.payload)
try:
return {
"success": EndpointService.create_endpoint(
tenant_id=tenant_id,
user_id=user.id,
plugin_unique_identifier=args.plugin_unique_identifier,
name=args.name,
settings=args.settings,
)
}
except PluginPermissionDeniedError as e:
raise ValueError(e.description) from e
@console_ns.route("/workspaces/current/endpoints/create")
class DeprecatedEndpointCreateApi(Resource):
"""Deprecated verb-based alias for endpoint creation."""
@console_ns.doc("create_endpoint_deprecated")
@console_ns.doc(deprecated=True)
@console_ns.doc(
description=(
"Deprecated legacy alias for creating a plugin endpoint. Use POST /workspaces/current/endpoints instead."
)
)
@console_ns.expect(console_ns.models[EndpointCreatePayload.__name__])
@console_ns.response(
200,
"Endpoint created successfully",
console_ns.models[EndpointCreateResponse.__name__],
)
@console_ns.response(403, "Admin privileges required")
@setup_required
@login_required
@is_admin_or_owner_required
@account_initialization_required
def post(self):
return _create_endpoint()
@console_ns.route("/workspaces/current/endpoints/list")
@@ -190,10 +267,56 @@ class EndpointListForSinglePluginApi(Resource):
)
@console_ns.route("/workspaces/current/endpoints/delete")
class EndpointDeleteApi(Resource):
@console_ns.route("/workspaces/current/endpoints/<string:id>")
class EndpointItemApi(Resource):
"""Canonical item resource for endpoint updates and deletion."""
@console_ns.doc("delete_endpoint")
@console_ns.doc(description="Delete a plugin endpoint")
@console_ns.doc(params={"id": {"description": "Endpoint ID", "type": "string", "required": True}})
@console_ns.response(
200,
"Endpoint deleted successfully",
console_ns.models[EndpointDeleteResponse.__name__],
)
@console_ns.response(403, "Admin privileges required")
@setup_required
@login_required
@is_admin_or_owner_required
@account_initialization_required
def delete(self, id: str):
return _delete_endpoint(endpoint_id=id)
@console_ns.doc("update_endpoint")
@console_ns.doc(description="Update a plugin endpoint")
@console_ns.expect(console_ns.models[EndpointUpdatePayload.__name__])
@console_ns.doc(params={"id": {"description": "Endpoint ID", "type": "string", "required": True}})
@console_ns.response(
200,
"Endpoint updated successfully",
console_ns.models[EndpointUpdateResponse.__name__],
)
@console_ns.response(403, "Admin privileges required")
@setup_required
@login_required
@is_admin_or_owner_required
@account_initialization_required
def patch(self, id: str):
return _update_endpoint(endpoint_id=id)
@console_ns.route("/workspaces/current/endpoints/delete")
class DeprecatedEndpointDeleteApi(Resource):
"""Deprecated verb-based alias for endpoint deletion."""
@console_ns.doc("delete_endpoint_deprecated")
@console_ns.doc(deprecated=True)
@console_ns.doc(
description=(
"Deprecated legacy alias for deleting a plugin endpoint. "
"Use DELETE /workspaces/current/endpoints/{id} instead."
)
)
@console_ns.expect(console_ns.models[EndpointIdPayload.__name__])
@console_ns.response(
200,
@@ -206,22 +329,23 @@ class EndpointDeleteApi(Resource):
@is_admin_or_owner_required
@account_initialization_required
def post(self):
user, tenant_id = current_account_with_tenant()
args = EndpointIdPayload.model_validate(console_ns.payload)
return {
"success": EndpointService.delete_endpoint(
tenant_id=tenant_id, user_id=user.id, endpoint_id=args.endpoint_id
)
}
return _delete_endpoint(endpoint_id=args.endpoint_id)
@console_ns.route("/workspaces/current/endpoints/update")
class EndpointUpdateApi(Resource):
@console_ns.doc("update_endpoint")
@console_ns.doc(description="Update a plugin endpoint")
@console_ns.expect(console_ns.models[EndpointUpdatePayload.__name__])
class DeprecatedEndpointUpdateApi(Resource):
"""Deprecated verb-based alias for endpoint updates."""
@console_ns.doc("update_endpoint_deprecated")
@console_ns.doc(deprecated=True)
@console_ns.doc(
description=(
"Deprecated legacy alias for updating a plugin endpoint. "
"Use PATCH /workspaces/current/endpoints/{id} instead."
)
)
@console_ns.expect(console_ns.models[LegacyEndpointUpdatePayload.__name__])
@console_ns.response(
200,
"Endpoint updated successfully",
@@ -233,19 +357,8 @@ class EndpointUpdateApi(Resource):
@is_admin_or_owner_required
@account_initialization_required
def post(self):
user, tenant_id = current_account_with_tenant()
args = EndpointUpdatePayload.model_validate(console_ns.payload)
return {
"success": EndpointService.update_endpoint(
tenant_id=tenant_id,
user_id=user.id,
endpoint_id=args.endpoint_id,
name=args.name,
settings=args.settings,
)
}
args = LegacyEndpointUpdatePayload.model_validate(console_ns.payload)
return _update_endpoint(endpoint_id=args.endpoint_id)
@console_ns.route("/workspaces/current/endpoints/enable")
+196 -128
View File
@@ -1,4 +1,12 @@
"""Service API endpoints for dataset document management.
The canonical Service API paths use hyphenated route segments. Legacy underscore
aliases remain registered for backward compatibility, but they must stay marked
deprecated in generated API docs so clients migrate toward the canonical paths.
"""
import json
from collections.abc import Mapping
from contextlib import ExitStack
from typing import Self
from uuid import UUID
@@ -117,12 +125,137 @@ register_schema_models(
)
@service_api_ns.route(
"/datasets/<uuid:dataset_id>/document/create_by_text",
"/datasets/<uuid:dataset_id>/document/create-by-text",
)
def _create_document_by_text(tenant_id: str, dataset_id: UUID) -> tuple[Mapping[str, object], int]:
"""Create a document from text for both canonical and legacy routes."""
payload = DocumentTextCreatePayload.model_validate(service_api_ns.payload or {})
args = payload.model_dump(exclude_none=True)
dataset_id_str = str(dataset_id)
tenant_id_str = str(tenant_id)
dataset = db.session.scalar(
select(Dataset).where(Dataset.tenant_id == tenant_id_str, Dataset.id == dataset_id_str).limit(1)
)
if not dataset:
raise ValueError("Dataset does not exist.")
if not dataset.indexing_technique and not args["indexing_technique"]:
raise ValueError("indexing_technique is required.")
embedding_model_provider = payload.embedding_model_provider
embedding_model = payload.embedding_model
if embedding_model_provider and embedding_model:
DatasetService.check_embedding_model_setting(tenant_id_str, embedding_model_provider, embedding_model)
retrieval_model = payload.retrieval_model
if (
retrieval_model
and retrieval_model.reranking_model
and retrieval_model.reranking_model.reranking_provider_name
and retrieval_model.reranking_model.reranking_model_name
):
DatasetService.check_reranking_model_setting(
tenant_id_str,
retrieval_model.reranking_model.reranking_provider_name,
retrieval_model.reranking_model.reranking_model_name,
)
if not current_user:
raise ValueError("current_user is required")
upload_file = FileService(db.engine).upload_text(
text=payload.text, text_name=payload.name, user_id=current_user.id, tenant_id=tenant_id_str
)
data_source = {
"type": "upload_file",
"info_list": {"data_source_type": "upload_file", "file_info_list": {"file_ids": [upload_file.id]}},
}
args["data_source"] = data_source
knowledge_config = KnowledgeConfig.model_validate(args)
DocumentService.document_create_args_validate(knowledge_config)
if not current_user:
raise ValueError("current_user is required")
try:
documents, batch = DocumentService.save_document_with_dataset_id(
dataset=dataset,
knowledge_config=knowledge_config,
account=current_user,
dataset_process_rule=dataset.latest_process_rule if "process_rule" not in args else None,
created_from="api",
)
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
document = documents[0]
documents_and_batch_fields = {"document": marshal(document, document_fields), "batch": batch}
return documents_and_batch_fields, 200
def _update_document_by_text(tenant_id: str, dataset_id: UUID, document_id: UUID) -> tuple[Mapping[str, object], int]:
"""Update a document from text for both canonical and legacy routes."""
payload = DocumentTextUpdate.model_validate(service_api_ns.payload or {})
dataset = db.session.scalar(
select(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == str(dataset_id)).limit(1)
)
args = payload.model_dump(exclude_none=True)
if not dataset:
raise ValueError("Dataset does not exist.")
retrieval_model = payload.retrieval_model
if (
retrieval_model
and retrieval_model.reranking_model
and retrieval_model.reranking_model.reranking_provider_name
and retrieval_model.reranking_model.reranking_model_name
):
DatasetService.check_reranking_model_setting(
tenant_id,
retrieval_model.reranking_model.reranking_provider_name,
retrieval_model.reranking_model.reranking_model_name,
)
# indexing_technique is already set in dataset since this is an update
args["indexing_technique"] = dataset.indexing_technique
if args.get("text"):
text = args.get("text")
name = args.get("name")
if not current_user:
raise ValueError("current_user is required")
upload_file = FileService(db.engine).upload_text(
text=str(text), text_name=str(name), user_id=current_user.id, tenant_id=tenant_id
)
data_source = {
"type": "upload_file",
"info_list": {"data_source_type": "upload_file", "file_info_list": {"file_ids": [upload_file.id]}},
}
args["data_source"] = data_source
args["original_document_id"] = str(document_id)
knowledge_config = KnowledgeConfig.model_validate(args)
DocumentService.document_create_args_validate(knowledge_config)
try:
documents, batch = DocumentService.save_document_with_dataset_id(
dataset=dataset,
knowledge_config=knowledge_config,
account=current_user,
dataset_process_rule=dataset.latest_process_rule if "process_rule" not in args else None,
created_from="api",
)
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
document = documents[0]
documents_and_batch_fields = {"document": marshal(document, document_fields), "batch": batch}
return documents_and_batch_fields, 200
@service_api_ns.route("/datasets/<uuid:dataset_id>/document/create-by-text")
class DocumentAddByTextApi(DatasetApiResource):
"""Resource for documents."""
"""Resource for the canonical text document creation route."""
@service_api_ns.expect(service_api_ns.models[DocumentTextCreatePayload.__name__])
@service_api_ns.doc("create_document_by_text")
@@ -138,81 +271,43 @@ class DocumentAddByTextApi(DatasetApiResource):
@cloud_edition_billing_resource_check("vector_space", "dataset")
@cloud_edition_billing_resource_check("documents", "dataset")
@cloud_edition_billing_rate_limit_check("knowledge", "dataset")
def post(self, tenant_id, dataset_id):
def post(self, tenant_id: str, dataset_id: UUID):
"""Create document by text."""
payload = DocumentTextCreatePayload.model_validate(service_api_ns.payload or {})
args = payload.model_dump(exclude_none=True)
return _create_document_by_text(tenant_id=tenant_id, dataset_id=dataset_id)
dataset_id = str(dataset_id)
tenant_id = str(tenant_id)
dataset = db.session.scalar(
select(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).limit(1)
@service_api_ns.route("/datasets/<uuid:dataset_id>/document/create_by_text")
class DeprecatedDocumentAddByTextApi(DatasetApiResource):
"""Deprecated resource alias for text document creation."""
@service_api_ns.expect(service_api_ns.models[DocumentTextCreatePayload.__name__])
@service_api_ns.doc("create_document_by_text_deprecated")
@service_api_ns.doc(deprecated=True)
@service_api_ns.doc(
description=(
"Deprecated legacy alias for creating a new document by providing text content. "
"Use /datasets/{dataset_id}/document/create-by-text instead."
)
if not dataset:
raise ValueError("Dataset does not exist.")
if not dataset.indexing_technique and not args["indexing_technique"]:
raise ValueError("indexing_technique is required.")
embedding_model_provider = payload.embedding_model_provider
embedding_model = payload.embedding_model
if embedding_model_provider and embedding_model:
DatasetService.check_embedding_model_setting(tenant_id, embedding_model_provider, embedding_model)
retrieval_model = payload.retrieval_model
if (
retrieval_model
and retrieval_model.reranking_model
and retrieval_model.reranking_model.reranking_provider_name
and retrieval_model.reranking_model.reranking_model_name
):
DatasetService.check_reranking_model_setting(
tenant_id,
retrieval_model.reranking_model.reranking_provider_name,
retrieval_model.reranking_model.reranking_model_name,
)
if not current_user:
raise ValueError("current_user is required")
upload_file = FileService(db.engine).upload_text(
text=payload.text, text_name=payload.name, user_id=current_user.id, tenant_id=tenant_id
)
data_source = {
"type": "upload_file",
"info_list": {"data_source_type": "upload_file", "file_info_list": {"file_ids": [upload_file.id]}},
)
@service_api_ns.doc(params={"dataset_id": "Dataset ID"})
@service_api_ns.doc(
responses={
200: "Document created successfully",
401: "Unauthorized - invalid API token",
400: "Bad request - invalid parameters",
}
args["data_source"] = data_source
knowledge_config = KnowledgeConfig.model_validate(args)
# validate args
DocumentService.document_create_args_validate(knowledge_config)
if not current_user:
raise ValueError("current_user is required")
try:
documents, batch = DocumentService.save_document_with_dataset_id(
dataset=dataset,
knowledge_config=knowledge_config,
account=current_user,
dataset_process_rule=dataset.latest_process_rule if "process_rule" not in args else None,
created_from="api",
)
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
document = documents[0]
documents_and_batch_fields = {"document": marshal(document, document_fields), "batch": batch}
return documents_and_batch_fields, 200
)
@cloud_edition_billing_resource_check("vector_space", "dataset")
@cloud_edition_billing_resource_check("documents", "dataset")
@cloud_edition_billing_rate_limit_check("knowledge", "dataset")
def post(self, tenant_id: str, dataset_id: UUID):
"""Create document by text through the deprecated underscore alias."""
return _create_document_by_text(tenant_id=tenant_id, dataset_id=dataset_id)
@service_api_ns.route(
"/datasets/<uuid:dataset_id>/documents/<uuid:document_id>/update_by_text",
"/datasets/<uuid:dataset_id>/documents/<uuid:document_id>/update-by-text",
)
@service_api_ns.route("/datasets/<uuid:dataset_id>/documents/<uuid:document_id>/update-by-text")
class DocumentUpdateByTextApi(DatasetApiResource):
"""Resource for update documents."""
"""Resource for the canonical text document update route."""
@service_api_ns.expect(service_api_ns.models[DocumentTextUpdate.__name__])
@service_api_ns.doc("update_document_by_text")
@@ -229,62 +324,35 @@ class DocumentUpdateByTextApi(DatasetApiResource):
@cloud_edition_billing_rate_limit_check("knowledge", "dataset")
def post(self, tenant_id: str, dataset_id: UUID, document_id: UUID):
"""Update document by text."""
payload = DocumentTextUpdate.model_validate(service_api_ns.payload or {})
dataset = db.session.scalar(
select(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == str(dataset_id)).limit(1)
return _update_document_by_text(tenant_id=tenant_id, dataset_id=dataset_id, document_id=document_id)
@service_api_ns.route("/datasets/<uuid:dataset_id>/documents/<uuid:document_id>/update_by_text")
class DeprecatedDocumentUpdateByTextApi(DatasetApiResource):
"""Deprecated resource alias for text document updates."""
@service_api_ns.expect(service_api_ns.models[DocumentTextUpdate.__name__])
@service_api_ns.doc("update_document_by_text_deprecated")
@service_api_ns.doc(deprecated=True)
@service_api_ns.doc(
description=(
"Deprecated legacy alias for updating an existing document by providing text content. "
"Use /datasets/{dataset_id}/documents/{document_id}/update-by-text instead."
)
args = payload.model_dump(exclude_none=True)
if not dataset:
raise ValueError("Dataset does not exist.")
retrieval_model = payload.retrieval_model
if (
retrieval_model
and retrieval_model.reranking_model
and retrieval_model.reranking_model.reranking_provider_name
and retrieval_model.reranking_model.reranking_model_name
):
DatasetService.check_reranking_model_setting(
tenant_id,
retrieval_model.reranking_model.reranking_provider_name,
retrieval_model.reranking_model.reranking_model_name,
)
# indexing_technique is already set in dataset since this is an update
args["indexing_technique"] = dataset.indexing_technique
if args.get("text"):
text = args.get("text")
name = args.get("name")
if not current_user:
raise ValueError("current_user is required")
upload_file = FileService(db.engine).upload_text(
text=str(text), text_name=str(name), user_id=current_user.id, tenant_id=tenant_id
)
data_source = {
"type": "upload_file",
"info_list": {"data_source_type": "upload_file", "file_info_list": {"file_ids": [upload_file.id]}},
}
args["data_source"] = data_source
# validate args
args["original_document_id"] = str(document_id)
knowledge_config = KnowledgeConfig.model_validate(args)
DocumentService.document_create_args_validate(knowledge_config)
try:
documents, batch = DocumentService.save_document_with_dataset_id(
dataset=dataset,
knowledge_config=knowledge_config,
account=current_user,
dataset_process_rule=dataset.latest_process_rule if "process_rule" not in args else None,
created_from="api",
)
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
document = documents[0]
documents_and_batch_fields = {"document": marshal(document, document_fields), "batch": batch}
return documents_and_batch_fields, 200
)
@service_api_ns.doc(params={"dataset_id": "Dataset ID", "document_id": "Document ID"})
@service_api_ns.doc(
responses={
200: "Document updated successfully",
401: "Unauthorized - invalid API token",
404: "Document not found",
}
)
@cloud_edition_billing_resource_check("vector_space", "dataset")
@cloud_edition_billing_rate_limit_check("knowledge", "dataset")
def post(self, tenant_id: str, dataset_id: UUID, document_id: UUID):
"""Update document by text through the deprecated underscore alias."""
return _update_document_by_text(tenant_id=tenant_id, dataset_id=dataset_id, document_id=document_id)
@service_api_ns.route(
@@ -1,5 +1,7 @@
from typing import Any
CUSTOM_FOLLOW_UP_PROMPT_MAX_LENGTH = 1000
class SuggestedQuestionsAfterAnswerConfigManager:
@classmethod
@@ -20,7 +22,11 @@ class SuggestedQuestionsAfterAnswerConfigManager:
@classmethod
def validate_and_set_defaults(cls, config: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
"""
Validate and set defaults for suggested questions feature
Validate and set defaults for suggested questions feature.
Optional fields:
- prompt: custom instruction prompt.
- model: provider/model configuration for suggested question generation.
:param config: app model config args
"""
@@ -39,4 +45,27 @@ class SuggestedQuestionsAfterAnswerConfigManager:
if not isinstance(config["suggested_questions_after_answer"]["enabled"], bool):
raise ValueError("enabled in suggested_questions_after_answer must be of boolean type")
prompt = config["suggested_questions_after_answer"].get("prompt")
if prompt is not None and not isinstance(prompt, str):
raise ValueError("prompt in suggested_questions_after_answer must be of string type")
if isinstance(prompt, str) and len(prompt) > CUSTOM_FOLLOW_UP_PROMPT_MAX_LENGTH:
raise ValueError(
f"prompt in suggested_questions_after_answer must be less than or equal to "
f"{CUSTOM_FOLLOW_UP_PROMPT_MAX_LENGTH} characters"
)
if "model" in config["suggested_questions_after_answer"]:
model_config = config["suggested_questions_after_answer"]["model"]
if not isinstance(model_config, dict):
raise ValueError("model in suggested_questions_after_answer must be of object type")
if "provider" not in model_config or not isinstance(model_config["provider"], str):
raise ValueError("provider in suggested_questions_after_answer.model must be of string type")
if "name" not in model_config or not isinstance(model_config["name"], str):
raise ValueError("name in suggested_questions_after_answer.model must be of string type")
if "completion_params" in model_config and not isinstance(model_config["completion_params"], dict):
raise ValueError("completion_params in suggested_questions_after_answer.model must be of object type")
return config, ["suggested_questions_after_answer"]
+88 -15
View File
@@ -2,7 +2,7 @@ import json
import logging
import re
from collections.abc import Sequence
from typing import Any, Protocol, TypedDict, cast
from typing import Any, NotRequired, Protocol, TypedDict, cast
import json_repair
from sqlalchemy import select
@@ -13,13 +13,13 @@ from core.llm_generator.output_parser.rule_config_generator import RuleConfigGen
from core.llm_generator.output_parser.suggested_questions_after_answer import SuggestedQuestionsAfterAnswerOutputParser
from core.llm_generator.prompts import (
CONVERSATION_TITLE_PROMPT,
DEFAULT_SUGGESTED_QUESTIONS_MAX_TOKENS,
DEFAULT_SUGGESTED_QUESTIONS_TEMPERATURE,
GENERATOR_QA_PROMPT,
JAVASCRIPT_CODE_GENERATOR_PROMPT_TEMPLATE,
LLM_MODIFY_CODE_SYSTEM,
LLM_MODIFY_PROMPT_SYSTEM,
PYTHON_CODE_GENERATOR_PROMPT_TEMPLATE,
SUGGESTED_QUESTIONS_MAX_TOKENS,
SUGGESTED_QUESTIONS_TEMPERATURE,
SYSTEM_STRUCTURED_OUTPUT_GENERATE,
WORKFLOW_RULE_CONFIG_PROMPT_GENERATE_TEMPLATE,
)
@@ -41,6 +41,36 @@ from models.workflow import Workflow
logger = logging.getLogger(__name__)
class SuggestedQuestionsModelConfig(TypedDict):
provider: str
name: str
completion_params: NotRequired[dict[str, object]]
def _normalize_completion_params(completion_params: dict[str, object]) -> tuple[dict[str, object], list[str]]:
"""
Normalize raw completion params into invocation parameters and stop sequences.
This mirrors the app-model access path by separating ``stop`` from provider
parameters before invocation, then drops non-positive token limits because
some plugin-backed models reject ``0`` after mapping ``max_tokens`` to their
provider-specific output-token field.
"""
normalized_parameters = dict(completion_params)
stop_value = normalized_parameters.pop("stop", [])
if isinstance(stop_value, list) and all(isinstance(item, str) for item in stop_value):
stop = stop_value
else:
stop = []
for token_limit_key in ("max_tokens", "max_output_tokens"):
token_limit = normalized_parameters.get(token_limit_key)
if isinstance(token_limit, int | float) and token_limit <= 0:
normalized_parameters.pop(token_limit_key, None)
return normalized_parameters, stop
class WorkflowServiceInterface(Protocol):
def get_draft_workflow(self, app_model: App, workflow_id: str | None = None) -> Workflow | None:
pass
@@ -123,8 +153,15 @@ class LLMGenerator:
return name
@classmethod
def generate_suggested_questions_after_answer(cls, tenant_id: str, histories: str) -> Sequence[str]:
output_parser = SuggestedQuestionsAfterAnswerOutputParser()
def generate_suggested_questions_after_answer(
cls,
tenant_id: str,
histories: str,
*,
instruction_prompt: str | None = None,
model_config: object | None = None,
) -> Sequence[str]:
output_parser = SuggestedQuestionsAfterAnswerOutputParser(instruction_prompt=instruction_prompt)
format_instructions = output_parser.get_format_instructions()
prompt_template = PromptTemplateParser(template="{{histories}}\n{{format_instructions}}\nquestions:\n")
@@ -133,10 +170,36 @@ class LLMGenerator:
try:
model_manager = ModelManager.for_tenant(tenant_id=tenant_id)
model_instance = model_manager.get_default_model_instance(
tenant_id=tenant_id,
model_type=ModelType.LLM,
)
configured_model = cast(dict[str, object], model_config) if isinstance(model_config, dict) else {}
provider = configured_model.get("provider")
model_name = configured_model.get("name")
use_configured_model = False
if isinstance(provider, str) and provider and isinstance(model_name, str) and model_name:
try:
model_instance = model_manager.get_model_instance(
tenant_id=tenant_id,
model_type=ModelType.LLM,
provider=provider,
model=model_name,
)
use_configured_model = True
except Exception:
logger.warning(
"Failed to use configured suggested-questions model %s/%s, fallback to default model",
provider,
model_name,
exc_info=True,
)
model_instance = model_manager.get_default_model_instance(
tenant_id=tenant_id,
model_type=ModelType.LLM,
)
else:
model_instance = model_manager.get_default_model_instance(
tenant_id=tenant_id,
model_type=ModelType.LLM,
)
except InvokeAuthorizationError:
return []
@@ -145,19 +208,29 @@ class LLMGenerator:
questions: Sequence[str] = []
try:
configured_completion_params = configured_model.get("completion_params")
if use_configured_model and isinstance(configured_completion_params, dict):
model_parameters, stop = _normalize_completion_params(configured_completion_params)
elif use_configured_model:
model_parameters = {}
stop = []
else:
# Default-model generation keeps the built-in suggested-questions tuning.
model_parameters = {
"max_tokens": DEFAULT_SUGGESTED_QUESTIONS_MAX_TOKENS,
"temperature": DEFAULT_SUGGESTED_QUESTIONS_TEMPERATURE,
}
stop = []
response: LLMResult = model_instance.invoke_llm(
prompt_messages=list(prompt_messages),
model_parameters={
"max_tokens": SUGGESTED_QUESTIONS_MAX_TOKENS,
"temperature": SUGGESTED_QUESTIONS_TEMPERATURE,
},
model_parameters=model_parameters,
stop=stop,
stream=False,
)
text_content = response.message.get_text_content()
questions = output_parser.parse(text_content) if text_content else []
except InvokeError:
questions = []
except Exception:
logger.exception("Failed to generate suggested questions after answer")
questions = []
@@ -3,17 +3,21 @@ import logging
import re
from collections.abc import Sequence
from core.llm_generator.prompts import SUGGESTED_QUESTIONS_AFTER_ANSWER_INSTRUCTION_PROMPT
from core.llm_generator.prompts import DEFAULT_SUGGESTED_QUESTIONS_AFTER_ANSWER_INSTRUCTION_PROMPT
logger = logging.getLogger(__name__)
class SuggestedQuestionsAfterAnswerOutputParser:
def __init__(self, instruction_prompt: str | None = None) -> None:
self._instruction_prompt = instruction_prompt or DEFAULT_SUGGESTED_QUESTIONS_AFTER_ANSWER_INSTRUCTION_PROMPT
def get_format_instructions(self) -> str:
return SUGGESTED_QUESTIONS_AFTER_ANSWER_INSTRUCTION_PROMPT
return self._instruction_prompt
def parse(self, text: str) -> Sequence[str]:
action_match = re.search(r"\[.*?\]", text.strip(), re.DOTALL)
stripped_text = text.strip()
action_match = re.search(r"\[.*?\]", stripped_text, re.DOTALL)
questions: list[str] = []
if action_match is not None:
try:
@@ -23,4 +27,6 @@ class SuggestedQuestionsAfterAnswerOutputParser:
else:
if isinstance(json_obj, list):
questions = [question for question in json_obj if isinstance(question, str)]
elif stripped_text:
logger.warning("Failed to find suggested questions payload array in text: %r", stripped_text[:200])
return questions
+4 -11
View File
@@ -1,5 +1,4 @@
# Written by YORKI MINAKO🤡, Edited by Xiaoyi, Edited by yasu-oh
import os
CONVERSATION_TITLE_PROMPT = """You are asked to generate a concise chat title by decomposing the users input into two parts: “Intention” and “Subject”.
@@ -96,8 +95,8 @@ JAVASCRIPT_CODE_GENERATOR_PROMPT_TEMPLATE = (
)
# Default prompt for suggested questions (can be overridden by environment variable)
_DEFAULT_SUGGESTED_QUESTIONS_AFTER_ANSWER_PROMPT = (
# Default prompt and model parameters for suggested questions.
DEFAULT_SUGGESTED_QUESTIONS_AFTER_ANSWER_INSTRUCTION_PROMPT = (
"Please help me predict the three most likely questions that human would ask, "
"and keep each question under 20 characters.\n"
"MAKE SURE your output is the SAME language as the Assistant's latest response. "
@@ -105,14 +104,8 @@ _DEFAULT_SUGGESTED_QUESTIONS_AFTER_ANSWER_PROMPT = (
'["question1","question2","question3"]\n'
)
# Environment variable override for suggested questions prompt
SUGGESTED_QUESTIONS_AFTER_ANSWER_INSTRUCTION_PROMPT = os.getenv(
"SUGGESTED_QUESTIONS_PROMPT", _DEFAULT_SUGGESTED_QUESTIONS_AFTER_ANSWER_PROMPT
)
# Configurable LLM parameters for suggested questions (can be overridden by environment variables)
SUGGESTED_QUESTIONS_MAX_TOKENS = int(os.getenv("SUGGESTED_QUESTIONS_MAX_TOKENS", "256"))
SUGGESTED_QUESTIONS_TEMPERATURE = float(os.getenv("SUGGESTED_QUESTIONS_TEMPERATURE", "0"))
DEFAULT_SUGGESTED_QUESTIONS_MAX_TOKENS = 256
DEFAULT_SUGGESTED_QUESTIONS_TEMPERATURE = 0.0
GENERATOR_QA_PROMPT = (
"<Task> The user will send a long text. Generate a Question and Answer pairs only using the knowledge"
+172
View File
@@ -0,0 +1,172 @@
"""Generate Flask-RESTX Swagger 2.0 specs without booting the full backend.
This helper intentionally avoids `app_factory.create_app()`. The normal backend
startup eagerly initializes database, Redis, Celery, and storage extensions,
which is unnecessary when the goal is only to serialize the Flask-RESTX
`/swagger.json` documents.
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import sys
from dataclasses import dataclass
from pathlib import Path
from flask import Flask
from flask_restx.swagger import Swagger
logger = logging.getLogger(__name__)
API_ROOT = Path(__file__).resolve().parents[1]
if str(API_ROOT) not in sys.path:
sys.path.insert(0, str(API_ROOT))
@dataclass(frozen=True)
class SpecTarget:
route: str
filename: str
SPEC_TARGETS: tuple[SpecTarget, ...] = (
SpecTarget(route="/console/api/swagger.json", filename="console-swagger.json"),
SpecTarget(route="/api/swagger.json", filename="web-swagger.json"),
SpecTarget(route="/v1/swagger.json", filename="service-swagger.json"),
)
_ORIGINAL_REGISTER_MODEL = Swagger.register_model
_ORIGINAL_REGISTER_FIELD = Swagger.register_field
def _apply_runtime_defaults() -> None:
"""Force the small config surface required for Swagger generation."""
os.environ.setdefault("SECRET_KEY", "spec-export")
os.environ.setdefault("STORAGE_TYPE", "local")
os.environ.setdefault("STORAGE_LOCAL_PATH", "/tmp/dify-storage")
os.environ.setdefault("SWAGGER_UI_ENABLED", "true")
from configs import dify_config
dify_config.SECRET_KEY = os.environ["SECRET_KEY"]
dify_config.STORAGE_TYPE = "local"
dify_config.STORAGE_LOCAL_PATH = os.environ["STORAGE_LOCAL_PATH"]
dify_config.SWAGGER_UI_ENABLED = os.environ["SWAGGER_UI_ENABLED"].lower() == "true"
def _patch_swagger_for_inline_nested_dicts() -> None:
"""Teach Flask-RESTX Swagger generation to tolerate inline nested field maps.
Some existing controllers use `fields.Nested({...})` with a raw field mapping
instead of a named `api.model(...)`. Flask-RESTX crashes on those anonymous
dicts during schema registration, so this helper upgrades them into temporary
named models at export time.
"""
if getattr(Swagger, "_dify_inline_nested_dict_patch", False):
return
def get_or_create_inline_model(self: Swagger, nested_fields: dict[object, object]) -> object:
anonymous_models = getattr(self, "_anonymous_inline_models", None)
if anonymous_models is None:
anonymous_models = {}
self._anonymous_inline_models = anonymous_models
anonymous_name = anonymous_models.get(id(nested_fields))
if anonymous_name is None:
anonymous_name = f"_AnonymousInlineModel{len(anonymous_models) + 1}"
anonymous_models[id(nested_fields)] = anonymous_name
self.api.model(anonymous_name, nested_fields)
return self.api.models[anonymous_name]
def register_model_with_inline_dict_support(self: Swagger, model: object) -> dict[str, str]:
if isinstance(model, dict):
model = get_or_create_inline_model(self, model)
return _ORIGINAL_REGISTER_MODEL(self, model)
def register_field_with_inline_dict_support(self: Swagger, field: object) -> None:
nested = getattr(field, "nested", None)
if isinstance(nested, dict):
field.model = get_or_create_inline_model(self, nested) # type: ignore
_ORIGINAL_REGISTER_FIELD(self, field)
Swagger.register_model = register_model_with_inline_dict_support
Swagger.register_field = register_field_with_inline_dict_support
Swagger._dify_inline_nested_dict_patch = True
def create_spec_app() -> Flask:
"""Build a minimal Flask app that only mounts the Swagger-producing blueprints."""
_apply_runtime_defaults()
_patch_swagger_for_inline_nested_dicts()
app = Flask(__name__)
from controllers.console import bp as console_bp
from controllers.service_api import bp as service_api_bp
from controllers.web import bp as web_bp
app.register_blueprint(console_bp)
app.register_blueprint(web_bp)
app.register_blueprint(service_api_bp)
return app
def generate_specs(output_dir: Path) -> list[Path]:
"""Write all Swagger specs to `output_dir` and return the written paths."""
output_dir.mkdir(parents=True, exist_ok=True)
app = create_spec_app()
client = app.test_client()
written_paths: list[Path] = []
for target in SPEC_TARGETS:
response = client.get(target.route)
if response.status_code != 200:
raise RuntimeError(f"failed to fetch {target.route}: {response.status_code}")
payload = response.get_json()
if not isinstance(payload, dict):
raise RuntimeError(f"unexpected response payload for {target.route}")
output_path = output_dir / target.filename
output_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
written_paths.append(output_path)
return written_paths
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"-o",
"--output-dir",
type=Path,
default=Path("openapi"),
help="Directory where the Swagger JSON files will be written.",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
written_paths = generate_specs(args.output_dir)
for path in written_paths:
logger.debug(path)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+4 -4
View File
@@ -8,7 +8,7 @@ from sqlalchemy import Index, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .account import Account
from .base import Base
from .base import Base, gen_uuidv7_string
from .engine import db
from .types import StringUUID
@@ -42,7 +42,7 @@ class WorkflowComment(Base):
Index("workflow_comments_created_at_idx", "created_at"),
)
id: Mapped[str] = mapped_column(StringUUID, server_default=sa.text("uuidv7()"))
id: Mapped[str] = mapped_column(StringUUID, default=gen_uuidv7_string)
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
app_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
position_x: Mapped[float] = mapped_column(sa.Float)
@@ -149,7 +149,7 @@ class WorkflowCommentReply(Base):
Index("comment_replies_created_at_idx", "created_at"),
)
id: Mapped[str] = mapped_column(StringUUID, server_default=sa.text("uuidv7()"))
id: Mapped[str] = mapped_column(StringUUID, default=gen_uuidv7_string)
comment_id: Mapped[str] = mapped_column(
StringUUID, sa.ForeignKey("workflow_comments.id", ondelete="CASCADE"), nullable=False
)
@@ -194,7 +194,7 @@ class WorkflowCommentMention(Base):
Index("comment_mentions_user_idx", "mentioned_user_id"),
)
id: Mapped[str] = mapped_column(StringUUID, server_default=sa.text("uuidv7()"))
id: Mapped[str] = mapped_column(StringUUID, default=gen_uuidv7_string)
comment_id: Mapped[str] = mapped_column(
StringUUID, sa.ForeignKey("workflow_comments.id", ondelete="CASCADE"), nullable=False
)
+21 -3
View File
@@ -91,6 +91,19 @@ class EnabledConfig(TypedDict):
enabled: bool
class SuggestedQuestionsAfterAnswerModelConfig(TypedDict):
provider: str
name: str
mode: NotRequired[str]
completion_params: NotRequired[dict[str, Any]]
class SuggestedQuestionsAfterAnswerConfig(TypedDict):
enabled: bool
model: NotRequired[SuggestedQuestionsAfterAnswerModelConfig]
prompt: NotRequired[str]
class EmbeddingModelInfo(TypedDict):
embedding_provider_name: str
embedding_model_name: str
@@ -220,7 +233,7 @@ class ModelConfig(TypedDict):
class AppModelConfigDict(TypedDict):
opening_statement: str | None
suggested_questions: list[str]
suggested_questions_after_answer: EnabledConfig
suggested_questions_after_answer: SuggestedQuestionsAfterAnswerConfig
speech_to_text: EnabledConfig
text_to_speech: EnabledConfig
retriever_resource: EnabledConfig
@@ -680,8 +693,13 @@ class AppModelConfig(TypeBase):
return cast(EnabledConfig, json.loads(value) if value else {"enabled": default_enabled})
@property
def suggested_questions_after_answer_dict(self) -> EnabledConfig:
return self._get_enabled_config(self.suggested_questions_after_answer)
def suggested_questions_after_answer_dict(self) -> SuggestedQuestionsAfterAnswerConfig:
return cast(
SuggestedQuestionsAfterAnswerConfig,
json.loads(self.suggested_questions_after_answer)
if self.suggested_questions_after_answer
else {"enabled": False},
)
@property
def speech_to_text_dict(self) -> EnabledConfig:
+1 -1
View File
@@ -173,7 +173,7 @@ dev = [
# "locust>=2.40.4", # Temporarily removed due to compatibility issues. Uncomment when resolved.
"pytest-timeout>=2.4.0",
"pytest-xdist>=3.8.0",
"pyrefly>=0.61.1",
"pyrefly>=0.62.0",
"xinference-client>=2.5.0",
]
+34 -13
View File
@@ -1,4 +1,6 @@
import logging
from collections.abc import Sequence
from typing import cast
from pydantic import TypeAdapter
from sqlalchemy import select
@@ -17,7 +19,16 @@ from graphon.model_runtime.entities.model_entities import ModelType
from libs.infinite_scroll_pagination import InfiniteScrollPagination
from models import Account
from models.enums import FeedbackFromSource, FeedbackRating
from models.model import App, AppMode, AppModelConfig, AppModelConfigDict, EndUser, Message, MessageFeedback
from models.model import (
App,
AppMode,
AppModelConfig,
AppModelConfigDict,
EndUser,
Message,
MessageFeedback,
SuggestedQuestionsAfterAnswerConfig,
)
from repositories.execution_extra_content_repository import ExecutionExtraContentRepository
from repositories.sqlalchemy_execution_extra_content_repository import (
SQLAlchemyExecutionExtraContentRepository,
@@ -32,6 +43,7 @@ from services.errors.message import (
from services.workflow_service import WorkflowService
_app_model_config_adapter: TypeAdapter[AppModelConfigDict] = TypeAdapter(AppModelConfigDict)
logger = logging.getLogger(__name__)
def _create_execution_extra_content_repository() -> ExecutionExtraContentRepository:
@@ -252,6 +264,7 @@ class MessageService:
)
model_manager = ModelManager.for_tenant(tenant_id=app_model.tenant_id)
suggested_questions_after_answer_config: SuggestedQuestionsAfterAnswerConfig = {"enabled": False}
if app_model.mode == AppMode.ADVANCED_CHAT:
workflow_service = WorkflowService()
@@ -271,9 +284,11 @@ class MessageService:
if not app_config.additional_features.suggested_questions_after_answer:
raise SuggestedQuestionsAfterAnswerDisabledError()
model_instance = model_manager.get_default_model_instance(
tenant_id=app_model.tenant_id, model_type=ModelType.LLM
)
suggested_questions_after_answer = workflow.features_dict.get("suggested_questions_after_answer")
if isinstance(suggested_questions_after_answer, dict):
suggested_questions_after_answer_config = cast(
SuggestedQuestionsAfterAnswerConfig, suggested_questions_after_answer
)
else:
if not conversation.override_model_configs:
app_model_config = db.session.scalar(
@@ -293,16 +308,14 @@ class MessageService:
if not app_model_config:
raise ValueError("did not find app model config")
suggested_questions_after_answer = app_model_config.suggested_questions_after_answer_dict
if suggested_questions_after_answer.get("enabled", False) is False:
suggested_questions_after_answer_config = app_model_config.suggested_questions_after_answer_dict
if suggested_questions_after_answer_config.get("enabled", False) is False:
raise SuggestedQuestionsAfterAnswerDisabledError()
model_instance = model_manager.get_model_instance(
tenant_id=app_model.tenant_id,
provider=app_model_config.model_dict["provider"],
model_type=ModelType.LLM,
model=app_model_config.model_dict["name"],
)
model_instance = model_manager.get_default_model_instance(
tenant_id=app_model.tenant_id,
model_type=ModelType.LLM,
)
# get memory of conversation (read-only)
memory = TokenBufferMemory(conversation=conversation, model_instance=model_instance)
@@ -312,9 +325,17 @@ class MessageService:
message_limit=3,
)
instruction_prompt = suggested_questions_after_answer_config.get("prompt")
if not isinstance(instruction_prompt, str) or not instruction_prompt.strip():
instruction_prompt = None
configured_model = suggested_questions_after_answer_config.get("model")
with measure_time() as timer:
questions_sequence = LLMGenerator.generate_suggested_questions_after_answer(
tenant_id=app_model.tenant_id, histories=histories
tenant_id=app_model.tenant_id,
histories=histories,
instruction_prompt=instruction_prompt,
model_config=configured_model,
)
questions: list[str] = list(questions_sequence)
@@ -0,0 +1,37 @@
"""Unit tests for the standalone Swagger export helper."""
import importlib.util
import json
import sys
from pathlib import Path
def _load_generate_swagger_specs_module():
api_dir = Path(__file__).resolve().parents[3]
script_path = api_dir / "dev" / "generate_swagger_specs.py"
spec = importlib.util.spec_from_file_location("generate_swagger_specs", script_path)
assert spec
assert spec.loader
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module) # type: ignore[attr-defined]
return module
def test_generate_specs_writes_console_web_and_service_swagger_files(tmp_path):
module = _load_generate_swagger_specs_module()
written_paths = module.generate_specs(tmp_path)
assert [path.name for path in written_paths] == [
"console-swagger.json",
"web-swagger.json",
"service-swagger.json",
]
for path in written_paths:
payload = json.loads(path.read_text(encoding="utf-8"))
assert payload["swagger"] == "2.0"
assert "paths" in payload
@@ -2,14 +2,17 @@ from unittest.mock import MagicMock, patch
import pytest
from controllers.console import console_ns
from controllers.console.workspace.endpoint import (
EndpointCreateApi,
EndpointDeleteApi,
DeprecatedEndpointCreateApi,
DeprecatedEndpointDeleteApi,
DeprecatedEndpointUpdateApi,
EndpointCollectionApi,
EndpointDisableApi,
EndpointEnableApi,
EndpointItemApi,
EndpointListApi,
EndpointListForSinglePluginApi,
EndpointUpdateApi,
)
from core.plugin.impl.exc import PluginPermissionDeniedError
@@ -35,9 +38,9 @@ def patch_current_account(user_and_tenant):
@pytest.mark.usefixtures("patch_current_account")
class TestEndpointCreateApi:
class TestEndpointCollectionApi:
def test_create_success(self, app):
api = EndpointCreateApi()
api = EndpointCollectionApi()
method = unwrap(api.post)
payload = {
@@ -55,7 +58,7 @@ class TestEndpointCreateApi:
assert result["success"] is True
def test_create_permission_denied(self, app):
api = EndpointCreateApi()
api = EndpointCollectionApi()
method = unwrap(api.post)
payload = {
@@ -75,7 +78,7 @@ class TestEndpointCreateApi:
method(api)
def test_create_validation_error(self, app):
api = EndpointCreateApi()
api = EndpointCollectionApi()
method = unwrap(api.post)
payload = {
@@ -91,6 +94,27 @@ class TestEndpointCreateApi:
method(api)
@pytest.mark.usefixtures("patch_current_account")
class TestDeprecatedEndpointCreateApi:
def test_create_success(self, app):
api = DeprecatedEndpointCreateApi()
method = unwrap(api.post)
payload = {
"plugin_unique_identifier": "plugin-1",
"name": "endpoint",
"settings": {"a": 1},
}
with (
app.test_request_context("/", json=payload),
patch("controllers.console.workspace.endpoint.EndpointService.create_endpoint", return_value=True),
):
result = method(api)
assert result["success"] is True
@pytest.mark.usefixtures("patch_current_account")
class TestEndpointListApi:
def test_list_success(self, app):
@@ -146,9 +170,96 @@ class TestEndpointListForSinglePluginApi:
@pytest.mark.usefixtures("patch_current_account")
class TestEndpointDeleteApi:
class TestEndpointItemApi:
def test_delete_success(self, app):
api = EndpointDeleteApi()
api = EndpointItemApi()
method = unwrap(api.delete)
with (
app.test_request_context("/", method="DELETE"),
patch(
"controllers.console.workspace.endpoint.EndpointService.delete_endpoint",
return_value=True,
) as mock_delete,
):
result = method(api, "e1")
assert result["success"] is True
mock_delete.assert_called_once_with(tenant_id="t1", user_id="u1", endpoint_id="e1")
def test_delete_service_failure(self, app):
api = EndpointItemApi()
method = unwrap(api.delete)
with (
app.test_request_context("/", method="DELETE"),
patch("controllers.console.workspace.endpoint.EndpointService.delete_endpoint", return_value=False),
):
result = method(api, "e1")
assert result["success"] is False
def test_update_success(self, app):
api = EndpointItemApi()
method = unwrap(api.patch)
payload = {
"name": "new-name",
"settings": {"x": 1},
}
with (
app.test_request_context("/", method="PATCH", json=payload),
patch(
"controllers.console.workspace.endpoint.EndpointService.update_endpoint",
return_value=True,
) as mock_update,
):
result = method(api, "e1")
assert result["success"] is True
mock_update.assert_called_once_with(
tenant_id="t1",
user_id="u1",
endpoint_id="e1",
name="new-name",
settings={"x": 1},
)
def test_update_validation_error(self, app):
api = EndpointItemApi()
method = unwrap(api.patch)
payload = {"settings": {}}
with (
app.test_request_context("/", method="PATCH", json=payload),
):
with pytest.raises(ValueError):
method(api, "e1")
def test_update_service_failure(self, app):
api = EndpointItemApi()
method = unwrap(api.patch)
payload = {
"name": "n",
"settings": {},
}
with (
app.test_request_context("/", method="PATCH", json=payload),
patch("controllers.console.workspace.endpoint.EndpointService.update_endpoint", return_value=False),
):
result = method(api, "e1")
assert result["success"] is False
@pytest.mark.usefixtures("patch_current_account")
class TestDeprecatedEndpointDeleteApi:
def test_delete_success(self, app):
api = DeprecatedEndpointDeleteApi()
method = unwrap(api.post)
payload = {"endpoint_id": "e1"}
@@ -162,7 +273,7 @@ class TestEndpointDeleteApi:
assert result["success"] is True
def test_delete_invalid_payload(self, app):
api = EndpointDeleteApi()
api = DeprecatedEndpointDeleteApi()
method = unwrap(api.post)
with (
@@ -172,7 +283,7 @@ class TestEndpointDeleteApi:
method(api)
def test_delete_service_failure(self, app):
api = EndpointDeleteApi()
api = DeprecatedEndpointDeleteApi()
method = unwrap(api.post)
payload = {"endpoint_id": "e1"}
@@ -187,9 +298,9 @@ class TestEndpointDeleteApi:
@pytest.mark.usefixtures("patch_current_account")
class TestEndpointUpdateApi:
class TestDeprecatedEndpointUpdateApi:
def test_update_success(self, app):
api = EndpointUpdateApi()
api = DeprecatedEndpointUpdateApi()
method = unwrap(api.post)
payload = {
@@ -207,7 +318,7 @@ class TestEndpointUpdateApi:
assert result["success"] is True
def test_update_validation_error(self, app):
api = EndpointUpdateApi()
api = DeprecatedEndpointUpdateApi()
method = unwrap(api.post)
payload = {"endpoint_id": "e1", "settings": {}}
@@ -219,7 +330,7 @@ class TestEndpointUpdateApi:
method(api)
def test_update_service_failure(self, app):
api = EndpointUpdateApi()
api = DeprecatedEndpointUpdateApi()
method = unwrap(api.post)
payload = {
@@ -237,6 +348,36 @@ class TestEndpointUpdateApi:
assert result["success"] is False
class TestEndpointRouteMetadata:
def test_legacy_write_routes_are_marked_deprecated(self):
assert DeprecatedEndpointCreateApi.post.__apidoc__["deprecated"] is True
assert DeprecatedEndpointDeleteApi.post.__apidoc__["deprecated"] is True
assert DeprecatedEndpointUpdateApi.post.__apidoc__["deprecated"] is True
assert EndpointCollectionApi.post.__apidoc__.get("deprecated") is not True
assert EndpointItemApi.delete.__apidoc__.get("deprecated") is not True
assert EndpointItemApi.patch.__apidoc__.get("deprecated") is not True
def test_canonical_and_legacy_write_routes_are_registered(self):
route_map = {
resource.__name__: urls
for resource, urls, _route_doc, _kwargs in console_ns.resources
if resource.__name__
in {
"EndpointCollectionApi",
"EndpointItemApi",
"DeprecatedEndpointCreateApi",
"DeprecatedEndpointDeleteApi",
"DeprecatedEndpointUpdateApi",
}
}
assert route_map["EndpointCollectionApi"] == ("/workspaces/current/endpoints",)
assert route_map["EndpointItemApi"] == ("/workspaces/current/endpoints/<string:id>",)
assert route_map["DeprecatedEndpointCreateApi"] == ("/workspaces/current/endpoints/create",)
assert route_map["DeprecatedEndpointDeleteApi"] == ("/workspaces/current/endpoints/delete",)
assert route_map["DeprecatedEndpointUpdateApi"] == ("/workspaces/current/endpoints/update",)
@pytest.mark.usefixtures("patch_current_account")
class TestEndpointEnableApi:
def test_enable_success(self, app):
@@ -22,6 +22,8 @@ import pytest
from werkzeug.exceptions import Forbidden, NotFound
from controllers.service_api.dataset.document import (
DeprecatedDocumentAddByTextApi,
DeprecatedDocumentUpdateByTextApi,
DocumentAddByFileApi,
DocumentAddByTextApi,
DocumentApi,
@@ -1005,7 +1007,7 @@ class TestDocumentAddByTextApi:
# Act
with app.test_request_context(
f"/datasets/{mock_dataset.id}/document/create_by_text",
f"/datasets/{mock_dataset.id}/document/create-by-text",
method="POST",
json={
"name": "Test Document",
@@ -1037,7 +1039,7 @@ class TestDocumentAddByTextApi:
# Act & Assert
with app.test_request_context(
f"/datasets/{mock_dataset.id}/document/create_by_text",
f"/datasets/{mock_dataset.id}/document/create-by-text",
method="POST",
json={"name": "Test Document", "text": "Content"},
headers={"Authorization": "Bearer test_token"},
@@ -1066,7 +1068,7 @@ class TestDocumentAddByTextApi:
# Act & Assert
with app.test_request_context(
f"/datasets/{mock_dataset.id}/document/create_by_text",
f"/datasets/{mock_dataset.id}/document/create-by-text",
method="POST",
json={"name": "Test Document", "text": "Content"},
headers={"Authorization": "Bearer test_token"},
@@ -1093,6 +1095,20 @@ class TestArchivedDocumentImmutableError:
assert error.code == 403
class TestDocumentTextRouteDeprecation:
"""Test that legacy underscore text routes stay marked deprecated."""
def test_create_by_text_legacy_alias_is_deprecated(self):
"""Ensure only the legacy create-by-text alias is marked deprecated."""
assert DeprecatedDocumentAddByTextApi.post.__apidoc__["deprecated"] is True
assert DocumentAddByTextApi.post.__apidoc__.get("deprecated") is not True
def test_update_by_text_legacy_alias_is_deprecated(self):
"""Ensure only the legacy update-by-text alias is marked deprecated."""
assert DeprecatedDocumentUpdateByTextApi.post.__apidoc__["deprecated"] is True
assert DocumentUpdateByTextApi.post.__apidoc__.get("deprecated") is not True
# =============================================================================
# Endpoint tests for DocumentUpdateByTextApi, DocumentAddByFileApi,
# DocumentUpdateByFileApi.
@@ -1162,7 +1178,7 @@ class TestDocumentUpdateByTextApiPost:
doc_id = str(uuid.uuid4())
with app.test_request_context(
f"/datasets/{mock_dataset.id}/documents/{doc_id}/update_by_text",
f"/datasets/{mock_dataset.id}/documents/{doc_id}/update-by-text",
method="POST",
json={"name": "Updated Doc", "text": "New content"},
headers={"Authorization": "Bearer test_token"},
@@ -1195,7 +1211,7 @@ class TestDocumentUpdateByTextApiPost:
doc_id = str(uuid.uuid4())
with app.test_request_context(
f"/datasets/{mock_dataset.id}/documents/{doc_id}/update_by_text",
f"/datasets/{mock_dataset.id}/documents/{doc_id}/update-by-text",
method="POST",
json={"name": "Doc", "text": "Content"},
headers={"Authorization": "Bearer test_token"},
@@ -77,6 +77,38 @@ class TestAdditionalFeatureManagers:
SuggestedQuestionsAfterAnswerConfigManager.validate_and_set_defaults(
{"suggested_questions_after_answer": {"enabled": "yes"}}
)
with pytest.raises(ValueError):
SuggestedQuestionsAfterAnswerConfigManager.validate_and_set_defaults(
{"suggested_questions_after_answer": {"enabled": True, "prompt": 123}}
)
with pytest.raises(ValueError, match="must be less than or equal to 1000 characters"):
SuggestedQuestionsAfterAnswerConfigManager.validate_and_set_defaults(
{"suggested_questions_after_answer": {"enabled": True, "prompt": "a" * 1001}}
)
with pytest.raises(ValueError):
SuggestedQuestionsAfterAnswerConfigManager.validate_and_set_defaults(
{"suggested_questions_after_answer": {"enabled": True, "model": "bad"}}
)
with pytest.raises(ValueError):
SuggestedQuestionsAfterAnswerConfigManager.validate_and_set_defaults(
{"suggested_questions_after_answer": {"enabled": True, "model": {"provider": "openai"}}}
)
validated_config, _ = SuggestedQuestionsAfterAnswerConfigManager.validate_and_set_defaults(
{
"suggested_questions_after_answer": {
"enabled": True,
"prompt": "custom prompt",
"model": {
"provider": "openai",
"name": "gpt-4o-mini",
"completion_params": {"max_tokens": 1024},
},
}
}
)
assert validated_config["suggested_questions_after_answer"]["prompt"] == "custom prompt"
assert validated_config["suggested_questions_after_answer"]["model"]["name"] == "gpt-4o-mini"
assert (
SuggestedQuestionsAfterAnswerConfigManager.convert({"suggested_questions_after_answer": {"enabled": True}})
@@ -6,7 +6,12 @@ import pytest
from core.app.app_config.entities import ModelConfig
from core.llm_generator.entities import RuleCodeGeneratePayload, RuleGeneratePayload, RuleStructuredOutputPayload
from core.llm_generator.llm_generator import LLMGenerator
from core.llm_generator.prompts import (
DEFAULT_SUGGESTED_QUESTIONS_MAX_TOKENS,
DEFAULT_SUGGESTED_QUESTIONS_TEMPERATURE,
)
from graphon.model_runtime.entities.llm_entities import LLMMode, LLMResult
from graphon.model_runtime.entities.model_entities import ModelType
from graphon.model_runtime.errors.invoke import InvokeAuthorizationError, InvokeError
@@ -96,6 +101,10 @@ class TestLLMGenerator:
questions = LLMGenerator.generate_suggested_questions_after_answer("tenant_id", "histories")
assert len(questions) == 2
assert questions[0] == "Question 1?"
assert mock_model_instance.invoke_llm.call_args.kwargs["model_parameters"] == {
"max_tokens": DEFAULT_SUGGESTED_QUESTIONS_MAX_TOKENS,
"temperature": DEFAULT_SUGGESTED_QUESTIONS_TEMPERATURE,
}
def test_generate_suggested_questions_after_answer_auth_error(self, mock_model_instance):
with patch("core.llm_generator.llm_generator.ModelManager.for_tenant") as mock_manager:
@@ -113,6 +122,97 @@ class TestLLMGenerator:
questions = LLMGenerator.generate_suggested_questions_after_answer("tenant_id", "histories")
assert questions == []
@patch("core.llm_generator.llm_generator.ModelManager.for_tenant")
def test_generate_suggested_questions_after_answer_with_custom_model_and_prompt(self, mock_for_tenant):
custom_model_instance = MagicMock()
custom_response = MagicMock()
custom_response.message.get_text_content.return_value = '["Question 1?"]'
custom_model_instance.invoke_llm.return_value = custom_response
mock_for_tenant.return_value.get_model_instance.return_value = custom_model_instance
questions = LLMGenerator.generate_suggested_questions_after_answer(
"tenant_id",
"histories",
instruction_prompt="custom prompt",
model_config={
"provider": "openai",
"name": "gpt-4o",
"completion_params": {"temperature": 0.2},
},
)
assert questions == ["Question 1?"]
mock_for_tenant.return_value.get_model_instance.assert_called_once_with(
tenant_id="tenant_id",
model_type=ModelType.LLM,
provider="openai",
model="gpt-4o",
)
invoke_kwargs = custom_model_instance.invoke_llm.call_args.kwargs
assert invoke_kwargs["model_parameters"] == {"temperature": 0.2}
assert invoke_kwargs["stop"] == []
assert "custom prompt" in invoke_kwargs["prompt_messages"][0].content
@patch("core.llm_generator.llm_generator.ModelManager.for_tenant")
def test_generate_suggested_questions_after_answer_fallback_to_default_model(self, mock_for_tenant):
default_model_instance = MagicMock()
default_response = MagicMock()
default_response.message.get_text_content.return_value = '["Question 1?"]'
default_model_instance.invoke_llm.return_value = default_response
mock_for_tenant.return_value.get_model_instance.side_effect = ValueError("invalid configured model")
mock_for_tenant.return_value.get_default_model_instance.return_value = default_model_instance
questions = LLMGenerator.generate_suggested_questions_after_answer(
"tenant_id",
"histories",
model_config={
"provider": "openai",
"name": "not-found-model",
"completion_params": {"temperature": 0.2},
},
)
assert questions == ["Question 1?"]
mock_for_tenant.return_value.get_default_model_instance.assert_called_once_with(
tenant_id="tenant_id",
model_type=ModelType.LLM,
)
assert default_model_instance.invoke_llm.call_args.kwargs["model_parameters"] == {
"max_tokens": DEFAULT_SUGGESTED_QUESTIONS_MAX_TOKENS,
"temperature": DEFAULT_SUGGESTED_QUESTIONS_TEMPERATURE,
}
assert default_model_instance.invoke_llm.call_args.kwargs["stop"] == []
@patch("core.llm_generator.llm_generator.ModelManager.for_tenant")
def test_generate_suggested_questions_after_answer_drops_non_positive_max_tokens(self, mock_for_tenant):
custom_model_instance = MagicMock()
custom_response = MagicMock()
custom_response.message.get_text_content.return_value = '["Question 1?"]'
custom_model_instance.invoke_llm.return_value = custom_response
mock_for_tenant.return_value.get_model_instance.return_value = custom_model_instance
questions = LLMGenerator.generate_suggested_questions_after_answer(
"tenant_id",
"histories",
model_config={
"provider": "openai",
"name": "gpt-4o",
"completion_params": {
"temperature": 0.2,
"max_tokens": 0,
"stop": ["END"],
},
},
)
assert questions == ["Question 1?"]
invoke_kwargs = custom_model_instance.invoke_llm.call_args.kwargs
assert invoke_kwargs["model_parameters"] == {"temperature": 0.2}
assert invoke_kwargs["stop"] == ["END"]
def test_generate_rule_config_no_variable_success(self, mock_model_instance, model_config_entity):
payload = RuleGeneratePayload(
instruction="test instruction", model_config=model_config_entity, no_variable=True
@@ -3,6 +3,7 @@ from unittest.mock import MagicMock, patch
import pytest
from graphon.model_runtime.entities.model_entities import ModelType
from libs.infinite_scroll_pagination import InfiniteScrollPagination
from models.enums import FeedbackFromSource, FeedbackRating
from models.model import App, AppMode, EndUser, Message
@@ -931,6 +932,130 @@ class TestMessageServiceSuggestedQuestions:
assert result == ["Q1?"]
mock_llm_gen.generate_suggested_questions_after_answer.assert_called_once()
@patch("services.message_service.db")
@patch("services.message_service.ModelManager.for_tenant")
@patch("services.message_service.TokenBufferMemory")
@patch("services.message_service.LLMGenerator")
@patch("services.message_service.TraceQueueManager")
@patch.object(MessageService, "get_message")
@patch("services.message_service.ConversationService")
def test_get_suggested_questions_chat_app_uses_frontend_model_and_prompt(
self,
mock_conversation_service,
mock_get_message,
mock_trace_manager,
mock_llm_gen,
mock_memory,
mock_model_manager,
mock_db,
factory,
):
"""Test suggested question generation uses frontend configured model and prompt."""
from core.app.entities.app_invoke_entities import InvokeFrom
app = factory.create_app_mock(mode=AppMode.CHAT.value)
app.tenant_id = "tenant-123"
user = factory.create_end_user_mock()
message = factory.create_message_mock()
mock_get_message.return_value = message
conversation = MagicMock()
conversation.override_model_configs = None
mock_conversation_service.get_conversation.return_value = conversation
app_model_config = MagicMock()
app_model_config.suggested_questions_after_answer_dict = {
"enabled": True,
"prompt": "custom prompt",
"model": {
"provider": "openai",
"name": "gpt-4o-mini",
"completion_params": {"max_tokens": 2048, "temperature": 0.1},
},
}
mock_db.session.scalar.return_value = app_model_config
mock_memory.return_value.get_history_prompt_text.return_value = "histories"
mock_llm_gen.generate_suggested_questions_after_answer.return_value = ["Q1?"]
result = MessageService.get_suggested_questions_after_answer(
app_model=app,
user=user,
message_id="msg-123",
invoke_from=InvokeFrom.WEB_APP,
)
assert result == ["Q1?"]
mock_model_manager.return_value.get_default_model_instance.assert_called_once_with(
tenant_id="tenant-123",
model_type=ModelType.LLM,
)
mock_memory.assert_called_once_with(
conversation=conversation,
model_instance=mock_model_manager.return_value.get_default_model_instance.return_value,
)
mock_llm_gen.generate_suggested_questions_after_answer.assert_called_once_with(
tenant_id="tenant-123",
histories="histories",
instruction_prompt="custom prompt",
model_config={
"provider": "openai",
"name": "gpt-4o-mini",
"completion_params": {"max_tokens": 2048, "temperature": 0.1},
},
)
@patch("services.message_service.db")
@patch("services.message_service.ModelManager.for_tenant")
@patch("services.message_service.TokenBufferMemory")
@patch("services.message_service.LLMGenerator")
@patch("services.message_service.TraceQueueManager")
@patch.object(MessageService, "get_message")
@patch("services.message_service.ConversationService")
def test_get_suggested_questions_chat_app_invalid_frontend_model_fallback_to_default(
self,
mock_conversation_service,
mock_get_message,
mock_trace_manager,
mock_llm_gen,
mock_memory,
mock_model_manager,
mock_db,
factory,
):
"""Test invalid frontend configured model falls back to tenant default model."""
app = factory.create_app_mock(mode=AppMode.CHAT.value)
app.tenant_id = "tenant-123"
user = factory.create_end_user_mock()
message = factory.create_message_mock()
mock_get_message.return_value = message
conversation = MagicMock()
conversation.override_model_configs = None
mock_conversation_service.get_conversation.return_value = conversation
app_model_config = MagicMock()
app_model_config.suggested_questions_after_answer_dict = {
"enabled": True,
"model": {"provider": "openai", "name": "invalid-model"},
}
mock_db.session.scalar.return_value = app_model_config
mock_model_manager.return_value.get_model_instance.side_effect = ValueError("invalid model")
mock_memory.return_value.get_history_prompt_text.return_value = "histories"
mock_llm_gen.generate_suggested_questions_after_answer.return_value = ["Q1?"]
result = MessageService.get_suggested_questions_after_answer(
app_model=app, user=user, message_id="msg-123", invoke_from=MagicMock()
)
assert result == ["Q1?"]
mock_model_manager.return_value.get_default_model_instance.assert_called_once_with(
tenant_id="tenant-123",
model_type=ModelType.LLM,
)
mock_model_manager.return_value.get_model_instance.assert_not_called()
# Test 30: get_suggested_questions_after_answer - Disabled Error
@patch("services.message_service.WorkflowService")
@patch("services.message_service.AdvancedChatAppConfigManager")
Generated
+32 -32
View File
@@ -1627,7 +1627,7 @@ dev = [
{ name = "lxml-stubs", specifier = ">=0.5.1" },
{ name = "mypy", specifier = ">=1.20.1" },
{ name = "pandas-stubs", specifier = ">=3.0.0" },
{ name = "pyrefly", specifier = ">=0.61.1" },
{ name = "pyrefly", specifier = ">=0.62.0" },
{ name = "pytest", specifier = ">=9.0.3" },
{ name = "pytest-benchmark", specifier = ">=5.2.3" },
{ name = "pytest-cov", specifier = ">=7.1.0" },
@@ -3687,28 +3687,28 @@ wheels = [
[[package]]
name = "lxml"
version = "6.0.2"
version = "6.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" }
sdist = { url = "https://files.pythonhosted.org/packages/28/30/9abc9e34c657c33834eaf6cd02124c61bdf5944d802aa48e69be8da3585d/lxml-6.1.0.tar.gz", hash = "sha256:bfd57d8008c4965709a919c3e9a98f76c2c7cb319086b3d26858250620023b13", size = 4197006, upload-time = "2026-04-18T04:32:51.613Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" },
{ url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" },
{ url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" },
{ url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" },
{ url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" },
{ url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" },
{ url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" },
{ url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" },
{ url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" },
{ url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" },
{ url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" },
{ url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" },
{ url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" },
{ url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" },
{ url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" },
{ url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" },
{ url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" },
{ url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" },
{ url = "https://files.pythonhosted.org/packages/d2/d4/9326838b59dc36dfae42eec9656b97520f9997eee1de47b8316aaeed169c/lxml-6.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d2f17a16cd8751e8eb233a7e41aecdf8e511712e00088bf9be455f604cd0d28d", size = 8570663, upload-time = "2026-04-18T04:27:48.253Z" },
{ url = "https://files.pythonhosted.org/packages/d8/a4/053745ce1f8303ccbb788b86c0db3a91b973675cefc42566a188637b7c40/lxml-6.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0cea5b1d3e6e77d71bd2b9972eb2446221a69dc52bb0b9c3c6f6e5700592d93", size = 4624024, upload-time = "2026-04-18T04:27:52.594Z" },
{ url = "https://files.pythonhosted.org/packages/90/97/a517944b20f8fd0932ad2109482bee4e29fe721416387a363306667941f6/lxml-6.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fc46da94826188ed45cb53bd8e3fc076ae22675aea2087843d4735627f867c6d", size = 4930895, upload-time = "2026-04-18T04:32:56.29Z" },
{ url = "https://files.pythonhosted.org/packages/94/7c/e08a970727d556caa040a44773c7b7e3ad0f0d73dedc863543e9a8b931f2/lxml-6.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9147d8e386ec3b82c3b15d88927f734f565b0aaadef7def562b853adca45784a", size = 5093820, upload-time = "2026-04-18T04:32:58.94Z" },
{ url = "https://files.pythonhosted.org/packages/88/ee/2a5c2aa2c32016a226ca25d3e1056a8102ea6e1fe308bf50213586635400/lxml-6.1.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5715e0e28736a070f3f34a7ccc09e2fdcba0e3060abbcf61a1a5718ff6d6b105", size = 5005790, upload-time = "2026-04-18T04:33:01.272Z" },
{ url = "https://files.pythonhosted.org/packages/e3/38/a0db9be8f38ad6043ab9429487c128dd1d30f07956ef43040402f8da49e8/lxml-6.1.0-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4937460dc5df0cdd2f06a86c285c28afda06aefa3af949f9477d3e8df430c485", size = 5630827, upload-time = "2026-04-18T04:33:04.036Z" },
{ url = "https://files.pythonhosted.org/packages/31/ba/3c13d3fc24b7cacf675f808a3a1baabf43a30d0cd24c98f94548e9aa58eb/lxml-6.1.0-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bc783ee3147e60a25aa0445ea82b3e8aabb83b240f2b95d32cb75587ff781814", size = 5240445, upload-time = "2026-04-18T04:33:06.87Z" },
{ url = "https://files.pythonhosted.org/packages/55/ba/eeef4ccba09b2212fe239f46c1692a98db1878e0872ae320756488878a94/lxml-6.1.0-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:40d9189f80075f2e1f88db21ef815a2b17b28adf8e50aaf5c789bfe737027f32", size = 5350121, upload-time = "2026-04-18T04:33:09.365Z" },
{ url = "https://files.pythonhosted.org/packages/7e/01/1da87c7b587c38d0cbe77a01aae3b9c1c49ed47d76918ef3db8fc151b1ca/lxml-6.1.0-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:05b9b8787e35bec69e68daf4952b2e6dfcfb0db7ecf1a06f8cdfbbac4eb71aad", size = 4694949, upload-time = "2026-04-18T04:33:11.628Z" },
{ url = "https://files.pythonhosted.org/packages/a1/88/7db0fe66d5aaf128443ee1623dec3db1576f3e4c17751ec0ef5866468590/lxml-6.1.0-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0f08beb0182e3e9a86fae124b3c47a7b41b7b69b225e1377db983802404e54", size = 5243901, upload-time = "2026-04-18T04:33:13.95Z" },
{ url = "https://files.pythonhosted.org/packages/00/a8/1346726af7d1f6fca1f11223ba34001462b0a3660416986d37641708d57c/lxml-6.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73becf6d8c81d4c76b1014dbd3584cb26d904492dcf73ca85dc8bff08dcd6d2d", size = 5048054, upload-time = "2026-04-18T04:33:16.965Z" },
{ url = "https://files.pythonhosted.org/packages/2e/b7/85057012f035d1a0c87e02f8c723ca3c3e6e0728bcf4cb62080b21b1c1e3/lxml-6.1.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1ae225f66e5938f4fa29d37e009a3bb3b13032ac57eb4eb42afa44f6e4054e69", size = 4777324, upload-time = "2026-04-18T04:33:19.832Z" },
{ url = "https://files.pythonhosted.org/packages/75/6c/ad2f94a91073ef570f33718040e8e160d5fb93331cf1ab3ca1323f939e2d/lxml-6.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:690022c7fae793b0489aa68a658822cea83e0d5933781811cabbf5ea3bcfe73d", size = 5645702, upload-time = "2026-04-18T04:33:22.436Z" },
{ url = "https://files.pythonhosted.org/packages/3b/89/0bb6c0bd549c19004c60eea9dc554dd78fd647b72314ef25d460e0d208c6/lxml-6.1.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:63aeafc26aac0be8aff14af7871249e87ea1319be92090bfd632ec68e03b16a5", size = 5232901, upload-time = "2026-04-18T04:33:26.21Z" },
{ url = "https://files.pythonhosted.org/packages/a1/d9/d609a11fb567da9399f525193e2b49847b5a409cdebe737f06a8b7126bdc/lxml-6.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:264c605ab9c0e4aa1a679636f4582c4d3313700009fac3ec9c3412ed0d8f3e1d", size = 5261333, upload-time = "2026-04-18T04:33:28.984Z" },
{ url = "https://files.pythonhosted.org/packages/a6/3a/ac3f99ec8ac93089e7dd556f279e0d14c24de0a74a507e143a2e4b496e7c/lxml-6.1.0-cp312-cp312-win32.whl", hash = "sha256:56971379bc5ee8037c5a0f09fa88f66cdb7d37c3e38af3e45cf539f41131ac1f", size = 3596289, upload-time = "2026-04-18T04:27:42.819Z" },
{ url = "https://files.pythonhosted.org/packages/f2/a7/0a915557538593cb1bbeedcd40e13c7a261822c26fecbbdb71dad0c2f540/lxml-6.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:bba078de0031c219e5dd06cf3e6bf8fb8e6e64a77819b358f53bb132e3e03366", size = 3997059, upload-time = "2026-04-18T04:27:46.764Z" },
{ url = "https://files.pythonhosted.org/packages/92/96/a5dc078cf0126fbfbc35611d77ecd5da80054b5893e28fb213a5613b9e1d/lxml-6.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:c3592631e652afa34999a088f98ba7dfc7d6aff0d535c410bea77a71743f3819", size = 3659552, upload-time = "2026-04-18T04:27:51.133Z" },
]
[[package]]
@@ -5357,19 +5357,19 @@ wheels = [
[[package]]
name = "pyrefly"
version = "0.61.1"
version = "0.62.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/c8/52fce3f0e3718d9ff71d16af41cef925e58613741328004d3aa3fe585057/pyrefly-0.61.1.tar.gz", hash = "sha256:2a871320b7d2b28b8635064b620097d7091e84c49e4808d915ad31dad685d0f5", size = 5535788, upload-time = "2026-04-17T18:47:33.958Z" }
sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/8874ed25781e7dd561c6d75fb4a7becf10a18d75b074f25b845cc334f781/pyrefly-0.62.0.tar.gz", hash = "sha256:da1fbe1075dc1e6c8e3134e9370b0a0e7a296061d782cca5bf83dbb8e4c10d7c", size = 5537672, upload-time = "2026-04-20T17:12:15.718Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/38/e94ff401405a05fbf81c9bbfa993a34ffd03be84812b545063c8efb56b44/pyrefly-0.61.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6e3ed857b99291fc4aa3b54ce22deb086c0174cf3a3775eccea7439efd16d925", size = 12969301, upload-time = "2026-04-17T18:47:06.036Z" },
{ url = "https://files.pythonhosted.org/packages/f3/be/53c7f9400696e46633c8cee8b6fd32ce7ab4a965ddf9ac4f4ea9e2034647/pyrefly-0.61.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cf6335c1baf9470ca8113f7ea8bdbd0b96081c82a911157c576cdfc8a67a9a87", size = 12475413, upload-time = "2026-04-17T18:47:08.863Z" },
{ url = "https://files.pythonhosted.org/packages/77/68/83cc3267620b14f81fa596a84efc7ebcf5c49f79b521499e85d1a4fca6d8/pyrefly-0.61.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:844b5baddc2a631f69648a4756c54c97d86e4b9c07e335b216668e24390b77b6", size = 36074785, upload-time = "2026-04-17T18:47:11.845Z" },
{ url = "https://files.pythonhosted.org/packages/d8/00/e8d437995b8dcea022f5310bc873f5de1dcc71da4876d5be917ee9a93fef/pyrefly-0.61.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eaa294f90622c5b3743af8e9de4263447f22bb0e8b60c80cf83292adb4f2d14b", size = 38802979, upload-time = "2026-04-17T18:47:16.058Z" },
{ url = "https://files.pythonhosted.org/packages/16/3f/f1cbc58e8875608ae740d9575de95c8bc6d4dce202f82b4fe90005727618/pyrefly-0.61.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a8d8c3fe08b9593dce23ad4bc7c393891a379c2d580aa1f263182567721bd6f", size = 37029339, upload-time = "2026-04-17T18:47:19.601Z" },
{ url = "https://files.pythonhosted.org/packages/18/8c/0ff67041c88c28f48b10ce15758831d1e4e60f11db5bfc09dcffd5edb6ba/pyrefly-0.61.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:305f2086f4d7d796244b337884d96cf0d32435420336a77840ca369cf6fa06fd", size = 41595667, upload-time = "2026-04-17T18:47:23.122Z" },
{ url = "https://files.pythonhosted.org/packages/ff/9e/62b8139b140931593a6b29334802ea6b86d033c0bfd9794950279732253b/pyrefly-0.61.1-py3-none-win32.whl", hash = "sha256:3271a019885a72c8dd064e928bb445af807771506842f5f2faaac17d8e6e73a5", size = 11963660, upload-time = "2026-04-17T18:47:25.86Z" },
{ url = "https://files.pythonhosted.org/packages/38/6e/73280243d12bec28f55b6edd4e70c5cf11e3d7de2395ecb4eb36cca7dab4/pyrefly-0.61.1-py3-none-win_amd64.whl", hash = "sha256:3e3763d5d76f505c5b8897db1446bde8e138d50a67751f2aa76d6c6034254836", size = 12804056, upload-time = "2026-04-17T18:47:28.674Z" },
{ url = "https://files.pythonhosted.org/packages/87/32/38ac5af84d96167412024abf5e2f49f15b777987a1942e7a442e8e5fef82/pyrefly-0.61.1-py3-none-win_arm64.whl", hash = "sha256:cef5631e2ab09702274ec2eaaafee28a114891cf85f2d31568b329727e3ff735", size = 12302467, upload-time = "2026-04-17T18:47:31.409Z" },
{ url = "https://files.pythonhosted.org/packages/1b/ea/09bd9da7d5df294db800312fb415be2fefbaa5594178e9e49f44fa071aea/pyrefly-0.62.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d78ec4f126dee1fa76215b193b964490ce10e62a32d2787a72c51623658b803", size = 13020414, upload-time = "2026-04-20T17:11:43.617Z" },
{ url = "https://files.pythonhosted.org/packages/4b/f0/f84afac4f220c4c8c801b779ee2ff28ad3f7731f4283c2e1b6ee9012e8c2/pyrefly-0.62.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2a41a34902d20756264486f9e309f22633d100261bd960feea6e858a098d985d", size = 12515659, upload-time = "2026-04-20T17:11:46.59Z" },
{ url = "https://files.pythonhosted.org/packages/40/0b/620c39cefa9ae1b25ee7a2da9d8d3c278b095649cb8435c5e01ea64f7c17/pyrefly-0.62.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4666c6b65aea662e5f77b64dc91c091b7ea5cede6aa66c0f4cbae26480403583", size = 36228332, upload-time = "2026-04-20T17:11:50.523Z" },
{ url = "https://files.pythonhosted.org/packages/2d/fb/47b8b76438c12761e509a3666cd5a99d4af7f21976ba8385feb475cbfe30/pyrefly-0.62.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1aefab798f47d37c13ded791192fee9b39a6d2b12e31f38ae06a1f80c4b26e22", size = 38995741, upload-time = "2026-04-20T17:11:54.702Z" },
{ url = "https://files.pythonhosted.org/packages/55/d2/03bd17673f61147cd5609cd7d6a1455eeccc17a07a7e141ed9931b0c42c0/pyrefly-0.62.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8fa986b50d56740da1d7ae7c660a505143cb9d286fa98cc7e5f4a759cc6eaa5d", size = 37205321, upload-time = "2026-04-20T17:11:58.9Z" },
{ url = "https://files.pythonhosted.org/packages/75/14/20ba7b7f2d182f9b7c1e24a3041dac9b5730ae28cfe1614a2c98706650f2/pyrefly-0.62.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32e9b175805c82ffb967e4708f4910bace7e1a12736907380cc9afdbaabb0efb", size = 41786834, upload-time = "2026-04-20T17:12:03.221Z" },
{ url = "https://files.pythonhosted.org/packages/fa/c8/5a7ba88c4fa1b5090d877f70fa1b742b921b9e7d8d3f4b6b9b1ba1820850/pyrefly-0.62.0-py3-none-win32.whl", hash = "sha256:1cd98edc20cab5bac8016c9220ee66080e39bd22e7f0e9bb3e2c4e2be1555eed", size = 12010170, upload-time = "2026-04-20T17:12:06.791Z" },
{ url = "https://files.pythonhosted.org/packages/2e/78/d8f810de010ff2ed594c630c724fd817ef430963249e9eb396ce8f785e9d/pyrefly-0.62.0-py3-none-win_amd64.whl", hash = "sha256:6994f8ee7d6720325ee52207fbdaca98a799a1efe462bb5ba90c47160f7f3e6e", size = 12861816, upload-time = "2026-04-20T17:12:09.689Z" },
{ url = "https://files.pythonhosted.org/packages/c7/a9/ac824ef6a3f50b7c0ec5974471f8f2cb205cd1edd53a5abbcf7ba37feb5d/pyrefly-0.62.0-py3-none-win_arm64.whl", hash = "sha256:362a5d47a5ac5aaa5258091e878a1759ff8b687d8cf462af1c516144f7b0108a", size = 12352977, upload-time = "2026-04-20T17:12:12.736Z" },
]
[[package]]
-253
View File
@@ -1,253 +0,0 @@
# Configurable Suggested Questions After Answer
This document explains how to configure the "Suggested Questions After Answer" feature in Dify using environment variables.
## Overview
The suggested questions feature generates follow-up questions after each AI response to help users continue the conversation. By default, Dify generates 3 short questions (under 20 characters each), but you can customize this behavior to better fit your specific use case.
## Environment Variables
### `SUGGESTED_QUESTIONS_PROMPT`
**Description**: Custom prompt template for generating suggested questions.
**Default**:
```
Please help me predict the three most likely questions that human would ask, and keep each question under 20 characters.
MAKE SURE your output is the SAME language as the Assistant's latest response.
The output must be an array in JSON format following the specified schema:
["question1","question2","question3"]
```
**Usage Examples**:
1. **Technical/Developer Questions (Your Use Case)**:
```bash
export SUGGESTED_QUESTIONS_PROMPT='Please help me predict the five most likely technical follow-up questions a developer would ask. Focus on implementation details, best practices, and architecture considerations. Keep each question between 40-60 characters. Output must be JSON array: ["question1","question2","question3","question4","question5"]'
```
1. **Customer Support**:
```bash
export SUGGESTED_QUESTIONS_PROMPT='Generate 3 helpful follow-up questions that guide customers toward solving their own problems. Focus on troubleshooting steps and common issues. Keep questions under 30 characters. JSON format: ["q1","q2","q3"]'
```
1. **Educational Content**:
```bash
export SUGGESTED_QUESTIONS_PROMPT='Create 4 thought-provoking questions that help students deeper understand the topic. Focus on concepts, relationships, and applications. Questions should be 25-40 characters. JSON: ["question1","question2","question3","question4"]'
```
1. **Multilingual Support**:
```bash
export SUGGESTED_QUESTIONS_PROMPT='Generate exactly 3 follow-up questions in the same language as the conversation. Adapt question length appropriately for the language (Chinese: 10-15 chars, English: 20-30 chars, Arabic: 25-35 chars). Always output valid JSON array.'
```
**Important Notes**:
- The prompt must request JSON array output format
- Include language matching instructions for multilingual support
- Specify clear character limits or question count requirements
- Focus on your specific domain or use case
### `SUGGESTED_QUESTIONS_MAX_TOKENS`
**Description**: Maximum number of tokens for the LLM response.
**Default**: `256`
**Usage**:
```bash
export SUGGESTED_QUESTIONS_MAX_TOKENS=512 # For longer questions or more questions
```
**Recommended Values**:
- `256`: Default, good for 3-4 short questions
- `384`: Medium, good for 4-5 medium-length questions
- `512`: High, good for 5+ longer questions or complex prompts
- `1024`: Maximum, for very complex question generation
### `SUGGESTED_QUESTIONS_TEMPERATURE`
**Description**: Temperature parameter for LLM creativity.
**Default**: `0.0`
**Usage**:
```bash
export SUGGESTED_QUESTIONS_TEMPERATURE=0.3 # Balanced creativity
```
**Recommended Values**:
- `0.0-0.2`: Very focused, predictable questions (good for technical support)
- `0.3-0.5`: Balanced creativity and relevance (good for general use)
- `0.6-0.8`: More creative, diverse questions (good for brainstorming)
- `0.9-1.0`: Maximum creativity (good for educational exploration)
## Configuration Examples
### Example 1: Developer Documentation Chatbot
```bash
# .env file
SUGGESTED_QUESTIONS_PROMPT='Generate exactly 5 technical follow-up questions that developers would ask after reading code documentation. Focus on implementation details, edge cases, performance considerations, and best practices. Each question should be 40-60 characters long. Output as JSON array: ["question1","question2","question3","question4","question5"]'
SUGGESTED_QUESTIONS_MAX_TOKENS=512
SUGGESTED_QUESTIONS_TEMPERATURE=0.3
```
### Example 2: Customer Service Bot
```bash
# .env file
SUGGESTED_QUESTIONS_PROMPT='Create 3 actionable follow-up questions that help customers resolve their own issues. Focus on common problems, troubleshooting steps, and product features. Keep questions simple and under 25 characters. JSON: ["q1","q2","q3"]'
SUGGESTED_QUESTIONS_MAX_TOKENS=256
SUGGESTED_QUESTIONS_TEMPERATURE=0.1
```
### Example 3: Educational Tutor
```bash
# .env file
SUGGESTED_QUESTIONS_PROMPT='Generate 4 thought-provoking questions that help students deepen their understanding of the topic. Focus on relationships between concepts, practical applications, and critical thinking. Questions should be 30-45 characters. Output: ["question1","question2","question3","question4"]'
SUGGESTED_QUESTIONS_MAX_TOKENS=384
SUGGESTED_QUESTIONS_TEMPERATURE=0.6
```
## Implementation Details
### How It Works
1. **Environment Variable Loading**: The system checks for environment variables at startup
1. **Fallback to Defaults**: If no environment variables are set, original behavior is preserved
1. **Prompt Template**: The custom prompt is used as-is, allowing full control over question generation
1. **LLM Parameters**: Custom max_tokens and temperature are passed to the LLM API
1. **JSON Parsing**: The system expects JSON array output and parses it accordingly
### File Changes
The implementation modifies these files:
- `api/core/llm_generator/prompts.py`: Environment variable support
- `api/core/llm_generator/llm_generator.py`: Custom LLM parameters
- `api/.env.example`: Documentation of new variables
### Backward Compatibility
- ✅ **Zero Breaking Changes**: Works exactly as before if no environment variables are set
- ✅ **Default Behavior Preserved**: Original prompt and parameters used as fallbacks
- ✅ **No Database Changes**: Pure environment variable configuration
- ✅ **No UI Changes Required**: Configuration happens at deployment level
## Testing Your Configuration
### Local Testing
1. Set environment variables:
```bash
export SUGGESTED_QUESTIONS_PROMPT='Your test prompt...'
export SUGGESTED_QUESTIONS_MAX_TOKENS=300
export SUGGESTED_QUESTIONS_TEMPERATURE=0.4
```
1. Start Dify API:
```bash
cd api
python -m flask run --host 0.0.0.0 --port=5001 --debug
```
1. Test the feature in your chat application and verify the questions match your expectations.
### Monitoring
Monitor the following when testing:
- **Question Quality**: Are questions relevant and helpful?
- **Language Matching**: Do questions match the conversation language?
- **JSON Format**: Is output properly formatted as JSON array?
- **Length Constraints**: Do questions follow your length requirements?
- **Response Time**: Are the custom parameters affecting performance?
## Troubleshooting
### Common Issues
1. **Invalid JSON Output**:
- **Problem**: LLM doesn't return valid JSON
- **Solution**: Make sure your prompt explicitly requests JSON array format
1. **Questions Too Long/Short**:
- **Problem**: Questions don't follow length constraints
- **Solution**: Be more specific about character limits in your prompt
1. **Too Few/Many Questions**:
- **Problem**: Wrong number of questions generated
- **Solution**: Clearly specify the exact number in your prompt
1. **Language Mismatch**:
- **Problem**: Questions in wrong language
- **Solution**: Include explicit language matching instructions in prompt
1. **Performance Issues**:
- **Problem**: Slow response times
- **Solution**: Reduce `SUGGESTED_QUESTIONS_MAX_TOKENS` or simplify prompt
### Debug Logging
To debug your configuration, you can temporarily add logging to see the actual prompt and parameters being used:
```python
import logging
logger = logging.getLogger(__name__)
# In llm_generator.py
logger.info(f"Suggested questions prompt: {prompt}")
logger.info(f"Max tokens: {SUGGESTED_QUESTIONS_MAX_TOKENS}")
logger.info(f"Temperature: {SUGGESTED_QUESTIONS_TEMPERATURE}")
```
## Migration Guide
### From Default Configuration
If you're currently using the default configuration and want to customize:
1. **Assess Your Needs**: Determine what aspects need customization (question count, length, domain focus)
1. **Design Your Prompt**: Write a custom prompt that addresses your specific use case
1. **Choose Parameters**: Select appropriate max_tokens and temperature values
1. **Test Incrementally**: Start with small changes and test thoroughly
1. **Deploy Gradually**: Roll out to production after successful testing
### Best Practices
1. **Start Simple**: Begin with minimal changes to the default prompt
1. **Test Thoroughly**: Test with various conversation types and languages
1. **Monitor Performance**: Watch for impact on response times and costs
1. **Get User Feedback**: Collect feedback on question quality and relevance
1. **Iterate**: Refine your configuration based on real-world usage
## Future Enhancements
This environment variable approach provides immediate customization while maintaining backward compatibility. Future enhancements could include:
1. **App-Level Configuration**: Different apps with different suggested question settings
1. **Dynamic Prompts**: Context-aware prompts based on conversation content
1. **Multi-Model Support**: Different models for different types of questions
1. **Analytics Dashboard**: Insights into question effectiveness and usage patterns
1. **A/B Testing**: Built-in testing of different prompt configurations
For now, the environment variable approach offers a simple, reliable way to customize the suggested questions feature for your specific needs.
@@ -0,0 +1,26 @@
@apps @authenticated @core
Feature: App detail navigation
Scenario: Opening a workflow app navigates to the workflow editor
Given I am signed in as the default E2E admin
And a "workflow" app has been created via API
When I open the app from the app list
Then I should land on the workflow editor
Scenario: Opening a chatbot app navigates to the configuration page
Given I am signed in as the default E2E admin
And a "chat" app has been created via API
When I open the app from the app list
Then I should land on the app configuration page
Scenario: The develop tab is accessible from a workflow app
Given I am signed in as the default E2E admin
And a "workflow" app has been created via API
When I navigate to the app develop page
Then I should be on the app develop page
Scenario: The overview tab is accessible from a workflow app
Given I am signed in as the default E2E admin
And a "workflow" app has been created via API
When I navigate to the app overview page
Then I should be on the app overview page
+1 -1
View File
@@ -1,4 +1,4 @@
@apps @authenticated
@apps @authenticated @core
Feature: Create app
Scenario: Create a new blank app and redirect to the editor
Given I am signed in as the default E2E admin
+1 -1
View File
@@ -1,4 +1,4 @@
@apps @authenticated
@apps @authenticated @core @mode-matrix
Feature: Create Chatbot app
Scenario: Create a new Chatbot app and redirect to the configuration page
Given I am signed in as the default E2E admin
@@ -1,4 +1,4 @@
@apps @authenticated
@apps @authenticated @core @mode-matrix
Feature: Create Workflow app
Scenario: Create a new Workflow app and redirect to the workflow editor
Given I am signed in as the default E2E admin
+11
View File
@@ -0,0 +1,11 @@
@apps @authenticated @core
Feature: Publish app
Scenario: Publish a workflow app for the first time
Given I am signed in as the default E2E admin
And a "workflow" app has been created via API
And a minimal workflow draft has been synced
When I open the app from the app list
And I open the publish panel
And I publish the app
Then the app should be marked as published
+8
View File
@@ -0,0 +1,8 @@
@auth @smoke @core @unauthenticated
Feature: Sign in
Scenario: Sign in with valid credentials and reach the apps console
Given I am not signed in
When I open the sign-in page
And I sign in as the default E2E admin
Then I should be on the apps console
+1 -1
View File
@@ -1,4 +1,4 @@
@auth @authenticated
@auth @authenticated @core
Feature: Sign out
Scenario: Sign out from the apps console
Given I am signed in as the default E2E admin
@@ -0,0 +1,21 @@
import type { DifyWorld } from '../../support/world'
import { Then, When } from '@cucumber/cucumber'
import { expect } from '@playwright/test'
When('I navigate to the app develop page', async function (this: DifyWorld) {
const appId = this.createdAppIds.at(-1)
await this.getPage().goto(`/app/${appId}/develop`)
})
When('I navigate to the app overview page', async function (this: DifyWorld) {
const appId = this.createdAppIds.at(-1)
await this.getPage().goto(`/app/${appId}/overview`)
})
Then('I should be on the app develop page', async function (this: DifyWorld) {
await expect(this.getPage()).toHaveURL(/\/app\/[^/]+\/develop(?:\?.*)?$/, { timeout: 30_000 })
})
Then('I should be on the app overview page', async function (this: DifyWorld) {
await expect(this.getPage()).toHaveURL(/\/app\/[^/]+\/overview(?:\?.*)?$/, { timeout: 30_000 })
})
@@ -0,0 +1,15 @@
import type { DifyWorld } from '../../support/world'
import { Then, When } from '@cucumber/cucumber'
import { expect } from '@playwright/test'
When('I open the publish panel', async function (this: DifyWorld) {
await this.getPage().getByRole('button', { name: 'Publish' }).first().click()
})
When('I publish the app', async function (this: DifyWorld) {
await this.getPage().getByRole('button', { name: /Publish Update/ }).click()
})
Then('the app should be marked as published', async function (this: DifyWorld) {
await expect(this.getPage().getByRole('button', { name: 'Published' })).toBeVisible({ timeout: 30_000 })
})
@@ -0,0 +1,20 @@
import type { DifyWorld } from '../../support/world'
import { Then, When } from '@cucumber/cucumber'
import { expect } from '@playwright/test'
import { adminCredentials } from '../../../fixtures/auth'
When('I open the sign-in page', async function (this: DifyWorld) {
await this.getPage().goto('/signin')
})
When('I sign in as the default E2E admin', async function (this: DifyWorld) {
const page = this.getPage()
await page.getByLabel('Email address').fill(adminCredentials.email)
await page.getByLabel('Password').fill(adminCredentials.password)
await page.getByRole('button', { name: 'Sign in' }).click()
})
Then('I should be on the apps console', async function (this: DifyWorld) {
await expect(this.getPage()).toHaveURL(/\/apps(?:\?.*)?$/, { timeout: 30_000 })
})
@@ -0,0 +1,22 @@
import type { DifyWorld } from '../../support/world'
import { Given, When } from '@cucumber/cucumber'
import { expect } from '@playwright/test'
import { createTestApp, syncMinimalWorkflowDraft } from '../../../support/api'
Given('a {string} app has been created via API', async function (this: DifyWorld, mode: string) {
const app = await createTestApp(`E2E ${Date.now()}`, mode)
this.createdAppIds.push(app.id)
this.lastCreatedAppName = app.name
})
Given('a minimal workflow draft has been synced', async function (this: DifyWorld) {
const appId = this.createdAppIds.at(-1)!
await syncMinimalWorkflowDraft(appId)
})
When('I open the app from the app list', async function (this: DifyWorld) {
const page = this.getPage()
await page.goto('/apps')
await expect(page.getByRole('button', { name: 'Create from Blank' })).toBeVisible()
await page.getByText(this.lastCreatedAppName!).click()
})
+2 -1
View File
@@ -12,13 +12,14 @@
"e2e:middleware:down": "tsx ./scripts/setup.ts middleware-down",
"e2e:middleware:up": "tsx ./scripts/setup.ts middleware-up",
"e2e:reset": "tsx ./scripts/setup.ts reset",
"type-check": "tsc"
"type-check": "tsgo"
},
"devDependencies": {
"@cucumber/cucumber": "catalog:",
"@dify/tsconfig": "workspace:*",
"@playwright/test": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"tsx": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
+28
View File
@@ -43,6 +43,34 @@ export async function createTestApp(name: string, mode = 'workflow'): Promise<Ap
}
}
export async function syncMinimalWorkflowDraft(appId: string): Promise<void> {
const ctx = await createApiContext()
try {
await ctx.post(`/console/api/apps/${appId}/workflows/draft`, {
data: {
graph: {
nodes: [
{
id: '1',
type: 'custom',
position: { x: 80, y: 282 },
data: { id: '1', type: 'start', title: 'Start', variables: [] },
},
],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
features: {},
environment_variables: [],
conversation_variables: [],
},
})
}
finally {
await ctx.dispose()
}
}
export async function deleteTestApp(id: string): Promise<void> {
const ctx = await createApiContext()
try {
+9 -171
View File
@@ -111,16 +111,6 @@
"count": 1
}
},
"web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/long-time-range-picker.tsx": {
"no-restricted-imports": {
"count": 2
}
},
"web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/range-selector.tsx": {
"no-restricted-imports": {
"count": 2
}
},
"web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/__tests__/svg-attribute-error-reproduction.spec.tsx": {
"no-console": {
"count": 19
@@ -534,11 +524,6 @@
"count": 1
}
},
"web/app/components/app/configuration/debug/chat-user-input.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/app/configuration/debug/debug-with-multiple-model/chat-item.tsx": {
"ts/no-explicit-any": {
"count": 6
@@ -584,7 +569,7 @@
},
"web/app/components/app/configuration/prompt-value-panel/index.tsx": {
"no-restricted-imports": {
"count": 2
"count": 1
}
},
"web/app/components/app/configuration/prompt-value-panel/utils.ts": {
@@ -681,7 +666,7 @@
},
"web/app/components/app/overview/settings/index.tsx": {
"no-restricted-imports": {
"count": 3
"count": 2
},
"react/set-state-in-effect": {
"count": 3
@@ -920,9 +905,6 @@
}
},
"web/app/components/base/chat/chat-with-history/inputs-form/content.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 3
}
@@ -1036,9 +1018,6 @@
}
},
"web/app/components/base/chat/embedded-chatbot/inputs-form/content.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 3
}
@@ -1175,11 +1154,6 @@
"count": 5
}
},
"web/app/components/base/features/new-feature-panel/moderation/form-generation.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/base/features/new-feature-panel/moderation/index.tsx": {
"ts/no-explicit-any": {
"count": 1
@@ -1195,7 +1169,7 @@
},
"web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx": {
"no-restricted-imports": {
"count": 2
"count": 1
}
},
"web/app/components/base/features/types.ts": {
@@ -2438,11 +2412,6 @@
"count": 4
}
},
"web/app/components/datasets/documents/components/documents-header.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/documents/components/operations.tsx": {
"no-restricted-imports": {
"count": 1
@@ -2576,11 +2545,6 @@
"count": 3
}
},
"web/app/components/datasets/documents/detail/completed/components/menu-bar.tsx": {
"no-restricted-imports": {
"count": 2
}
},
"web/app/components/datasets/documents/detail/completed/components/segment-list-content.tsx": {
"ts/no-non-null-asserted-optional-chain": {
"count": 1
@@ -2596,11 +2560,6 @@
"count": 5
}
},
"web/app/components/datasets/documents/detail/completed/hooks/use-search-filter.ts": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/documents/detail/completed/index.tsx": {
"no-barrel-files/no-barrel-files": {
"count": 2
@@ -2617,11 +2576,6 @@
"count": 1
}
},
"web/app/components/datasets/documents/detail/completed/status-item.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/documents/detail/context.ts": {
"ts/no-explicit-any": {
"count": 1
@@ -2642,11 +2596,6 @@
"count": 1
}
},
"web/app/components/datasets/documents/detail/metadata/components/field-info.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/documents/detail/metadata/components/metadata-field-list.tsx": {
"ts/no-non-null-asserted-optional-chain": {
"count": 1
@@ -3034,11 +2983,6 @@
"count": 1
}
},
"web/app/components/header/account-setting/language-page/index.tsx": {
"no-restricted-imports": {
"count": 2
}
},
"web/app/components/header/account-setting/members-page/invite-modal/index.tsx": {
"react/set-state-in-effect": {
"count": 3
@@ -3121,7 +3065,7 @@
},
"web/app/components/header/account-setting/model-provider-page/model-modal/Form.tsx": {
"no-restricted-imports": {
"count": 2
"count": 1
},
"ts/no-explicit-any": {
"count": 6
@@ -3273,16 +3217,13 @@
},
"web/app/components/plugins/install-plugin/install-from-github/index.tsx": {
"no-restricted-imports": {
"count": 2
"count": 1
},
"ts/no-explicit-any": {
"count": 3
}
},
"web/app/components/plugins/install-plugin/install-from-github/steps/selectPackage.tsx": {
"no-restricted-imports": {
"count": 2
},
"ts/no-explicit-any": {
"count": 1
}
@@ -3386,9 +3327,6 @@
}
},
"web/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-form.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 8
}
@@ -3492,7 +3430,7 @@
"count": 3
},
"no-restricted-imports": {
"count": 3
"count": 1
}
},
"web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.tsx": {
@@ -3561,11 +3499,6 @@
"count": 7
}
},
"web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.tsx": {
"no-restricted-imports": {
"count": 2
}
},
"web/app/components/plugins/plugin-detail-panel/tool-selector/components/schema-modal.tsx": {
"no-restricted-imports": {
"count": 1
@@ -3609,11 +3542,6 @@
"count": 1
}
},
"web/app/components/plugins/plugin-page/debug-info.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/plugins/plugin-page/empty/index.tsx": {
"react/set-state-in-effect": {
"count": 2
@@ -3867,9 +3795,6 @@
}
},
"web/app/components/share/text-generation/run-once/index.tsx": {
"no-restricted-imports": {
"count": 1
},
"react/set-state-in-effect": {
"count": 1
},
@@ -4289,9 +4214,6 @@
}
},
"web/app/components/workflow/nodes/_base/components/before-run-form/form-item.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 11
}
@@ -4371,14 +4293,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/_base/components/form-input-item.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 4
}
},
"web/app/components/workflow/nodes/_base/components/form-input-type-switch.tsx": {
"no-restricted-imports": {
"count": 1
@@ -4435,11 +4349,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/_base/components/node-handle.tsx": {
"react/set-state-in-effect": {
"count": 1
}
},
"web/app/components/workflow/nodes/_base/components/option-card.tsx": {
"no-restricted-imports": {
"count": 1
@@ -4476,11 +4385,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/_base/components/variable/constant-field.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/_base/components/variable/match-schema-type.ts": {
"ts/no-explicit-any": {
"count": 8
@@ -4890,11 +4794,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/if-else/components/condition-list/condition-item.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/if-else/components/condition-list/condition-operator.tsx": {
"no-restricted-imports": {
"count": 1
@@ -4905,11 +4804,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/if-else/components/condition-wrap.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/if-else/default.ts": {
"ts/no-explicit-any": {
"count": 1
@@ -4940,16 +4834,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/iteration/panel.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/iteration/use-config.ts": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/iteration/use-single-run-form-params.ts": {
"ts/no-explicit-any": {
"count": 6
@@ -5052,17 +4936,6 @@
}
},
"web/app/components/workflow/nodes/list-operator/components/filter-condition.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/workflow/nodes/list-operator/components/sub-variable-picker.tsx": {
"no-restricted-imports": {
"count": 2
},
"ts/no-explicit-any": {
"count": 1
}
@@ -5202,11 +5075,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/loop/components/condition-list/condition-item.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/loop/components/condition-list/condition-operator.tsx": {
"no-restricted-imports": {
"count": 1
@@ -5217,31 +5085,16 @@
"count": 1
}
},
"web/app/components/workflow/nodes/loop/components/condition-wrap.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/loop/components/loop-variables/form-item.tsx": {
"ts/no-explicit-any": {
"count": 3
}
},
"web/app/components/workflow/nodes/loop/components/loop-variables/input-mode-selec.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/loop/components/loop-variables/item.tsx": {
"ts/no-explicit-any": {
"count": 4
}
},
"web/app/components/workflow/nodes/loop/components/loop-variables/variable-type-select.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/loop/default.ts": {
"ts/no-explicit-any": {
"count": 1
@@ -5277,7 +5130,7 @@
},
"web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/update.tsx": {
"no-restricted-imports": {
"count": 2
"count": 1
},
"ts/no-explicit-any": {
"count": 1
@@ -5494,11 +5347,6 @@
"count": 7
}
},
"web/app/components/workflow/nodes/trigger-schedule/components/frequency-selector.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/trigger-schedule/components/monthly-days-selector.tsx": {
"no-restricted-imports": {
"count": 1
@@ -5512,11 +5360,6 @@
"count": 10
}
},
"web/app/components/workflow/nodes/trigger-webhook/components/generic-table.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/trigger-webhook/components/parameter-table.tsx": {
"ts/no-non-null-asserted-optional-chain": {
"count": 1
@@ -5529,7 +5372,7 @@
},
"web/app/components/workflow/nodes/trigger-webhook/panel.tsx": {
"no-restricted-imports": {
"count": 2
"count": 1
}
},
"web/app/components/workflow/nodes/utils.ts": {
@@ -6028,11 +5871,6 @@
"count": 1
}
},
"web/app/signin/invite-settings/page.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/signin/layout.tsx": {
"ts/no-explicit-any": {
"count": 1
@@ -6040,7 +5878,7 @@
},
"web/app/signin/one-more-step.tsx": {
"no-restricted-imports": {
"count": 2
"count": 1
},
"ts/no-explicit-any": {
"count": 1
+1 -1
View File
@@ -88,7 +88,7 @@ See `[web/docs/overlay-migration.md](../../web/docs/overlay-migration.md)` for t
- `pnpm -C packages/dify-ui test` — Vitest unit tests for primitives.
- `pnpm -C packages/dify-ui storybook` — Storybook on the default port. Each primitive has `index.stories.tsx`.
- `pnpm -C packages/dify-ui type-check``tsc --noEmit` for this package only.
- `pnpm -C packages/dify-ui type-check``tsgo --noEmit` for this package only.
See `[AGENTS.md](./AGENTS.md)` for:
+2 -1
View File
@@ -83,7 +83,7 @@
"storybook:build": "storybook build",
"test": "vp test",
"test:watch": "vp test --watch",
"type-check": "tsc"
"type-check": "tsgo"
},
"peerDependencies": {
"@base-ui/react": "catalog:",
@@ -109,6 +109,7 @@
"@tailwindcss/vite": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@typescript/native-preview": "catalog:",
"@vitejs/plugin-react": "catalog:",
"@vitest/coverage-v8": "catalog:",
"class-variance-authority": "catalog:",
+3 -1
View File
@@ -2,5 +2,7 @@
"extends": "@dify/tsconfig/react.json",
"compilerOptions": {
"types": ["vite-plus/test/globals"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts", "tailwind.config.ts"],
"exclude": ["node_modules", "dist", "storybook-static", "coverage"]
}
@@ -8,9 +8,10 @@
},
"scripts": {
"build": "vp pack",
"type-check": "tsc"
"type-check": "tsgo"
},
"dependencies": {
"@typescript/native-preview": "catalog:",
"typescript": "catalog:"
},
"devDependencies": {
@@ -117,17 +117,17 @@ async function runTypeCheck(
await fs.mkdir(TYPECHECK_CACHE_DIR, { recursive: true })
const tscArgs = ['exec', 'tsc', '--noEmit', '--pretty', 'false']
const tsgoArgs = ['exec', 'tsgo', '--noEmit', '--pretty', 'false']
if (incremental) {
tscArgs.push('--incremental', '--tsBuildInfoFile', buildInfoPath)
tsgoArgs.push('--incremental', '--tsBuildInfoFile', buildInfoPath)
}
else {
tscArgs.push('--incremental', 'false')
tsgoArgs.push('--incremental', 'false')
}
tscArgs.push('--project', projectPath)
tsgoArgs.push('--project', projectPath)
try {
const { stdout, stderr } = await execFileAsync('pnpm', tscArgs, {
const { stdout, stderr } = await execFileAsync('pnpm', tsgoArgs, {
cwd: projectDirectory,
env: {
...process.env,
+1244 -1003
View File
File diff suppressed because it is too large Load Diff
+33 -33
View File
@@ -47,12 +47,12 @@ overrides:
yaml@>=2.0.0 <2.8.3: 2.8.3
yauzl@<3.2.1: 3.2.1
catalog:
'@amplitude/analytics-browser': 2.39.0
'@amplitude/plugin-session-replay-browser': 1.27.7
'@amplitude/analytics-browser': 2.41.0
'@amplitude/plugin-session-replay-browser': 1.27.10
'@antfu/eslint-config': 8.2.0
'@base-ui/react': 1.4.1
'@chromatic-com/storybook': 5.1.2
'@cucumber/cucumber': 12.8.0
'@cucumber/cucumber': 12.8.1
'@egoist/tailwindcss-icons': 1.9.2
'@emoji-mart/data': 1.2.1
'@eslint-react/eslint-plugin': 3.0.0
@@ -75,8 +75,8 @@ catalog:
'@mdx-js/react': 3.1.1
'@mdx-js/rollup': 3.1.1
'@monaco-editor/react': 4.7.0
'@next/eslint-plugin-next': 16.2.3
'@next/mdx': 16.2.3
'@next/eslint-plugin-next': 16.2.4
'@next/mdx': 16.2.4
'@orpc/client': 1.13.14
'@orpc/contract': 1.13.14
'@orpc/openapi-client': 1.13.14
@@ -84,7 +84,7 @@ catalog:
'@playwright/test': 1.59.1
'@remixicon/react': 4.9.0
'@rgrove/parse-xml': 4.2.0
'@sentry/react': 10.48.0
'@sentry/react': 10.49.0
'@storybook/addon-docs': 10.3.5
'@storybook/addon-links': 10.3.5
'@storybook/addon-onboarding': 10.3.5
@@ -95,23 +95,23 @@ catalog:
'@streamdown/math': 1.0.2
'@svgdotjs/svg.js': 3.2.5
'@t3-oss/env-nextjs': 0.13.11
'@tailwindcss/postcss': 4.2.2
'@tailwindcss/postcss': 4.2.4
'@tailwindcss/typography': 0.5.19
'@tailwindcss/vite': 4.2.2
'@tanstack/eslint-plugin-query': 5.99.0
'@tailwindcss/vite': 4.2.4
'@tanstack/eslint-plugin-query': 5.99.2
'@tanstack/react-devtools': 0.10.2
'@tanstack/react-form': 1.29.0
'@tanstack/react-form-devtools': 0.2.21
'@tanstack/react-query': 5.99.0
'@tanstack/react-query-devtools': 5.99.0
'@tanstack/react-virtual': 3.13.23
'@tanstack/react-form': 1.29.1
'@tanstack/react-form-devtools': 0.2.22
'@tanstack/react-query': 5.99.2
'@tanstack/react-query-devtools': 5.99.2
'@tanstack/react-virtual': 3.13.24
'@testing-library/dom': 10.4.1
'@testing-library/jest-dom': 6.9.1
'@testing-library/react': 16.3.2
'@testing-library/user-event': 14.6.1
'@tsslint/cli': 3.0.3
'@tsslint/compat-eslint': 3.0.3
'@tsslint/config': 3.0.3
'@tsslint/cli': 3.0.4
'@tsslint/compat-eslint': 3.0.4
'@tsslint/config': 3.0.4
'@types/js-cookie': 3.0.6
'@types/js-yaml': 4.0.9
'@types/negotiator': 0.6.4
@@ -120,12 +120,12 @@ catalog:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3
'@types/sortablejs': 1.15.9
'@typescript-eslint/eslint-plugin': 8.58.2
'@typescript-eslint/parser': 8.58.2
'@typescript/native-preview': 7.0.0-dev.20260413.1
'@typescript-eslint/eslint-plugin': 8.59.0
'@typescript-eslint/parser': 8.59.0
'@typescript/native-preview': 7.0.0-dev.20260422.1
'@vitejs/plugin-react': 6.0.1
'@vitejs/plugin-rsc': 0.5.24
'@vitest/coverage-v8': 4.1.4
'@vitest/coverage-v8': 4.1.5
abcjs: 6.6.2
agentation: 3.0.2
ahooks: 3.9.7
@@ -138,22 +138,22 @@ catalog:
cron-parser: 5.5.0
dayjs: 1.11.20
decimal.js: 10.6.0
dompurify: 3.4.0
dompurify: 3.4.1
echarts: 6.0.0
echarts-for-react: 3.0.6
elkjs: 0.11.1
embla-carousel-autoplay: 8.6.0
embla-carousel-react: 8.6.0
emoji-mart: 5.6.0
es-toolkit: 1.45.1
eslint: 10.2.0
es-toolkit: 1.46.0
eslint: 10.2.1
eslint-markdown: 0.6.1
eslint-plugin-better-tailwindcss: 4.4.1
eslint-plugin-hyoban: 0.14.1
eslint-plugin-markdown-preferences: 0.41.1
eslint-plugin-no-barrel-files: 1.3.1
eslint-plugin-react-refresh: 0.5.2
eslint-plugin-sonarjs: 4.0.2
eslint-plugin-sonarjs: 4.0.3
eslint-plugin-storybook: 10.3.5
fast-deep-equal: 3.1.3
happy-dom: 20.9.0
@@ -161,7 +161,7 @@ catalog:
hono: 4.12.14
html-entities: 2.6.0
html-to-image: 1.11.13
i18next: 26.0.4
i18next: 26.0.6
i18next-resources-to-backend: 1.2.1
iconify-import-svg: 0.2.0
immer: 11.1.4
@@ -171,21 +171,21 @@ catalog:
js-yaml: 4.1.1
jsonschema: 1.5.0
katex: 0.16.45
knip: 6.4.1
ky: 2.0.0
knip: 6.6.1
ky: 2.0.2
lamejs: 1.2.1
lexical: 0.43.0
loro-crdt: 1.10.8
loro-crdt: 1.11.1
mermaid: 11.14.0
mime: 4.1.0
mitt: 3.0.1
negotiator: 1.0.0
next: 16.2.3
next: 16.2.4
next-themes: 0.4.6
nuqs: 2.8.9
pinyin-pro: 3.28.1
playwright: 1.59.1
postcss: 8.5.9
postcss: 8.5.10
qrcode.react: 4.2.0
qs: 6.15.1
react: 19.2.5
@@ -213,10 +213,10 @@ catalog:
streamdown: 2.5.0
string-ts: 2.3.1
tailwind-merge: 3.5.0
tailwindcss: 4.2.2
tailwindcss: 4.2.4
tldts: 7.0.28
tsx: 4.21.0
typescript: 6.0.2
typescript: 6.0.3
uglify-js: 3.19.3
unist-util-visit: 5.1.0
use-context-selector: 2.0.0
+2 -1
View File
@@ -48,7 +48,7 @@
"build": "vp pack",
"lint": "eslint",
"lint:fix": "eslint --fix",
"type-check": "tsc",
"type-check": "tsgo",
"test": "vp test",
"test:coverage": "vp test --coverage",
"publish:check": "./scripts/publish.sh --dry-run",
@@ -60,6 +60,7 @@
"@types/node": "catalog:",
"@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:",
"@typescript/native-preview": "catalog:",
"@vitest/coverage-v8": "catalog:",
"eslint": "catalog:",
"typescript": "catalog:",
@@ -1,14 +1,17 @@
'use client'
import type { FC } from 'react'
import type { PeriodParams } from '@/app/components/app/overview/app-chart'
import type { Item } from '@/app/components/base/select'
import type { I18nKeysByPrefix } from '@/types/i18n'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import dayjs from 'dayjs'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { SimpleSelect } from '@/app/components/base/select'
type TimePeriodName = I18nKeysByPrefix<'appLog', 'filter.period.'>
type TimePeriodOption = {
value: string
name: string
}
type Props = {
periodMapping: { [key: string]: { value: number, name: TimePeriodName } }
@@ -24,8 +27,18 @@ const LongTimeRangePicker: FC<Props> = ({
queryDateFormat,
}) => {
const { t } = useTranslation()
const items = React.useMemo<TimePeriodOption[]>(() => {
return Object.entries(periodMapping).map(([key, period]) => ({
value: key,
name: t(`filter.period.${period.name}`, { ns: 'appLog' }),
}))
}, [periodMapping, t])
const [value, setValue] = React.useState('2')
const selectedItem = React.useMemo(() => {
return items.find(item => item.value === value) ?? null
}, [items, value])
const handleSelect = React.useCallback((item: Item) => {
const handleSelect = React.useCallback((item: TimePeriodOption) => {
const id = item.value
const value = periodMapping[id]?.value ?? '-1'
const name = item.name || t('filter.period.allTime', { ns: 'appLog' })
@@ -55,13 +68,30 @@ const LongTimeRangePicker: FC<Props> = ({
}, [onSelect, periodMapping, queryDateFormat, t])
return (
<SimpleSelect
items={Object.entries(periodMapping).map(([k, v]) => ({ value: k, name: t(`filter.period.${v.name}`, { ns: 'appLog' }) }))}
className="mt-0 w-40!"
notClearable={true}
onSelect={handleSelect}
defaultValue="2"
/>
<Select
value={selectedItem?.value ?? null}
onValueChange={(nextValue) => {
if (!nextValue)
return
const nextItem = items.find(item => item.value === nextValue)
if (!nextItem)
return
setValue(nextValue)
handleSelect(nextItem)
}}
>
<SelectTrigger className="mt-0 w-fit max-w-none">
{selectedItem?.name ?? t('placeholder.select', { ns: 'common' })}
</SelectTrigger>
<SelectContent>
{items.map(item => (
<SelectItem key={item.value} value={item.value}>
<SelectItemText>{item.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)
}
export default React.memo(LongTimeRangePicker)
@@ -1,19 +1,22 @@
'use client'
import type { FC } from 'react'
import type { PeriodParamsWithTimeRange, TimeRange } from '@/app/components/app/overview/app-chart'
import type { Item } from '@/app/components/base/select'
import type { I18nKeysByPrefix } from '@/types/i18n'
import { cn } from '@langgenius/dify-ui/cn'
import { RiArrowDownSLine, RiCheckLine } from '@remixicon/react'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { RiArrowDownSLine } from '@remixicon/react'
import dayjs from 'dayjs'
import * as React from 'react'
import { useCallback } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SimpleSelect } from '@/app/components/base/select'
const today = dayjs()
type TimePeriodName = I18nKeysByPrefix<'appLog', 'filter.period.'>
type TimePeriodOption = {
value: number
name: string
}
type Props = {
isCustomRange: boolean
@@ -27,8 +30,19 @@ const RangeSelector: FC<Props> = ({
onSelect,
}) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const items = useMemo<TimePeriodOption[]>(() => {
return ranges.map(range => ({
...range,
name: t(`filter.period.${range.name}`, { ns: 'appLog' }),
}))
}, [ranges, t])
const [value, setValue] = useState('0')
const selectedItem = useMemo(() => {
return items.find(item => String(item.value) === value) ?? null
}, [items, value])
const handleSelectRange = useCallback((item: Item) => {
const handleSelectRange = useCallback((item: TimePeriodOption) => {
const { name, value } = item
let period: TimeRange | null = null
if (value === 0) {
@@ -42,44 +56,38 @@ const RangeSelector: FC<Props> = ({
onSelect({ query: period!, name })
}, [onSelect])
const renderTrigger = useCallback((item: Item | null, isOpen: boolean) => {
return (
<div className={cn('flex h-8 cursor-pointer items-center space-x-1.5 rounded-lg bg-components-input-bg-normal pr-2 pl-3', isOpen && 'bg-state-base-hover-alt')}>
<div className="system-sm-regular text-components-input-text-filled">{isCustomRange ? t('filter.period.custom', { ns: 'appLog' }) : item?.name}</div>
<RiArrowDownSLine className={cn('size-4 text-text-quaternary', isOpen && 'text-text-secondary')} />
</div>
)
}, [isCustomRange])
const renderOption = useCallback(({ item, selected }: { item: Item, selected: boolean }) => {
return (
<>
{selected && (
<span
className={cn(
'absolute top-[9px] left-2 flex items-center text-text-accent',
)}
>
<RiCheckLine className="h-4 w-4" aria-hidden="true" />
</span>
)}
<span className={cn('block truncate system-md-regular')}>{item.name}</span>
</>
)
}, [])
return (
<SimpleSelect
items={ranges.map(v => ({ ...v, name: t(`filter.period.${v.name}`, { ns: 'appLog' }) }))}
className="mt-0 w-40!"
notClearable={true}
onSelect={handleSelectRange}
defaultValue={0}
wrapperClassName="h-8"
optionWrapClassName="w-[200px] translate-x-[-24px]"
renderTrigger={renderTrigger}
optionClassName="flex items-center py-0 pl-7 pr-2 h-8"
renderOption={renderOption}
/>
<Select
value={selectedItem ? String(selectedItem.value) : null}
open={open}
onOpenChange={setOpen}
onValueChange={(nextValue) => {
if (!nextValue)
return
const nextItem = items.find(item => String(item.value) === nextValue)
if (!nextItem)
return
setValue(nextValue)
handleSelectRange(nextItem)
}}
>
<SelectTrigger
className="h-auto w-fit max-w-none border-0 bg-transparent p-0 hover:bg-transparent focus-visible:bg-transparent [&>*:last-child]:hidden"
>
<div className={cn('flex h-8 cursor-pointer items-center space-x-1.5 rounded-lg bg-components-input-bg-normal pr-2 pl-3', open && 'bg-state-base-hover-alt')}>
<div className="system-sm-regular text-components-input-text-filled">{isCustomRange ? t('filter.period.custom', { ns: 'appLog' }) : selectedItem?.name}</div>
<RiArrowDownSLine className={cn('size-4 text-text-quaternary', open && 'text-text-secondary')} />
</div>
</SelectTrigger>
<SelectContent className="translate-x-[-24px]" popupClassName="w-[200px]" listClassName="p-1">
{items.map(item => (
<SelectItem key={item.value} value={String(item.value)} className="h-8 py-0 pr-2 pl-7 system-md-regular">
<SelectItemText className="px-0">{item.name}</SelectItemText>
<SelectItemIndicator className="absolute top-[8px] left-2 ml-0" />
</SelectItem>
))}
</SelectContent>
</Select>
)
}
export default React.memo(RangeSelector)
+12
View File
@@ -17,6 +17,15 @@ import DatasetSidebarDropdown from './dataset-sidebar-dropdown'
import NavLink from './nav-link'
import ToggleButton from './toggle-button'
const isShortcutFromInputArea = (target: EventTarget | null) => {
if (!(target instanceof HTMLElement))
return false
return target.tagName === 'INPUT'
|| target.tagName === 'TEXTAREA'
|| target.isContentEditable
}
type IAppDetailNavProps = {
iconType?: 'app' | 'dataset'
navigation: Array<{
@@ -70,6 +79,9 @@ const AppDetailNav = ({
}, [appSidebarExpand, setAppSidebarExpand])
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.b`, (e) => {
if (isShortcutFromInputArea(e.target))
return
e.preventDefault()
handleToggle()
}, { exactMatch: true, useCapture: true })
@@ -5,16 +5,31 @@ import { InputVarType } from '@/app/components/workflow/types'
import ConfigModalFormFields from '../form-fields'
vi.mock('@/app/components/base/file-uploader', () => ({
FileUploaderInAttachmentWrapper: ({ onChange }: { onChange: (files: Array<Record<string, unknown>>) => void }) => (
<button
type="button"
onClick={() => onChange([
{ fileId: 'file-1', type: 'local_file', url: 'https://example.com/file.png' },
{ fileId: 'file-2', type: 'remote_url', url: 'https://example.com/file-2.png' },
])}
>
upload-file
</button>
FileUploaderInAttachmentWrapper: ({
onChange,
value,
fileConfig,
}: {
onChange: (files?: Array<Record<string, unknown>>) => void
value: Array<Record<string, unknown>>
fileConfig: Record<string, unknown>
}) => (
<div>
<span data-testid="file-uploader-value">{JSON.stringify(value)}</span>
<span data-testid="file-uploader-config">{JSON.stringify(fileConfig)}</span>
<button
type="button"
onClick={() => onChange([
{ fileId: 'file-1', type: 'local_file', url: 'https://example.com/file.png' },
{ fileId: 'file-2', type: 'remote_url', url: 'https://example.com/file-2.png' },
])}
>
upload-file
</button>
<button type="button" data-testid="upload-empty-file" onClick={() => onChange(undefined)}>
upload-empty-file
</button>
</div>
),
}))
@@ -38,12 +53,6 @@ vi.mock('@/app/components/base/checkbox', () => ({
),
}))
vi.mock('@/app/components/base/select', () => ({
default: ({ onSelect }: { onSelect: (item: { value: string }) => void }) => (
<button type="button" onClick={() => onSelect({ value: 'beta' })}>legacy-select</button>
),
}))
vi.mock('@langgenius/dify-ui/select', async (importOriginal) => {
const actual = await importOriginal<typeof import('@langgenius/dify-ui/select')>()
@@ -52,6 +61,7 @@ vi.mock('@langgenius/dify-ui/select', async (importOriginal) => {
Select: ({ value, onValueChange, children }: { value: string, onValueChange: (value: string) => void, children: ReactNode }) => (
<div>
<button type="button" onClick={() => onValueChange(value === 'true' ? 'false' : 'beta')}>{`ui-select:${value}`}</button>
<button type="button" onClick={() => onValueChange('__empty__')}>ui-select-empty</button>
{children}
</div>
),
@@ -86,8 +96,8 @@ vi.mock('../../config-select', () => ({
}))
vi.mock('../../config-string', () => ({
default: ({ onChange }: { onChange: (value: number) => void }) => (
<button type="button" onClick={() => onChange(64)}>config-string</button>
default: ({ onChange, maxLength }: { onChange: (value: number) => void, maxLength: number }) => (
<button type="button" data-max-length={String(maxLength)} onClick={() => onChange(64)}>config-string</button>
),
}))
@@ -211,4 +221,150 @@ describe('ConfigModalFormFields', () => {
fireEvent.click(screen.getByText('json-editor'))
expect(jsonProps.onJSONSchemaChange).toHaveBeenCalledWith('{\n "type": "object"\n}')
})
it('should update text input metadata and clear empty defaults for string inputs', () => {
const textProps = createBaseProps()
textProps.isStringInput = true
textProps.tempPayload = {
...textProps.tempPayload,
type: InputVarType.textInput,
default: 'hello',
}
render(<ConfigModalFormFields {...textProps} />)
const variableInput = screen.getByDisplayValue('question')
fireEvent.click(screen.getByText('type-selector'))
fireEvent.change(variableInput, { target: { value: 'prompt' } })
fireEvent.blur(variableInput)
fireEvent.change(screen.getByDisplayValue('Question'), { target: { value: 'Prompt Label' } })
fireEvent.click(screen.getByText('config-string'))
fireEvent.change(screen.getByDisplayValue('hello'), { target: { value: '' } })
expect(textProps.onTypeChange).toHaveBeenCalledWith({ value: InputVarType.select })
expect(textProps.onVarNameChange).toHaveBeenCalled()
expect(textProps.onVarKeyBlur).toHaveBeenCalled()
expect(textProps.payloadChangeHandlers.label).toHaveBeenCalledWith('Prompt Label')
expect(textProps.payloadChangeHandlers.max_length).toHaveBeenCalledWith(64)
expect(textProps.payloadChangeHandlers.default).toHaveBeenCalledWith(undefined)
})
it('should clear select defaults and apply uploader fallback values', () => {
const selectProps = createBaseProps()
selectProps.tempPayload = { ...selectProps.tempPayload, type: InputVarType.select, default: 'alpha' }
selectProps.options = ['alpha', ' ', 'beta']
render(<ConfigModalFormFields {...selectProps} />)
fireEvent.click(screen.getByText('ui-select-empty'))
expect(selectProps.payloadChangeHandlers.default).toHaveBeenCalledWith(undefined)
const singleFallbackProps = createBaseProps()
singleFallbackProps.tempPayload = {
...singleFallbackProps.tempPayload,
type: InputVarType.singleFile,
default: undefined,
}
render(<ConfigModalFormFields {...singleFallbackProps} />)
expect(screen.getAllByTestId('file-uploader-value')[0]).toHaveTextContent('[]')
expect(screen.getAllByTestId('file-uploader-config')[0]).toHaveTextContent('"allowed_file_types":["document"]')
expect(screen.getAllByTestId('file-uploader-config')[0]).toHaveTextContent('"allowed_file_upload_methods":["remote_url"]')
expect(screen.getAllByTestId('file-uploader-config')[0]).toHaveTextContent('"number_limits":1')
fireEvent.click(screen.getAllByTestId('upload-empty-file')[0]!)
expect(singleFallbackProps.payloadChangeHandlers.default).toHaveBeenCalledWith(undefined)
const multiFallbackProps = createBaseProps()
multiFallbackProps.tempPayload = {
...multiFallbackProps.tempPayload,
type: InputVarType.multiFiles,
default: undefined,
max_length: undefined,
}
render(<ConfigModalFormFields {...multiFallbackProps} />)
expect(screen.getAllByTestId('file-uploader-value')[1]).toHaveTextContent('[]')
expect(screen.getAllByTestId('file-uploader-config')[1]).toHaveTextContent('"number_limits":5')
fireEvent.click(screen.getAllByTestId('upload-empty-file')[1]!)
expect(multiFallbackProps.payloadChangeHandlers.default).toHaveBeenCalledWith(undefined)
})
it('should clear number defaults and skip rendering the default selector when options are missing', () => {
const numberProps = createBaseProps()
numberProps.tempPayload = { ...numberProps.tempPayload, type: InputVarType.number, default: '9' }
render(<ConfigModalFormFields {...numberProps} />)
fireEvent.change(screen.getByDisplayValue('9'), { target: { value: '' } })
expect(numberProps.payloadChangeHandlers.default).toHaveBeenCalledWith(undefined)
const selectWithoutOptionsProps = createBaseProps()
selectWithoutOptionsProps.tempPayload = { ...selectWithoutOptionsProps.tempPayload, type: InputVarType.select }
selectWithoutOptionsProps.options = undefined
render(<ConfigModalFormFields {...selectWithoutOptionsProps} />)
expect(screen.getAllByText('config-select')).toHaveLength(1)
expect(screen.queryByText('ui-select:__empty__')).not.toBeInTheDocument()
})
it('should preserve existing select and file defaults when present', () => {
const selectProps = createBaseProps()
selectProps.tempPayload = { ...selectProps.tempPayload, type: InputVarType.select, default: undefined }
selectProps.options = ['alpha', 'beta']
render(<ConfigModalFormFields {...selectProps} />)
expect(screen.getByText('ui-select:__empty__')).toBeInTheDocument()
const existingFile = { fileId: 'existing-file', type: 'local_file', url: 'https://example.com/existing.png' }
const singleFileProps = createBaseProps()
singleFileProps.tempPayload = {
...singleFileProps.tempPayload,
type: InputVarType.singleFile,
default: existingFile,
}
render(<ConfigModalFormFields {...singleFileProps} />)
expect(screen.getAllByTestId('file-uploader-value')[0]).toHaveTextContent('"fileId":"existing-file"')
const existingFiles = [
{ fileId: 'file-1', type: 'local_file', url: 'https://example.com/1.png' },
{ fileId: 'file-2', type: 'remote_url', url: 'https://example.com/2.png' },
]
const multiFileProps = createBaseProps()
multiFileProps.tempPayload = {
...multiFileProps.tempPayload,
type: InputVarType.multiFiles,
default: existingFiles,
max_length: 2,
}
render(<ConfigModalFormFields {...multiFileProps} />)
expect(screen.getAllByTestId('file-uploader-value')[1]).toHaveTextContent('"fileId":"file-1"')
expect(screen.getAllByTestId('file-uploader-config')[1]).toHaveTextContent('"number_limits":2')
})
it('should render empty fallback values for text, paragraph, and number defaults', () => {
const textProps = createBaseProps()
textProps.isStringInput = true
textProps.tempPayload = { ...textProps.tempPayload, type: InputVarType.textInput, default: undefined }
const textView = render(<ConfigModalFormFields {...textProps} />)
expect(screen.getAllByPlaceholderText('variableConfig.inputPlaceholder')[2]).toHaveValue('')
expect(screen.getByText('config-string')).toHaveAttribute('data-max-length', '256')
textView.unmount()
const paragraphProps = createBaseProps()
paragraphProps.isStringInput = true
paragraphProps.tempPayload = { ...paragraphProps.tempPayload, type: InputVarType.paragraph, default: undefined }
const paragraphView = render(<ConfigModalFormFields {...paragraphProps} />)
expect(screen.getByText('config-string')).toHaveAttribute('data-max-length', 'Infinity')
expect(paragraphView.container.querySelector('textarea')).toHaveValue('')
paragraphView.unmount()
const numberProps = createBaseProps()
numberProps.tempPayload = { ...numberProps.tempPayload, type: InputVarType.number, default: undefined }
render(<ConfigModalFormFields {...numberProps} />)
expect(screen.getByRole('spinbutton')).toHaveValue(null)
})
})
@@ -40,28 +40,49 @@ vi.mock('@/app/components/base/input', () => ({
),
}))
vi.mock('@/app/components/base/select', () => ({
default: ({ defaultValue, onSelect, items, disabled, className }: {
defaultValue: string
onSelect: (item: { value: string }) => void
items: { name: string, value: string }[]
allowSearch?: boolean
vi.mock('@langgenius/dify-ui/select', async () => {
const React = await import('react')
const SelectContext = React.createContext<{
disabled?: boolean
className?: string
}) => (
<select
data-testid="select-input"
value={defaultValue}
onChange={e => onSelect({ value: e.target.value })}
disabled={disabled}
className={className}
>
{items.map(item => (
<option key={item.value} value={item.value}>{item.name}</option>
))}
</select>
),
}))
onValueChange?: (value: string) => void
}>({})
return {
Select: ({ children, disabled, onValueChange }: {
children: React.ReactNode
disabled?: boolean
onValueChange?: (value: string) => void
}) => (
<SelectContext.Provider value={{ disabled, onValueChange }}>
<div>{children}</div>
</SelectContext.Provider>
),
SelectTrigger: ({ children, className }: { children: React.ReactNode, className?: string }) => {
const context = React.useContext(SelectContext)
return (
<div>
<button data-testid="select-input" type="button" disabled={context.disabled} className={className}>
{children}
</button>
<button data-testid="select-empty" type="button" onClick={() => context.onValueChange?.('')}>
empty select value
</button>
</div>
)
},
SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectItem: ({ children, value }: { children: React.ReactNode, value: string }) => {
const context = React.useContext(SelectContext)
return (
<button data-testid={`select-${value}`} type="button" role="option" onClick={() => context.onValueChange?.(value)}>
{children}
</button>
)
},
SelectItemText: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SelectItemIndicator: () => null,
}
})
vi.mock('@/app/components/base/textarea', () => ({
default: ({ value, onChange, placeholder, readOnly, className }: {
@@ -410,11 +431,24 @@ describe('ChatUserInput', () => {
}))
render(<ChatUserInput inputs={{ choice: 'A' }} />)
fireEvent.change(screen.getByTestId('select-input'), { target: { value: 'B' } })
fireEvent.click(screen.getByTestId('select-B'))
expect(mockSetInputs).toHaveBeenCalledWith({ choice: 'B' })
})
it('should ignore empty select updates', () => {
mockUseContext.mockReturnValue(createContextValue({
modelConfig: createModelConfig([
createPromptVariable({ key: 'choice', name: 'Choice', type: 'select', options: ['A', 'B', 'C'] }),
]),
}))
render(<ChatUserInput inputs={{}} />)
fireEvent.click(screen.getByTestId('select-empty'))
expect(mockSetInputs).not.toHaveBeenCalled()
})
it('should call setInputs when number input changes', () => {
mockUseContext.mockReturnValue(createContextValue({
modelConfig: createModelConfig([
@@ -443,20 +477,30 @@ describe('ChatUserInput', () => {
})
it('should not call setInputs for unknown keys', () => {
const filteredPromptVariables = {
length: 1,
forEach: vi.fn(),
map: (callback: (value: ExtendedPromptVariable, index: number) => unknown) => [
callback(createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), 0),
],
}
mockUseContext.mockReturnValue(createContextValue({
modelConfig: createModelConfig([
createPromptVariable({ key: 'name', name: 'Name', type: 'string' }),
]),
modelConfig: {
...createModelConfig(),
configs: {
prompt_template: '',
prompt_variables: {
filter: () => filteredPromptVariables,
} as unknown as PromptVariable[],
},
},
}))
render(<ChatUserInput inputs={{}} />)
// The component filters by promptVariableObj, so unknown keys won't trigger updates
// This is tested indirectly - only valid keys should trigger setInputs
fireEvent.change(screen.getByTestId('input-Name'), { target: { value: 'Valid' } })
expect(mockSetInputs).toHaveBeenCalledTimes(1)
expect(mockSetInputs).toHaveBeenCalledWith({ name: 'Valid' })
expect(mockSetInputs).not.toHaveBeenCalled()
})
})
@@ -652,7 +696,7 @@ describe('ChatUserInput', () => {
render(<ChatUserInput inputs={{}} />)
const select = screen.getByTestId('select-input')
expect(select).toBeInTheDocument()
expect(select.children).toHaveLength(0)
expect(screen.queryAllByRole('option')).toHaveLength(0)
})
it('should handle select with undefined options', () => {
@@ -1,11 +1,11 @@
import type { Inputs } from '@/models/debug'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import * as React from 'react'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Input from '@/app/components/base/input'
import Select from '@/app/components/base/select'
import Textarea from '@/app/components/base/textarea'
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
import ConfigContext from '@/context/debug-configuration'
@@ -102,13 +102,26 @@ const ChatUserInput = ({
)}
{type === 'select' && (
<Select
className="w-full"
defaultValue={inputs[key] as string}
onSelect={(i) => { handleInputValueChange(key, i.value as string) }}
items={(options || []).map(i => ({ name: i, value: i }))}
allowSearch={false}
value={inputs[key] ? String(inputs[key]) : null}
disabled={readonly}
/>
onValueChange={(nextValue) => {
if (!nextValue)
return
handleInputValueChange(key, nextValue)
}}
>
<SelectTrigger className="w-full">
{String(inputs[key] || t('placeholder.select', { ns: 'common' }))}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{(options || []).map(option => (
<SelectItem key={option} value={option}>
<SelectItemText>{option}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)}
{type === 'number' && (
<Input
@@ -9,6 +9,29 @@ import PromptValuePanel from '../index'
const mockSetShowAppConfigureFeaturesModal = vi.fn()
vi.mock('@langgenius/dify-ui/button', () => ({
Button: ({
children,
onClick,
disabled,
className,
}: {
children: React.ReactNode
onClick?: () => void
disabled?: boolean
className?: string
}) => (
<button
type="button"
data-disabled={disabled ? 'true' : 'false'}
className={className}
onClick={() => onClick?.()}
>
{children}
</button>
),
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: { setShowAppConfigureFeaturesModal: typeof mockSetShowAppConfigureFeaturesModal }) => unknown) => selector({
setShowAppConfigureFeaturesModal: mockSetShowAppConfigureFeaturesModal,
@@ -24,15 +47,51 @@ vi.mock('@/app/components/base/features/new-feature-panel/feature-bar', () => ({
),
}))
vi.mock('@/app/components/base/select', () => ({
default: ({ onSelect }: { onSelect: (item: { value: string }) => void }) => (
<button type="button" onClick={() => onSelect({ value: 'selected-option' })}>select-input</button>
),
}))
vi.mock('@langgenius/dify-ui/select', async () => {
const React = await import('react')
const SelectContext = React.createContext<{
onValueChange?: (value: string) => void
}>({})
return {
Select: ({ children, onValueChange }: {
children: React.ReactNode
onValueChange?: (value: string) => void
}) => (
<SelectContext.Provider value={{ onValueChange }}>
<div>{children}</div>
</SelectContext.Provider>
),
SelectTrigger: ({ children }: { children: React.ReactNode }) => {
const context = React.useContext(SelectContext)
return (
<div>
<button type="button">{children}</button>
<button data-testid="select-empty" type="button" onClick={() => context.onValueChange?.('')}>
empty select value
</button>
</div>
)
},
SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectItem: ({ children, value }: { children: React.ReactNode, value: string }) => {
const context = React.useContext(SelectContext)
return (
<button type="button" onClick={() => context.onValueChange?.(value)}>
{children}
</button>
)
},
SelectItemText: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SelectItemIndicator: () => null,
}
})
vi.mock('@/app/components/workflow/nodes/_base/components/before-run-form/bool-input', () => ({
default: ({ onChange }: { onChange: (value: boolean) => void }) => (
<button type="button" onClick={() => onChange(true)}>bool-input</button>
default: ({ name, onChange }: { name: string, onChange: (value: boolean) => void }) => (
<button type="button" data-testid={`bool-input-${name}`} onClick={() => onChange(true)}>
bool-input
</button>
),
}))
@@ -121,7 +180,7 @@ describe('PromptValuePanel', () => {
})
const runButton = screen.getByRole('button', { name: 'appDebug.inputs.run' })
expect(runButton).not.toBeDisabled()
expect(runButton).toHaveAttribute('data-disabled', 'false')
fireEvent.click(runButton)
await waitFor(() => expect(mockOnSend).toHaveBeenCalledTimes(1))
})
@@ -137,9 +196,22 @@ describe('PromptValuePanel', () => {
})
const runButton = screen.getByRole('button', { name: 'appDebug.inputs.run' })
expect(runButton).toBeDisabled()
fireEvent.click(runButton)
expect(mockOnSend).not.toHaveBeenCalled()
expect(runButton).toHaveAttribute('data-disabled', 'true')
})
it('invokes the tooltip-branch run handler when the click callback is triggered', () => {
renderPanel({
context: {
mode: AppModeEnum.CHAT,
},
props: {
appType: AppModeEnum.CHAT,
},
})
fireEvent.click(screen.getByRole('button', { name: 'appDebug.inputs.run' }))
expect(mockOnSend).toHaveBeenCalledTimes(1)
})
it('hydrates default values, supports advanced prompt gating, and toggles the feature panel', () => {
@@ -163,12 +235,33 @@ describe('PromptValuePanel', () => {
})
expect(mockSetInputs).toHaveBeenCalledWith({ textVar: 'default text' })
expect(screen.getByRole('button', { name: 'appDebug.inputs.run' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'appDebug.inputs.run' })).toHaveAttribute('data-disabled', 'true')
fireEvent.click(screen.getByText('feature bar'))
expect(mockSetShowAppConfigureFeaturesModal).toHaveBeenCalled()
})
it('disables run for advanced completion mode when the completion prompt is empty', () => {
renderPanel({
context: {
isAdvancedMode: true,
modelModeType: ModelModeType.completion,
completionPromptConfig: {
prompt: { text: '' },
conversation_histories_role: { user_prefix: 'user', assistant_prefix: 'assistant' },
},
modelConfig: {
configs: {
prompt_template: '',
prompt_variables: [],
},
},
},
})
expect(screen.getByRole('button', { name: 'appDebug.inputs.run' })).toHaveAttribute('data-disabled', 'true')
})
it('renders paragraph, select, number, checkbox, and vision inputs', () => {
const onVisionFilesChange = vi.fn()
renderPanel({
@@ -203,13 +296,13 @@ describe('PromptValuePanel', () => {
})
fireEvent.change(screen.getByPlaceholderText('Paragraph Var'), { target: { value: 'updated paragraph' } })
fireEvent.click(screen.getByText('select-input'))
fireEvent.click(screen.getByText('b'))
fireEvent.change(screen.getByDisplayValue('1'), { target: { value: '2' } })
fireEvent.click(screen.getByText('bool-input'))
fireEvent.click(screen.getByText('image-uploader'))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ paragraphVar: 'updated paragraph' }))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ selectVar: 'selected-option' }))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ selectVar: 'b' }))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ numberVar: '2' }))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ boolVar: true }))
expect(onVisionFilesChange).toHaveBeenCalledWith([
@@ -222,6 +315,127 @@ describe('PromptValuePanel', () => {
])
})
it('ignores empty select values when choosing prompt options', () => {
renderPanel({
context: {
modelConfig: {
configs: {
prompt_template: 'prompt template',
prompt_variables: [
{ key: 'selectVar', name: 'Select Var', type: 'select', options: ['a', 'b'], required: false },
],
},
},
},
props: {
inputs: {
selectVar: 'a',
},
},
})
fireEvent.click(screen.getByTestId('select-empty'))
expect(mockSetInputs).not.toHaveBeenCalled()
})
it('ignores updates when the rendered field is not tracked in the prompt variable lookup', () => {
const filteredPromptVariables = {
length: 1,
forEach: vi.fn(),
map: (callback: (value: { key: string, name: string, type: string, required: boolean }, index: number) => unknown) => [
callback({ key: 'textVar', name: 'Text Var', type: 'string', required: true }, 0),
],
}
renderPanel({
context: {
modelConfig: {
configs: {
prompt_template: 'prompt template',
prompt_variables: {
filter: () => filteredPromptVariables,
},
},
},
},
props: {
inputs: { textVar: '' },
},
})
fireEvent.change(screen.getByPlaceholderText('Text Var'), { target: { value: 'ignored' } })
expect(mockSetInputs).not.toHaveBeenCalled()
})
it('renders empty select and number placeholders when no value is provided', () => {
renderPanel({
context: {
modelConfig: {
configs: {
prompt_template: 'prompt template',
prompt_variables: [
{ key: 'selectVar', name: 'Select Var', type: 'select', required: false },
{ key: 'numberVar', name: 'Number Var', type: 'number', required: true },
],
},
},
},
props: {
inputs: {
selectVar: '',
numberVar: '',
},
},
})
expect(screen.getByText('common.placeholder.select')).toBeInTheDocument()
expect(screen.getByPlaceholderText('Number Var')).toHaveValue(null)
expect(screen.queryAllByRole('option')).toHaveLength(0)
})
it('falls back to the checkbox key when the label is missing from the rendered collection', () => {
const filteredPromptVariables = {
length: 1,
forEach: vi.fn(),
map: (callback: (value: { key: string, name: string, type: string, required: boolean }, index: number) => unknown) => [
callback({ key: 'boolVar', name: '', type: 'checkbox', required: false }, 0),
],
}
renderPanel({
context: {
modelConfig: {
configs: {
prompt_template: 'prompt template',
prompt_variables: {
filter: () => filteredPromptVariables,
},
},
},
},
props: {
inputs: {
boolVar: false,
},
},
})
expect(screen.getByTestId('bool-input-boolVar')).toBeInTheDocument()
})
it('marks actions as disabled when readonly even if the prompt is runnable', () => {
renderPanel({
context: {
readonly: true,
},
})
expect(screen.getByRole('button', { name: 'common.operation.clear' })).toHaveAttribute('data-disabled', 'true')
expect(screen.getByRole('button', { name: 'appDebug.inputs.run' })).toHaveAttribute('data-disabled', 'true')
})
it('collapses the user input panel and hides the clear and run actions', () => {
renderPanel()
@@ -4,6 +4,7 @@ import type { Inputs } from '@/models/debug'
import type { VisionFile, VisionSettings } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import {
RiArrowDownSLine,
RiArrowRightSLine,
@@ -17,7 +18,6 @@ import { useStore as useAppStore } from '@/app/components/app/store'
import FeatureBar from '@/app/components/base/features/new-feature-panel/feature-bar'
import TextGenerationImageUploader from '@/app/components/base/image-uploader/text-generation-image-uploader'
import Input from '@/app/components/base/input'
import Select from '@/app/components/base/select'
import Textarea from '@/app/components/base/textarea'
import Tooltip from '@/app/components/base/tooltip'
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
@@ -156,14 +156,26 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({
)}
{type === 'select' && (
<Select
className="w-full"
defaultValue={inputs[key] as string}
onSelect={(i) => { handleInputValueChange(key, i.value as string) }}
items={(options || []).map(i => ({ name: i, value: i }))}
allowSearch={false}
bgClassName="bg-gray-50"
value={inputs[key] ? String(inputs[key]) : null}
disabled={readonly}
/>
onValueChange={(nextValue) => {
if (!nextValue)
return
handleInputValueChange(key, nextValue)
}}
>
<SelectTrigger className="w-full bg-gray-50">
{String(inputs[key] || t('placeholder.select', { ns: 'common' }))}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{(options || []).map(option => (
<SelectItem key={option} value={option}>
<SelectItemText>{option}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)}
{type === 'number' && (
<Input
@@ -5,6 +5,7 @@ import type { AppDetailResponse } from '@/models/app'
import type { AppIconType, AppSSO, Language } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { Switch } from '@langgenius/dify-ui/switch'
import { toast } from '@langgenius/dify-ui/toast'
import { RiArrowRightSLine, RiCloseLine } from '@remixicon/react'
@@ -19,7 +20,6 @@ import { SparklesSoft } from '@/app/components/base/icons/src/public/common'
import Input from '@/app/components/base/input'
import Modal from '@/app/components/base/modal'
import PremiumBadge from '@/app/components/base/premium-badge'
import { SimpleSelect } from '@/app/components/base/select'
import Textarea from '@/app/components/base/textarea'
import Tooltip from '@/app/components/base/tooltip'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
@@ -57,6 +57,10 @@ export type ConfigParams = {
}
const prefixSettings = 'overview.appInfo.settings'
type SelectOption = {
value: string
name: string
}
const SettingsModal: FC<ISettingsModalProps> = ({
isChat,
@@ -110,6 +114,8 @@ const SettingsModal: FC<ISettingsModalProps> = ({
const { enableBilling, plan, webappCopyrightEnabled } = useProviderContext()
const { setShowPricingModal, setShowAccountSettingModal } = useModalContext()
const isFreePlan = plan.type === 'sandbox'
const languageOptions: SelectOption[] = languages.filter(item => item.supported)
const selectedLanguage = languageOptions.find(item => item.value === language)
const handlePlanClick = useCallback(() => {
if (isFreePlan)
setShowPricingModal()
@@ -303,13 +309,26 @@ const SettingsModal: FC<ISettingsModalProps> = ({
{/* language */}
<div className="flex items-center">
<div className={cn('grow py-1 system-sm-semibold text-text-secondary')}>{t(`${prefixSettings}.language`, { ns: 'appOverview' })}</div>
<SimpleSelect
wrapperClassName="w-[200px]"
items={languages.filter(item => item.supported)}
defaultValue={language}
onSelect={item => setLanguage(item.value as Language)}
notClearable
/>
<Select
value={selectedLanguage?.value ?? null}
onValueChange={(nextValue) => {
if (!nextValue)
return
setLanguage(nextValue as Language)
}}
>
<SelectTrigger size="large" className="w-[200px]">
{selectedLanguage?.name ?? t('placeholder.select', { ns: 'common' })}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{languageOptions.map(item => (
<SelectItem key={item.value} value={item.value}>
<SelectItemText>{item.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* theme color */}
{isChat && (
@@ -270,7 +270,7 @@ describe('InputsFormContent', () => {
renderWithContext(<InputsFormContent />, context)
const selNodes = screen.getAllByText('Sel')
expect(selNodes.length).toBeGreaterThan(0)
expect(screen.queryByText('existing')).toBeNull()
expect(screen.getByText('existing')).toBeInTheDocument()
})
it('handles select input empty branches (no current value -> show placeholder)', () => {
@@ -1,9 +1,9 @@
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import * as React from 'react'
import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
import Input from '@/app/components/base/input'
import { PortalSelect } from '@/app/components/base/select'
import Textarea from '@/app/components/base/textarea'
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
@@ -85,13 +85,22 @@ const InputsFormContent = ({ showTip }: Props) => {
/>
)}
{form.type === InputVarType.select && (
<PortalSelect
popupClassName="z-[60] w-[200px]"
value={inputsFormValue?.[form.variable] ?? form.default ?? ''}
items={form.options.map((option: string) => ({ value: option, name: option }))}
onSelect={item => handleFormChange(form.variable, item.value as string)}
placeholder={form.label}
/>
<Select
value={(inputsFormValue?.[form.variable] ?? form.default ?? '') || null}
onValueChange={value => value && handleFormChange(form.variable, value)}
>
<SelectTrigger className="w-full">
{String(inputsFormValue?.[form.variable] ?? form.default ?? form.label)}
</SelectTrigger>
<SelectContent popupClassName="z-[60] w-(--anchor-width)">
{form.options.map((option: string) => (
<SelectItem key={option} value={option}>
<SelectItemText>{option}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)}
{form.type === InputVarType.singleFile && (
<FileUploaderInAttachmentWrapper
@@ -1,9 +1,9 @@
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import * as React from 'react'
import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
import Input from '@/app/components/base/input'
import { PortalSelect } from '@/app/components/base/select'
import Textarea from '@/app/components/base/textarea'
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
@@ -85,13 +85,22 @@ const InputsFormContent = ({ showTip }: Props) => {
/>
)}
{form.type === InputVarType.select && (
<PortalSelect
popupClassName="z-[60] w-[200px]"
value={inputsFormValue?.[form.variable] ?? form.default ?? ''}
items={form.options.map((option: string) => ({ value: option, name: option }))}
onSelect={item => handleFormChange(form.variable, item.value as string)}
placeholder={form.label}
/>
<Select
value={(inputsFormValue?.[form.variable] ?? form.default ?? '') || null}
onValueChange={value => value && handleFormChange(form.variable, value)}
>
<SelectTrigger className="w-full">
{String(inputsFormValue?.[form.variable] ?? form.default ?? form.label)}
</SelectTrigger>
<SelectContent popupClassName="z-[60] w-(--anchor-width)">
{form.options.map((option: string) => (
<SelectItem key={option} value={option}>
<SelectItemText>{option}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)}
{form.type === InputVarType.singleFile && (
<FileUploaderInAttachmentWrapper
@@ -40,11 +40,11 @@ describe('CopyFeedback', () => {
expect(mockCopy).toHaveBeenCalledWith('test content')
})
it('calls reset on mouse leave', () => {
it('does not reset on mouse leave (relies on hook timeout)', () => {
render(<CopyFeedback content="test content" />)
const button = screen.getByRole('button')
fireEvent.mouseLeave(button.firstChild as Element)
expect(mockReset).toHaveBeenCalledTimes(1)
expect(mockReset).not.toHaveBeenCalled()
})
})
})
@@ -88,11 +88,11 @@ describe('CopyFeedbackNew', () => {
expect(mockCopy).toHaveBeenCalledWith('test content')
})
it('calls reset on mouse leave', () => {
it('does not reset on mouse leave (relies on hook timeout)', () => {
const { container } = render(<CopyFeedbackNew content="test content" />)
const clickableArea = container.querySelector('.cursor-pointer')!.firstChild as HTMLElement
fireEvent.mouseLeave(clickableArea)
expect(mockReset).toHaveBeenCalledTimes(1)
expect(mockReset).not.toHaveBeenCalled()
})
})
})
@@ -19,7 +19,10 @@ const prefixEmbedded = 'overview.appInfo.embedded'
const CopyFeedback = ({ content }: Props) => {
const { t } = useTranslation()
const { copied, copy, reset } = useClipboard()
// Rely on useClipboard's own timer to flip `copied` back to false so the
// "Copied" tooltip stays visible long enough to be read, matching the
// KeyValueItem pattern. Do NOT reset on mouse leave.
const { copied, copy } = useClipboard({ timeout: 2000 })
const tooltipText = copied
? t(`${prefixEmbedded}.copied`, { ns: 'appOverview' })
@@ -36,10 +39,7 @@ const CopyFeedback = ({ content }: Props) => {
popupContent={safeText}
>
<ActionButton>
<div
onClick={handleCopy}
onMouseLeave={reset}
>
<div onClick={handleCopy}>
{copied && <RiClipboardFill className="h-4 w-4" />}
{!copied && <RiClipboardLine className="h-4 w-4" />}
</div>
@@ -52,7 +52,7 @@ export default CopyFeedback
export const CopyFeedbackNew = ({ content, className }: Pick<Props, 'className' | 'content'>) => {
const { t } = useTranslation()
const { copied, copy, reset } = useClipboard()
const { copied, copy } = useClipboard({ timeout: 2000 })
const tooltipText = copied
? t(`${prefixEmbedded}.copied`, { ns: 'appOverview' })
@@ -73,7 +73,6 @@ export const CopyFeedbackNew = ({ content, className }: Pick<Props, 'className'
>
<div
onClick={handleCopy}
onMouseLeave={reset}
className={`h-full w-full ${copyStyle.copyIcon} ${copied ? copyStyle.copied : ''}`}
>
</div>
@@ -206,7 +206,7 @@ const TimePicker = ({
>
<PopoverTrigger
nativeButton={false}
className={triggerFullWidth ? 'block! w-full' : undefined}
className={triggerFullWidth ? 'flex! w-full' : undefined}
render={renderTrigger
? renderTrigger({
inputElem,
@@ -0,0 +1,97 @@
import type { SuggestedQuestionsAfterAnswer } from '@/app/components/base/features/types'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import FollowUpSettingModal from '../follow-up-setting-modal'
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelListAndDefaultModelAndCurrentProviderAndModel: () => ({
defaultModel: {
provider: {
provider: 'openai',
},
model: 'gpt-4o-mini',
},
}),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({
default: ({ provider, modelId }: { provider: string, modelId: string }) => (
<div data-testid="model-parameter-modal">{`${provider}:${modelId}`}</div>
),
}))
const renderModal = (data: SuggestedQuestionsAfterAnswer = { enabled: true }) => {
const onSave = vi.fn()
const onCancel = vi.fn()
render(
<FollowUpSettingModal
data={data}
onSave={onSave}
onCancel={onCancel}
/>,
)
return {
onSave,
onCancel,
}
}
describe('FollowUpSettingModal', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Default Prompt', () => {
it('should show the system default prompt and save without a custom prompt when no custom prompt is configured', async () => {
const user = userEvent.setup()
const { onSave } = renderModal()
expect(screen.getByText('appDebug.feature.suggestedQuestionsAfterAnswer.modal.defaultPromptOption')).toBeInTheDocument()
expect(screen.getByText(/Please predict the three most likely follow-up questions a user would ask/)).toBeInTheDocument()
await user.click(screen.getByText(/common\.operation\.save/))
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
prompt: undefined,
model: expect.objectContaining({
provider: 'openai',
name: 'gpt-4o-mini',
}),
}))
})
})
describe('Custom Prompt', () => {
it('should enable custom prompt input and save the custom prompt when selected', async () => {
const user = userEvent.setup()
const { onSave } = renderModal()
await user.click(screen.getByText('appDebug.feature.suggestedQuestionsAfterAnswer.modal.customPromptOption').closest('button')!)
const textarea = screen.getByPlaceholderText('appDebug.feature.suggestedQuestionsAfterAnswer.modal.promptPlaceholder')
expect(textarea).toHaveAttribute('maxLength', '1000')
fireEvent.change(
textarea,
{ target: { value: 'Use a custom follow-up prompt.' } },
)
await user.click(screen.getByText(/common\.operation\.save/))
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
prompt: 'Use a custom follow-up prompt.',
}))
})
it('should disable save when custom prompt is selected but empty', async () => {
const user = userEvent.setup()
renderModal()
await user.click(screen.getByText('appDebug.feature.suggestedQuestionsAfterAnswer.modal.customPromptOption').closest('button')!)
expect(screen.getByText(/common\.operation\.save/).closest('button')).toBeDisabled()
})
})
})
@@ -1,12 +1,55 @@
import type { OnFeaturesChange } from '../../types'
import type {
OnFeaturesChange,
SuggestedQuestionsAfterAnswer,
} from '../../types'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { FeaturesProvider } from '../../context'
import FollowUp from '../follow-up'
const renderWithProvider = (props: { disabled?: boolean, onChange?: OnFeaturesChange } = {}) => {
vi.mock('../follow-up-setting-modal', () => ({
default: ({ onSave, onCancel }: { onSave: (newState: unknown) => void, onCancel: () => void }) => (
<div data-testid="follow-up-setting-modal">
<button
type="button"
onClick={() => onSave({
enabled: true,
prompt: 'test prompt',
model: {
provider: 'openai',
name: 'gpt-4o-mini',
mode: 'chat',
completion_params: {
temperature: 0.7,
max_tokens: 0,
top_p: 0,
echo: false,
stop: [],
presence_penalty: 0,
frequency_penalty: 0,
},
},
})}
>
save-settings
</button>
<button type="button" onClick={onCancel}>cancel-settings</button>
</div>
),
}))
const renderWithProvider = (
props: {
disabled?: boolean
onChange?: OnFeaturesChange
suggested?: SuggestedQuestionsAfterAnswer
} = {},
) => {
return render(
<FeaturesProvider>
<FeaturesProvider features={{
suggested: props.suggested || { enabled: false },
}}
>
<FollowUp disabled={props.disabled} onChange={props.onChange} />
</FeaturesProvider>,
)
@@ -45,4 +88,44 @@ describe('FollowUp', () => {
expect(() => fireEvent.click(screen.getByRole('switch'))).not.toThrow()
})
it('should render edit button when enabled and hovering', () => {
renderWithProvider({
suggested: {
enabled: true,
},
})
fireEvent.mouseEnter(screen.getByText(/feature\.suggestedQuestionsAfterAnswer\.title/).closest('[class]')!)
expect(screen.getByText(/operation\.settings/)).toBeInTheDocument()
})
it('should open settings modal and save follow-up config', () => {
const onChange = vi.fn()
renderWithProvider({
onChange,
suggested: {
enabled: true,
},
})
fireEvent.mouseEnter(screen.getByText(/feature\.suggestedQuestionsAfterAnswer\.title/).closest('[class]')!)
fireEvent.click(screen.getByText(/operation\.settings/))
expect(screen.getByTestId('follow-up-setting-modal')).toBeInTheDocument()
fireEvent.click(screen.getByText('save-settings'))
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
suggested: expect.objectContaining({
enabled: true,
prompt: 'test prompt',
model: expect.objectContaining({
provider: 'openai',
name: 'gpt-4o-mini',
}),
}),
}))
})
})
@@ -0,0 +1,241 @@
import type { SuggestedQuestionsAfterAnswer } from '@/app/components/base/features/types'
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type {
CompletionParams,
Model,
ModelModeType,
} from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { produce } from 'immer'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Radio from '@/app/components/base/radio/ui'
import Textarea from '@/app/components/base/textarea'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
import { ModelModeType as ModelModeTypeEnum } from '@/types/app'
type FollowUpSettingModalProps = {
data: SuggestedQuestionsAfterAnswer
onSave: (newState: SuggestedQuestionsAfterAnswer) => void
onCancel: () => void
}
const DEFAULT_COMPLETION_PARAMS: CompletionParams = {
temperature: 0.7,
max_tokens: 0,
top_p: 0,
echo: false,
stop: [],
presence_penalty: 0,
frequency_penalty: 0,
}
const DEFAULT_FOLLOW_UP_PROMPT = `Please predict the three most likely follow-up questions a user would ask, keep each question under 20 characters, use the same language as the assistant's latest response, and output a JSON array like ["question1", "question2", "question3"].`
const CUSTOM_FOLLOW_UP_PROMPT_MAX_LENGTH = 1000
const getInitialModel = (model?: Model): Model => ({
provider: model?.provider || '',
name: model?.name || '',
mode: model?.mode || ModelModeTypeEnum.chat,
completion_params: {
...DEFAULT_COMPLETION_PARAMS,
...(model?.completion_params || {}),
},
})
const PROMPT_MODE = {
default: 'default',
custom: 'custom',
} as const
type PromptMode = typeof PROMPT_MODE[keyof typeof PROMPT_MODE]
const FollowUpSettingModal = ({
data,
onSave,
onCancel,
}: FollowUpSettingModalProps) => {
const { t } = useTranslation()
const [model, setModel] = useState<Model>(() => getInitialModel(data.model))
const [prompt, setPrompt] = useState(data.prompt || '')
const [promptMode, setPromptMode] = useState<PromptMode>(
data.prompt ? PROMPT_MODE.custom : PROMPT_MODE.default,
)
const { defaultModel } = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textGeneration)
const selectedModel = useMemo<Model>(() => {
if (model.provider && model.name)
return model
if (!defaultModel)
return model
return {
...model,
provider: defaultModel.provider.provider,
name: defaultModel.model,
}
}, [defaultModel, model])
const handleModelChange = useCallback((newValue: { modelId: string, provider: string, mode?: string, features?: string[] }) => {
setModel(prev => ({
...prev,
provider: newValue.provider,
name: newValue.modelId,
mode: (newValue.mode as ModelModeType) || prev.mode || ModelModeTypeEnum.chat,
}))
}, [])
const handleCompletionParamsChange = useCallback((newParams: FormValue) => {
setModel({
...selectedModel,
completion_params: {
...DEFAULT_COMPLETION_PARAMS,
...(newParams as Partial<CompletionParams>),
},
})
}, [selectedModel])
const handleSave = useCallback(() => {
const trimmedPrompt = prompt.trim()
const nextFollowUpState = produce(data, (draft) => {
if (selectedModel.provider && selectedModel.name)
draft.model = selectedModel
else
draft.model = undefined
draft.prompt = promptMode === PROMPT_MODE.custom
? (trimmedPrompt || undefined)
: undefined
})
onSave(nextFollowUpState)
}, [data, onSave, prompt, promptMode, selectedModel])
const isCustomPromptInvalid = promptMode === PROMPT_MODE.custom && !prompt.trim()
return (
<Dialog
open
onOpenChange={(open) => {
if (!open)
onCancel()
}}
>
<DialogContent className="w-[640px]! max-w-none! p-8! pb-6!">
<DialogCloseButton className="top-8 right-8" />
<DialogTitle className="pr-8 text-xl font-semibold text-text-primary">
{t('feature.suggestedQuestionsAfterAnswer.modal.title', { ns: 'appDebug' })}
</DialogTitle>
<div className="mt-6 space-y-4">
<div>
<div className="mb-1.5 system-sm-semibold-uppercase text-text-secondary">
{t('feature.suggestedQuestionsAfterAnswer.modal.modelLabel', { ns: 'appDebug' })}
</div>
<ModelParameterModal
popupClassName="w-[520px]!"
isAdvancedMode
provider={selectedModel.provider}
completionParams={selectedModel.completion_params}
modelId={selectedModel.name}
setModel={handleModelChange}
onCompletionParamsChange={handleCompletionParamsChange}
hideDebugWithMultipleModel
/>
</div>
<div>
<div className="mb-1.5 system-sm-semibold-uppercase text-text-secondary">
{t('feature.suggestedQuestionsAfterAnswer.modal.promptLabel', { ns: 'appDebug' })}
</div>
<div className="space-y-3" role="radiogroup" aria-label={t('feature.suggestedQuestionsAfterAnswer.modal.promptLabel', { ns: 'appDebug' }) || ''}>
<button
type="button"
role="radio"
aria-checked={promptMode === PROMPT_MODE.default}
className={cn(
'w-full rounded-xl border p-4 text-left transition-colors',
promptMode === PROMPT_MODE.default
? 'border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg'
: 'border-components-option-card-option-border bg-components-option-card-option-bg hover:bg-state-base-hover',
)}
onClick={() => setPromptMode(PROMPT_MODE.default)}
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="system-sm-semibold text-text-primary">
{t('feature.suggestedQuestionsAfterAnswer.modal.defaultPromptOption', { ns: 'appDebug' })}
</div>
<div className="mt-1 system-xs-regular text-text-tertiary">
{t('feature.suggestedQuestionsAfterAnswer.modal.defaultPromptOptionDescription', { ns: 'appDebug' })}
</div>
</div>
<div aria-hidden="true">
<Radio isChecked={promptMode === PROMPT_MODE.default} />
</div>
</div>
{promptMode === PROMPT_MODE.default && (
<div className="mt-3 rounded-lg border border-components-input-border-active bg-components-input-bg-normal px-3 py-2">
<div className="system-sm-regular break-words whitespace-pre-wrap text-text-secondary">
{DEFAULT_FOLLOW_UP_PROMPT}
</div>
</div>
)}
</button>
<button
type="button"
role="radio"
aria-checked={promptMode === PROMPT_MODE.custom}
className={cn(
'w-full rounded-xl border p-4 text-left transition-colors',
promptMode === PROMPT_MODE.custom
? 'border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg'
: 'border-components-option-card-option-border bg-components-option-card-option-bg hover:bg-state-base-hover',
)}
onClick={() => setPromptMode(PROMPT_MODE.custom)}
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="system-sm-semibold text-text-primary">
{t('feature.suggestedQuestionsAfterAnswer.modal.customPromptOption', { ns: 'appDebug' })}
</div>
<div className="mt-1 system-xs-regular text-text-tertiary">
{t('feature.suggestedQuestionsAfterAnswer.modal.customPromptOptionDescription', { ns: 'appDebug' })}
</div>
</div>
<div aria-hidden="true">
<Radio isChecked={promptMode === PROMPT_MODE.custom} />
</div>
</div>
{promptMode === PROMPT_MODE.custom && (
<Textarea
className="mt-3 min-h-32 resize-y border-components-input-border-active bg-components-input-bg-normal"
value={prompt}
onChange={e => setPrompt(e.target.value)}
maxLength={CUSTOM_FOLLOW_UP_PROMPT_MAX_LENGTH}
placeholder={t('feature.suggestedQuestionsAfterAnswer.modal.promptPlaceholder', { ns: 'appDebug' }) || ''}
/>
)}
</button>
</div>
</div>
</div>
<div className="mt-6 flex items-center justify-end gap-2">
<Button onClick={onCancel}>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button
variant="primary"
disabled={isCustomPromptInvalid}
onClick={handleSave}
>
{t('operation.save', { ns: 'common' })}
</Button>
</div>
</DialogContent>
</Dialog>
)
}
export default FollowUpSettingModal
@@ -1,10 +1,16 @@
import type { OnFeaturesChange } from '@/app/components/base/features/types'
import type {
OnFeaturesChange,
SuggestedQuestionsAfterAnswer,
} from '@/app/components/base/features/types'
import { Button } from '@langgenius/dify-ui/button'
import { RiEqualizer2Line } from '@remixicon/react'
import { produce } from 'immer'
import * as React from 'react'
import { useCallback } from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
import FeatureCard from '@/app/components/base/features/new-feature-panel/feature-card'
import FollowUpSettingModal from '@/app/components/base/features/new-feature-panel/follow-up-setting-modal'
import { FeatureEnum } from '@/app/components/base/features/types'
import { VirtualAssistant } from '@/app/components/base/icons/src/vender/features'
@@ -18,8 +24,10 @@ const FollowUp = ({
onChange,
}: Props) => {
const { t } = useTranslation()
const features = useFeatures(s => s.features)
const suggested = useFeatures(s => s.features.suggested)
const featuresStore = useFeaturesStore()
const [isHovering, setIsHovering] = useState(false)
const [isShowSettingModal, setIsShowSettingModal] = useState(false)
const handleChange = useCallback((type: FeatureEnum, enabled: boolean) => {
const {
@@ -38,19 +46,76 @@ const FollowUp = ({
onChange(newFeatures)
}, [featuresStore, onChange])
const handleSave = useCallback((newSuggested: SuggestedQuestionsAfterAnswer) => {
const {
features,
setFeatures,
} = featuresStore!.getState()
const newFeatures = produce(features, (draft) => {
draft.suggested = {
...newSuggested,
enabled: true,
}
})
setFeatures(newFeatures)
setIsShowSettingModal(false)
if (onChange)
onChange(newFeatures)
}, [featuresStore, onChange])
const handleOpenSettingModal = useCallback(() => {
if (disabled)
return
setIsShowSettingModal(true)
}, [disabled])
return (
<FeatureCard
icon={(
<div className="shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-light-blue-light-500 p-1 shadow-xs">
<VirtualAssistant className="h-4 w-4 text-text-primary-on-surface" />
</div>
<>
<FeatureCard
icon={(
<div className="shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-light-blue-light-500 p-1 shadow-xs">
<VirtualAssistant className="h-4 w-4 text-text-primary-on-surface" />
</div>
)}
title={t('feature.suggestedQuestionsAfterAnswer.title', { ns: 'appDebug' })}
value={!!suggested?.enabled}
onChange={state => handleChange(FeatureEnum.suggested, state)}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
disabled={disabled}
>
<>
{!suggested?.enabled && (
<div className="line-clamp-2 min-h-8 system-xs-regular text-text-tertiary">
{t('feature.suggestedQuestionsAfterAnswer.description', { ns: 'appDebug' })}
</div>
)}
{!!suggested?.enabled && (
<>
{!isHovering && (
<div className="line-clamp-2 min-h-8 system-xs-regular text-text-tertiary">
{suggested.model?.name || t('feature.suggestedQuestionsAfterAnswer.modal.defaultModel', { ns: 'appDebug' })}
</div>
)}
{isHovering && (
<Button className="w-full" onClick={handleOpenSettingModal} disabled={disabled}>
<RiEqualizer2Line className="mr-1 h-4 w-4" />
{t('operation.settings', { ns: 'common' })}
</Button>
)}
</>
)}
</>
</FeatureCard>
{isShowSettingModal && (
<FollowUpSettingModal
data={suggested || { enabled: true }}
onSave={handleSave}
onCancel={() => setIsShowSettingModal(false)}
/>
)}
title={t('feature.suggestedQuestionsAfterAnswer.title', { ns: 'appDebug' })}
value={!!features.suggested?.enabled}
description={t('feature.suggestedQuestionsAfterAnswer.description', { ns: 'appDebug' })!}
onChange={state => handleChange(FeatureEnum.suggested, state)}
disabled={disabled}
/>
</>
)
}
@@ -132,8 +132,8 @@ describe('FormGeneration', () => {
})
render(<FormGeneration forms={[form]} value={{}} onChange={onChange} />)
fireEvent.click(screen.getByText(/placeholder\.select/))
fireEvent.click(screen.getByText('GPT-4'))
fireEvent.click(screen.getByRole('combobox'))
fireEvent.click(screen.getByRole('option', { name: 'GPT-4' }))
expect(onChange).toHaveBeenCalledWith({ model: 'gpt-4' })
})
@@ -152,7 +152,7 @@ describe('FormGeneration', () => {
render(<FormGeneration forms={[form]} value={{}} onChange={vi.fn()} />)
expect(screen.getByText('模型')).toBeInTheDocument()
fireEvent.click(screen.getByText(/placeholder\.select/))
expect(screen.getByText('智谱-4')).toBeInTheDocument()
fireEvent.click(screen.getByRole('combobox'))
expect(screen.getByRole('option', { name: '智谱-4' })).toBeInTheDocument()
})
})
@@ -1,7 +1,7 @@
import type { FC } from 'react'
import type { CodeBasedExtensionForm } from '@/models/common'
import type { ModerationConfig } from '@/models/debug'
import { PortalSelect } from '@/app/components/base/select'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import Textarea from '@/app/components/base/textarea'
import { useLocale } from '@/context/i18n'
@@ -24,53 +24,65 @@ const FormGeneration: FC<FormGenerationProps> = ({
return (
<>
{
forms.map((form, index) => (
<div
key={index}
className="py-2"
>
<div className="flex h-9 items-center text-sm font-medium text-text-primary">
{locale === 'zh-Hans' ? form.label['zh-Hans'] : form.label['en-US']}
</div>
{
form.type === 'text-input' && (
<input
value={value?.[form.variable] || ''}
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={form.placeholder}
onChange={e => handleFormChange(form.variable, e.target.value)}
/>
)
}
{
form.type === 'paragraph' && (
<div className="relative">
<Textarea
className="resize-none"
forms.map((form, index) => {
const selectOptions = form.type === 'select'
? form.options.map(option => ({
name: option.label[locale === 'zh-Hans' ? 'zh-Hans' : 'en-US'],
value: option.value,
}))
: []
const selectedOption = selectOptions.find(option => option.value === value?.[form.variable]) ?? null
return (
<div
key={index}
className="py-2"
>
<div className="flex h-9 items-center text-sm font-medium text-text-primary">
{locale === 'zh-Hans' ? form.label['zh-Hans'] : form.label['en-US']}
</div>
{
form.type === 'text-input' && (
<input
value={value?.[form.variable] || ''}
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={form.placeholder}
onChange={e => handleFormChange(form.variable, e.target.value)}
/>
</div>
)
}
{
form.type === 'select' && (
<PortalSelect
value={value?.[form.variable]}
items={form.options.map((option) => {
return {
name: option.label[locale === 'zh-Hans' ? 'zh-Hans' : 'en-US'],
value: option.value,
}
})}
onSelect={item => handleFormChange(form.variable, item.value as string)}
popupClassName="w-[576px] z-102!"
/>
)
}
</div>
))
)
}
{
form.type === 'paragraph' && (
<div className="relative">
<Textarea
className="resize-none"
value={value?.[form.variable] || ''}
placeholder={form.placeholder}
onChange={e => handleFormChange(form.variable, e.target.value)}
/>
</div>
)
}
{
form.type === 'select' && (
<Select value={selectedOption?.value ?? null} onValueChange={nextValue => nextValue && handleFormChange(form.variable, nextValue)}>
<SelectTrigger className="w-full">
{selectedOption?.name ?? form.placeholder}
</SelectTrigger>
<SelectContent popupClassName="z-102 w-(--anchor-width)">
{selectOptions.map(option => (
<SelectItem key={option.value} value={option.value}>
<SelectItemText>{option.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)
}
</div>
)
})
}
</>
)
@@ -1,6 +1,5 @@
'use client'
import type { OnFeaturesChange } from '@/app/components/base/features/types'
import type { Item } from '@/app/components/base/select'
import { Listbox, ListboxButton, ListboxOption, ListboxOptions, Transition } from '@headlessui/react'
import { cn } from '@langgenius/dify-ui/cn'
import { Switch } from '@langgenius/dify-ui/switch'
@@ -17,6 +16,11 @@ import { usePathname } from '@/next/navigation'
import { useAppVoices } from '@/service/use-apps'
import { TtsAutoPlay } from '@/types/app'
type SelectOption = {
value: string | number
name: string
}
type VoiceParamConfigProps = {
onClose: () => void
onChange?: OnFeaturesChange
@@ -99,7 +103,7 @@ const VoiceParamConfig = ({
</div>
<Listbox
value={languageItem}
onChange={(value: Item) => {
onChange={(value: SelectOption) => {
handleChange({
language: String(value.value),
})
@@ -166,7 +170,7 @@ const VoiceParamConfig = ({
<Listbox
value={voiceItem}
disabled={!languageItem}
onChange={(value: Item) => {
onChange={(value: SelectOption) => {
handleChange({
voice: String(value.value),
})
@@ -195,7 +199,7 @@ const VoiceParamConfig = ({
<ListboxOptions
className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md border-[0.5px] border-components-panel-border bg-components-panel-bg px-1 py-1 text-base shadow-lg focus:outline-hidden sm:text-sm"
>
{voiceItems?.map((item: Item) => (
{voiceItems?.map((item: SelectOption) => (
<ListboxOption
key={item.value}
className="relative cursor-pointer rounded-lg py-2 pr-9 pl-3 text-text-secondary select-none hover:bg-state-base-hover data-active:bg-state-base-active"
+10 -2
View File
@@ -1,5 +1,10 @@
import type { FileUploadConfigResponse } from '@/models/common'
import type { Resolution, TransferMethod, TtsAutoPlay } from '@/types/app'
import type {
Model,
Resolution,
TransferMethod,
TtsAutoPlay,
} from '@/types/app'
export type EnabledOrDisabled = {
enabled?: boolean
@@ -12,7 +17,10 @@ export type OpeningStatement = EnabledOrDisabled & {
suggested_questions?: string[]
}
export type SuggestedQuestionsAfterAnswer = EnabledOrDisabled
export type SuggestedQuestionsAfterAnswer = EnabledOrDisabled & {
model?: Model
prompt?: string
}
export type TextToSpeech = EnabledOrDisabled & {
language?: string
@@ -1,6 +1,5 @@
'use client'
import type { FC } from 'react'
import type { Item } from '@/app/components/base/select'
import type { BuiltInMetadataItem, MetadataItemWithValueLength } from '@/app/components/datasets/metadata/types'
import type { SortType } from '@/service/datasets'
import { PlusIcon } from '@heroicons/react/24/solid'
@@ -19,6 +18,11 @@ import { useDocLink } from '@/context/i18n'
import { DataSourceType } from '@/models/datasets'
import { useIndexStatus } from '../status-item/hooks'
type SelectOption = {
value: string | number
name: string
}
type DocumentsHeaderProps = {
// Dataset info
datasetId: string
@@ -82,7 +86,7 @@ const DocumentsHeader: FC<DocumentsHeaderProps> = ({
const isDataSourceNotion = dataSourceType === DataSourceType.NOTION
const isDataSourceWeb = dataSourceType === DataSourceType.WEB
const statusFilterItems: Item[] = useMemo(() => [
const statusFilterItems: SelectOption[] = useMemo(() => [
{ value: 'all', name: t('list.index.all', { ns: 'datasetDocuments' }) as string },
{ value: 'queuing', name: DOC_INDEX_STATUS_MAP.queuing.text },
{ value: 'indexing', name: DOC_INDEX_STATUS_MAP.indexing.text },
@@ -94,7 +98,7 @@ const DocumentsHeader: FC<DocumentsHeaderProps> = ({
{ value: 'archived', name: DOC_INDEX_STATUS_MAP.archived.text },
], [DOC_INDEX_STATUS_MAP, t])
const sortItems: Item[] = useMemo(() => [
const sortItems: SelectOption[] = useMemo(() => [
{ value: 'created_at', name: t('list.sort.uploadTime', { ns: 'datasetDocuments' }) as string },
{ value: 'hit_count', name: t('list.sort.hitCount', { ns: 'datasetDocuments' }) as string },
], [t])
@@ -82,7 +82,7 @@ describe('MenuBar', () => {
it('should call renderOption for each item when dropdown is opened', async () => {
render(<MenuBar {...defaultProps} />)
const selectButton = screen.getByRole('button', { name: /All/i })
const selectButton = screen.getByRole('combobox')
fireEvent.click(selectButton)
// After opening, renderOption is called for each item, rendering the mocked StatusItem
@@ -1,14 +1,19 @@
'use client'
import type { FC } from 'react'
import type { Item } from '@/app/components/base/select'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import Checkbox from '@/app/components/base/checkbox'
import Divider from '@/app/components/base/divider'
import Input from '@/app/components/base/input'
import { SimpleSelect } from '@/app/components/base/select'
import DisplayToggle from '../display-toggle'
import StatusItem from '../status-item'
import s from '../style.module.css'
type Item = {
value: number | string
name: string
} & Record<string, unknown>
type MenuBarProps = {
isAllSelected: boolean
isSomeSelected: boolean
@@ -38,6 +43,8 @@ const MenuBar: FC<MenuBarProps> = ({
isCollapsed,
toggleCollapsed,
}) => {
const selectedStatus = statusList.find(item => item.value === selectDefaultValue) ?? null
return (
<div className={s.docSearchWrapper}>
<Checkbox
@@ -48,17 +55,29 @@ const MenuBar: FC<MenuBarProps> = ({
disabled={isLoading}
/>
<div className="flex-1 pl-5 system-sm-semibold-uppercase text-text-secondary">{totalText}</div>
<SimpleSelect
onSelect={onChangeStatus}
items={statusList}
defaultValue={selectDefaultValue}
className={s.select}
wrapperClassName="h-fit mr-2"
optionWrapClassName="w-[160px]"
optionClassName="p-0"
renderOption={({ item, selected }) => <StatusItem item={item} selected={selected} />}
notClearable
/>
<Select
value={selectedStatus ? String(selectedStatus.value) : null}
onValueChange={(nextValue) => {
if (!nextValue)
return
const nextItem = statusList.find(item => String(item.value) === nextValue)
if (nextItem)
onChangeStatus(nextItem)
}}
>
<SelectTrigger className={cn(s.select, 'mr-2 h-fit')}>
{selectedStatus?.name ?? ''}
</SelectTrigger>
<SelectContent popupClassName="w-[160px]">
{statusList.map(item => (
<SelectItem key={item.value} value={String(item.value)} className="h-auto p-0">
<SelectItemText className="sr-only m-0 p-0">{item.name}</SelectItemText>
<StatusItem item={item} selected={item.value === selectDefaultValue} />
{item.value === selectDefaultValue && <SelectItemIndicator className="hidden" />}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
showLeftIcon
showClearIcon
@@ -1,16 +1,20 @@
import type { Item } from '@/app/components/base/select'
import { useDebounceFn } from 'ahooks'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
type SelectOption = {
value: string | number
name: string
}
type UseSearchFilterReturn = {
inputValue: string
searchValue: string
selectedStatus: boolean | 'all'
statusList: Item[]
statusList: SelectOption[]
selectDefaultValue: 'all' | 0 | 1
handleInputChange: (value: string) => void
onChangeStatus: (item: Item) => void
onChangeStatus: (item: SelectOption) => void
onClearFilter: () => void
resetPage: () => void
}
@@ -27,7 +31,7 @@ export const useSearchFilter = (options: UseSearchFilterOptions): UseSearchFilte
const [searchValue, setSearchValue] = useState<string>('')
const [selectedStatus, setSelectedStatus] = useState<boolean | 'all'>('all')
const statusList = useRef<Item[]>([
const statusList = useRef<SelectOption[]>([
{ value: 'all', name: t('list.index.all', { ns: 'datasetDocuments' }) },
{ value: 0, name: t('list.status.disabled', { ns: 'datasetDocuments' }) },
{ value: 1, name: t('list.status.enabled', { ns: 'datasetDocuments' }) },
@@ -43,7 +47,7 @@ export const useSearchFilter = (options: UseSearchFilterOptions): UseSearchFilte
handleSearch()
}, [handleSearch])
const onChangeStatus = useCallback(({ value }: Item) => {
const onChangeStatus = useCallback(({ value }: SelectOption) => {
setSelectedStatus(value === 'all' ? 'all' : !!value)
onPageChange(1)
}, [onPageChange])
@@ -1,10 +1,14 @@
import type { FC } from 'react'
import type { Item } from '@/app/components/base/select'
import { RiCheckLine } from '@remixicon/react'
import * as React from 'react'
type StatusOption = {
value: string | number
name: string
}
type IStatusItemProps = {
item: Item
item: StatusOption
selected: boolean
}
@@ -480,7 +480,7 @@ describe('FieldInfo', () => {
// Assert - SimpleSelect should be rendered
// Assert - SimpleSelect should be rendered
expect(screen.getByRole('button'))!.toBeInTheDocument()
expect(screen.getByRole('combobox'))!.toBeInTheDocument()
})
it('should render textarea when showEdit is true and inputType is textarea', () => {
@@ -2,10 +2,10 @@
import type { FC, ReactNode } from 'react'
import type { inputType } from '@/hooks/use-metadata'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { useTranslation } from 'react-i18next'
import AutoHeightTextarea from '@/app/components/base/auto-height-textarea'
import Input from '@/app/components/base/input'
import { SimpleSelect } from '@/app/components/base/select'
import { getTextWidthWithCanvas } from '@/utils'
import s from '../style.module.css'
@@ -36,6 +36,7 @@ const FieldInfo: FC<FieldInfoProps> = ({
const textNeedWrap = getTextWidthWithCanvas(displayedValue) > 190
const editAlignTop = showEdit && inputType === 'textarea'
const readAlignTop = !showEdit && textNeedWrap
const selectedOption = selectOptions.find(option => option.value === value)
const renderContent = () => {
if (!showEdit)
@@ -43,14 +44,26 @@ const FieldInfo: FC<FieldInfoProps> = ({
if (inputType === 'select') {
return (
<SimpleSelect
onSelect={({ value }) => onUpdate?.(value as string)}
items={selectOptions}
defaultValue={value}
className={s.select}
wrapperClassName={s.selectWrapper}
placeholder={`${t('metadata.placeholder.select', { ns: 'datasetDocuments' })}${label}`}
/>
<Select
value={selectedOption?.value ?? null}
onValueChange={(nextValue) => {
if (!nextValue)
return
onUpdate?.(nextValue)
}}
>
<SelectTrigger className={cn(s.select, s.selectWrapper)}>
{selectedOption?.name ?? `${t('metadata.placeholder.select', { ns: 'datasetDocuments' })}${label}`}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{selectOptions.map(option => (
<SelectItem key={option.value} value={option.value}>
<SelectItemText>{option.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)
}
@@ -1,3 +1,4 @@
import { Popover } from '@langgenius/dify-ui/popover'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -7,18 +8,15 @@ vi.mock('@/hooks/use-api-access-url', () => ({
useDatasetApiAccessUrl: () => 'https://docs.dify.ai/api-reference/datasets',
}))
vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({
default: ({ isShow, onClose }: { isShow: boolean, onClose: () => void }) =>
isShow ? <div data-testid="secret-key-modal"><button onClick={onClose}>close</button></div> : null,
}))
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
<Popover open>
{children}
</Popover>
</QueryClientProvider>
)
}
@@ -28,8 +26,10 @@ const renderWithProviders = (ui: React.ReactElement) => {
}
describe('Card (Service API)', () => {
const onOpenSecretKeyModal = vi.fn()
const defaultProps = {
apiBaseUrl: 'https://api.dify.ai/v1',
onOpenSecretKeyModal,
}
beforeEach(() => {
@@ -77,48 +77,33 @@ describe('Card (Service API)', () => {
// Props: tests different apiBaseUrl values
describe('Props', () => {
it('should display provided apiBaseUrl', () => {
renderWithProviders(<Card apiBaseUrl="https://custom-api.example.com" />)
renderWithProviders(<Card apiBaseUrl="https://custom-api.example.com" onOpenSecretKeyModal={onOpenSecretKeyModal} />)
expect(screen.getByText('https://custom-api.example.com')).toBeInTheDocument()
})
it('should show green indicator when apiBaseUrl is provided', () => {
renderWithProviders(<Card apiBaseUrl="https://api.dify.ai" />)
renderWithProviders(<Card apiBaseUrl="https://api.dify.ai" onOpenSecretKeyModal={onOpenSecretKeyModal} />)
// The Indicator component receives color="green" when apiBaseUrl is truthy
const statusText = screen.getByText(/serviceApi\.enabled/)
expect(statusText).toHaveClass('text-text-success')
})
it('should show yellow indicator when apiBaseUrl is empty', () => {
renderWithProviders(<Card apiBaseUrl="" />)
renderWithProviders(<Card apiBaseUrl="" onOpenSecretKeyModal={onOpenSecretKeyModal} />)
// Still shows "enabled" text but indicator color differs
expect(screen.getByText(/serviceApi\.enabled/)).toBeInTheDocument()
})
})
// User Interactions: tests button clicks and modal
// User Interactions: tests button clicks
describe('User Interactions', () => {
it('should open secret key modal when API key button is clicked', () => {
renderWithProviders(<Card {...defaultProps} />)
// Modal should not be visible before clicking
expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument()
const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/).closest('button')
fireEvent.click(apiKeyButton!)
// Modal should appear after clicking
expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
})
it('should close secret key modal when onClose is called', () => {
it('should call onOpenSecretKeyModal when API key button is clicked', () => {
renderWithProviders(<Card {...defaultProps} />)
const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/).closest('button')
fireEvent.click(apiKeyButton!)
expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
fireEvent.click(screen.getByText('close'))
expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument()
expect(onOpenSecretKeyModal).toHaveBeenCalledTimes(1)
})
it('should render API reference as a link', () => {
@@ -148,20 +133,20 @@ describe('Card (Service API)', () => {
// Edge Cases: tests empty/long URLs
describe('Edge Cases', () => {
it('should handle empty apiBaseUrl', () => {
renderWithProviders(<Card apiBaseUrl="" />)
renderWithProviders(<Card apiBaseUrl="" onOpenSecretKeyModal={onOpenSecretKeyModal} />)
// Should still render the structure
expect(screen.getByText(/serviceApi\.card\.endpoint/)).toBeInTheDocument()
})
it('should handle very long apiBaseUrl', () => {
const longUrl = `https://api.dify.ai/${'path/'.repeat(50)}`
renderWithProviders(<Card apiBaseUrl={longUrl} />)
renderWithProviders(<Card apiBaseUrl={longUrl} onOpenSecretKeyModal={onOpenSecretKeyModal} />)
expect(screen.getByText(longUrl)).toBeInTheDocument()
})
it('should handle apiBaseUrl with special characters', () => {
const specialUrl = 'https://api.dify.ai/v1?key=value&foo=bar'
renderWithProviders(<Card apiBaseUrl={specialUrl} />)
renderWithProviders(<Card apiBaseUrl={specialUrl} onOpenSecretKeyModal={onOpenSecretKeyModal} />)
expect(screen.getByText(specialUrl)).toBeInTheDocument()
})
})
@@ -1,9 +1,8 @@
import { Popover } from '@langgenius/dify-ui/popover'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Component Imports (after mocks)
import Card from '../card'
import ServiceApi from '../index'
@@ -43,7 +42,8 @@ vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({
),
}))
// ServiceApi Component Tests
const renderCard = (ui: React.ReactElement) =>
render(<Popover open>{ui}</Popover>)
describe('ServiceApi', () => {
beforeEach(() => {
@@ -80,18 +80,15 @@ describe('ServiceApi', () => {
})
})
// Props Variations Tests
describe('Props Variations', () => {
it('should show green Indicator when apiBaseUrl is provided', () => {
it('should show Indicator when apiBaseUrl is provided', () => {
const { container } = render(<ServiceApi apiBaseUrl="https://api.example.com" />)
// When apiBaseUrl is truthy, Indicator color is green
const triggerContainer = container.querySelector('.relative.flex.h-8')
expect(triggerContainer).toBeInTheDocument()
})
it('should show yellow Indicator when apiBaseUrl is empty', () => {
it('should show Indicator when apiBaseUrl is empty', () => {
const { container } = render(<ServiceApi apiBaseUrl="" />)
// When apiBaseUrl is falsy, Indicator color is yellow
const triggerContainer = container.querySelector('.relative.flex.h-8')
expect(triggerContainer).toBeInTheDocument()
})
@@ -110,28 +107,7 @@ describe('ServiceApi', () => {
})
describe('User Interactions', () => {
it('should toggle popup open state on click', async () => {
const user = userEvent.setup()
render(<ServiceApi apiBaseUrl="https://api.example.com" />)
const trigger = screen.getByText(/serviceApi\.title/i).closest('[class*="cursor-pointer"]')
expect(trigger).toBeInTheDocument()
if (trigger)
await user.click(trigger)
// After click, the Card should be rendered
})
it('should apply hover styles on trigger', () => {
render(<ServiceApi apiBaseUrl="https://api.example.com" />)
const trigger = screen.getByText(/serviceApi\.title/i).closest('div[class*="cursor-pointer"]')
expect(trigger).toHaveClass('cursor-pointer')
})
it('should toggle open state from false to true on first click', async () => {
it('should open popup on trigger click', async () => {
const user = userEvent.setup()
render(<ServiceApi apiBaseUrl="https://api.example.com" />)
@@ -140,56 +116,13 @@ describe('ServiceApi', () => {
if (trigger)
await user.click(trigger)
// Card should be visible after clicking
await waitFor(() => {
expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument()
})
})
it('should toggle open state back to false on second click', async () => {
const user = userEvent.setup()
render(<ServiceApi apiBaseUrl="https://api.example.com" />)
const trigger = screen.getByText(/serviceApi\.title/i).closest('[class*="cursor-pointer"]')
if (trigger) {
await user.click(trigger) // open
await user.click(trigger) // close
}
// Component should handle the toggle without errors
})
it('should apply open state styling when popup is open', async () => {
const user = userEvent.setup()
render(<ServiceApi apiBaseUrl="https://api.example.com" />)
const trigger = screen.getByText(/serviceApi\.title/i).closest('[class*="cursor-pointer"]')
if (trigger)
await user.click(trigger)
// When open, the trigger should have hover background class
})
})
// Portal and Card Integration Tests
describe('Portal and Card Integration', () => {
it('should render Card component inside portal when open', async () => {
const user = userEvent.setup()
render(<ServiceApi apiBaseUrl="https://api.example.com" />)
const trigger = screen.getByText(/serviceApi\.title/i).closest('[class*="cursor-pointer"]')
if (trigger)
await user.click(trigger)
// Wait for portal content to appear
await waitFor(() => {
expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument()
})
})
it('should pass apiBaseUrl prop to Card component', async () => {
const user = userEvent.setup()
const testUrl = 'https://test-api.example.com'
@@ -204,38 +137,9 @@ describe('ServiceApi', () => {
expect(screen.getByText(testUrl)).toBeInTheDocument()
})
})
it('should use correct portal placement configuration', () => {
render(<ServiceApi apiBaseUrl="https://api.example.com" />)
// PortalToFollowElem is configured with placement="top-start"
expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument()
})
it('should use correct portal offset configuration', () => {
render(<ServiceApi apiBaseUrl="https://api.example.com" />)
// PortalToFollowElem is configured with offset={{ mainAxis: 4, crossAxis: -4 }}
expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle rapid toggle clicks gracefully', async () => {
const user = userEvent.setup()
render(<ServiceApi apiBaseUrl="https://api.example.com" />)
const trigger = screen.getByText(/serviceApi\.title/i).closest('[class*="cursor-pointer"]')
if (trigger) {
// Rapid clicks
await user.click(trigger)
await user.click(trigger)
await user.click(trigger)
}
// Component should handle state changes without errors
expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument()
})
it('should render correctly with empty apiBaseUrl', () => {
render(<ServiceApi apiBaseUrl="" />)
expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument()
@@ -248,391 +152,60 @@ describe('ServiceApi', () => {
rerender(<ServiceApi apiBaseUrl="https://new-api.example.com" />)
// Component should still render after prop change
expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument()
})
it('should handle undefined-like apiBaseUrl values', () => {
// Empty string is the closest to undefined for this prop
render(<ServiceApi apiBaseUrl="" />)
expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument()
})
})
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<ServiceApi apiBaseUrl="https://api.example.com" />)
rerender(<ServiceApi apiBaseUrl="https://api.example.com" />)
expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument()
})
it('should not re-render unnecessarily with same props', () => {
const { rerender } = render(<ServiceApi apiBaseUrl="https://api.example.com" />)
rerender(<ServiceApi apiBaseUrl="https://api.example.com" />)
rerender(<ServiceApi apiBaseUrl="https://api.example.com" />)
expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument()
})
it('should update when apiBaseUrl prop changes', () => {
const { rerender } = render(<ServiceApi apiBaseUrl="https://api.example.com" />)
rerender(<ServiceApi apiBaseUrl="https://new-api.example.com" />)
expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument()
})
})
})
// Card Component Tests
describe('Card (service-api)', () => {
const onOpenSecretKeyModal = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Card apiBaseUrl="https://api.example.com" />)
renderCard(<Card apiBaseUrl="https://api.example.com" onOpenSecretKeyModal={onOpenSecretKeyModal} />)
expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument()
})
it('should display card title', () => {
render(<Card apiBaseUrl="https://api.example.com" />)
expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument()
})
it('should display enabled status', () => {
render(<Card apiBaseUrl="https://api.example.com" />)
expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument()
})
it('should render endpoint label', () => {
render(<Card apiBaseUrl="https://api.example.com" />)
expect(screen.getByText(/serviceApi\.card\.endpoint/i)).toBeInTheDocument()
})
it('should display apiBaseUrl in endpoint field', () => {
const testUrl = 'https://api.example.com'
render(<Card apiBaseUrl={testUrl} />)
renderCard(<Card apiBaseUrl={testUrl} onOpenSecretKeyModal={onOpenSecretKeyModal} />)
expect(screen.getByText(testUrl)).toBeInTheDocument()
})
it('should render Indicator component', () => {
const { container } = render(<Card apiBaseUrl="https://api.example.com" />)
// Card container should be present
const cardContainer = container.querySelector('.flex.w-\\[360px\\]')
expect(cardContainer).toBeInTheDocument()
})
it('should render API Key button', () => {
render(<Card apiBaseUrl="https://api.example.com" />)
renderCard(<Card apiBaseUrl="https://api.example.com" onOpenSecretKeyModal={onOpenSecretKeyModal} />)
expect(screen.getByText(/serviceApi\.card\.apiKey/i)).toBeInTheDocument()
})
it('should render API Reference button', () => {
render(<Card apiBaseUrl="https://api.example.com" />)
renderCard(<Card apiBaseUrl="https://api.example.com" onOpenSecretKeyModal={onOpenSecretKeyModal} />)
expect(screen.getByText(/serviceApi\.card\.apiReference/i)).toBeInTheDocument()
})
it('should render CopyFeedback component for endpoint', () => {
const { container } = render(<Card apiBaseUrl="https://api.example.com" />)
// CopyFeedback should be in the endpoint section
const copyButton = container.querySelector('[class*="bg-components-input-bg-normal"]')
expect(copyButton).toBeInTheDocument()
})
it('should render ApiAggregate icon in header', () => {
const { container } = render(<Card apiBaseUrl="https://api.example.com" />)
const icon = container.querySelector('svg')
expect(icon).toBeInTheDocument()
})
})
// Props Variations Tests
describe('Props Variations', () => {
it('should show green Indicator when apiBaseUrl is provided', () => {
const { container } = render(<Card apiBaseUrl="https://api.example.com" />)
const cardContainer = container.querySelector('.flex.w-\\[360px\\]')
expect(cardContainer).toBeInTheDocument()
})
it('should show yellow Indicator when apiBaseUrl is empty', () => {
const { container } = render(<Card apiBaseUrl="" />)
const cardContainer = container.querySelector('.flex.w-\\[360px\\]')
expect(cardContainer).toBeInTheDocument()
})
it('should display different apiBaseUrl values correctly', () => {
const testUrls = [
'https://api.example.com',
'https://localhost:3000',
'https://api.production.example.com/v1',
]
testUrls.forEach((url) => {
const { unmount } = render(<Card apiBaseUrl={url} />)
expect(screen.getByText(url)).toBeInTheDocument()
unmount()
})
})
it('should handle empty apiBaseUrl', () => {
render(<Card apiBaseUrl="" />)
expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument()
})
it('should truncate long apiBaseUrl', () => {
const longUrl = 'https://api.example.com/v1/very/long/path/to/endpoint/that/should/truncate'
const { container } = render(<Card apiBaseUrl={longUrl} />)
const truncateElement = container.querySelector('.truncate')
expect(truncateElement).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should open SecretKeyModal when API Key button is clicked', async () => {
it('should call onOpenSecretKeyModal when API Key button is clicked', async () => {
const user = userEvent.setup()
render(<Card apiBaseUrl="https://api.example.com" />)
const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/i).closest('button')
expect(apiKeyButton).toBeInTheDocument()
if (apiKeyButton)
await user.click(apiKeyButton)
await waitFor(() => {
expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
})
})
it('should close SecretKeyModal when close button is clicked', async () => {
const user = userEvent.setup()
render(<Card apiBaseUrl="https://api.example.com" />)
renderCard(<Card apiBaseUrl="https://api.example.com" onOpenSecretKeyModal={onOpenSecretKeyModal} />)
const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/i).closest('button')
if (apiKeyButton)
await user.click(apiKeyButton)
await waitFor(() => {
expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
})
const closeButton = screen.getByTestId('close-modal-btn')
await user.click(closeButton)
await waitFor(() => {
expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument()
})
expect(onOpenSecretKeyModal).toHaveBeenCalledTimes(1)
})
it('should have correct href for API Reference link', () => {
render(<Card apiBaseUrl="https://api.example.com" />)
renderCard(<Card apiBaseUrl="https://api.example.com" onOpenSecretKeyModal={onOpenSecretKeyModal} />)
const apiRefLink = screen.getByText(/serviceApi\.card\.apiReference/i).closest('a')
expect(apiRefLink).toHaveAttribute('href', 'https://docs.dify.ai/api-reference/datasets')
})
it('should open API Reference in new tab', () => {
render(<Card apiBaseUrl="https://api.example.com" />)
const apiRefLink = screen.getByText(/serviceApi\.card\.apiReference/i).closest('a')
expect(apiRefLink).toHaveAttribute('target', '_blank')
expect(apiRefLink).toHaveAttribute('rel', 'noopener noreferrer')
})
it('should toggle modal visibility correctly', async () => {
const user = userEvent.setup()
render(<Card apiBaseUrl="https://api.example.com" />)
// Initially modal should not be visible
expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument()
const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/i).closest('button')
if (apiKeyButton)
await user.click(apiKeyButton)
// Modal should be visible
await waitFor(() => {
expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
})
const closeButton = screen.getByTestId('close-modal-btn')
await user.click(closeButton)
// Modal should not be visible again
await waitFor(() => {
expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument()
})
})
})
// Modal State Tests
describe('Modal State', () => {
it('should initialize with modal closed', () => {
render(<Card apiBaseUrl="https://api.example.com" />)
expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument()
})
it('should open modal on handleOpenSecretKeyModal', async () => {
const user = userEvent.setup()
render(<Card apiBaseUrl="https://api.example.com" />)
const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/i).closest('button')
if (apiKeyButton)
await user.click(apiKeyButton)
await waitFor(() => {
expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
})
})
it('should close modal on handleCloseSecretKeyModal', async () => {
const user = userEvent.setup()
render(<Card apiBaseUrl="https://api.example.com" />)
// Open modal first
const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/i).closest('button')
if (apiKeyButton)
await user.click(apiKeyButton)
await waitFor(() => {
expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
})
const closeButton = screen.getByTestId('close-modal-btn')
await user.click(closeButton)
await waitFor(() => {
expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument()
})
})
it('should handle multiple open/close cycles', async () => {
const user = userEvent.setup()
render(<Card apiBaseUrl="https://api.example.com" />)
const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/i).closest('button')
// First cycle
if (apiKeyButton)
await user.click(apiKeyButton)
await waitFor(() => {
expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
})
await user.click(screen.getByTestId('close-modal-btn'))
await waitFor(() => {
expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument()
})
// Second cycle
if (apiKeyButton)
await user.click(apiKeyButton)
await waitFor(() => {
expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
})
})
})
describe('Edge Cases', () => {
it('should handle empty apiBaseUrl gracefully', () => {
render(<Card apiBaseUrl="" />)
expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument()
// Endpoint field should show empty string
})
it('should handle very long apiBaseUrl', () => {
const longUrl = 'https://'.concat('a'.repeat(500), '.com')
render(<Card apiBaseUrl={longUrl} />)
expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument()
})
it('should handle special characters in apiBaseUrl', () => {
const specialUrl = 'https://api.example.com/path?query=test&param=value#anchor'
render(<Card apiBaseUrl={specialUrl} />)
expect(screen.getByText(specialUrl)).toBeInTheDocument()
})
it('should render without errors when all buttons are clickable', async () => {
const user = userEvent.setup()
render(<Card apiBaseUrl="https://api.example.com" />)
const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/i).closest('button')
if (apiKeyButton)
await user.click(apiKeyButton)
await waitFor(() => {
expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
})
await user.click(screen.getByTestId('close-modal-btn'))
await waitFor(() => {
expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument()
})
// Component should still be functional
expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument()
})
})
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<Card apiBaseUrl="https://api.example.com" />)
rerender(<Card apiBaseUrl="https://api.example.com" />)
expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument()
})
it('should use useCallback for handlers', () => {
const { rerender } = render(<Card apiBaseUrl="https://api.example.com" />)
rerender(<Card apiBaseUrl="https://api.example.com" />)
// Component should render without issues with memoized callbacks
expect(screen.getByText(/serviceApi\.card\.apiKey/i)).toBeInTheDocument()
})
it('should update when apiBaseUrl prop changes', () => {
const { rerender } = render(<Card apiBaseUrl="https://api.example.com" />)
expect(screen.getByText('https://api.example.com')).toBeInTheDocument()
rerender(<Card apiBaseUrl="https://new-api.example.com" />)
expect(screen.getByText('https://new-api.example.com')).toBeInTheDocument()
})
})
// Copy Functionality Tests
describe('Copy Functionality', () => {
it('should render CopyFeedback component for apiBaseUrl', () => {
const { container } = render(<Card apiBaseUrl="https://api.example.com" />)
const copyContainer = container.querySelector('[class*="bg-components-input-bg-normal"]')
expect(copyContainer).toBeInTheDocument()
})
it('should pass apiBaseUrl to CopyFeedback component', () => {
const testUrl = 'https://api.example.com'
render(<Card apiBaseUrl={testUrl} />)
// The URL should be displayed in the copy section
expect(screen.getByText(testUrl)).toBeInTheDocument()
})
})
})
@@ -641,78 +214,33 @@ describe('ServiceApi Integration', () => {
vi.clearAllMocks()
})
it('should open Card popup and display endpoint', async () => {
const user = userEvent.setup()
const testUrl = 'https://api.example.com'
render(<ServiceApi apiBaseUrl={testUrl} />)
// Open popup
const trigger = screen.getByText(/serviceApi\.title/i).closest('[class*="cursor-pointer"]')
if (trigger)
await user.click(trigger)
// Wait for Card to appear
await waitFor(() => {
expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument()
expect(screen.getByText(testUrl)).toBeInTheDocument()
})
})
it('should complete full workflow: open -> view endpoint -> access API key', async () => {
it('should close popover and open modal when API Key button is clicked', async () => {
const user = userEvent.setup()
render(<ServiceApi apiBaseUrl="https://api.example.com" />)
// Open popup
// Open popover
const trigger = screen.getByText(/serviceApi\.title/i).closest('[class*="cursor-pointer"]')
if (trigger)
await user.click(trigger)
// Verify Card content
await waitFor(() => {
expect(screen.getByText(/serviceApi\.card\.title/i)).toBeInTheDocument()
expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument()
expect(screen.getByText(/serviceApi\.card\.apiKey/i)).toBeInTheDocument()
})
// Open API Key modal
// Click API Key button (wrapped by PopoverClose)
const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/i).closest('button')
if (apiKeyButton)
await user.click(apiKeyButton)
// Verify modal appears
// Modal should appear
await waitFor(() => {
expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
})
})
it('should navigate to API Reference from Card', async () => {
const user = userEvent.setup()
render(<ServiceApi apiBaseUrl="https://api.example.com" />)
// Open popup
const trigger = screen.getByText(/serviceApi\.title/i).closest('[class*="cursor-pointer"]')
if (trigger)
await user.click(trigger)
// Wait for Card to appear
// Popover should be closed — Card title no longer in document
await waitFor(() => {
expect(screen.getByText(/serviceApi\.card\.apiReference/i)).toBeInTheDocument()
expect(screen.queryByText(/serviceApi\.card\.title/i)).not.toBeInTheDocument()
})
// Verify link
const apiRefLink = screen.getByText(/serviceApi\.card\.apiReference/i).closest('a')
expect(apiRefLink).toHaveAttribute('href', 'https://docs.dify.ai/api-reference/datasets')
})
it('should reflect apiBaseUrl status in Indicator color', () => {
// With URL - should be green
const { rerender } = render(<ServiceApi apiBaseUrl="https://api.example.com" />)
expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument()
// Without URL - should be yellow
rerender(<ServiceApi apiBaseUrl="" />)
expect(screen.getByText(/serviceApi\.title/i)).toBeInTheDocument()
})
})
@@ -1,35 +1,27 @@
import { Button } from '@langgenius/dify-ui/button'
import { PopoverClose } from '@langgenius/dify-ui/popover'
import { RiBookOpenLine, RiKey2Line } from '@remixicon/react'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import CopyFeedback from '@/app/components/base/copy-feedback'
import { ApiAggregate } from '@/app/components/base/icons/src/vender/knowledge'
import SecretKeyModal from '@/app/components/develop/secret-key/secret-key-modal'
import Indicator from '@/app/components/header/indicator'
import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url'
import Link from '@/next/link'
type CardProps = {
apiBaseUrl: string
onOpenSecretKeyModal: () => void
}
const Card = ({
apiBaseUrl,
onOpenSecretKeyModal,
}: CardProps) => {
const { t } = useTranslation()
const [isSecretKeyModalVisible, setIsSecretKeyModalVisible] = useState(false)
const apiReferenceUrl = useDatasetApiAccessUrl()
const handleOpenSecretKeyModal = useCallback(() => {
setIsSecretKeyModalVisible(true)
}, [])
const handleCloseSecretKeyModal = useCallback(() => {
setIsSecretKeyModalVisible(false)
}, [])
return (
<div className="flex w-[360px] flex-col rounded-xl border border-components-panel-border bg-components-panel-bg shadow-lg shadow-shadow-shadow-1">
<div className="flex flex-col gap-y-3 p-4">
@@ -74,17 +66,21 @@ const Card = ({
</div>
{/* Actions */}
<div className="flex gap-x-1 border-t-[0.5px] border-divider-subtle p-4">
<Button
variant="ghost"
size="small"
className="gap-x-px text-text-tertiary"
onClick={handleOpenSecretKeyModal}
>
<RiKey2Line className="size-3.5 shrink-0" />
<span className="px-[3px] system-xs-medium">
{t('serviceApi.card.apiKey', { ns: 'dataset' })}
</span>
</Button>
<PopoverClose
render={(
<Button
variant="ghost"
size="small"
className="gap-x-px text-text-tertiary"
onClick={onOpenSecretKeyModal}
>
<RiKey2Line className="size-3.5 shrink-0" />
<span className="px-[3px] system-xs-medium">
{t('serviceApi.card.apiKey', { ns: 'dataset' })}
</span>
</Button>
)}
/>
<Link
href={apiReferenceUrl}
target="_blank"
@@ -102,10 +98,6 @@ const Card = ({
</Button>
</Link>
</div>
<SecretKeyModal
isShow={isSecretKeyModalVisible}
onClose={handleCloseSecretKeyModal}
/>
</div>
)
}
@@ -1,8 +1,9 @@
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import * as React from 'react'
import { useState } from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import SecretKeyModal from '@/app/components/develop/secret-key/secret-key-modal'
import Indicator from '@/app/components/header/indicator'
import Card from './card'
@@ -15,6 +16,15 @@ const ServiceApi = ({
}: ServiceApiProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [isSecretKeyModalVisible, setIsSecretKeyModalVisible] = useState(false)
const handleOpenSecretKeyModal = useCallback(() => {
setIsSecretKeyModalVisible(true)
}, [])
const handleCloseSecretKeyModal = useCallback(() => {
setIsSecretKeyModalVisible(false)
}, [])
return (
<div>
@@ -49,9 +59,14 @@ const ServiceApi = ({
>
<Card
apiBaseUrl={apiBaseUrl}
onOpenSecretKeyModal={handleOpenSecretKeyModal}
/>
</PopoverContent>
</Popover>
<SecretKeyModal
isShow={isSecretKeyModalVisible}
onClose={handleCloseSecretKeyModal}
/>
</div>
)
}
@@ -99,6 +99,14 @@ vi.mock('../../external-api/external-api-panel', () => ({
),
}))
// Mock SecretKeyModal — it depends on user profile context and service APIs
// not configured in this test. ServiceApi always mounts the modal (controlled
// by `isShow`) so we provide a lightweight stub.
vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({
default: ({ isShow }: { isShow: boolean }) =>
isShow ? <div data-testid="secret-key-modal" /> : null,
}))
// Mock TagManagementModal
vi.mock('@/app/components/base/tag-management', () => ({
default: () => <div data-testid="tag-management-modal" />,
@@ -11,53 +11,56 @@ const mockMutateUserProfile = vi.fn()
let mockLocale: string | undefined = 'en-US'
let mockUserProfile: UserProfileResponse
vi.mock('@/app/components/base/select', async () => {
vi.mock('@langgenius/dify-ui/select', async () => {
const React = await import('react')
const SelectContext = React.createContext<{
disabled?: boolean
onValueChange?: (value: string) => void
}>({})
return {
SimpleSelect: ({
items = [],
defaultValue,
onSelect,
Select: ({
children,
disabled,
onValueChange,
}: {
items?: Array<{ value: string | number, name: string }>
defaultValue?: string | number
onSelect: (item: { value: string | number, name: string }) => void
children: React.ReactNode
disabled?: boolean
onValueChange?: (value: string) => void
}) => {
const [open, setOpen] = React.useState(false)
const [selectedValue, setSelectedValue] = React.useState<string | number | undefined>(defaultValue)
const selected = items.find(item => item.value === selectedValue)
?? items.find(item => item.value === defaultValue)
?? null
return (
<SelectContext.Provider value={{ disabled, onValueChange }}>
<div>{children}</div>
</SelectContext.Provider>
)
},
SelectTrigger: ({ children }: { children: React.ReactNode }) => {
const context = React.useContext(SelectContext)
return (
<div>
<button type="button" disabled={disabled} onClick={() => setOpen(prev => !prev)}>
{selected?.name ?? ''}
<button type="button" disabled={context.disabled}>
{children}
</button>
<button data-testid="select-empty" type="button" onClick={() => context.onValueChange?.('')}>
empty value
</button>
<button data-testid="select-invalid" type="button" onClick={() => context.onValueChange?.('__missing__')}>
invalid value
</button>
{open && (
<div>
{items.map(item => (
<button
key={item.value}
type="button"
role="option"
onClick={() => {
setSelectedValue(item.value)
onSelect(item)
setOpen(false)
}}
>
{item.name}
</button>
))}
</div>
)}
</div>
)
},
SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectItem: ({ children, value }: { children: React.ReactNode, value: string }) => {
const context = React.useContext(SelectContext)
return (
<button type="button" role="option" onClick={() => context.onValueChange?.(value)}>
{children}
</button>
)
},
SelectItemText: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SelectItemIndicator: () => null,
}
})
@@ -118,7 +121,7 @@ const getSectionByLabel = (sectionLabel: string) => {
const selectOption = async (sectionLabel: string, optionName: string) => {
const section = getSectionByLabel(sectionLabel)
await act(async () => {
fireEvent.click(within(section).getByRole('button'))
fireEvent.click(within(section).getAllByRole('button')[0]!)
})
await act(async () => {
fireEvent.click(await within(section).findByRole('option', { name: optionName }))
@@ -164,6 +167,18 @@ describe('LanguagePage - Rendering', () => {
expect(screen.getByRole('button', { name: english.name })).toBeInTheDocument()
expect(screen.getByRole('button', { name: niueTimezone.name })).toBeInTheDocument()
})
it('should render placeholders when the current locale or timezone is unsupported', () => {
mockLocale = 'unsupported-locale'
mockUserProfile = createUserProfile({
interface_language: 'unsupported-locale',
timezone: 'Unsupported/Timezone',
})
renderPage()
expect(screen.getAllByRole('button', { name: 'common.placeholder.select' })).toHaveLength(2)
})
})
// Interactions
@@ -206,7 +221,12 @@ describe('LanguagePage - Interactions', () => {
await selectOption('common.language.timezone', midwayTimezone.name)
expect(await screen.findByText('common.actionMsg.modifiedSuccessfully')).toBeInTheDocument()
expect(screen.getByRole('button', { name: midwayTimezone.name })).toBeInTheDocument()
await waitFor(() => {
expect(updateUserProfileMock).toHaveBeenCalledWith({
url: '/account/timezone',
body: { timezone: midwayTimezone.value },
})
})
}, 15000)
it('should show error toast when timezone update fails', async () => {
@@ -219,4 +239,30 @@ describe('LanguagePage - Interactions', () => {
expect(await screen.findByText('Timezone failed')).toBeInTheDocument()
}, 15000)
it('should ignore empty and unknown language selections', async () => {
renderPage()
const section = getSectionByLabel('common.language.displayLanguage')
await act(async () => {
fireEvent.click(within(section).getByTestId('select-empty'))
fireEvent.click(within(section).getByTestId('select-invalid'))
})
expect(updateUserProfileMock).not.toHaveBeenCalled()
})
it('should ignore empty and unknown timezone selections', async () => {
renderPage()
const section = getSectionByLabel('common.language.timezone')
await act(async () => {
fireEvent.click(within(section).getByTestId('select-empty'))
fireEvent.click(within(section).getByTestId('select-invalid'))
})
expect(updateUserProfileMock).not.toHaveBeenCalled()
})
})
@@ -1,10 +1,9 @@
'use client'
import type { Item } from '@/app/components/base/select'
import type { Locale } from '@/i18n-config'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { toast } from '@langgenius/dify-ui/toast'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SimpleSelect } from '@/app/components/base/select'
import { useAppContext } from '@/context/app-context'
import { useLocale } from '@/context/i18n'
import { setLocaleOnClient } from '@/i18n-config'
@@ -13,6 +12,16 @@ import { useRouter } from '@/next/navigation'
import { updateUserProfile } from '@/service/common'
import { timezones } from '@/utils/timezone'
type SelectOption = {
value: string
name: string
}
type TimezoneOption = {
value: string | number
name: string
}
const titleClassName = `
mb-2 system-sm-semibold text-text-secondary
`
@@ -22,7 +31,10 @@ export default function LanguagePage() {
const [editing, setEditing] = useState(false)
const { t } = useTranslation()
const router = useRouter()
const handleSelectLanguage = async (item: Item) => {
const languageOptions: SelectOption[] = languages.filter(item => item.supported)
const selectedLanguage = languageOptions.find(item => item.value === (locale || userProfile.interface_language))
const selectedTimezone = timezones.find(item => item.value === userProfile.timezone)
const handleSelectLanguage = async (item: SelectOption) => {
const url = '/account/interface-language'
const bodyKey = 'interface_language'
setEditing(true)
@@ -39,7 +51,7 @@ export default function LanguagePage() {
setEditing(false)
}
}
const handleSelectTimezone = async (item: Item) => {
const handleSelectTimezone = async (item: TimezoneOption) => {
const url = '/account/timezone'
const bodyKey = 'timezone'
setEditing(true)
@@ -59,11 +71,55 @@ export default function LanguagePage() {
<>
<div className="mb-8">
<div className={titleClassName}>{t('language.displayLanguage', { ns: 'common' })}</div>
<SimpleSelect defaultValue={locale || userProfile.interface_language} items={languages.filter(item => item.supported)} onSelect={item => handleSelectLanguage(item)} disabled={editing} notClearable={true} />
<Select
value={selectedLanguage?.value ?? null}
disabled={editing}
onValueChange={(nextValue) => {
if (!nextValue)
return
const nextItem = languageOptions.find(item => item.value === nextValue)
if (nextItem)
handleSelectLanguage(nextItem)
}}
>
<SelectTrigger size="large">
{selectedLanguage?.name ?? t('placeholder.select', { ns: 'common' })}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{languageOptions.map(item => (
<SelectItem key={item.value} value={item.value}>
<SelectItemText>{item.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="mb-8">
<div className={titleClassName}>{t('language.timezone', { ns: 'common' })}</div>
<SimpleSelect defaultValue={userProfile.timezone} items={timezones} onSelect={item => handleSelectTimezone(item)} disabled={editing} notClearable={true} />
<Select
value={selectedTimezone ? String(selectedTimezone.value) : null}
disabled={editing}
onValueChange={(nextValue) => {
if (!nextValue)
return
const nextItem = timezones.find(item => String(item.value) === nextValue)
if (nextItem)
handleSelectTimezone(nextItem)
}}
>
<SelectTrigger size="large">
{selectedTimezone?.name ?? t('placeholder.select', { ns: 'common' })}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{timezones.map(item => (
<SelectItem key={item.value} value={String(item.value)}>
<SelectItemText>{item.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)
@@ -13,10 +13,10 @@ import type {
NodeOutPutVar,
} from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { useCallback, useState } from 'react'
import Radio from '@/app/components/base/radio'
import RadioE from '@/app/components/base/radio/ui'
import { SimpleSelect } from '@/app/components/base/select'
import Tooltip from '@/app/components/base/tooltip'
import AppSelector from '@/app/components/plugins/plugin-detail-panel/app-selector'
import ModelParameterModal from '@/app/components/plugins/plugin-detail-panel/model-selector'
@@ -253,6 +253,17 @@ function Form<
if (show_on.length && !show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value))
return null
const filteredOptions = options.filter((option) => {
if (option.show_on.length)
return option.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value)
return true
}).map(option => ({ value: option.value, name: option.label[language] || option.label.en_US }))
const currentValue = (isShowDefaultValue && ((value[variable] as string) === '' || value[variable] === undefined || value[variable] === null))
? formSchema.default
: value[variable]
const selectedOption = filteredOptions.find(option => option.value === currentValue)
return (
<div key={variable} className={cn(itemClassName, 'py-3')}>
<div className={cn(fieldLabelClassName, 'flex items-center py-2 system-sm-semibold text-text-secondary')}>
@@ -263,20 +274,27 @@ function Form<
)}
{tooltipContent}
</div>
<SimpleSelect
wrapperClassName="h-8"
className={cn(inputClassName)}
<Select
disabled={readonly}
defaultValue={(isShowDefaultValue && ((value[variable] as string) === '' || value[variable] === undefined || value[variable] === null)) ? formSchema.default : value[variable]}
items={options.filter((option) => {
if (option.show_on.length)
return option.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value)
return true
}).map(option => ({ value: option.value, name: option.label[language] || option.label.en_US }))}
onSelect={item => handleFormChange(variable, item.value as string)}
placeholder={placeholder?.[language] || placeholder?.en_US}
/>
value={selectedOption?.value ?? null}
onValueChange={(nextValue) => {
if (!nextValue)
return
handleFormChange(variable, nextValue)
}}
>
<SelectTrigger size="medium" className={cn(inputClassName)}>
{selectedOption?.name ?? placeholder?.[language] ?? placeholder?.en_US}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{filteredOptions.map(option => (
<SelectItem key={option.value} value={option.value}>
<SelectItemText>{option.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
{fieldMoreInfo?.(formSchema)}
{validating && changeKey === variable && <ValidatingTip />}
</div>
@@ -10,6 +10,7 @@ import type {
} from '../../declarations'
import type { NodeOutPutVar } from '@/app/components/workflow/types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { FormTypeEnum } from '../../declarations'
import Form from '../Form'
@@ -288,7 +289,8 @@ describe('Form', () => {
expect(onChange).toHaveBeenCalledTimes(1)
})
it('should render select and checkbox fields and update checkbox value', () => {
it('should render select and checkbox fields and update checkbox value', async () => {
const user = userEvent.setup()
const formSchemas: AnyFormSchema[] = [
createSelectSchema({
variable: 'model',
@@ -339,10 +341,10 @@ describe('Form', () => {
)
expect(screen.getByText('Select A'))!.toBeInTheDocument()
fireEvent.click(screen.getByText('Select A'))
fireEvent.click(screen.getByText('Select B'))
await user.click(screen.getByRole('combobox'))
await user.click(screen.getByRole('option', { name: 'Select B' }))
fireEvent.click(screen.getByText('True'))
await user.click(screen.getByText('True'))
expect(onChange).toHaveBeenCalledWith({ model: 'b', agree: false, toggle: 'on' })
expect(onChange).toHaveBeenCalledWith({ model: 'a', agree: true, toggle: 'on' })
@@ -989,9 +991,8 @@ describe('Form', () => {
/>,
)
const selectTrigger = screen.getByRole('button', { name: 'Select A' })
fireEvent.click(selectTrigger)
expect(screen.queryByText('Select B')).not.toBeInTheDocument()
const selectTrigger = screen.getByRole('combobox')
expect(selectTrigger).toBeDisabled()
})
// isShowDefaultValue=false: value used even if empty
@@ -1899,7 +1900,8 @@ describe('Form', () => {
expect(screen.getByText('Select Tools'))!.toBeInTheDocument()
})
it('should show ValidatingTip for select field being validated', () => {
it('should show ValidatingTip for select field being validated', async () => {
const user = userEvent.setup()
// Arrange: value 'a' is pre-selected so 'Select A' text appears in the trigger button
const formSchemas: AnyFormSchema[] = [
createSelectSchema({
@@ -1923,14 +1925,14 @@ describe('Form', () => {
/>,
)
// First click opens the dropdown (Select A is the trigger button text)
fireEvent.click(screen.getByText('Select A'))
// Then click on 'Select B' option in the open dropdown
fireEvent.click(screen.getByText('Select B'))
await user.click(screen.getByRole('combobox'))
await user.click(screen.getByRole('option', { name: 'Select B' }))
// Assert: ValidatingTip shows for the select field
// Assert: ValidatingTip shows for the select field
expect(screen.getByText('Validating...'))!.toBeInTheDocument()
await waitFor(() => {
expect(screen.getByText('Validating...'))!.toBeInTheDocument()
})
})
it('should show ValidatingTip for toolSelector field being validated', () => {
@@ -295,15 +295,15 @@ const ModelModal: FC<ModelModalProps> = ({
<Dialog open onOpenChange={handleOpenChange}>
<DialogContent
backdropProps={{ forceRender: true }}
className="w-[640px] max-w-[640px] overflow-hidden p-0"
className="flex w-[640px] max-w-[640px] flex-col overflow-hidden p-0"
>
<DialogCloseButton className="top-5 right-5 h-8 w-8" />
<div className="p-6 pb-3">
<div className="shrink-0 p-6 pb-3">
{modalTitle}
{modalDesc}
{modalModel}
</div>
<div className="max-h-[calc(100vh-320px)] overflow-y-auto px-6 py-3">
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-3">
{
mode === ModelModalModeEnum.configCustomModel && (
<AuthForm
@@ -365,7 +365,7 @@ const ModelModal: FC<ModelModalProps> = ({
)
}
</div>
<div className="flex justify-between p-6 pt-5">
<div className="flex shrink-0 justify-between p-6 pt-5">
{
(provider.help && (provider.help.title || provider.help.url))
? (
@@ -410,7 +410,7 @@ const ModelModal: FC<ModelModalProps> = ({
</div>
{
(mode === ModelModalModeEnum.configCustomModel || mode === ModelModalModeEnum.configProviderCredential) && (
<div className="border-t-[0.5px] border-t-divider-regular">
<div className="shrink-0 border-t-[0.5px] border-t-divider-regular">
<div className="flex items-center justify-center rounded-b-2xl bg-background-section-burn py-3 text-xs text-text-tertiary">
<Lock01 className="mr-1 h-3 w-3 text-text-tertiary" />
{t('modelProvider.encrypted.front', { ns: 'common' })}
@@ -1,7 +1,6 @@
'use client'
import type { PluginDeclaration, UpdateFromGitHubPayload } from '../../types'
import type { Item } from '@/app/components/base/select'
import type { InstallState } from '@/app/components/plugins/types'
import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
@@ -22,6 +21,11 @@ import SetURL from './steps/setURL'
const i18nPrefix = 'installFromGitHub'
type SelectOption = {
value: string | number
name: string
}
type InstallFromGitHubProps = {
updatePayload?: UpdateFromGitHubPayload
onClose: () => void
@@ -53,12 +57,12 @@ const InstallFromGitHub: React.FC<InstallFromGitHubProps> = ({ updatePayload, on
const [manifest, setManifest] = useState<PluginDeclaration | null>(null)
const [errorMsg, setErrorMsg] = useState<string | null>(null)
const versions: Item[] = state.releases.map(release => ({
const versions: SelectOption[] = state.releases.map(release => ({
value: release.tag_name,
name: release.tag_name,
}))
const packages: Item[] = state.selectedVersion
const packages: SelectOption[] = state.selectedVersion
? (state.releases
.find(release => release.tag_name === state.selectedVersion)
?.assets
@@ -198,10 +202,10 @@ const InstallFromGitHub: React.FC<InstallFromGitHubProps> = ({ updatePayload, on
repoUrl={state.repoUrl}
selectedVersion={state.selectedVersion}
versions={versions}
onSelectVersion={item => setState(prevState => ({ ...prevState, selectedVersion: item.value as string }))}
onSelectVersion={item => setState(prevState => ({ ...prevState, selectedVersion: String(item.value) }))}
selectedPackage={state.selectedPackage}
packages={packages}
onSelectPackage={item => setState(prevState => ({ ...prevState, selectedPackage: item.value as string }))}
onSelectPackage={item => setState(prevState => ({ ...prevState, selectedPackage: String(item.value) }))}
onUploaded={handleUploaded}
onFailed={handleUploadFail}
onBack={handleBack}
@@ -1,10 +1,14 @@
import type { PluginDeclaration, UpdateFromGitHubPayload } from '../../../../types'
import type { Item } from '@/app/components/base/select'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginCategoryEnum } from '../../../../types'
import SelectPackage from '../selectPackage'
type SelectOption = {
value: string | number
name: string
}
// Mock upload helper from hooks module
const { mockHandleUpload } = vi.hoisted(() => ({
mockHandleUpload: vi.fn(),
@@ -17,6 +21,53 @@ vi.mock('../../../hooks', async (importOriginal) => {
}
})
vi.mock('@langgenius/dify-ui/select', async () => {
const React = await import('react')
const SelectContext = React.createContext<{
readOnly?: boolean
onValueChange?: (value: string) => void
}>({})
return {
Select: ({ children, readOnly, onValueChange }: {
children: React.ReactNode
readOnly?: boolean
onValueChange?: (value: string) => void
}) => (
<SelectContext.Provider value={{ readOnly, onValueChange }}>
<div>{children}</div>
</SelectContext.Provider>
),
SelectTrigger: ({ children }: { children: React.ReactNode }) => {
const context = React.useContext(SelectContext)
return (
<div>
<div data-testid="select-trigger" className={context.readOnly ? 'cursor-not-allowed' : 'cursor-pointer'}>
{children}
</div>
<button data-testid="select-empty" type="button" onClick={() => context.onValueChange?.('')}>
empty select value
</button>
<button data-testid="select-invalid" type="button" onClick={() => context.onValueChange?.('__missing__')}>
invalid select value
</button>
</div>
)
},
SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectItem: ({ children, value }: { children: React.ReactNode, value: string }) => {
const context = React.useContext(SelectContext)
return (
<button type="button" onClick={() => context.onValueChange?.(value)}>
{children}
</button>
)
},
SelectItemText: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SelectItemIndicator: () => null,
}
})
// Factory functions
const createMockManifest = (): PluginDeclaration => ({
plugin_unique_identifier: 'test-uid',
@@ -39,12 +90,12 @@ const createMockManifest = (): PluginDeclaration => ({
trigger: {} as PluginDeclaration['trigger'],
})
const createVersions = (): Item[] => [
const createVersions = (): SelectOption[] => [
{ value: 'v1.0.0', name: 'v1.0.0' },
{ value: 'v0.9.0', name: 'v0.9.0' },
]
const createPackages = (): Item[] => [
const createPackages = (): SelectOption[] => [
{ value: 'plugin.zip', name: 'plugin.zip' },
{ value: 'plugin.tar.gz', name: 'plugin.tar.gz' },
]
@@ -64,11 +115,11 @@ type TestProps = {
updatePayload?: UpdateFromGitHubPayload
repoUrl?: string
selectedVersion?: string
versions?: Item[]
onSelectVersion?: (item: Item) => void
versions?: SelectOption[]
onSelectVersion?: (item: SelectOption) => void
selectedPackage?: string
packages?: Item[]
onSelectPackage?: (item: Item) => void
packages?: SelectOption[]
onSelectPackage?: (item: SelectOption) => void
onUploaded?: (result: { uniqueIdentifier: string, manifest: PluginDeclaration }) => void
onFailed?: (errorMsg: string) => void
onBack?: () => void
@@ -80,10 +131,10 @@ describe('SelectPackage', () => {
repoUrl: 'https://github.com/owner/repo',
selectedVersion: '',
versions: createVersions(),
onSelectVersion: vi.fn() as (item: Item) => void,
onSelectVersion: vi.fn() as (item: SelectOption) => void,
selectedPackage: '',
packages: createPackages(),
onSelectPackage: vi.fn() as (item: Item) => void,
onSelectPackage: vi.fn() as (item: SelectOption) => void,
onUploaded: vi.fn() as (result: { uniqueIdentifier: string, manifest: PluginDeclaration }) => void,
onFailed: vi.fn() as (errorMsg: string) => void,
onBack: vi.fn() as () => void,
@@ -96,6 +147,14 @@ describe('SelectPackage', () => {
return render(<SelectPackage {...(props as Parameters<typeof SelectPackage>[0])} />)
}
const getSection = (label: string): HTMLElement => {
const labelElement = screen.getByText(label)
const section = labelElement.closest('label')?.nextElementSibling
if (!(section instanceof HTMLElement))
throw new Error(`Missing section for ${label}`)
return section
}
beforeEach(() => {
vi.clearAllMocks()
mockHandleUpload.mockReset()
@@ -144,13 +203,13 @@ describe('SelectPackage', () => {
renderSelectPackage({ selectedVersion: 'v1.0.0' })
// PortalSelect should display the selected version
expect(screen.getByText('v1.0.0')).toBeInTheDocument()
expect(screen.getAllByText('v1.0.0').length).toBeGreaterThan(0)
})
it('should pass selectedPackage to PortalSelect', () => {
renderSelectPackage({ selectedPackage: 'plugin.zip' })
expect(screen.getByText('plugin.zip')).toBeInTheDocument()
expect(screen.getAllByText('plugin.zip').length).toBeGreaterThan(0)
})
it('should show installed version badge when updatePayload version differs', () => {
@@ -231,6 +290,54 @@ describe('SelectPackage', () => {
expect(mockHandleUpload).not.toHaveBeenCalled()
})
it('should ignore empty and unknown version selections', () => {
const onSelectVersion = vi.fn()
renderSelectPackage({ onSelectVersion })
const section = getSection('plugin.installFromGitHub.selectVersion')
fireEvent.click(within(section).getByTestId('select-empty'))
fireEvent.click(within(section).getByTestId('select-invalid'))
expect(onSelectVersion).not.toHaveBeenCalled()
})
it('should select a valid version option', () => {
const onSelectVersion = vi.fn()
renderSelectPackage({ onSelectVersion })
const section = getSection('plugin.installFromGitHub.selectVersion')
fireEvent.click(within(section).getByRole('button', { name: 'v0.9.0' }))
expect(onSelectVersion).toHaveBeenCalledWith({ value: 'v0.9.0', name: 'v0.9.0' })
})
it('should ignore empty and unknown package selections', () => {
const onSelectPackage = vi.fn()
renderSelectPackage({
selectedVersion: 'v1.0.0',
onSelectPackage,
})
const section = getSection('plugin.installFromGitHub.selectPackage')
fireEvent.click(within(section).getByTestId('select-empty'))
fireEvent.click(within(section).getByTestId('select-invalid'))
expect(onSelectPackage).not.toHaveBeenCalled()
})
it('should select a valid package option', () => {
const onSelectPackage = vi.fn()
renderSelectPackage({
selectedVersion: 'v1.0.0',
onSelectPackage,
})
const section = getSection('plugin.installFromGitHub.selectPackage')
fireEvent.click(within(section).getByRole('button', { name: 'plugin.tar.gz' }))
expect(onSelectPackage).toHaveBeenCalledWith({ value: 'plugin.tar.gz', name: 'plugin.tar.gz' })
})
})
// ================================
@@ -424,8 +531,7 @@ describe('SelectPackage', () => {
renderSelectPackage({ selectedVersion: '' })
// When no version is selected, package select should be readonly
// This is tested by verifying the component renders correctly
const trigger = screen.getByText('plugin.installFromGitHub.selectPackagePlaceholder').closest('div')
const trigger = screen.getAllByTestId('select-trigger')[1]
expect(trigger).toHaveClass('cursor-not-allowed')
})
@@ -433,7 +539,7 @@ describe('SelectPackage', () => {
renderSelectPackage({ selectedVersion: 'v1.0.0' })
// When version is selected, package select should be active
const trigger = screen.getByText('plugin.installFromGitHub.selectPackagePlaceholder').closest('div')
const trigger = screen.getAllByTestId('select-trigger')[1]
expect(trigger).toHaveClass('cursor-pointer')
})
})
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import SetURL from '../setURL'
@@ -53,6 +53,15 @@ describe('SetURL', () => {
const input = screen.getByLabelText('plugin.installFromGitHub.gitHubRepo')
expect(input).toBeInTheDocument()
})
it('should auto-focus the input on mount', async () => {
render(<SetURL {...defaultProps} />)
const input = screen.getByRole('textbox')
await waitFor(() => {
expect(input).toHaveFocus()
})
})
})
// ================================
@@ -1,24 +1,29 @@
'use client'
import type { PluginDeclaration, UpdateFromGitHubPayload } from '../../../types'
import type { Item } from '@/app/components/base/select'
import { Button } from '@langgenius/dify-ui/button'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { PortalSelect } from '@/app/components/base/select'
import Badge from '@/app/components/base/badge'
import { handleUpload } from '../../hooks'
const i18nPrefix = 'installFromGitHub'
type SelectOption = {
value: string | number
name: string
}
type SelectPackageProps = {
updatePayload: UpdateFromGitHubPayload
repoUrl: string
selectedVersion: string
versions: Item[]
onSelectVersion: (item: Item) => void
versions: SelectOption[]
onSelectVersion: (item: SelectOption) => void
selectedPackage: string
packages: Item[]
onSelectPackage: (item: Item) => void
packages: SelectOption[]
onSelectPackage: (item: SelectOption) => void
onUploaded: (result: {
uniqueIdentifier: string
manifest: PluginDeclaration
@@ -43,6 +48,8 @@ const SelectPackage: React.FC<SelectPackageProps> = ({
const { t } = useTranslation()
const isEdit = Boolean(updatePayload)
const [isUploading, setIsUploading] = React.useState(false)
const selectedVersionOption = versions.find(item => String(item.value) === selectedVersion) ?? null
const selectedPackageOption = packages.find(item => String(item.value) === selectedPackage) ?? null
const handleUploadPackage = async () => {
if (isUploading)
@@ -76,30 +83,73 @@ const SelectPackage: React.FC<SelectPackageProps> = ({
>
<span className="system-sm-semibold">{t(`${i18nPrefix}.selectVersion`, { ns: 'plugin' })}</span>
</label>
<PortalSelect
value={selectedVersion}
onSelect={onSelectVersion}
items={versions}
installedValue={updatePayload?.originalPackageInfo.version}
placeholder={t(`${i18nPrefix}.selectVersionPlaceholder`, { ns: 'plugin' }) || ''}
popupClassName="w-[512px] z-1001"
triggerClassName="text-components-input-text-filled"
/>
<Select
value={selectedVersionOption ? String(selectedVersionOption.value) : null}
onValueChange={(value) => {
if (!value)
return
const selectedItem = versions.find(item => String(item.value) === value)
if (selectedItem)
onSelectVersion(selectedItem)
}}
>
<SelectTrigger className="h-9 text-components-input-text-filled">
<div className="flex items-center justify-between gap-2">
<span className="truncate">
{selectedVersionOption?.name ?? t(`${i18nPrefix}.selectVersionPlaceholder`, { ns: 'plugin' }) ?? ''}
</span>
{!!(updatePayload?.originalPackageInfo.version && selectedVersionOption && selectedVersionOption.value !== updatePayload.originalPackageInfo.version) && (
<Badge>
{updatePayload.originalPackageInfo.version}
{' '}
{'->'}
{' '}
{selectedVersionOption.value}
</Badge>
)}
</div>
</SelectTrigger>
<SelectContent popupClassName="z-1001 w-[512px]">
{versions.map(item => (
<SelectItem key={item.value} value={String(item.value)}>
<SelectItemText>{item.name}</SelectItemText>
{item.value === updatePayload?.originalPackageInfo.version && (
<Badge uppercase={true} className="ml-1 shrink-0">INSTALLED</Badge>
)}
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
<label
htmlFor="package"
className="flex flex-col items-start justify-center self-stretch text-text-secondary"
>
<span className="system-sm-semibold">{t(`${i18nPrefix}.selectPackage`, { ns: 'plugin' })}</span>
</label>
<PortalSelect
value={selectedPackage}
onSelect={onSelectPackage}
items={packages}
readonly={!selectedVersion}
placeholder={t(`${i18nPrefix}.selectPackagePlaceholder`, { ns: 'plugin' }) || ''}
popupClassName="w-[512px] z-1001"
triggerClassName="text-components-input-text-filled"
/>
<Select
value={selectedPackageOption ? String(selectedPackageOption.value) : null}
readOnly={!selectedVersion}
onValueChange={(value) => {
if (!value)
return
const selectedItem = packages.find(item => String(item.value) === value)
if (selectedItem)
onSelectPackage(selectedItem)
}}
>
<SelectTrigger className="h-9 text-components-input-text-filled">
{selectedPackageOption?.name ?? t(`${i18nPrefix}.selectPackagePlaceholder`, { ns: 'plugin' }) ?? ''}
</SelectTrigger>
<SelectContent popupClassName="z-1001 w-[512px]">
{packages.map(item => (
<SelectItem key={item.value} value={String(item.value)}>
<SelectItemText>{item.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
<div className="mt-4 flex items-center justify-end gap-2 self-stretch">
{!isEdit
&& (
@@ -13,6 +13,18 @@ type SetURLProps = {
const SetURL: React.FC<SetURLProps> = ({ repoUrl, onChange, onNext, onCancel }) => {
const { t } = useTranslation()
const inputRef = React.useRef<HTMLInputElement>(null)
// Focus the input after the dropdown's focus-return animation settles.
// Using rAF avoids racing the DropdownMenu FloatingFocusManager that returns
// focus to the trigger on close.
React.useEffect(() => {
const frame = requestAnimationFrame(() => {
inputRef.current?.focus()
})
return () => cancelAnimationFrame(frame)
}, [])
return (
<>
<label
@@ -22,6 +34,7 @@ const SetURL: React.FC<SetURLProps> = ({ repoUrl, onChange, onNext, onCancel })
<span className="system-sm-semibold">{t('installFromGitHub.gitHubRepo', { ns: 'plugin' })}</span>
</label>
<input
ref={inputRef}
type="url"
id="repoUrl"
name="repoUrl"
@@ -6,38 +6,82 @@ import AppInputsForm from '../app-inputs-form'
vi.mock('@/app/components/base/file-uploader', () => ({
FileUploaderInAttachmentWrapper: ({
onChange,
value,
}: {
onChange: (files: Array<Record<string, unknown>>) => void
}) => (
<button data-testid="file-uploader" onClick={() => onChange([{ id: 'file-1', name: 'demo.png' }])}>
Upload
</button>
),
}))
vi.mock('@/app/components/base/select', () => ({
PortalSelect: ({
items,
onSelect,
}: {
items: Array<{ value: string, name: string }>
onSelect: (item: { value: string }) => void
value: Array<Record<string, unknown>>
}) => (
<div>
{items.map(item => (
<button key={item.value} data-testid={`select-${item.value}`} onClick={() => onSelect(item)}>
{item.name}
</button>
))}
<span data-testid="file-uploader-value">{JSON.stringify(value)}</span>
<button data-testid="file-uploader" onClick={() => onChange([{ id: 'file-1', name: 'demo.png' }])}>
Upload
</button>
<button data-testid="file-uploader-empty" onClick={() => onChange([])}>
Upload Empty
</button>
</div>
),
}))
vi.mock('@langgenius/dify-ui/select', async () => {
const React = await import('react')
const SelectContext = React.createContext<{
onValueChange?: (value: string) => void
}>({})
return {
Select: ({ children, onValueChange }: {
children: React.ReactNode
onValueChange?: (value: string) => void
}) => (
<SelectContext.Provider value={{ onValueChange }}>
<div>{children}</div>
</SelectContext.Provider>
),
SelectTrigger: ({ children }: { children: React.ReactNode }) => {
const context = React.useContext(SelectContext)
return (
<div>
<button type="button">{children}</button>
<button data-testid="select-empty" type="button" onClick={() => context.onValueChange?.('')}>
Empty Select
</button>
</div>
)
},
SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectItem: ({ children, value }: { children: React.ReactNode, value: string }) => {
const context = React.useContext(SelectContext)
return (
<button key={value} data-testid={`select-${value}`} type="button" onClick={() => context.onValueChange?.(value)}>
{children}
</button>
)
},
SelectItemText: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SelectItemIndicator: () => null,
}
})
describe('AppInputsForm', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should return null when no form items are provided', () => {
const { container } = render(
<AppInputsForm
inputsForms={[]}
inputs={{}}
inputsRef={{ current: {} }}
onFormChange={vi.fn()}
/>,
)
expect(container.firstChild).toBeNull()
})
it('should update text input values', () => {
const onFormChange = vi.fn()
const inputsRef = { current: { question: '' } }
@@ -58,6 +102,26 @@ describe('AppInputsForm', () => {
expect(onFormChange).toHaveBeenCalledWith({ question: 'hello' })
})
it('should update number input values', () => {
const onFormChange = vi.fn()
const inputsRef = { current: { count: '' } }
render(
<AppInputsForm
inputsForms={[{ variable: 'count', label: 'Count', type: InputVarType.number, required: false }]}
inputs={{ count: '' }}
inputsRef={inputsRef}
onFormChange={onFormChange}
/>,
)
fireEvent.change(screen.getByPlaceholderText('Count'), {
target: { value: '42' },
})
expect(onFormChange).toHaveBeenCalledWith({ count: '42' })
})
it('should update select values', () => {
const onFormChange = vi.fn()
const inputsRef = { current: { tone: '' } }
@@ -76,6 +140,25 @@ describe('AppInputsForm', () => {
expect(onFormChange).toHaveBeenCalledWith({ tone: 'formal' })
})
it('should ignore empty select values and render the placeholder when there is no current selection', () => {
const onFormChange = vi.fn()
const inputsRef = { current: { tone: '' } }
render(
<AppInputsForm
inputsForms={[{ variable: 'tone', label: 'Tone', type: InputVarType.select, options: ['friendly', 'formal'], required: false }]}
inputs={{ tone: '' }}
inputsRef={inputsRef}
onFormChange={onFormChange}
/>,
)
expect(screen.getAllByText('Tone').length).toBeGreaterThan(0)
fireEvent.click(screen.getByTestId('select-empty'))
expect(onFormChange).not.toHaveBeenCalled()
})
it('should update uploaded single file values', () => {
const onFormChange = vi.fn()
const inputsRef = { current: { attachment: null } }
@@ -103,4 +186,83 @@ describe('AppInputsForm', () => {
attachment: { id: 'file-1', name: 'demo.png' },
})
})
it('should update paragraph fields and preserve sibling input values', () => {
const onFormChange = vi.fn()
const inputsRef = { current: { description: 'old', topic: 'existing' } }
render(
<AppInputsForm
inputsForms={[{ variable: 'description', label: 'Description', type: InputVarType.paragraph, required: false }]}
inputs={{ description: '' }}
inputsRef={inputsRef}
onFormChange={onFormChange}
/>,
)
fireEvent.change(screen.getByPlaceholderText('Description'), {
target: { value: 'updated paragraph' },
})
expect(onFormChange).toHaveBeenCalledWith({
description: 'updated paragraph',
topic: 'existing',
})
})
it('should keep multi-file values and forward empty multi-file uploads', () => {
const onFormChange = vi.fn()
const existingFiles = [{ id: 'existing-file', name: 'existing.png' }]
render(
<AppInputsForm
inputsForms={[{
variable: 'files',
label: 'Files',
type: InputVarType.multiFiles,
required: true,
max_length: 3,
allowed_file_types: ['image'],
allowed_file_extensions: ['.png'],
allowed_file_upload_methods: ['local_file'],
}]}
inputs={{ files: existingFiles }}
inputsRef={{ current: { files: existingFiles } }}
onFormChange={onFormChange}
/>,
)
expect(screen.getByTestId('file-uploader-value')).toHaveTextContent('"existing-file"')
expect(screen.queryByText('workflow.panel.optional')).not.toBeInTheDocument()
fireEvent.click(screen.getByTestId('file-uploader-empty'))
expect(onFormChange).toHaveBeenCalledWith({ files: [] })
})
it('should preserve existing single-file values and forward empty single-file uploads as undefined', () => {
const onFormChange = vi.fn()
const existingFile = { id: 'existing-file', name: 'existing.png' }
render(
<AppInputsForm
inputsForms={[{
variable: 'attachment',
label: 'Attachment',
type: InputVarType.singleFile,
required: false,
allowed_file_types: ['image'],
allowed_file_extensions: ['.png'],
allowed_file_upload_methods: ['local_file'],
}]}
inputs={{ attachment: existingFile }}
inputsRef={{ current: { attachment: existingFile } }}
onFormChange={onFormChange}
/>,
)
expect(screen.getByTestId('file-uploader-value')).toHaveTextContent('"existing-file"')
fireEvent.click(screen.getByTestId('file-uploader-empty'))
expect(onFormChange).toHaveBeenCalledWith({ attachment: undefined })
})
})
@@ -244,28 +244,42 @@ vi.mock('@/app/components/base/file-uploader', () => ({
),
}))
// Mock PortalSelect for testing select field interactions
vi.mock('@/app/components/base/select', () => ({
PortalSelect: ({ onSelect, value, placeholder, items }: {
onSelect: (item: { value: string }) => void
value: string
placeholder: string
items: Array<{ value: string, name: string }>
}) => (
<div data-testid="portal-select">
<span data-testid="select-value">{value || placeholder}</span>
{items?.map((item: { value: string, name: string }) => (
// Mock Select for testing select field interactions
vi.mock('@langgenius/dify-ui/select', async () => {
const React = await import('react')
const SelectContext = React.createContext<{
onValueChange?: (value: string) => void
}>({})
return {
Select: ({ children, onValueChange }: {
children: React.ReactNode
onValueChange?: (value: string) => void
}) => (
<SelectContext.Provider value={{ onValueChange }}>
<div data-testid="portal-select">{children}</div>
</SelectContext.Provider>
),
SelectTrigger: ({ children }: { children: React.ReactNode }) => (
<span data-testid="select-value">{children}</span>
),
SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectItem: ({ children, value }: { children: React.ReactNode, value: string }) => {
const context = React.useContext(SelectContext)
return (
<button
key={item.value}
data-testid={`select-option-${item.value}`}
onClick={() => onSelect(item)}
key={value}
data-testid={`select-option-${value}`}
onClick={() => context.onValueChange?.(value)}
>
{item.name}
{children}
</button>
))}
</div>
),
}))
)
},
SelectItemText: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SelectItemIndicator: () => null,
}
})
// Mock Input component with onClear support
vi.mock('@/app/components/base/input', () => ({
@@ -1,8 +1,8 @@
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
import Input from '@/app/components/base/input'
import { PortalSelect } from '@/app/components/base/select'
import Textarea from '@/app/components/base/textarea'
import { InputVarType } from '@/app/components/workflow/types'
@@ -62,14 +62,23 @@ const AppInputsForm = ({
)
}
if (form.type === InputVarType.select) {
const selectOptions: Array<{ value: string, name: string }> = options.map((option: string) => ({ value: option, name: option }))
const selectedOption = selectOptions.find(option => option.value === (inputs[variable] || '')) ?? null
return (
<PortalSelect
popupClassName="w-[356px] z-1050"
value={inputs[variable] || ''}
items={options.map((option: string) => ({ value: option, name: option }))}
onSelect={item => handleFormChange(variable, item.value as string)}
placeholder={label}
/>
<Select value={selectedOption?.value ?? null} onValueChange={value => value && handleFormChange(variable, value)}>
<SelectTrigger className="w-full">
{selectedOption?.name ?? label}
</SelectTrigger>
<SelectContent popupClassName="z-1050 w-(--anchor-width)">
{selectOptions.map(option => (
<SelectItem key={option.value} value={option.value}>
<SelectItemText>{option.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)
}
if (form.type === InputVarType.singleFile) {
@@ -126,36 +126,77 @@ vi.mock('../oauth-client', () => ({
),
}))
vi.mock('@/app/components/base/select/custom', () => ({
default: ({ options, value, onChange, CustomTrigger, CustomOption, containerProps }: {
options: Array<{ value: string, label: string, show: boolean, extra?: React.ReactNode, tag?: React.ReactNode }>
value: string
onChange: (value: string) => void
CustomTrigger: () => React.ReactNode
CustomOption: (option: { label: string, tag?: React.ReactNode, extra?: React.ReactNode }) => React.ReactNode
containerProps?: { open?: boolean }
}) => (
<div
data-testid="custom-select"
data-value={value}
data-options-count={options?.length || 0}
data-container-open={containerProps?.open}
>
<div data-testid="custom-trigger">{CustomTrigger()}</div>
<div data-testid="options-container">
{options?.map(option => (
vi.mock('@langgenius/dify-ui/select', async () => {
const React = await import('react')
const SelectContext = React.createContext<{
onValueChange?: (value: string) => void
}>({})
const countOptions = (children: React.ReactNode): number => {
return React.Children.toArray(children).reduce<number>((count, child) => {
if (!React.isValidElement<{ children?: React.ReactNode }>(child))
return count
return count + React.Children.toArray(child.props.children).filter((nestedChild) => {
return React.isValidElement<{ value?: string }>(nestedChild) && 'value' in nestedChild.props
}).length
}, 0)
}
return {
Select: ({
children,
value,
open,
onValueChange,
}: {
children: React.ReactNode
value: string | null
open?: boolean
onValueChange?: (value: string) => void
}) => {
const currentValue = value ?? DEFAULT_METHOD
const optionsCount = countOptions(children)
const containerOpen
= currentValue === DEFAULT_METHOD || (currentValue === SupportedCreationMethods.OAUTH && optionsCount === 1)
? undefined
: String(open ?? false)
return (
<SelectContext.Provider value={{ onValueChange }}>
<div
key={option.value}
data-testid={`option-${option.value}`}
onClick={() => onChange(option.value)}
data-testid="custom-select"
data-value={currentValue}
data-options-count={optionsCount}
data-container-open={containerOpen}
>
{CustomOption(option)}
{children}
</div>
))}
</div>
</div>
),
}))
</SelectContext.Provider>
)
},
SelectTrigger: ({ children, className }: { children: React.ReactNode, render?: React.ReactNode, className?: string }) => {
return <div data-testid="custom-trigger" className={className}>{children}</div>
},
SelectContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="options-container">{children}</div>
),
SelectItem: ({ children, value }: { children: React.ReactNode, value: string }) => {
const context = React.useContext(SelectContext)
return (
<div
data-testid={`option-${value}`}
onClick={() => context.onValueChange?.(value)}
>
{children}
</div>
)
},
SelectItemText: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SelectItemIndicator: () => null,
}
})
const createProviderInfo = (overrides: Partial<TriggerProviderApiEntity> = {}): TriggerProviderApiEntity => ({
author: 'test-author',
@@ -1,7 +1,7 @@
import type { Option } from '@/app/components/base/select/custom'
import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectTrigger } from '@langgenius/dify-ui/select'
import { toast } from '@langgenius/dify-ui/toast'
import { RiAddLine, RiEqualizer2Line } from '@remixicon/react'
import { useBoolean } from 'ahooks'
@@ -9,7 +9,6 @@ import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ActionButton, ActionButtonState } from '@/app/components/base/action-button'
import Badge from '@/app/components/base/badge'
import CustomSelect from '@/app/components/base/select/custom'
import Tooltip from '@/app/components/base/tooltip'
import { openOAuthPopup } from '@/hooks/use-oauth'
import { useInitiateTriggerOAuth, useTriggerOAuthConfig, useTriggerProviderInfo } from '@/service/use-triggers'
@@ -28,6 +27,14 @@ type Props = {
const MAX_COUNT = 10
type CreateTypeOption = {
value: SupportedCreationMethods
label: string
show: boolean
extra?: React.ReactNode
tag?: React.ReactNode
}
export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BUTTON, shape = 'square' }: Props) => {
const { t } = useTranslation()
const { subscriptions } = useSubscriptionList()
@@ -35,6 +42,7 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU
const [selectedCreateInfo, setSelectedCreateInfo] = useState<{ type: SupportedCreationMethods, builder?: TriggerSubscriptionBuilder } | null>(null)
const detail = usePluginStore(state => state.detail)
const [isMenuOpen, setIsMenuOpen] = useState(false)
const { data: providerInfo } = useTriggerProviderInfo(detail?.provider || '')
const supportedMethods = useMemo(() => providerInfo?.supported_creation_methods || [], [providerInfo?.supported_creation_methods])
@@ -63,7 +71,7 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU
showClientSettingsModal()
}, [showClientSettingsModal])
const allOptions = useMemo(() => {
const allOptions = useMemo<CreateTypeOption[]>(() => {
const showCustomBadge = oauthConfig?.custom_enabled && oauthConfig?.custom_configured
return [
@@ -99,6 +107,10 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU
},
]
}, [t, oauthConfig, supportedMethods, methodType, onClickClientSettings])
const visibleOptions = useMemo(() => {
return allOptions.filter(option => option.show)
}, [allOptions])
const shouldAllowSelect = methodType === DEFAULT_METHOD || (methodType === SupportedCreationMethods.OAUTH && supportedMethods.length === 1)
const onChooseCreateType = async (type: SupportedCreationMethods) => {
if (type === SupportedCreationMethods.OAUTH) {
@@ -145,24 +157,23 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU
return (
<>
<CustomSelect<Option & { show: boolean, extra?: React.ReactNode, tag?: React.ReactNode }>
options={allOptions.filter(option => option.show)}
value={methodType}
onChange={value => onChooseCreateType(value as SupportedCreationMethods)}
containerProps={{
open: (methodType === DEFAULT_METHOD || (methodType === SupportedCreationMethods.OAUTH && supportedMethods.length === 1)) ? undefined : false,
placement: 'bottom-start',
offset: 4,
triggerPopupSameWidth: buttonType === CreateButtonType.FULL_BUTTON,
<Select
value={methodType === DEFAULT_METHOD ? null : methodType}
open={shouldAllowSelect ? isMenuOpen : false}
onOpenChange={setIsMenuOpen}
onValueChange={(value) => {
if (!value)
return
setIsMenuOpen(false)
void onChooseCreateType(value as SupportedCreationMethods)
}}
triggerProps={{
className: cn('h-8 bg-transparent px-0 hover:bg-transparent', methodType !== DEFAULT_METHOD && supportedMethods.length > 1 && 'pointer-events-none', buttonType === CreateButtonType.FULL_BUTTON && 'grow'),
}}
popupProps={{
wrapperClassName: 'z-1000',
}}
CustomTrigger={() => {
return buttonType === CreateButtonType.FULL_BUTTON
>
<SelectTrigger
render={<div />}
nativeButton={false}
className={cn('h-8 border-0 bg-transparent px-0 hover:bg-transparent focus-visible:bg-transparent [&>*:last-child]:hidden', buttonType === CreateButtonType.FULL_BUTTON && 'grow')}
>
{buttonType === CreateButtonType.FULL_BUTTON
? (
<Button
variant="primary"
@@ -210,18 +221,21 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU
<RiAddLine className="size-4" />
</ActionButton>
</Tooltip>
)
}}
CustomOption={option => (
<>
<div className="mr-8 flex grow items-center gap-1 truncate px-1">
{option.label}
{option.tag}
</div>
{option.extra}
</>
)}
/>
)}
</SelectTrigger>
<SelectContent placement="bottom-start" sideOffset={4} popupClassName={cn('z-1000', buttonType === CreateButtonType.FULL_BUTTON && 'min-w-(--anchor-width)')}>
{visibleOptions.map(option => (
<SelectItem key={option.value} value={option.value}>
<div className="mr-8 flex grow items-center gap-1 truncate px-1">
{option.label}
{option.tag}
</div>
{option.extra}
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
{selectedCreateInfo && (
<CommonCreateModal
createType={selectedCreateInfo.type}
@@ -153,8 +153,8 @@ vi.mock('@/app/components/plugins/plugin-auth', () => ({
}))
// Portal components need mocking for controlled positioning in tests
vi.mock('@langgenius/dify-ui/popover', () => ({
Popover: ({
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({
children,
open,
}: {
@@ -165,7 +165,7 @@ vi.mock('@langgenius/dify-ui/popover', () => ({
{children}
</div>
),
PopoverTrigger: ({
PortalToFollowElemTrigger: ({
children,
render,
onClick,
@@ -178,7 +178,7 @@ vi.mock('@langgenius/dify-ui/popover', () => ({
{render ?? children}
</div>
),
PopoverContent: ({ children }: { children: ReactNode }) => (
PortalToFollowElemContent: ({ children }: { children: ReactNode }) => (
<div data-testid="portal-content">{children}</div>
),
}))
@@ -1,3 +1,4 @@
import type { ReactNode } from 'react'
import type { ToolFormSchema } from '@/app/components/tools/utils/to-form-schema'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -7,28 +8,42 @@ import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/typ
import ReasoningConfigForm from '../reasoning-config-form'
vi.mock('@/app/components/base/input', () => ({
default: ({ value, onChange }: { value?: string, onChange: (e: { target: { value: string } }) => void }) => (
<input data-testid="number-input" value={value} onChange={e => onChange({ target: { value: e.target.value } })} />
default: ({ value, onChange, placeholder }: { value?: string, onChange: (e: { target: { value: string } }) => void, placeholder?: string }) => (
<input data-testid="number-input" placeholder={placeholder} value={value} onChange={e => onChange({ target: { value: e.target.value } })} />
),
}))
vi.mock('@/app/components/base/select', () => ({
SimpleSelect: ({
items,
onSelect,
}: {
items: Array<{ value: string, name: string }>
onSelect: (item: { value: string }) => void
}) => (
<div>
{items.map(item => (
<button key={item.value} data-testid={`select-${item.value}`} onClick={() => onSelect({ value: item.value })}>
{item.name}
vi.mock('@langgenius/dify-ui/select', async () => {
const React = await import('react')
const SelectContext = React.createContext<{
onValueChange?: (value: string) => void
}>({})
return {
Select: ({ children, onValueChange }: {
children: React.ReactNode
onValueChange?: (value: string) => void
}) => (
<SelectContext.Provider value={{ onValueChange }}>
<div>{children}</div>
</SelectContext.Provider>
),
SelectTrigger: ({ children }: { children: React.ReactNode }) => (
<button type="button">{children}</button>
),
SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectItem: ({ children, value }: { children: React.ReactNode, value: string }) => {
const context = React.useContext(SelectContext)
return (
<button key={value} data-testid={`select-${value}`} type="button" onClick={() => context.onValueChange?.(value)}>
{children}
</button>
))}
</div>
),
}))
)
},
SelectItemText: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SelectItemIndicator: () => null,
}
})
vi.mock('@langgenius/dify-ui/switch', () => ({
Switch: ({ checked, onCheckedChange }: { checked: boolean, onCheckedChange: (checked: boolean) => void }) => (
@@ -47,9 +62,10 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', ()
}))
vi.mock('@/app/components/plugins/plugin-detail-panel/app-selector', () => ({
default: ({ onSelect }: { onSelect: (value: Record<string, unknown>) => void }) => (
default: ({ onSelect, scope }: { onSelect: (value: Record<string, unknown>) => void, scope?: string }) => (
<button
data-testid="app-selector"
data-scope={scope}
onClick={() => onSelect({ app_id: 'app-1', inputs: { topic: 'hello' } })}
>
Select App
@@ -66,10 +82,13 @@ vi.mock('@/app/components/plugins/plugin-detail-panel/model-selector', () => ({
}))
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
default: ({ onChange }: { onChange: (value: string) => void }) => (
<button data-testid="code-editor" onClick={() => onChange('{"foo":"bar"}')}>
Update JSON
</button>
default: ({ onChange, placeholder }: { onChange: (value: string) => void, placeholder?: ReactNode }) => (
<div>
<div data-testid="code-editor-placeholder">{placeholder}</div>
<button data-testid="code-editor" onClick={() => onChange('{"foo":"bar"}')}>
Update JSON
</button>
</div>
),
}))
@@ -90,8 +109,8 @@ vi.mock('@/app/components/workflow/nodes/_base/components/form-input-type-switch
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
default: ({ onChange }: { onChange: (value: string) => void }) => (
<button data-testid="var-picker" onClick={() => onChange(['node', 'field'] as unknown as string)}>
default: ({ onChange, value }: { onChange: (value: string) => void, value: string | string[] }) => (
<button data-testid="var-picker" data-value={JSON.stringify(value)} onClick={() => onChange(['node', 'field'] as unknown as string)}>
Pick Variable
</button>
),
@@ -337,4 +356,198 @@ describe('ReasoningConfigForm', () => {
},
})
})
it('should update number, boolean, and select fields', () => {
const onChange = vi.fn()
render(
<ReasoningConfigForm
value={{
count: {
auto: 0,
value: { type: VarKindType.constant, value: '' },
},
enabled: {
auto: 0,
value: { type: VarKindType.constant, value: false },
},
choice: {
auto: 0,
value: { type: VarKindType.constant, value: '' },
},
}}
onChange={onChange}
schemas={[
createSchema({
variable: 'count',
type: FormTypeEnum.textNumber,
label: { en_US: 'Count', zh_Hans: '数量' },
placeholder: { en_US: 'Enter count', zh_Hans: '输入数量' },
}),
createSchema({
variable: 'enabled',
type: FormTypeEnum.checkbox,
label: { en_US: 'Enabled', zh_Hans: '启用' },
}),
createSchema({
variable: 'choice',
type: FormTypeEnum.select,
label: { en_US: 'Choice', zh_Hans: '选择' },
placeholder: { en_US: 'Pick one', zh_Hans: '选择一个' },
options: [
{
value: 'alpha',
label: { en_US: 'Alpha', zh_Hans: 'Alpha' },
show_on: [],
},
{
value: 'beta',
label: { en_US: 'Beta', zh_Hans: 'Beta' },
show_on: [],
},
],
}),
]}
nodeOutputVars={[]}
availableNodes={[]}
nodeId="node-1"
/>,
)
expect(screen.getByText('Pick one')).toBeInTheDocument()
expect(screen.getByPlaceholderText('Enter count')).toBeInTheDocument()
fireEvent.change(screen.getByTestId('number-input'), { target: { value: '7' } })
fireEvent.click(screen.getByTestId('boolean-input'))
fireEvent.click(screen.getByTestId('select-beta'))
expect(onChange).toHaveBeenNthCalledWith(1, expect.objectContaining({
count: {
auto: 0,
value: { type: VarKindType.constant, value: '7' },
},
}))
expect(onChange).toHaveBeenNthCalledWith(2, expect.objectContaining({
enabled: {
auto: 0,
value: { type: VarKindType.constant, value: true },
},
}))
expect(onChange).toHaveBeenNthCalledWith(3, expect.objectContaining({
choice: {
auto: 0,
value: { type: VarKindType.constant, value: 'beta' },
},
}))
})
it('should render selected select values and update object json fields', () => {
const onChange = vi.fn()
render(
<ReasoningConfigForm
value={{
config: {
auto: 0,
value: { type: VarKindType.constant, value: '{}' },
},
choice: {
auto: 0,
value: { type: VarKindType.constant, value: 'alpha' },
},
}}
onChange={onChange}
schemas={[
createSchema({
variable: 'config',
type: FormTypeEnum.object,
input_schema: { type: Type.object, properties: {}, additionalProperties: false },
placeholder: { en_US: '{\n "foo": "bar"\n}', zh_Hans: '{\n "foo": "bar"\n}' },
}),
createSchema({
variable: 'choice',
type: FormTypeEnum.select,
placeholder: { en_US: 'Pick one', zh_Hans: '选择一个' },
options: [
{
value: 'alpha',
label: { en_US: 'Alpha', zh_Hans: 'Alpha' },
show_on: [],
},
{
value: 'beta',
label: { en_US: 'Beta', zh_Hans: 'Beta' },
show_on: [],
},
],
}),
]}
nodeOutputVars={[]}
availableNodes={[]}
nodeId="node-1"
/>,
)
expect(screen.getAllByText('Alpha').length).toBeGreaterThan(0)
expect(screen.getByTestId('code-editor-placeholder')).toHaveTextContent('"foo": "bar"')
fireEvent.click(screen.getByTestId('code-editor'))
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
config: {
auto: 0,
value: { type: VarKindType.constant, value: '{"foo":"bar"}' },
},
}))
})
it('should render json placeholders, default app scope, variable links, and helper urls', () => {
const onChange = vi.fn()
render(
<ReasoningConfigForm
value={{
config: {
auto: 0,
value: { type: VarKindType.constant, value: '{}' },
},
app: {
auto: 0,
value: { type: VarKindType.constant, value: null },
},
files: {
auto: 0,
value: { type: VarKindType.variable, value: '' },
},
}}
onChange={onChange}
schemas={[
createSchema({
variable: 'config',
type: FormTypeEnum.object,
input_schema: { type: Type.object, properties: {}, additionalProperties: false },
placeholder: { en_US: '{\n "foo": "bar"\n}', zh_Hans: '{\n "foo": "bar"\n}' },
}),
createSchema({
variable: 'app',
type: FormTypeEnum.appSelector,
scope: '' as never,
}),
createSchema({
variable: 'files',
type: FormTypeEnum.files,
url: 'https://example.com/help',
}),
]}
nodeOutputVars={[]}
availableNodes={[]}
nodeId="node-1"
/>,
)
expect(screen.getByTestId('code-editor-placeholder')).toHaveTextContent('"foo": "bar"')
expect(screen.getByTestId('app-selector')).toHaveAttribute('data-scope', 'all')
expect(screen.getByTestId('var-picker')).toHaveAttribute('data-value', '[]')
expect(screen.getByRole('link', { name: 'tools.howToGet' })).toHaveAttribute('href', 'https://example.com/help')
})
})
@@ -7,6 +7,7 @@ import type {
ValueSelector,
} from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { Switch } from '@langgenius/dify-ui/switch'
import {
RiArrowRightUpLine,
@@ -16,7 +17,7 @@ import { useBoolean } from 'ahooks'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import { SimpleSelect } from '@/app/components/base/select'
// eslint-disable-next-line no-restricted-imports -- legacy tooltip migration is handled separately from this change
import Tooltip from '@/app/components/base/tooltip'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
@@ -156,6 +157,9 @@ const ReasoningConfigForm: React.FC<Props> = ({
language,
schema,
})
const selectedOption = isSelect && options
? pickerProps.selectItems.find(item => item.value === (varInput?.value as string | number | undefined)) ?? null
: null
return (
<div key={variable} className="space-y-0.5">
@@ -225,13 +229,19 @@ const ReasoningConfigForm: React.FC<Props> = ({
/>
)}
{isSelect && options && (
<SimpleSelect
wrapperClassName="h-8 grow"
defaultValue={varInput?.value as string | number | undefined}
items={pickerProps.selectItems}
onSelect={item => handleValueChange(variable, type)(item.value as string)}
placeholder={placeholder?.[language] || placeholder?.en_US}
/>
<Select value={selectedOption ? String(selectedOption.value) : null} onValueChange={value => value && handleValueChange(variable, type)(value)}>
<SelectTrigger className="h-8 grow">
{selectedOption?.name ?? placeholder?.[language] ?? placeholder?.en_US}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{pickerProps.selectItems.map(item => (
<SelectItem key={item.value} value={String(item.value)}>
<SelectItemText>{item.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)}
{isShowJSONEditor && isConstant && (
<div className="mt-1 w-full">

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