Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0736ac1baf | ||
|
|
0c8dec3315 | ||
|
|
bd1a0b8908 | ||
|
|
38e831c1b3 | ||
|
|
1c5d62d98a | ||
|
|
6b4736bf78 | ||
|
|
c9503fd818 | ||
|
|
91a1df96cb | ||
|
|
5b2c5da945 | ||
|
|
b59ecea346 | ||
|
|
61c0948136 | ||
|
|
f746c7bdf2 | ||
|
|
2a3deee385 | ||
|
|
4b6803ba06 | ||
|
|
4c908c8f39 | ||
|
|
afec528f51 | ||
|
|
491061b8f4 | ||
|
|
8b1533438f | ||
|
|
ba924fc97b | ||
|
|
712e522220 | ||
|
|
33eebe8cfc | ||
|
|
2e1b11bdb2 |
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 user’s 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"
|
||||
|
||||
@@ -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())
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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
@@ -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]]
|
||||
|
||||
@@ -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,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,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
|
||||
|
||||
@@ -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
|
||||
@@ -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,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
@@ -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:",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+1244
-1003
File diff suppressed because it is too large
Load Diff
+33
-33
@@ -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
|
||||
|
||||
@@ -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:",
|
||||
|
||||
+40
-10
@@ -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)
|
||||
|
||||
+50
-42
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
|
||||
+174
-18
@@ -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 && (
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+97
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -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>
|
||||
)
|
||||
})
|
||||
}
|
||||
</>
|
||||
)
|
||||
|
||||
+8
-4
@@ -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"
|
||||
|
||||
@@ -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])
|
||||
|
||||
+1
-1
@@ -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¶m=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>
|
||||
</>
|
||||
)
|
||||
|
||||
+32
-14
@@ -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>
|
||||
|
||||
+15
-13
@@ -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}
|
||||
|
||||
+121
-15
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
+10
-1
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
|
||||
+74
-24
@@ -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"
|
||||
|
||||
+181
-19
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
+33
-19
@@ -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) {
|
||||
|
||||
+68
-27
@@ -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}
|
||||
|
||||
+4
-4
@@ -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>
|
||||
),
|
||||
}))
|
||||
|
||||
+238
-25
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
+18
-8
@@ -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
Reference in New Issue
Block a user