Merge branch 'main' into jzh

This commit is contained in:
JzoNg 2026-04-22 20:44:13 +08:00
commit b371dd2cdf
135 changed files with 5965 additions and 2596 deletions

View File

@ -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 │

View File

@ -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

View File

@ -30,7 +30,7 @@ The codebase is split into:
## Language Style
- **Python**: Keep type hints on functions and attributes, and implement relevant special methods (e.g., `__repr__`, `__str__`). Prefer `TypedDict` over `dict` or `Mapping` for type safety and better code documentation.
- **TypeScript**: Use the strict config, rely on ESLint (`pnpm lint:fix` preferred) plus `pnpm type-check:tsgo`, and avoid `any` types.
- **TypeScript**: Use the strict config, rely on ESLint (`pnpm lint:fix` preferred) plus `pnpm type-check`, and avoid `any` types.
## General Practices

View File

@ -139,19 +139,6 @@ Star Dify on GitHub and be instantly notified of new releases.
If you need to customize the configuration, please refer to the comments in our [.env.example](docker/.env.example) file and update the corresponding values in your `.env` file. Additionally, you might need to make adjustments to the `docker-compose.yaml` file itself, such as changing image versions, port mappings, or volume mounts, based on your specific deployment environment and requirements. After making any changes, please re-run `docker compose up -d`. You can find the full list of available environment variables [here](https://docs.dify.ai/getting-started/install-self-hosted/environments).
#### Customizing Suggested Questions
You can now customize the "Suggested Questions After Answer" feature to better fit your use case. For example, to generate longer, more technical questions:
```bash
# In your .env file
SUGGESTED_QUESTIONS_PROMPT='Please help me predict the five most likely technical follow-up questions a developer would ask. Focus on implementation details, best practices, and architecture considerations. Keep each question between 40-60 characters. Output must be JSON array: ["question1","question2","question3","question4","question5"]'
SUGGESTED_QUESTIONS_MAX_TOKENS=512
SUGGESTED_QUESTIONS_TEMPERATURE=0.3
```
See the [Suggested Questions Configuration Guide](docs/suggested-questions-configuration.md) for detailed examples and usage instructions.
### Metrics Monitoring with Grafana
Import the dashboard to Grafana, using Dify's PostgreSQL database as data source, to monitor metrics in granularity of apps, tenants, messages, and more.

View File

@ -709,22 +709,6 @@ SWAGGER_UI_PATH=/swagger-ui.html
# Set to false to export dataset IDs as plain text for easier cross-environment import
DSL_EXPORT_ENCRYPT_DATASET_ID=true
# Suggested Questions After Answer Configuration
# These environment variables allow customization of the suggested questions feature
#
# Custom prompt for generating suggested questions (optional)
# If not set, uses the default prompt that generates 3 questions under 20 characters each
# Example: "Please help me predict the five most likely technical follow-up questions a developer would ask. Focus on implementation details, best practices, and architecture considerations. Keep each question between 40-60 characters. Output must be JSON array: [\"question1\",\"question2\",\"question3\",\"question4\",\"question5\"]"
# SUGGESTED_QUESTIONS_PROMPT=
# Maximum number of tokens for suggested questions generation (default: 256)
# Adjust this value for longer questions or more questions
# SUGGESTED_QUESTIONS_MAX_TOKENS=256
# Temperature for suggested questions generation (default: 0.0)
# Higher values (0.5-1.0) produce more creative questions, lower values (0.0-0.3) produce more focused questions
# SUGGESTED_QUESTIONS_TEMPERATURE=0
# Tenant isolated task queue configuration
TENANT_ISOLATED_TASK_CONCURRENCY=1

View File

@ -1,3 +1,11 @@
"""Console workspace endpoint controllers.
This module exposes workspace-scoped plugin endpoint management APIs. The
canonical write routes follow resource-oriented paths, while the historical
verb-based aliases stay available as deprecated resources so OpenAPI metadata
marks only the legacy paths as deprecated.
"""
from typing import Any
from flask import request
@ -25,7 +33,12 @@ class EndpointIdPayload(BaseModel):
endpoint_id: str
class EndpointUpdatePayload(EndpointIdPayload):
class EndpointUpdatePayload(BaseModel):
settings: dict[str, Any]
name: str = Field(min_length=1)
class LegacyEndpointUpdatePayload(EndpointIdPayload):
settings: dict[str, Any]
name: str = Field(min_length=1)
@ -76,6 +89,7 @@ register_schema_models(
EndpointCreatePayload,
EndpointIdPayload,
EndpointUpdatePayload,
LegacyEndpointUpdatePayload,
EndpointListQuery,
EndpointListForPluginQuery,
EndpointCreateResponse,
@ -88,8 +102,60 @@ register_schema_models(
)
@console_ns.route("/workspaces/current/endpoints/create")
class EndpointCreateApi(Resource):
def _create_endpoint() -> dict[str, bool]:
"""Create a plugin endpoint for the current workspace."""
user, tenant_id = current_account_with_tenant()
args = EndpointCreatePayload.model_validate(console_ns.payload)
try:
return {
"success": EndpointService.create_endpoint(
tenant_id=tenant_id,
user_id=user.id,
plugin_unique_identifier=args.plugin_unique_identifier,
name=args.name,
settings=args.settings,
)
}
except PluginPermissionDeniedError as e:
raise ValueError(e.description) from e
def _update_endpoint(endpoint_id: str) -> dict[str, bool]:
"""Update a plugin endpoint identified by the canonical path parameter."""
user, tenant_id = current_account_with_tenant()
args = EndpointUpdatePayload.model_validate(console_ns.payload)
return {
"success": EndpointService.update_endpoint(
tenant_id=tenant_id,
user_id=user.id,
endpoint_id=endpoint_id,
name=args.name,
settings=args.settings,
)
}
def _delete_endpoint(endpoint_id: str) -> dict[str, bool]:
"""Delete a plugin endpoint identified by the canonical path parameter."""
user, tenant_id = current_account_with_tenant()
return {
"success": EndpointService.delete_endpoint(
tenant_id=tenant_id,
user_id=user.id,
endpoint_id=endpoint_id,
)
}
@console_ns.route("/workspaces/current/endpoints")
class EndpointCollectionApi(Resource):
"""Canonical collection resource for endpoint creation."""
@console_ns.doc("create_endpoint")
@console_ns.doc(description="Create a new plugin endpoint")
@console_ns.expect(console_ns.models[EndpointCreatePayload.__name__])
@ -104,22 +170,33 @@ class EndpointCreateApi(Resource):
@is_admin_or_owner_required
@account_initialization_required
def post(self):
user, tenant_id = current_account_with_tenant()
return _create_endpoint()
args = EndpointCreatePayload.model_validate(console_ns.payload)
try:
return {
"success": EndpointService.create_endpoint(
tenant_id=tenant_id,
user_id=user.id,
plugin_unique_identifier=args.plugin_unique_identifier,
name=args.name,
settings=args.settings,
)
}
except PluginPermissionDeniedError as e:
raise ValueError(e.description) from e
@console_ns.route("/workspaces/current/endpoints/create")
class DeprecatedEndpointCreateApi(Resource):
"""Deprecated verb-based alias for endpoint creation."""
@console_ns.doc("create_endpoint_deprecated")
@console_ns.doc(deprecated=True)
@console_ns.doc(
description=(
"Deprecated legacy alias for creating a plugin endpoint. Use POST /workspaces/current/endpoints instead."
)
)
@console_ns.expect(console_ns.models[EndpointCreatePayload.__name__])
@console_ns.response(
200,
"Endpoint created successfully",
console_ns.models[EndpointCreateResponse.__name__],
)
@console_ns.response(403, "Admin privileges required")
@setup_required
@login_required
@is_admin_or_owner_required
@account_initialization_required
def post(self):
return _create_endpoint()
@console_ns.route("/workspaces/current/endpoints/list")
@ -190,10 +267,56 @@ class EndpointListForSinglePluginApi(Resource):
)
@console_ns.route("/workspaces/current/endpoints/delete")
class EndpointDeleteApi(Resource):
@console_ns.route("/workspaces/current/endpoints/<string:id>")
class EndpointItemApi(Resource):
"""Canonical item resource for endpoint updates and deletion."""
@console_ns.doc("delete_endpoint")
@console_ns.doc(description="Delete a plugin endpoint")
@console_ns.doc(params={"id": {"description": "Endpoint ID", "type": "string", "required": True}})
@console_ns.response(
200,
"Endpoint deleted successfully",
console_ns.models[EndpointDeleteResponse.__name__],
)
@console_ns.response(403, "Admin privileges required")
@setup_required
@login_required
@is_admin_or_owner_required
@account_initialization_required
def delete(self, id: str):
return _delete_endpoint(endpoint_id=id)
@console_ns.doc("update_endpoint")
@console_ns.doc(description="Update a plugin endpoint")
@console_ns.expect(console_ns.models[EndpointUpdatePayload.__name__])
@console_ns.doc(params={"id": {"description": "Endpoint ID", "type": "string", "required": True}})
@console_ns.response(
200,
"Endpoint updated successfully",
console_ns.models[EndpointUpdateResponse.__name__],
)
@console_ns.response(403, "Admin privileges required")
@setup_required
@login_required
@is_admin_or_owner_required
@account_initialization_required
def patch(self, id: str):
return _update_endpoint(endpoint_id=id)
@console_ns.route("/workspaces/current/endpoints/delete")
class DeprecatedEndpointDeleteApi(Resource):
"""Deprecated verb-based alias for endpoint deletion."""
@console_ns.doc("delete_endpoint_deprecated")
@console_ns.doc(deprecated=True)
@console_ns.doc(
description=(
"Deprecated legacy alias for deleting a plugin endpoint. "
"Use DELETE /workspaces/current/endpoints/{id} instead."
)
)
@console_ns.expect(console_ns.models[EndpointIdPayload.__name__])
@console_ns.response(
200,
@ -206,22 +329,23 @@ class EndpointDeleteApi(Resource):
@is_admin_or_owner_required
@account_initialization_required
def post(self):
user, tenant_id = current_account_with_tenant()
args = EndpointIdPayload.model_validate(console_ns.payload)
return {
"success": EndpointService.delete_endpoint(
tenant_id=tenant_id, user_id=user.id, endpoint_id=args.endpoint_id
)
}
return _delete_endpoint(endpoint_id=args.endpoint_id)
@console_ns.route("/workspaces/current/endpoints/update")
class EndpointUpdateApi(Resource):
@console_ns.doc("update_endpoint")
@console_ns.doc(description="Update a plugin endpoint")
@console_ns.expect(console_ns.models[EndpointUpdatePayload.__name__])
class DeprecatedEndpointUpdateApi(Resource):
"""Deprecated verb-based alias for endpoint updates."""
@console_ns.doc("update_endpoint_deprecated")
@console_ns.doc(deprecated=True)
@console_ns.doc(
description=(
"Deprecated legacy alias for updating a plugin endpoint. "
"Use PATCH /workspaces/current/endpoints/{id} instead."
)
)
@console_ns.expect(console_ns.models[LegacyEndpointUpdatePayload.__name__])
@console_ns.response(
200,
"Endpoint updated successfully",
@ -233,19 +357,8 @@ class EndpointUpdateApi(Resource):
@is_admin_or_owner_required
@account_initialization_required
def post(self):
user, tenant_id = current_account_with_tenant()
args = EndpointUpdatePayload.model_validate(console_ns.payload)
return {
"success": EndpointService.update_endpoint(
tenant_id=tenant_id,
user_id=user.id,
endpoint_id=args.endpoint_id,
name=args.name,
settings=args.settings,
)
}
args = LegacyEndpointUpdatePayload.model_validate(console_ns.payload)
return _update_endpoint(endpoint_id=args.endpoint_id)
@console_ns.route("/workspaces/current/endpoints/enable")

View File

@ -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"]

View File

@ -2,7 +2,7 @@ import json
import logging
import re
from collections.abc import Sequence
from typing import Any, Protocol, TypedDict, cast
from typing import Any, NotRequired, Protocol, TypedDict, cast
import json_repair
from sqlalchemy import select
@ -13,13 +13,13 @@ from core.llm_generator.output_parser.rule_config_generator import RuleConfigGen
from core.llm_generator.output_parser.suggested_questions_after_answer import SuggestedQuestionsAfterAnswerOutputParser
from core.llm_generator.prompts import (
CONVERSATION_TITLE_PROMPT,
DEFAULT_SUGGESTED_QUESTIONS_MAX_TOKENS,
DEFAULT_SUGGESTED_QUESTIONS_TEMPERATURE,
GENERATOR_QA_PROMPT,
JAVASCRIPT_CODE_GENERATOR_PROMPT_TEMPLATE,
LLM_MODIFY_CODE_SYSTEM,
LLM_MODIFY_PROMPT_SYSTEM,
PYTHON_CODE_GENERATOR_PROMPT_TEMPLATE,
SUGGESTED_QUESTIONS_MAX_TOKENS,
SUGGESTED_QUESTIONS_TEMPERATURE,
SYSTEM_STRUCTURED_OUTPUT_GENERATE,
WORKFLOW_RULE_CONFIG_PROMPT_GENERATE_TEMPLATE,
)
@ -41,6 +41,36 @@ from models.workflow import Workflow
logger = logging.getLogger(__name__)
class SuggestedQuestionsModelConfig(TypedDict):
provider: str
name: str
completion_params: NotRequired[dict[str, object]]
def _normalize_completion_params(completion_params: dict[str, object]) -> tuple[dict[str, object], list[str]]:
"""
Normalize raw completion params into invocation parameters and stop sequences.
This mirrors the app-model access path by separating ``stop`` from provider
parameters before invocation, then drops non-positive token limits because
some plugin-backed models reject ``0`` after mapping ``max_tokens`` to their
provider-specific output-token field.
"""
normalized_parameters = dict(completion_params)
stop_value = normalized_parameters.pop("stop", [])
if isinstance(stop_value, list) and all(isinstance(item, str) for item in stop_value):
stop = stop_value
else:
stop = []
for token_limit_key in ("max_tokens", "max_output_tokens"):
token_limit = normalized_parameters.get(token_limit_key)
if isinstance(token_limit, int | float) and token_limit <= 0:
normalized_parameters.pop(token_limit_key, None)
return normalized_parameters, stop
class WorkflowServiceInterface(Protocol):
def get_draft_workflow(self, app_model: App, workflow_id: str | None = None) -> Workflow | None:
pass
@ -123,8 +153,15 @@ class LLMGenerator:
return name
@classmethod
def generate_suggested_questions_after_answer(cls, tenant_id: str, histories: str) -> Sequence[str]:
output_parser = SuggestedQuestionsAfterAnswerOutputParser()
def generate_suggested_questions_after_answer(
cls,
tenant_id: str,
histories: str,
*,
instruction_prompt: str | None = None,
model_config: object | None = None,
) -> Sequence[str]:
output_parser = SuggestedQuestionsAfterAnswerOutputParser(instruction_prompt=instruction_prompt)
format_instructions = output_parser.get_format_instructions()
prompt_template = PromptTemplateParser(template="{{histories}}\n{{format_instructions}}\nquestions:\n")
@ -133,10 +170,36 @@ class LLMGenerator:
try:
model_manager = ModelManager.for_tenant(tenant_id=tenant_id)
model_instance = model_manager.get_default_model_instance(
tenant_id=tenant_id,
model_type=ModelType.LLM,
)
configured_model = cast(dict[str, object], model_config) if isinstance(model_config, dict) else {}
provider = configured_model.get("provider")
model_name = configured_model.get("name")
use_configured_model = False
if isinstance(provider, str) and provider and isinstance(model_name, str) and model_name:
try:
model_instance = model_manager.get_model_instance(
tenant_id=tenant_id,
model_type=ModelType.LLM,
provider=provider,
model=model_name,
)
use_configured_model = True
except Exception:
logger.warning(
"Failed to use configured suggested-questions model %s/%s, fallback to default model",
provider,
model_name,
exc_info=True,
)
model_instance = model_manager.get_default_model_instance(
tenant_id=tenant_id,
model_type=ModelType.LLM,
)
else:
model_instance = model_manager.get_default_model_instance(
tenant_id=tenant_id,
model_type=ModelType.LLM,
)
except InvokeAuthorizationError:
return []
@ -145,19 +208,29 @@ class LLMGenerator:
questions: Sequence[str] = []
try:
configured_completion_params = configured_model.get("completion_params")
if use_configured_model and isinstance(configured_completion_params, dict):
model_parameters, stop = _normalize_completion_params(configured_completion_params)
elif use_configured_model:
model_parameters = {}
stop = []
else:
# Default-model generation keeps the built-in suggested-questions tuning.
model_parameters = {
"max_tokens": DEFAULT_SUGGESTED_QUESTIONS_MAX_TOKENS,
"temperature": DEFAULT_SUGGESTED_QUESTIONS_TEMPERATURE,
}
stop = []
response: LLMResult = model_instance.invoke_llm(
prompt_messages=list(prompt_messages),
model_parameters={
"max_tokens": SUGGESTED_QUESTIONS_MAX_TOKENS,
"temperature": SUGGESTED_QUESTIONS_TEMPERATURE,
},
model_parameters=model_parameters,
stop=stop,
stream=False,
)
text_content = response.message.get_text_content()
questions = output_parser.parse(text_content) if text_content else []
except InvokeError:
questions = []
except Exception:
logger.exception("Failed to generate suggested questions after answer")
questions = []

View File

@ -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

View File

@ -1,5 +1,4 @@
# Written by YORKI MINAKO🤡, Edited by Xiaoyi, Edited by yasu-oh
import os
CONVERSATION_TITLE_PROMPT = """You are asked to generate a concise chat title by decomposing the users input into two parts: “Intention” and “Subject”.
@ -96,8 +95,8 @@ JAVASCRIPT_CODE_GENERATOR_PROMPT_TEMPLATE = (
)
# Default prompt for suggested questions (can be overridden by environment variable)
_DEFAULT_SUGGESTED_QUESTIONS_AFTER_ANSWER_PROMPT = (
# Default prompt and model parameters for suggested questions.
DEFAULT_SUGGESTED_QUESTIONS_AFTER_ANSWER_INSTRUCTION_PROMPT = (
"Please help me predict the three most likely questions that human would ask, "
"and keep each question under 20 characters.\n"
"MAKE SURE your output is the SAME language as the Assistant's latest response. "
@ -105,14 +104,8 @@ _DEFAULT_SUGGESTED_QUESTIONS_AFTER_ANSWER_PROMPT = (
'["question1","question2","question3"]\n'
)
# Environment variable override for suggested questions prompt
SUGGESTED_QUESTIONS_AFTER_ANSWER_INSTRUCTION_PROMPT = os.getenv(
"SUGGESTED_QUESTIONS_PROMPT", _DEFAULT_SUGGESTED_QUESTIONS_AFTER_ANSWER_PROMPT
)
# Configurable LLM parameters for suggested questions (can be overridden by environment variables)
SUGGESTED_QUESTIONS_MAX_TOKENS = int(os.getenv("SUGGESTED_QUESTIONS_MAX_TOKENS", "256"))
SUGGESTED_QUESTIONS_TEMPERATURE = float(os.getenv("SUGGESTED_QUESTIONS_TEMPERATURE", "0"))
DEFAULT_SUGGESTED_QUESTIONS_MAX_TOKENS = 256
DEFAULT_SUGGESTED_QUESTIONS_TEMPERATURE = 0.0
GENERATOR_QA_PROMPT = (
"<Task> The user will send a long text. Generate a Question and Answer pairs only using the knowledge"

View File

@ -91,6 +91,19 @@ class EnabledConfig(TypedDict):
enabled: bool
class SuggestedQuestionsAfterAnswerModelConfig(TypedDict):
provider: str
name: str
mode: NotRequired[str]
completion_params: NotRequired[dict[str, Any]]
class SuggestedQuestionsAfterAnswerConfig(TypedDict):
enabled: bool
model: NotRequired[SuggestedQuestionsAfterAnswerModelConfig]
prompt: NotRequired[str]
class EmbeddingModelInfo(TypedDict):
embedding_provider_name: str
embedding_model_name: str
@ -220,7 +233,7 @@ class ModelConfig(TypedDict):
class AppModelConfigDict(TypedDict):
opening_statement: str | None
suggested_questions: list[str]
suggested_questions_after_answer: EnabledConfig
suggested_questions_after_answer: SuggestedQuestionsAfterAnswerConfig
speech_to_text: EnabledConfig
text_to_speech: EnabledConfig
retriever_resource: EnabledConfig
@ -680,8 +693,13 @@ class AppModelConfig(TypeBase):
return cast(EnabledConfig, json.loads(value) if value else {"enabled": default_enabled})
@property
def suggested_questions_after_answer_dict(self) -> EnabledConfig:
return self._get_enabled_config(self.suggested_questions_after_answer)
def suggested_questions_after_answer_dict(self) -> SuggestedQuestionsAfterAnswerConfig:
return cast(
SuggestedQuestionsAfterAnswerConfig,
json.loads(self.suggested_questions_after_answer)
if self.suggested_questions_after_answer
else {"enabled": False},
)
@property
def speech_to_text_dict(self) -> EnabledConfig:

View File

@ -173,7 +173,7 @@ dev = [
# "locust>=2.40.4", # Temporarily removed due to compatibility issues. Uncomment when resolved.
"pytest-timeout>=2.4.0",
"pytest-xdist>=3.8.0",
"pyrefly>=0.61.1",
"pyrefly>=0.62.0",
"xinference-client>=2.5.0",
]

View File

@ -1,4 +1,6 @@
import logging
from collections.abc import Sequence
from typing import cast
from pydantic import TypeAdapter
from sqlalchemy import select
@ -17,7 +19,16 @@ from graphon.model_runtime.entities.model_entities import ModelType
from libs.infinite_scroll_pagination import InfiniteScrollPagination
from models import Account
from models.enums import FeedbackFromSource, FeedbackRating
from models.model import App, AppMode, AppModelConfig, AppModelConfigDict, EndUser, Message, MessageFeedback
from models.model import (
App,
AppMode,
AppModelConfig,
AppModelConfigDict,
EndUser,
Message,
MessageFeedback,
SuggestedQuestionsAfterAnswerConfig,
)
from repositories.execution_extra_content_repository import ExecutionExtraContentRepository
from repositories.sqlalchemy_execution_extra_content_repository import (
SQLAlchemyExecutionExtraContentRepository,
@ -32,6 +43,7 @@ from services.errors.message import (
from services.workflow_service import WorkflowService
_app_model_config_adapter: TypeAdapter[AppModelConfigDict] = TypeAdapter(AppModelConfigDict)
logger = logging.getLogger(__name__)
def _create_execution_extra_content_repository() -> ExecutionExtraContentRepository:
@ -252,6 +264,7 @@ class MessageService:
)
model_manager = ModelManager.for_tenant(tenant_id=app_model.tenant_id)
suggested_questions_after_answer_config: SuggestedQuestionsAfterAnswerConfig = {"enabled": False}
if app_model.mode == AppMode.ADVANCED_CHAT:
workflow_service = WorkflowService()
@ -271,9 +284,11 @@ class MessageService:
if not app_config.additional_features.suggested_questions_after_answer:
raise SuggestedQuestionsAfterAnswerDisabledError()
model_instance = model_manager.get_default_model_instance(
tenant_id=app_model.tenant_id, model_type=ModelType.LLM
)
suggested_questions_after_answer = workflow.features_dict.get("suggested_questions_after_answer")
if isinstance(suggested_questions_after_answer, dict):
suggested_questions_after_answer_config = cast(
SuggestedQuestionsAfterAnswerConfig, suggested_questions_after_answer
)
else:
if not conversation.override_model_configs:
app_model_config = db.session.scalar(
@ -293,16 +308,14 @@ class MessageService:
if not app_model_config:
raise ValueError("did not find app model config")
suggested_questions_after_answer = app_model_config.suggested_questions_after_answer_dict
if suggested_questions_after_answer.get("enabled", False) is False:
suggested_questions_after_answer_config = app_model_config.suggested_questions_after_answer_dict
if suggested_questions_after_answer_config.get("enabled", False) is False:
raise SuggestedQuestionsAfterAnswerDisabledError()
model_instance = model_manager.get_model_instance(
tenant_id=app_model.tenant_id,
provider=app_model_config.model_dict["provider"],
model_type=ModelType.LLM,
model=app_model_config.model_dict["name"],
)
model_instance = model_manager.get_default_model_instance(
tenant_id=app_model.tenant_id,
model_type=ModelType.LLM,
)
# get memory of conversation (read-only)
memory = TokenBufferMemory(conversation=conversation, model_instance=model_instance)
@ -312,9 +325,17 @@ class MessageService:
message_limit=3,
)
instruction_prompt = suggested_questions_after_answer_config.get("prompt")
if not isinstance(instruction_prompt, str) or not instruction_prompt.strip():
instruction_prompt = None
configured_model = suggested_questions_after_answer_config.get("model")
with measure_time() as timer:
questions_sequence = LLMGenerator.generate_suggested_questions_after_answer(
tenant_id=app_model.tenant_id, histories=histories
tenant_id=app_model.tenant_id,
histories=histories,
instruction_prompt=instruction_prompt,
model_config=configured_model,
)
questions: list[str] = list(questions_sequence)

View File

@ -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):

View File

@ -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}})

View File

@ -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

View File

@ -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")

64
api/uv.lock generated
View File

@ -1627,7 +1627,7 @@ dev = [
{ name = "lxml-stubs", specifier = ">=0.5.1" },
{ name = "mypy", specifier = ">=1.20.1" },
{ name = "pandas-stubs", specifier = ">=3.0.0" },
{ name = "pyrefly", specifier = ">=0.61.1" },
{ name = "pyrefly", specifier = ">=0.62.0" },
{ name = "pytest", specifier = ">=9.0.3" },
{ name = "pytest-benchmark", specifier = ">=5.2.3" },
{ name = "pytest-cov", specifier = ">=7.1.0" },
@ -3687,28 +3687,28 @@ wheels = [
[[package]]
name = "lxml"
version = "6.0.2"
version = "6.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" }
sdist = { url = "https://files.pythonhosted.org/packages/28/30/9abc9e34c657c33834eaf6cd02124c61bdf5944d802aa48e69be8da3585d/lxml-6.1.0.tar.gz", hash = "sha256:bfd57d8008c4965709a919c3e9a98f76c2c7cb319086b3d26858250620023b13", size = 4197006, upload-time = "2026-04-18T04:32:51.613Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" },
{ url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" },
{ url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" },
{ url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" },
{ url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" },
{ url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" },
{ url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" },
{ url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" },
{ url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" },
{ url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" },
{ url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" },
{ url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" },
{ url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" },
{ url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" },
{ url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" },
{ url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" },
{ url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" },
{ url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" },
{ url = "https://files.pythonhosted.org/packages/d2/d4/9326838b59dc36dfae42eec9656b97520f9997eee1de47b8316aaeed169c/lxml-6.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d2f17a16cd8751e8eb233a7e41aecdf8e511712e00088bf9be455f604cd0d28d", size = 8570663, upload-time = "2026-04-18T04:27:48.253Z" },
{ url = "https://files.pythonhosted.org/packages/d8/a4/053745ce1f8303ccbb788b86c0db3a91b973675cefc42566a188637b7c40/lxml-6.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0cea5b1d3e6e77d71bd2b9972eb2446221a69dc52bb0b9c3c6f6e5700592d93", size = 4624024, upload-time = "2026-04-18T04:27:52.594Z" },
{ url = "https://files.pythonhosted.org/packages/90/97/a517944b20f8fd0932ad2109482bee4e29fe721416387a363306667941f6/lxml-6.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fc46da94826188ed45cb53bd8e3fc076ae22675aea2087843d4735627f867c6d", size = 4930895, upload-time = "2026-04-18T04:32:56.29Z" },
{ url = "https://files.pythonhosted.org/packages/94/7c/e08a970727d556caa040a44773c7b7e3ad0f0d73dedc863543e9a8b931f2/lxml-6.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9147d8e386ec3b82c3b15d88927f734f565b0aaadef7def562b853adca45784a", size = 5093820, upload-time = "2026-04-18T04:32:58.94Z" },
{ url = "https://files.pythonhosted.org/packages/88/ee/2a5c2aa2c32016a226ca25d3e1056a8102ea6e1fe308bf50213586635400/lxml-6.1.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5715e0e28736a070f3f34a7ccc09e2fdcba0e3060abbcf61a1a5718ff6d6b105", size = 5005790, upload-time = "2026-04-18T04:33:01.272Z" },
{ url = "https://files.pythonhosted.org/packages/e3/38/a0db9be8f38ad6043ab9429487c128dd1d30f07956ef43040402f8da49e8/lxml-6.1.0-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4937460dc5df0cdd2f06a86c285c28afda06aefa3af949f9477d3e8df430c485", size = 5630827, upload-time = "2026-04-18T04:33:04.036Z" },
{ url = "https://files.pythonhosted.org/packages/31/ba/3c13d3fc24b7cacf675f808a3a1baabf43a30d0cd24c98f94548e9aa58eb/lxml-6.1.0-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bc783ee3147e60a25aa0445ea82b3e8aabb83b240f2b95d32cb75587ff781814", size = 5240445, upload-time = "2026-04-18T04:33:06.87Z" },
{ url = "https://files.pythonhosted.org/packages/55/ba/eeef4ccba09b2212fe239f46c1692a98db1878e0872ae320756488878a94/lxml-6.1.0-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:40d9189f80075f2e1f88db21ef815a2b17b28adf8e50aaf5c789bfe737027f32", size = 5350121, upload-time = "2026-04-18T04:33:09.365Z" },
{ url = "https://files.pythonhosted.org/packages/7e/01/1da87c7b587c38d0cbe77a01aae3b9c1c49ed47d76918ef3db8fc151b1ca/lxml-6.1.0-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:05b9b8787e35bec69e68daf4952b2e6dfcfb0db7ecf1a06f8cdfbbac4eb71aad", size = 4694949, upload-time = "2026-04-18T04:33:11.628Z" },
{ url = "https://files.pythonhosted.org/packages/a1/88/7db0fe66d5aaf128443ee1623dec3db1576f3e4c17751ec0ef5866468590/lxml-6.1.0-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0f08beb0182e3e9a86fae124b3c47a7b41b7b69b225e1377db983802404e54", size = 5243901, upload-time = "2026-04-18T04:33:13.95Z" },
{ url = "https://files.pythonhosted.org/packages/00/a8/1346726af7d1f6fca1f11223ba34001462b0a3660416986d37641708d57c/lxml-6.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73becf6d8c81d4c76b1014dbd3584cb26d904492dcf73ca85dc8bff08dcd6d2d", size = 5048054, upload-time = "2026-04-18T04:33:16.965Z" },
{ url = "https://files.pythonhosted.org/packages/2e/b7/85057012f035d1a0c87e02f8c723ca3c3e6e0728bcf4cb62080b21b1c1e3/lxml-6.1.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1ae225f66e5938f4fa29d37e009a3bb3b13032ac57eb4eb42afa44f6e4054e69", size = 4777324, upload-time = "2026-04-18T04:33:19.832Z" },
{ url = "https://files.pythonhosted.org/packages/75/6c/ad2f94a91073ef570f33718040e8e160d5fb93331cf1ab3ca1323f939e2d/lxml-6.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:690022c7fae793b0489aa68a658822cea83e0d5933781811cabbf5ea3bcfe73d", size = 5645702, upload-time = "2026-04-18T04:33:22.436Z" },
{ url = "https://files.pythonhosted.org/packages/3b/89/0bb6c0bd549c19004c60eea9dc554dd78fd647b72314ef25d460e0d208c6/lxml-6.1.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:63aeafc26aac0be8aff14af7871249e87ea1319be92090bfd632ec68e03b16a5", size = 5232901, upload-time = "2026-04-18T04:33:26.21Z" },
{ url = "https://files.pythonhosted.org/packages/a1/d9/d609a11fb567da9399f525193e2b49847b5a409cdebe737f06a8b7126bdc/lxml-6.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:264c605ab9c0e4aa1a679636f4582c4d3313700009fac3ec9c3412ed0d8f3e1d", size = 5261333, upload-time = "2026-04-18T04:33:28.984Z" },
{ url = "https://files.pythonhosted.org/packages/a6/3a/ac3f99ec8ac93089e7dd556f279e0d14c24de0a74a507e143a2e4b496e7c/lxml-6.1.0-cp312-cp312-win32.whl", hash = "sha256:56971379bc5ee8037c5a0f09fa88f66cdb7d37c3e38af3e45cf539f41131ac1f", size = 3596289, upload-time = "2026-04-18T04:27:42.819Z" },
{ url = "https://files.pythonhosted.org/packages/f2/a7/0a915557538593cb1bbeedcd40e13c7a261822c26fecbbdb71dad0c2f540/lxml-6.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:bba078de0031c219e5dd06cf3e6bf8fb8e6e64a77819b358f53bb132e3e03366", size = 3997059, upload-time = "2026-04-18T04:27:46.764Z" },
{ url = "https://files.pythonhosted.org/packages/92/96/a5dc078cf0126fbfbc35611d77ecd5da80054b5893e28fb213a5613b9e1d/lxml-6.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:c3592631e652afa34999a088f98ba7dfc7d6aff0d535c410bea77a71743f3819", size = 3659552, upload-time = "2026-04-18T04:27:51.133Z" },
]
[[package]]
@ -5357,19 +5357,19 @@ wheels = [
[[package]]
name = "pyrefly"
version = "0.61.1"
version = "0.62.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/c8/52fce3f0e3718d9ff71d16af41cef925e58613741328004d3aa3fe585057/pyrefly-0.61.1.tar.gz", hash = "sha256:2a871320b7d2b28b8635064b620097d7091e84c49e4808d915ad31dad685d0f5", size = 5535788, upload-time = "2026-04-17T18:47:33.958Z" }
sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/8874ed25781e7dd561c6d75fb4a7becf10a18d75b074f25b845cc334f781/pyrefly-0.62.0.tar.gz", hash = "sha256:da1fbe1075dc1e6c8e3134e9370b0a0e7a296061d782cca5bf83dbb8e4c10d7c", size = 5537672, upload-time = "2026-04-20T17:12:15.718Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/38/e94ff401405a05fbf81c9bbfa993a34ffd03be84812b545063c8efb56b44/pyrefly-0.61.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6e3ed857b99291fc4aa3b54ce22deb086c0174cf3a3775eccea7439efd16d925", size = 12969301, upload-time = "2026-04-17T18:47:06.036Z" },
{ url = "https://files.pythonhosted.org/packages/f3/be/53c7f9400696e46633c8cee8b6fd32ce7ab4a965ddf9ac4f4ea9e2034647/pyrefly-0.61.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cf6335c1baf9470ca8113f7ea8bdbd0b96081c82a911157c576cdfc8a67a9a87", size = 12475413, upload-time = "2026-04-17T18:47:08.863Z" },
{ url = "https://files.pythonhosted.org/packages/77/68/83cc3267620b14f81fa596a84efc7ebcf5c49f79b521499e85d1a4fca6d8/pyrefly-0.61.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:844b5baddc2a631f69648a4756c54c97d86e4b9c07e335b216668e24390b77b6", size = 36074785, upload-time = "2026-04-17T18:47:11.845Z" },
{ url = "https://files.pythonhosted.org/packages/d8/00/e8d437995b8dcea022f5310bc873f5de1dcc71da4876d5be917ee9a93fef/pyrefly-0.61.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eaa294f90622c5b3743af8e9de4263447f22bb0e8b60c80cf83292adb4f2d14b", size = 38802979, upload-time = "2026-04-17T18:47:16.058Z" },
{ url = "https://files.pythonhosted.org/packages/16/3f/f1cbc58e8875608ae740d9575de95c8bc6d4dce202f82b4fe90005727618/pyrefly-0.61.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a8d8c3fe08b9593dce23ad4bc7c393891a379c2d580aa1f263182567721bd6f", size = 37029339, upload-time = "2026-04-17T18:47:19.601Z" },
{ url = "https://files.pythonhosted.org/packages/18/8c/0ff67041c88c28f48b10ce15758831d1e4e60f11db5bfc09dcffd5edb6ba/pyrefly-0.61.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:305f2086f4d7d796244b337884d96cf0d32435420336a77840ca369cf6fa06fd", size = 41595667, upload-time = "2026-04-17T18:47:23.122Z" },
{ url = "https://files.pythonhosted.org/packages/ff/9e/62b8139b140931593a6b29334802ea6b86d033c0bfd9794950279732253b/pyrefly-0.61.1-py3-none-win32.whl", hash = "sha256:3271a019885a72c8dd064e928bb445af807771506842f5f2faaac17d8e6e73a5", size = 11963660, upload-time = "2026-04-17T18:47:25.86Z" },
{ url = "https://files.pythonhosted.org/packages/38/6e/73280243d12bec28f55b6edd4e70c5cf11e3d7de2395ecb4eb36cca7dab4/pyrefly-0.61.1-py3-none-win_amd64.whl", hash = "sha256:3e3763d5d76f505c5b8897db1446bde8e138d50a67751f2aa76d6c6034254836", size = 12804056, upload-time = "2026-04-17T18:47:28.674Z" },
{ url = "https://files.pythonhosted.org/packages/87/32/38ac5af84d96167412024abf5e2f49f15b777987a1942e7a442e8e5fef82/pyrefly-0.61.1-py3-none-win_arm64.whl", hash = "sha256:cef5631e2ab09702274ec2eaaafee28a114891cf85f2d31568b329727e3ff735", size = 12302467, upload-time = "2026-04-17T18:47:31.409Z" },
{ url = "https://files.pythonhosted.org/packages/1b/ea/09bd9da7d5df294db800312fb415be2fefbaa5594178e9e49f44fa071aea/pyrefly-0.62.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d78ec4f126dee1fa76215b193b964490ce10e62a32d2787a72c51623658b803", size = 13020414, upload-time = "2026-04-20T17:11:43.617Z" },
{ url = "https://files.pythonhosted.org/packages/4b/f0/f84afac4f220c4c8c801b779ee2ff28ad3f7731f4283c2e1b6ee9012e8c2/pyrefly-0.62.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2a41a34902d20756264486f9e309f22633d100261bd960feea6e858a098d985d", size = 12515659, upload-time = "2026-04-20T17:11:46.59Z" },
{ url = "https://files.pythonhosted.org/packages/40/0b/620c39cefa9ae1b25ee7a2da9d8d3c278b095649cb8435c5e01ea64f7c17/pyrefly-0.62.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4666c6b65aea662e5f77b64dc91c091b7ea5cede6aa66c0f4cbae26480403583", size = 36228332, upload-time = "2026-04-20T17:11:50.523Z" },
{ url = "https://files.pythonhosted.org/packages/2d/fb/47b8b76438c12761e509a3666cd5a99d4af7f21976ba8385feb475cbfe30/pyrefly-0.62.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1aefab798f47d37c13ded791192fee9b39a6d2b12e31f38ae06a1f80c4b26e22", size = 38995741, upload-time = "2026-04-20T17:11:54.702Z" },
{ url = "https://files.pythonhosted.org/packages/55/d2/03bd17673f61147cd5609cd7d6a1455eeccc17a07a7e141ed9931b0c42c0/pyrefly-0.62.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8fa986b50d56740da1d7ae7c660a505143cb9d286fa98cc7e5f4a759cc6eaa5d", size = 37205321, upload-time = "2026-04-20T17:11:58.9Z" },
{ url = "https://files.pythonhosted.org/packages/75/14/20ba7b7f2d182f9b7c1e24a3041dac9b5730ae28cfe1614a2c98706650f2/pyrefly-0.62.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32e9b175805c82ffb967e4708f4910bace7e1a12736907380cc9afdbaabb0efb", size = 41786834, upload-time = "2026-04-20T17:12:03.221Z" },
{ url = "https://files.pythonhosted.org/packages/fa/c8/5a7ba88c4fa1b5090d877f70fa1b742b921b9e7d8d3f4b6b9b1ba1820850/pyrefly-0.62.0-py3-none-win32.whl", hash = "sha256:1cd98edc20cab5bac8016c9220ee66080e39bd22e7f0e9bb3e2c4e2be1555eed", size = 12010170, upload-time = "2026-04-20T17:12:06.791Z" },
{ url = "https://files.pythonhosted.org/packages/2e/78/d8f810de010ff2ed594c630c724fd817ef430963249e9eb396ce8f785e9d/pyrefly-0.62.0-py3-none-win_amd64.whl", hash = "sha256:6994f8ee7d6720325ee52207fbdaca98a799a1efe462bb5ba90c47160f7f3e6e", size = 12861816, upload-time = "2026-04-20T17:12:09.689Z" },
{ url = "https://files.pythonhosted.org/packages/c7/a9/ac824ef6a3f50b7c0ec5974471f8f2cb205cd1edd53a5abbcf7ba37feb5d/pyrefly-0.62.0-py3-none-win_arm64.whl", hash = "sha256:362a5d47a5ac5aaa5258091e878a1759ff8b687d8cf462af1c516144f7b0108a", size = 12352977, upload-time = "2026-04-20T17:12:12.736Z" },
]
[[package]]

View File

@ -1,253 +0,0 @@
# Configurable Suggested Questions After Answer
This document explains how to configure the "Suggested Questions After Answer" feature in Dify using environment variables.
## Overview
The suggested questions feature generates follow-up questions after each AI response to help users continue the conversation. By default, Dify generates 3 short questions (under 20 characters each), but you can customize this behavior to better fit your specific use case.
## Environment Variables
### `SUGGESTED_QUESTIONS_PROMPT`
**Description**: Custom prompt template for generating suggested questions.
**Default**:
```
Please help me predict the three most likely questions that human would ask, and keep each question under 20 characters.
MAKE SURE your output is the SAME language as the Assistant's latest response.
The output must be an array in JSON format following the specified schema:
["question1","question2","question3"]
```
**Usage Examples**:
1. **Technical/Developer Questions (Your Use Case)**:
```bash
export SUGGESTED_QUESTIONS_PROMPT='Please help me predict the five most likely technical follow-up questions a developer would ask. Focus on implementation details, best practices, and architecture considerations. Keep each question between 40-60 characters. Output must be JSON array: ["question1","question2","question3","question4","question5"]'
```
1. **Customer Support**:
```bash
export SUGGESTED_QUESTIONS_PROMPT='Generate 3 helpful follow-up questions that guide customers toward solving their own problems. Focus on troubleshooting steps and common issues. Keep questions under 30 characters. JSON format: ["q1","q2","q3"]'
```
1. **Educational Content**:
```bash
export SUGGESTED_QUESTIONS_PROMPT='Create 4 thought-provoking questions that help students deeper understand the topic. Focus on concepts, relationships, and applications. Questions should be 25-40 characters. JSON: ["question1","question2","question3","question4"]'
```
1. **Multilingual Support**:
```bash
export SUGGESTED_QUESTIONS_PROMPT='Generate exactly 3 follow-up questions in the same language as the conversation. Adapt question length appropriately for the language (Chinese: 10-15 chars, English: 20-30 chars, Arabic: 25-35 chars). Always output valid JSON array.'
```
**Important Notes**:
- The prompt must request JSON array output format
- Include language matching instructions for multilingual support
- Specify clear character limits or question count requirements
- Focus on your specific domain or use case
### `SUGGESTED_QUESTIONS_MAX_TOKENS`
**Description**: Maximum number of tokens for the LLM response.
**Default**: `256`
**Usage**:
```bash
export SUGGESTED_QUESTIONS_MAX_TOKENS=512 # For longer questions or more questions
```
**Recommended Values**:
- `256`: Default, good for 3-4 short questions
- `384`: Medium, good for 4-5 medium-length questions
- `512`: High, good for 5+ longer questions or complex prompts
- `1024`: Maximum, for very complex question generation
### `SUGGESTED_QUESTIONS_TEMPERATURE`
**Description**: Temperature parameter for LLM creativity.
**Default**: `0.0`
**Usage**:
```bash
export SUGGESTED_QUESTIONS_TEMPERATURE=0.3 # Balanced creativity
```
**Recommended Values**:
- `0.0-0.2`: Very focused, predictable questions (good for technical support)
- `0.3-0.5`: Balanced creativity and relevance (good for general use)
- `0.6-0.8`: More creative, diverse questions (good for brainstorming)
- `0.9-1.0`: Maximum creativity (good for educational exploration)
## Configuration Examples
### Example 1: Developer Documentation Chatbot
```bash
# .env file
SUGGESTED_QUESTIONS_PROMPT='Generate exactly 5 technical follow-up questions that developers would ask after reading code documentation. Focus on implementation details, edge cases, performance considerations, and best practices. Each question should be 40-60 characters long. Output as JSON array: ["question1","question2","question3","question4","question5"]'
SUGGESTED_QUESTIONS_MAX_TOKENS=512
SUGGESTED_QUESTIONS_TEMPERATURE=0.3
```
### Example 2: Customer Service Bot
```bash
# .env file
SUGGESTED_QUESTIONS_PROMPT='Create 3 actionable follow-up questions that help customers resolve their own issues. Focus on common problems, troubleshooting steps, and product features. Keep questions simple and under 25 characters. JSON: ["q1","q2","q3"]'
SUGGESTED_QUESTIONS_MAX_TOKENS=256
SUGGESTED_QUESTIONS_TEMPERATURE=0.1
```
### Example 3: Educational Tutor
```bash
# .env file
SUGGESTED_QUESTIONS_PROMPT='Generate 4 thought-provoking questions that help students deepen their understanding of the topic. Focus on relationships between concepts, practical applications, and critical thinking. Questions should be 30-45 characters. Output: ["question1","question2","question3","question4"]'
SUGGESTED_QUESTIONS_MAX_TOKENS=384
SUGGESTED_QUESTIONS_TEMPERATURE=0.6
```
## Implementation Details
### How It Works
1. **Environment Variable Loading**: The system checks for environment variables at startup
1. **Fallback to Defaults**: If no environment variables are set, original behavior is preserved
1. **Prompt Template**: The custom prompt is used as-is, allowing full control over question generation
1. **LLM Parameters**: Custom max_tokens and temperature are passed to the LLM API
1. **JSON Parsing**: The system expects JSON array output and parses it accordingly
### File Changes
The implementation modifies these files:
- `api/core/llm_generator/prompts.py`: Environment variable support
- `api/core/llm_generator/llm_generator.py`: Custom LLM parameters
- `api/.env.example`: Documentation of new variables
### Backward Compatibility
- ✅ **Zero Breaking Changes**: Works exactly as before if no environment variables are set
- ✅ **Default Behavior Preserved**: Original prompt and parameters used as fallbacks
- ✅ **No Database Changes**: Pure environment variable configuration
- ✅ **No UI Changes Required**: Configuration happens at deployment level
## Testing Your Configuration
### Local Testing
1. Set environment variables:
```bash
export SUGGESTED_QUESTIONS_PROMPT='Your test prompt...'
export SUGGESTED_QUESTIONS_MAX_TOKENS=300
export SUGGESTED_QUESTIONS_TEMPERATURE=0.4
```
1. Start Dify API:
```bash
cd api
python -m flask run --host 0.0.0.0 --port=5001 --debug
```
1. Test the feature in your chat application and verify the questions match your expectations.
### Monitoring
Monitor the following when testing:
- **Question Quality**: Are questions relevant and helpful?
- **Language Matching**: Do questions match the conversation language?
- **JSON Format**: Is output properly formatted as JSON array?
- **Length Constraints**: Do questions follow your length requirements?
- **Response Time**: Are the custom parameters affecting performance?
## Troubleshooting
### Common Issues
1. **Invalid JSON Output**:
- **Problem**: LLM doesn't return valid JSON
- **Solution**: Make sure your prompt explicitly requests JSON array format
1. **Questions Too Long/Short**:
- **Problem**: Questions don't follow length constraints
- **Solution**: Be more specific about character limits in your prompt
1. **Too Few/Many Questions**:
- **Problem**: Wrong number of questions generated
- **Solution**: Clearly specify the exact number in your prompt
1. **Language Mismatch**:
- **Problem**: Questions in wrong language
- **Solution**: Include explicit language matching instructions in prompt
1. **Performance Issues**:
- **Problem**: Slow response times
- **Solution**: Reduce `SUGGESTED_QUESTIONS_MAX_TOKENS` or simplify prompt
### Debug Logging
To debug your configuration, you can temporarily add logging to see the actual prompt and parameters being used:
```python
import logging
logger = logging.getLogger(__name__)
# In llm_generator.py
logger.info(f"Suggested questions prompt: {prompt}")
logger.info(f"Max tokens: {SUGGESTED_QUESTIONS_MAX_TOKENS}")
logger.info(f"Temperature: {SUGGESTED_QUESTIONS_TEMPERATURE}")
```
## Migration Guide
### From Default Configuration
If you're currently using the default configuration and want to customize:
1. **Assess Your Needs**: Determine what aspects need customization (question count, length, domain focus)
1. **Design Your Prompt**: Write a custom prompt that addresses your specific use case
1. **Choose Parameters**: Select appropriate max_tokens and temperature values
1. **Test Incrementally**: Start with small changes and test thoroughly
1. **Deploy Gradually**: Roll out to production after successful testing
### Best Practices
1. **Start Simple**: Begin with minimal changes to the default prompt
1. **Test Thoroughly**: Test with various conversation types and languages
1. **Monitor Performance**: Watch for impact on response times and costs
1. **Get User Feedback**: Collect feedback on question quality and relevance
1. **Iterate**: Refine your configuration based on real-world usage
## Future Enhancements
This environment variable approach provides immediate customization while maintaining backward compatibility. Future enhancements could include:
1. **App-Level Configuration**: Different apps with different suggested question settings
1. **Dynamic Prompts**: Context-aware prompts based on conversation content
1. **Multi-Model Support**: Different models for different types of questions
1. **Analytics Dashboard**: Insights into question effectiveness and usage patterns
1. **A/B Testing**: Built-in testing of different prompt configurations
For now, the environment variable approach offers a simple, reliable way to customize the suggested questions feature for your specific needs.

View File

@ -12,13 +12,14 @@
"e2e:middleware:down": "tsx ./scripts/setup.ts middleware-down",
"e2e:middleware:up": "tsx ./scripts/setup.ts middleware-up",
"e2e:reset": "tsx ./scripts/setup.ts reset",
"type-check": "tsc"
"type-check": "tsgo"
},
"devDependencies": {
"@cucumber/cucumber": "catalog:",
"@dify/tsconfig": "workspace:*",
"@playwright/test": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"tsx": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",

View File

@ -111,16 +111,6 @@
"count": 1
}
},
"web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/long-time-range-picker.tsx": {
"no-restricted-imports": {
"count": 2
}
},
"web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/range-selector.tsx": {
"no-restricted-imports": {
"count": 2
}
},
"web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/__tests__/svg-attribute-error-reproduction.spec.tsx": {
"no-console": {
"count": 19
@ -534,11 +524,6 @@
"count": 1
}
},
"web/app/components/app/configuration/debug/chat-user-input.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/app/configuration/debug/debug-with-multiple-model/chat-item.tsx": {
"ts/no-explicit-any": {
"count": 6
@ -584,7 +569,7 @@
},
"web/app/components/app/configuration/prompt-value-panel/index.tsx": {
"no-restricted-imports": {
"count": 2
"count": 1
}
},
"web/app/components/app/configuration/prompt-value-panel/utils.ts": {
@ -681,7 +666,7 @@
},
"web/app/components/app/overview/settings/index.tsx": {
"no-restricted-imports": {
"count": 3
"count": 2
},
"react/set-state-in-effect": {
"count": 3
@ -920,9 +905,6 @@
}
},
"web/app/components/base/chat/chat-with-history/inputs-form/content.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 3
}
@ -1036,9 +1018,6 @@
}
},
"web/app/components/base/chat/embedded-chatbot/inputs-form/content.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 3
}
@ -1175,11 +1154,6 @@
"count": 5
}
},
"web/app/components/base/features/new-feature-panel/moderation/form-generation.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/base/features/new-feature-panel/moderation/index.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -1195,7 +1169,7 @@
},
"web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx": {
"no-restricted-imports": {
"count": 2
"count": 1
}
},
"web/app/components/base/features/types.ts": {
@ -2438,11 +2412,6 @@
"count": 4
}
},
"web/app/components/datasets/documents/components/documents-header.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/documents/components/operations.tsx": {
"no-restricted-imports": {
"count": 1
@ -2576,11 +2545,6 @@
"count": 3
}
},
"web/app/components/datasets/documents/detail/completed/components/menu-bar.tsx": {
"no-restricted-imports": {
"count": 2
}
},
"web/app/components/datasets/documents/detail/completed/components/segment-list-content.tsx": {
"ts/no-non-null-asserted-optional-chain": {
"count": 1
@ -2596,11 +2560,6 @@
"count": 5
}
},
"web/app/components/datasets/documents/detail/completed/hooks/use-search-filter.ts": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/documents/detail/completed/index.tsx": {
"no-barrel-files/no-barrel-files": {
"count": 2
@ -2617,11 +2576,6 @@
"count": 1
}
},
"web/app/components/datasets/documents/detail/completed/status-item.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/documents/detail/context.ts": {
"ts/no-explicit-any": {
"count": 1
@ -2642,11 +2596,6 @@
"count": 1
}
},
"web/app/components/datasets/documents/detail/metadata/components/field-info.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/documents/detail/metadata/components/metadata-field-list.tsx": {
"ts/no-non-null-asserted-optional-chain": {
"count": 1
@ -3034,11 +2983,6 @@
"count": 1
}
},
"web/app/components/header/account-setting/language-page/index.tsx": {
"no-restricted-imports": {
"count": 2
}
},
"web/app/components/header/account-setting/members-page/invite-modal/index.tsx": {
"react/set-state-in-effect": {
"count": 3
@ -3121,7 +3065,7 @@
},
"web/app/components/header/account-setting/model-provider-page/model-modal/Form.tsx": {
"no-restricted-imports": {
"count": 2
"count": 1
},
"ts/no-explicit-any": {
"count": 6
@ -3273,16 +3217,13 @@
},
"web/app/components/plugins/install-plugin/install-from-github/index.tsx": {
"no-restricted-imports": {
"count": 2
"count": 1
},
"ts/no-explicit-any": {
"count": 3
}
},
"web/app/components/plugins/install-plugin/install-from-github/steps/selectPackage.tsx": {
"no-restricted-imports": {
"count": 2
},
"ts/no-explicit-any": {
"count": 1
}
@ -3386,9 +3327,6 @@
}
},
"web/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-form.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 8
}
@ -3492,7 +3430,7 @@
"count": 3
},
"no-restricted-imports": {
"count": 3
"count": 1
}
},
"web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.tsx": {
@ -3561,11 +3499,6 @@
"count": 7
}
},
"web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.tsx": {
"no-restricted-imports": {
"count": 2
}
},
"web/app/components/plugins/plugin-detail-panel/tool-selector/components/schema-modal.tsx": {
"no-restricted-imports": {
"count": 1
@ -3867,9 +3800,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 +4219,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 +4298,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 +4354,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 +4390,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 +4799,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 +4809,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 +4839,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 +4941,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 +5080,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 +5090,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 +5135,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 +5352,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 +5365,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 +5377,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 +5876,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 +5883,7 @@
},
"web/app/signin/one-more-step.tsx": {
"no-restricted-imports": {
"count": 2
"count": 1
},
"ts/no-explicit-any": {
"count": 1

View File

@ -88,7 +88,7 @@ See `[web/docs/overlay-migration.md](../../web/docs/overlay-migration.md)` for t
- `pnpm -C packages/dify-ui test` — Vitest unit tests for primitives.
- `pnpm -C packages/dify-ui storybook` — Storybook on the default port. Each primitive has `index.stories.tsx`.
- `pnpm -C packages/dify-ui type-check``tsc --noEmit` for this package only.
- `pnpm -C packages/dify-ui type-check``tsgo --noEmit` for this package only.
See `[AGENTS.md](./AGENTS.md)` for:

View File

@ -83,7 +83,7 @@
"storybook:build": "storybook build",
"test": "vp test",
"test:watch": "vp test --watch",
"type-check": "tsc"
"type-check": "tsgo"
},
"peerDependencies": {
"@base-ui/react": "catalog:",
@ -109,6 +109,7 @@
"@tailwindcss/vite": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@typescript/native-preview": "catalog:",
"@vitejs/plugin-react": "catalog:",
"@vitest/coverage-v8": "catalog:",
"class-variance-authority": "catalog:",

View File

@ -2,5 +2,7 @@
"extends": "@dify/tsconfig/react.json",
"compilerOptions": {
"types": ["vite-plus/test/globals"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts", "tailwind.config.ts"],
"exclude": ["node_modules", "dist", "storybook-static", "coverage"]
}

View File

@ -8,9 +8,10 @@
},
"scripts": {
"build": "vp pack",
"type-check": "tsc"
"type-check": "tsgo"
},
"dependencies": {
"@typescript/native-preview": "catalog:",
"typescript": "catalog:"
},
"devDependencies": {

View File

@ -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,

2247
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -47,12 +47,12 @@ overrides:
yaml@>=2.0.0 <2.8.3: 2.8.3
yauzl@<3.2.1: 3.2.1
catalog:
'@amplitude/analytics-browser': 2.39.0
'@amplitude/plugin-session-replay-browser': 1.27.7
'@amplitude/analytics-browser': 2.41.0
'@amplitude/plugin-session-replay-browser': 1.27.10
'@antfu/eslint-config': 8.2.0
'@base-ui/react': 1.4.1
'@chromatic-com/storybook': 5.1.2
'@cucumber/cucumber': 12.8.0
'@cucumber/cucumber': 12.8.1
'@egoist/tailwindcss-icons': 1.9.2
'@emoji-mart/data': 1.2.1
'@eslint-react/eslint-plugin': 3.0.0
@ -75,8 +75,8 @@ catalog:
'@mdx-js/react': 3.1.1
'@mdx-js/rollup': 3.1.1
'@monaco-editor/react': 4.7.0
'@next/eslint-plugin-next': 16.2.3
'@next/mdx': 16.2.3
'@next/eslint-plugin-next': 16.2.4
'@next/mdx': 16.2.4
'@orpc/client': 1.13.14
'@orpc/contract': 1.13.14
'@orpc/openapi-client': 1.13.14
@ -84,7 +84,7 @@ catalog:
'@playwright/test': 1.59.1
'@remixicon/react': 4.9.0
'@rgrove/parse-xml': 4.2.0
'@sentry/react': 10.48.0
'@sentry/react': 10.49.0
'@storybook/addon-docs': 10.3.5
'@storybook/addon-links': 10.3.5
'@storybook/addon-onboarding': 10.3.5
@ -95,23 +95,23 @@ catalog:
'@streamdown/math': 1.0.2
'@svgdotjs/svg.js': 3.2.5
'@t3-oss/env-nextjs': 0.13.11
'@tailwindcss/postcss': 4.2.2
'@tailwindcss/postcss': 4.2.4
'@tailwindcss/typography': 0.5.19
'@tailwindcss/vite': 4.2.2
'@tanstack/eslint-plugin-query': 5.99.0
'@tailwindcss/vite': 4.2.4
'@tanstack/eslint-plugin-query': 5.99.2
'@tanstack/react-devtools': 0.10.2
'@tanstack/react-form': 1.29.0
'@tanstack/react-form-devtools': 0.2.21
'@tanstack/react-query': 5.99.0
'@tanstack/react-query-devtools': 5.99.0
'@tanstack/react-virtual': 3.13.23
'@tanstack/react-form': 1.29.1
'@tanstack/react-form-devtools': 0.2.22
'@tanstack/react-query': 5.99.2
'@tanstack/react-query-devtools': 5.99.2
'@tanstack/react-virtual': 3.13.24
'@testing-library/dom': 10.4.1
'@testing-library/jest-dom': 6.9.1
'@testing-library/react': 16.3.2
'@testing-library/user-event': 14.6.1
'@tsslint/cli': 3.0.3
'@tsslint/compat-eslint': 3.0.3
'@tsslint/config': 3.0.3
'@tsslint/cli': 3.0.4
'@tsslint/compat-eslint': 3.0.4
'@tsslint/config': 3.0.4
'@types/js-cookie': 3.0.6
'@types/js-yaml': 4.0.9
'@types/negotiator': 0.6.4
@ -120,12 +120,12 @@ catalog:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3
'@types/sortablejs': 1.15.9
'@typescript-eslint/eslint-plugin': 8.58.2
'@typescript-eslint/parser': 8.58.2
'@typescript/native-preview': 7.0.0-dev.20260413.1
'@typescript-eslint/eslint-plugin': 8.59.0
'@typescript-eslint/parser': 8.59.0
'@typescript/native-preview': 7.0.0-dev.20260422.1
'@vitejs/plugin-react': 6.0.1
'@vitejs/plugin-rsc': 0.5.24
'@vitest/coverage-v8': 4.1.4
'@vitest/coverage-v8': 4.1.5
abcjs: 6.6.2
agentation: 3.0.2
ahooks: 3.9.7
@ -138,22 +138,22 @@ catalog:
cron-parser: 5.5.0
dayjs: 1.11.20
decimal.js: 10.6.0
dompurify: 3.4.0
dompurify: 3.4.1
echarts: 6.0.0
echarts-for-react: 3.0.6
elkjs: 0.11.1
embla-carousel-autoplay: 8.6.0
embla-carousel-react: 8.6.0
emoji-mart: 5.6.0
es-toolkit: 1.45.1
eslint: 10.2.0
es-toolkit: 1.46.0
eslint: 10.2.1
eslint-markdown: 0.6.1
eslint-plugin-better-tailwindcss: 4.4.1
eslint-plugin-hyoban: 0.14.1
eslint-plugin-markdown-preferences: 0.41.1
eslint-plugin-no-barrel-files: 1.3.1
eslint-plugin-react-refresh: 0.5.2
eslint-plugin-sonarjs: 4.0.2
eslint-plugin-sonarjs: 4.0.3
eslint-plugin-storybook: 10.3.5
fast-deep-equal: 3.1.3
happy-dom: 20.9.0
@ -161,7 +161,7 @@ catalog:
hono: 4.12.14
html-entities: 2.6.0
html-to-image: 1.11.13
i18next: 26.0.4
i18next: 26.0.6
i18next-resources-to-backend: 1.2.1
iconify-import-svg: 0.2.0
immer: 11.1.4
@ -171,21 +171,21 @@ catalog:
js-yaml: 4.1.1
jsonschema: 1.5.0
katex: 0.16.45
knip: 6.4.1
ky: 2.0.0
knip: 6.6.1
ky: 2.0.2
lamejs: 1.2.1
lexical: 0.43.0
loro-crdt: 1.10.8
loro-crdt: 1.11.1
mermaid: 11.14.0
mime: 4.1.0
mitt: 3.0.1
negotiator: 1.0.0
next: 16.2.3
next: 16.2.4
next-themes: 0.4.6
nuqs: 2.8.9
pinyin-pro: 3.28.1
playwright: 1.59.1
postcss: 8.5.9
postcss: 8.5.10
qrcode.react: 4.2.0
qs: 6.15.1
react: 19.2.5
@ -213,10 +213,10 @@ catalog:
streamdown: 2.5.0
string-ts: 2.3.1
tailwind-merge: 3.5.0
tailwindcss: 4.2.2
tailwindcss: 4.2.4
tldts: 7.0.28
tsx: 4.21.0
typescript: 6.0.2
typescript: 6.0.3
uglify-js: 3.19.3
unist-util-visit: 5.1.0
use-context-selector: 2.0.0

View File

@ -48,7 +48,7 @@
"build": "vp pack",
"lint": "eslint",
"lint:fix": "eslint --fix",
"type-check": "tsc",
"type-check": "tsgo",
"test": "vp test",
"test:coverage": "vp test --coverage",
"publish:check": "./scripts/publish.sh --dry-run",
@ -60,6 +60,7 @@
"@types/node": "catalog:",
"@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:",
"@typescript/native-preview": "catalog:",
"@vitest/coverage-v8": "catalog:",
"eslint": "catalog:",
"typescript": "catalog:",

View File

@ -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)

View File

@ -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)

View File

@ -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)
})
})

View File

@ -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', () => {

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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 && (

View File

@ -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)', () => {

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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()
})
})
})

View File

@ -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',
}),
}),
}))
})
})

View File

@ -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

View File

@ -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}
/>
</>
)
}

View File

@ -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()
})
})

View File

@ -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>
)
})
}
</>
)

View File

@ -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"

View File

@ -1,5 +1,10 @@
import type { FileUploadConfigResponse } from '@/models/common'
import type { Resolution, TransferMethod, TtsAutoPlay } from '@/types/app'
import type {
Model,
Resolution,
TransferMethod,
TtsAutoPlay,
} from '@/types/app'
export type EnabledOrDisabled = {
enabled?: boolean
@ -12,7 +17,10 @@ export type OpeningStatement = EnabledOrDisabled & {
suggested_questions?: string[]
}
export type SuggestedQuestionsAfterAnswer = EnabledOrDisabled
export type SuggestedQuestionsAfterAnswer = EnabledOrDisabled & {
model?: Model
prompt?: string
}
export type TextToSpeech = EnabledOrDisabled & {
language?: string

View File

@ -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])

View File

@ -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

View File

@ -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

View File

@ -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])

View File

@ -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
}

View File

@ -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', () => {

View File

@ -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>
)
}

View File

@ -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()
})
})

View File

@ -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>
</>
)

View File

@ -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>

View File

@ -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', () => {

View File

@ -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' })}

View File

@ -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}

View File

@ -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')
})
})

View File

@ -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
&& (

View File

@ -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 })
})
})

View File

@ -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', () => ({

View File

@ -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) {

View File

@ -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',

View File

@ -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}

View File

@ -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>
),
}))

View File

@ -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')
})
})

View File

@ -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">

View File

@ -8,13 +8,14 @@ import type { Node } from 'reactflow'
import type { ToolValue } from '@/app/components/workflow/block-selector/types'
import type { NodeOutPutVar } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
// eslint-disable-next-line no-restricted-imports -- legacy overlay migration is handled separately from this change
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { CollectionType } from '@/app/components/tools/types'
import Link from '@/next/link'
import {
@ -102,21 +103,15 @@ const ToolSelector: FC<Props> = ({
getSettingsValue,
} = state
const handleTriggerClick = (event: React.MouseEvent<HTMLElement>) => {
event.preventDefault()
const handleTriggerClick = () => {
if (disabled)
return
if (!currentProvider || !currentTool)
return
setIsShow(true)
}
// Determine portal open state based on controlled vs uncontrolled mode
const portalOpen = trigger ? controlledState : isShow
const onPortalOpenChange = trigger ? onControlledStateChange : setIsShow
const resolvedOffset = typeof offset === 'number' || typeof offset === 'function' ? undefined : offset
const sideOffset = typeof offset === 'number' ? offset : resolvedOffset?.mainAxis ?? 0
const alignOffset = typeof offset === 'number' ? 0 : resolvedOffset?.crossAxis ?? resolvedOffset?.alignmentAxis ?? 0
// Build error tooltip content
const renderErrorTip = () => (
@ -140,58 +135,57 @@ const ToolSelector: FC<Props> = ({
)
return (
<Popover
<PortalToFollowElem
placement={placement}
offset={offset}
open={portalOpen}
onOpenChange={onPortalOpenChange}
>
<PopoverTrigger
render={(
<div className="w-full">
{trigger}
{/* Default trigger - no value */}
{!trigger && !value?.provider_name && (
<ToolTrigger
isConfigure
open={isShow}
value={value}
provider={currentProvider}
/>
)}
{/* Default trigger - with value */}
{!trigger && value?.provider_name && (
<ToolItem
open={isShow}
icon={currentProvider?.icon || manifestIcon}
isMCPTool={currentProvider?.type === CollectionType.mcp}
providerName={value.provider_name}
providerShowName={value.provider_show_name}
toolLabel={value.tool_label || value.tool_name}
showSwitch={supportEnableSwitch}
switchValue={value.enabled}
onSwitchChange={handleEnabledChange}
onDelete={onDelete}
noAuth={currentProvider && currentTool && !currentProvider.is_team_authorization}
uninstalled={!currentProvider && inMarketPlace}
versionMismatch={currentProvider && inMarketPlace && !currentTool}
installInfo={manifest?.latest_package_identifier}
onInstall={handleInstall}
isError={(!currentProvider || !currentTool) && !inMarketPlace}
errorTip={renderErrorTip()}
/>
)}
</div>
)}
onClick={handleTriggerClick}
/>
<PopoverContent
placement={placement}
sideOffset={sideOffset}
alignOffset={alignOffset}
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
<PortalToFollowElemTrigger
className="w-full"
onClick={() => {
if (!currentProvider || !currentTool)
return
handleTriggerClick()
}}
>
{trigger}
{/* Default trigger - no value */}
{!trigger && !value?.provider_name && (
<ToolTrigger
isConfigure
open={isShow}
value={value}
provider={currentProvider}
/>
)}
{/* Default trigger - with value */}
{!trigger && value?.provider_name && (
<ToolItem
open={isShow}
icon={currentProvider?.icon || manifestIcon}
isMCPTool={currentProvider?.type === CollectionType.mcp}
providerName={value.provider_name}
providerShowName={value.provider_show_name}
toolLabel={value.tool_label || value.tool_name}
showSwitch={supportEnableSwitch}
switchValue={value.enabled}
onSwitchChange={handleEnabledChange}
onDelete={onDelete}
noAuth={currentProvider && currentTool && !currentProvider.is_team_authorization}
uninstalled={!currentProvider && inMarketPlace}
versionMismatch={currentProvider && inMarketPlace && !currentTool}
installInfo={manifest?.latest_package_identifier}
onInstall={handleInstall}
isError={(!currentProvider || !currentTool) && !inMarketPlace}
errorTip={renderErrorTip()}
/>
)}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-10">
<div className={cn(
'relative max-h-[642px] min-h-20 w-[361px] rounded-xl',
'border-[0.5px] border-components-panel-border bg-components-panel-bg-blur',
@ -246,8 +240,8 @@ const ToolSelector: FC<Props> = ({
onParamsFormChange={handleParamsFormChange}
/>
</div>
</PopoverContent>
</Popover>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}

View File

@ -6,6 +6,7 @@ import type { SiteInfo } from '@/models/share'
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 {
RiLoader2Line,
RiPlayLargeLine,
@ -17,7 +18,6 @@ import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uplo
import { StopCircle } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices'
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 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'
@ -128,12 +128,25 @@ const RunOnce: FC<IRunOnceProps> = ({
<div className="mt-1">
{item.type === 'select' && (
<Select
className="w-full"
defaultValue={inputs[item.key] as (string | number | undefined)}
onSelect={(i) => { handleInputsChange({ ...inputsRef.current, [item.key]: i.value }) }}
items={(item.options || []).map(i => ({ name: i, value: i }))}
allowSearch={false}
/>
value={inputs[item.key] ? String(inputs[item.key]) : null}
onValueChange={(nextValue) => {
if (!nextValue)
return
handleInputsChange({ ...inputsRef.current, [item.key]: nextValue })
}}
>
<SelectTrigger className="w-full">
{String(inputs[item.key] || item.default || t('placeholder.select', { ns: 'common' }))}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{(item.options || []).map(option => (
<SelectItem key={option} value={option}>
<SelectItemText>{option}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)}
{item.type === 'string' && (
<Input

View File

@ -8,17 +8,18 @@ import type { ToolDefaultValue, ToolValue } from './types'
import type { CustomCollectionBackend } from '@/app/components/tools/types'
import type { BlockEnum, OnSelectBlock } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { toast } from '@langgenius/dify-ui/toast'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
// eslint-disable-next-line no-restricted-imports -- legacy overlay migration is handled separately from this change
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import SearchBox from '@/app/components/plugins/marketplace/search-box'
import EditCustomToolModal from '@/app/components/tools/edit-custom-collection-modal'
import AllTools from '@/app/components/workflow/block-selector/all-tools'
@ -43,7 +44,7 @@ type Props = {
disabled: boolean
trigger: React.ReactNode
placement?: Placement
offset?: OffsetOptions | number
offset?: OffsetOptions
isShow: boolean
onShowChange: (isShow: boolean) => void
onSelect: (tool: ToolDefaultValue) => void
@ -120,6 +121,12 @@ const ToolPicker: FC<Props> = ({
const handleAddedCustomTool = invalidateCustomTools
const handleTriggerClick = () => {
if (disabled)
return
onShowChange(true)
}
const handleSelect = (_type: BlockEnum, tool?: ToolDefaultValue) => {
onSelect(tool!)
}
@ -133,11 +140,6 @@ const ToolPicker: FC<Props> = ({
setTrue: showEditCustomCollectionModal,
}] = useBoolean(false)
const handleShowAddCustomCollectionModal = useCallback(() => {
onShowChange(false)
showEditCustomCollectionModal()
}, [onShowChange, showEditCustomCollectionModal])
const doCreateCustomToolCollection = async (data: CustomCollectionBackend) => {
await createCustomCollection(data)
toast.success(t('api.actionSuccess', { ns: 'common' }))
@ -156,35 +158,20 @@ const ToolPicker: FC<Props> = ({
)
}
const resolvedTrigger = React.isValidElement(trigger) ? trigger : <div>{trigger}</div>
const resolvedOffset = typeof offset === 'object' && offset !== null
? offset as { mainAxis?: number, crossAxis?: number, alignmentAxis?: number | null }
: undefined
const sideOffset = typeof offset === 'number' ? offset : resolvedOffset?.mainAxis ?? 0
const alignOffset = typeof offset === 'number' ? 0 : resolvedOffset?.crossAxis ?? resolvedOffset?.alignmentAxis ?? 0
return (
<Popover
<PortalToFollowElem
placement={placement}
offset={offset}
open={isShow}
onOpenChange={(nextOpen) => {
if (disabled && nextOpen)
return
onShowChange(nextOpen)
}}
onOpenChange={onShowChange}
>
<PopoverTrigger
render={resolvedTrigger}
onClick={(e) => {
if (disabled)
e.preventDefault()
}}
/>
<PopoverContent
placement={placement}
sideOffset={sideOffset}
alignOffset={alignOffset}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
<PortalToFollowElemTrigger
onClick={handleTriggerClick}
>
{trigger}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1002">
<div className={cn('relative min-h-20 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs', panelClassName)}>
<div className="p-2 pb-1">
<SearchBox
@ -195,7 +182,7 @@ const ToolPicker: FC<Props> = ({
placeholder={t('searchTools', { ns: 'plugin' })!}
supportAddCustomTool={supportAddCustomTool}
onAddedCustomTool={handleAddedCustomTool}
onShowAddCustomCollectionModal={handleShowAddCustomCollectionModal}
onShowAddCustomCollectionModal={showEditCustomCollectionModal}
inputClassName="grow"
/>
</div>
@ -223,8 +210,8 @@ const ToolPicker: FC<Props> = ({
}}
/>
</div>
</PopoverContent>
</Popover>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}

View File

@ -194,7 +194,7 @@ describe('FormInputItem branches', () => {
},
})
fireEvent.click(screen.getByRole('button'))
fireEvent.click(screen.getByRole('combobox'))
expect(document.querySelector('img[src="/basic.svg"]')).toBeInTheDocument()
fireEvent.click(screen.getByText('basic'))
@ -261,7 +261,10 @@ describe('FormInputItem branches', () => {
expect(mockFetchDynamicOptions).toHaveBeenCalledTimes(1)
})
fireEvent.click(screen.getByRole('button'))
await waitFor(() => {
expect(screen.getByRole('combobox')).not.toBeDisabled()
})
fireEvent.click(screen.getByRole('combobox'))
expect(document.querySelector('img[src="/remote.svg"]')).toBeInTheDocument()
fireEvent.click(screen.getByText('remote'))

View File

@ -0,0 +1,373 @@
import type { ReactNode } from 'react'
import type { CommonNodeType } from '@/app/components/workflow/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { BlockEnum, NodeRunningStatus } from '@/app/components/workflow/types'
import { NodeSourceHandle, NodeTargetHandle } from '../node-handle'
type MockHooksState = {
availablePrevBlocks: BlockEnum[]
availableNextBlocks: BlockEnum[]
isChatMode: boolean
isReadOnly: boolean
}
type MockStoreState = {
shouldAutoOpenStartNodeSelector: boolean
setShouldAutoOpenStartNodeSelector?: (open: boolean) => void
setHasSelectedStartNode?: (selected: boolean) => void
}
const {
mockHandleNodeAdd,
mockSetShouldAutoOpenStartNodeSelector,
mockSetHasSelectedStartNode,
mockWorkflowStoreSetState,
mockHooksState,
mockStoreState,
} = vi.hoisted(() => {
const mockHooksState: MockHooksState = {
availablePrevBlocks: [],
availableNextBlocks: [],
isChatMode: false,
isReadOnly: false,
}
const mockStoreState: MockStoreState = {
shouldAutoOpenStartNodeSelector: false,
setShouldAutoOpenStartNodeSelector: undefined,
setHasSelectedStartNode: undefined,
}
return {
mockHandleNodeAdd: vi.fn(),
mockSetShouldAutoOpenStartNodeSelector: vi.fn(),
mockSetHasSelectedStartNode: vi.fn(),
mockWorkflowStoreSetState: vi.fn(),
mockHooksState,
mockStoreState,
}
})
type HandleProps = {
id?: string
className?: string
children?: ReactNode
onClick?: () => void
}
type BlockSelectorProps = {
open?: boolean
onOpenChange?: (open: boolean) => void
onSelect?: (type: BlockEnum, pluginDefaultValue?: { pluginId: string }) => void
triggerClassName?: (open: boolean) => string
}
vi.mock('reactflow', () => ({
Handle: ({ id, className, children, onClick }: HandleProps) => (
<div
data-testid={`handle-${id ?? 'unknown'}`}
data-handleid={id}
className={className}
onClick={onClick}
>
{children}
</div>
),
Position: {
Left: 'left',
Right: 'right',
},
}))
vi.mock('@/app/components/workflow/block-selector', () => ({
default: ({ open = false, onOpenChange, onSelect, triggerClassName }: BlockSelectorProps) => (
<div>
<button
type="button"
className={triggerClassName?.(open)}
onClick={(e) => {
e.stopPropagation()
onOpenChange?.(!open)
}}
>
add-node
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onSelect?.(BlockEnum.Answer, { pluginId: 'plugin-1' })
}}
>
select-node
</button>
</div>
),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useAvailableBlocks: () => ({
availablePrevBlocks: mockHooksState.availablePrevBlocks,
availableNextBlocks: mockHooksState.availableNextBlocks,
}),
useIsChatMode: () => mockHooksState.isChatMode,
useNodesInteractions: () => ({
handleNodeAdd: mockHandleNodeAdd,
}),
useNodesReadOnly: () => ({
getNodesReadOnly: () => mockHooksState.isReadOnly,
}),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: <T,>(selector: (state: MockStoreState) => T) => selector(mockStoreState),
useWorkflowStore: () => ({
setState: mockWorkflowStoreSetState,
}),
}))
const createNodeData = (overrides: Partial<CommonNodeType> = {}): CommonNodeType => ({
type: BlockEnum.Code,
title: 'Node',
desc: '',
selected: false,
...overrides,
})
const getAddNodeButton = () => screen.getByRole('button', { name: 'add-node' })
const queryAddNodeButton = () => screen.queryByRole('button', { name: 'add-node' })
const getSelectNodeButton = () => screen.getByRole('button', { name: 'select-node' })
const renderTargetHandle = (dataOverrides: Partial<CommonNodeType> = {}) => {
return render(
<NodeTargetHandle
id="target-node"
data={createNodeData(dataOverrides)}
handleId="target-handle"
nodeSelectorClassName="custom-selector"
handleClassName="custom-target-handle"
/>,
)
}
const renderSourceHandle = (
dataOverrides: Partial<CommonNodeType> = {},
propsOverrides: Partial<React.ComponentProps<typeof NodeSourceHandle>> = {},
) => {
return render(
<NodeSourceHandle
id="source-node"
data={createNodeData(dataOverrides)}
handleId="source-handle"
nodeSelectorClassName="custom-selector"
handleClassName="custom-source-handle"
{...propsOverrides}
/>,
)
}
describe('node-handle', () => {
beforeEach(() => {
vi.clearAllMocks()
mockHooksState.availablePrevBlocks = [BlockEnum.Code]
mockHooksState.availableNextBlocks = [BlockEnum.Code]
mockHooksState.isChatMode = false
mockHooksState.isReadOnly = false
mockStoreState.shouldAutoOpenStartNodeSelector = false
mockStoreState.setShouldAutoOpenStartNodeSelector = mockSetShouldAutoOpenStartNodeSelector
mockStoreState.setHasSelectedStartNode = mockSetHasSelectedStartNode
})
// Target-side tests cover selector visibility, connection locking, and status rendering.
describe('NodeTargetHandle', () => {
it('should toggle the target add trigger and select the next node', () => {
renderTargetHandle()
const handle = screen.getByTestId('handle-target-handle')
const addNodeButton = getAddNodeButton()
expect(addNodeButton).toHaveClass('custom-selector')
expect(addNodeButton).toHaveClass('opacity-0')
expect(addNodeButton).toHaveClass('pointer-events-none')
fireEvent.click(addNodeButton)
expect(addNodeButton).toHaveClass('opacity-100')
expect(addNodeButton).toHaveClass('pointer-events-auto')
fireEvent.click(handle)
expect(addNodeButton).toHaveClass('opacity-0')
fireEvent.click(getSelectNodeButton())
expect(mockHandleNodeAdd).toHaveBeenCalledWith(
{
nodeType: BlockEnum.Answer,
pluginDefaultValue: { pluginId: 'plugin-1' },
},
{
nextNodeId: 'target-node',
nextNodeTargetHandle: 'target-handle',
},
)
})
it('should not render the target add trigger when the handle is already connected', () => {
renderTargetHandle({
_connectedTargetHandleIds: ['target-handle'],
})
fireEvent.click(screen.getByTestId('handle-target-handle'))
expect(queryAddNodeButton()).not.toBeInTheDocument()
})
it('should hide the target handle for workflow entry nodes', () => {
renderTargetHandle({ type: BlockEnum.TriggerPlugin })
expect(screen.getByTestId('handle-target-handle')).toHaveClass('opacity-0')
})
it('should keep the target add trigger visible when the node is selected', () => {
renderTargetHandle({
selected: true,
})
expect(getAddNodeButton()).toHaveClass('opacity-100')
expect(getAddNodeButton()).toHaveClass('pointer-events-auto')
})
it.each([
['succeeded', NodeRunningStatus.Succeeded, 'after:bg-workflow-link-line-success-handle'],
['failed', NodeRunningStatus.Failed, 'after:bg-workflow-link-line-error-handle'],
['exception', NodeRunningStatus.Exception, 'after:bg-workflow-link-line-failure-handle'],
])('should render the target %s status class', (_label, runningStatus, expectedClass) => {
renderTargetHandle({
_runningStatus: runningStatus,
})
expect(screen.getByTestId('handle-target-handle')).toHaveClass(expectedClass)
expect(screen.getByTestId('handle-target-handle')).toHaveClass('custom-target-handle')
})
})
// Source-side tests cover selector opening paths, previous-node selection, and status styling.
describe('NodeSourceHandle', () => {
it('should toggle the source add trigger and select the previous node', () => {
renderSourceHandle()
const handle = screen.getByTestId('handle-source-handle')
const addNodeButton = getAddNodeButton()
expect(addNodeButton).toHaveClass('opacity-0')
fireEvent.click(addNodeButton)
expect(addNodeButton).toHaveClass('opacity-100')
expect(addNodeButton).toHaveClass('pointer-events-auto')
fireEvent.click(getSelectNodeButton())
expect(mockHandleNodeAdd).toHaveBeenCalledWith(
{
nodeType: BlockEnum.Answer,
pluginDefaultValue: { pluginId: 'plugin-1' },
},
{
prevNodeId: 'source-node',
prevNodeSourceHandle: 'source-handle',
},
)
fireEvent.click(handle)
expect(addNodeButton).toHaveClass('opacity-0')
})
it('should keep the source add trigger visible when the node is selected', () => {
renderSourceHandle({
selected: true,
})
const addNodeButton = getAddNodeButton()
expect(addNodeButton).toHaveClass('custom-selector')
expect(addNodeButton).toHaveClass('opacity-100')
expect(addNodeButton).toHaveClass('pointer-events-auto')
})
it.each([
['succeeded', NodeRunningStatus.Succeeded, undefined, 'after:bg-workflow-link-line-success-handle'],
['failed', NodeRunningStatus.Failed, undefined, 'after:bg-workflow-link-line-error-handle'],
['exception', NodeRunningStatus.Exception, true, 'after:bg-workflow-link-line-failure-handle'],
])('should render the source %s status class', (_label, runningStatus, showExceptionStatus, expectedClass) => {
renderSourceHandle(
{
_runningStatus: runningStatus,
},
{
showExceptionStatus,
},
)
expect(screen.getByTestId('handle-source-handle')).toHaveClass(expectedClass)
expect(screen.getByTestId('handle-source-handle')).toHaveClass('custom-source-handle')
})
})
// Auto-open tests cover workflow start-trigger variants, chat-mode bypass, and store fallback paths.
describe('NodeSourceHandle auto-open', () => {
it.each([
BlockEnum.Start,
BlockEnum.TriggerSchedule,
BlockEnum.TriggerWebhook,
BlockEnum.TriggerPlugin,
])('should auto-open immediately for %s nodes', (type) => {
mockStoreState.shouldAutoOpenStartNodeSelector = true
renderSourceHandle({ type })
const addNodeButton = getAddNodeButton()
expect(addNodeButton).toHaveClass('opacity-100')
expect(addNodeButton).toHaveClass('pointer-events-auto')
expect(mockSetShouldAutoOpenStartNodeSelector).toHaveBeenCalledWith(false)
expect(mockSetHasSelectedStartNode).toHaveBeenCalledWith(false)
})
it('should skip source auto-open in chat mode and only reset the start selector flag', () => {
mockHooksState.isChatMode = true
mockStoreState.shouldAutoOpenStartNodeSelector = true
renderSourceHandle({ type: BlockEnum.Start })
expect(getAddNodeButton()).toHaveClass('opacity-0')
expect(mockSetShouldAutoOpenStartNodeSelector).toHaveBeenCalledWith(false)
expect(mockSetHasSelectedStartNode).not.toHaveBeenCalled()
})
it('should use the workflow store fallback when the selector setters are unavailable', () => {
mockStoreState.shouldAutoOpenStartNodeSelector = true
mockStoreState.setShouldAutoOpenStartNodeSelector = undefined
mockStoreState.setHasSelectedStartNode = undefined
renderSourceHandle({ type: BlockEnum.Start })
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ shouldAutoOpenStartNodeSelector: false })
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ hasSelectedStartNode: false })
})
it('should not auto-open when the node type is not a workflow entry node', () => {
mockStoreState.shouldAutoOpenStartNodeSelector = true
renderSourceHandle({ type: BlockEnum.Code })
expect(getAddNodeButton()).toHaveClass('opacity-0')
expect(mockSetShouldAutoOpenStartNodeSelector).not.toHaveBeenCalled()
expect(mockSetHasSelectedStartNode).not.toHaveBeenCalled()
expect(mockWorkflowStoreSetState).not.toHaveBeenCalled()
})
})
})

View File

@ -3,6 +3,7 @@ import type { FC } from 'react'
import type { InputVar } from '../../../../types'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import {
RiDeleteBinLine,
} from '@remixicon/react'
@ -17,7 +18,6 @@ import { Variable02 } from '@/app/components/base/icons/src/vender/solid/develop
import TextGenerationImageUploader from '@/app/components/base/image-uploader/text-generation-image-uploader'
import Input from '@/app/components/base/input'
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
import Select from '@/app/components/base/select'
import Textarea from '@/app/components/base/textarea'
import { VarBlockIcon } from '@/app/components/workflow/block-icon'
import { useHooksStore } from '@/app/components/workflow/hooks-store'
@ -181,12 +181,25 @@ const FormItem: FC<Props> = ({
{
type === InputVarType.select && (
<Select
className="w-full"
defaultValue={value || payload.default || ''}
items={payload.options?.map(option => ({ name: option, value: option })) || []}
onSelect={i => onChange(i.value)}
allowSearch={false}
/>
value={value || payload.default || null}
onValueChange={(nextValue) => {
if (!nextValue)
return
onChange(nextValue)
}}
>
<SelectTrigger className="w-full">
{String(value || payload.default || t('placeholder.select', { ns: 'common' }))}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{(payload.options || []).map(option => (
<SelectItem key={option} value={option}>
<SelectItemText>{option}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)
}

View File

@ -6,10 +6,10 @@ import type { Event, Tool } from '@/app/components/tools/types'
import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types'
import type { ToolWithProvider, ValueSelector, Var } 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 { useEffect, useMemo, useState } from 'react'
import CheckboxList from '@/app/components/base/checkbox-list'
import Input from '@/app/components/base/input'
import { SimpleSelect } from '@/app/components/base/select'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import AppSelector from '@/app/components/plugins/plugin-detail-panel/app-selector'
import ModelParameterModal from '@/app/components/plugins/plugin-detail-panel/model-selector'
@ -32,7 +32,6 @@ import {
getSelectedLabels,
getTargetVarType,
getVarKindType,
hasOptionIcon,
mapSelectItems,
normalizeVariableSelectorValue,
} from './form-input-item.helpers'
@ -47,17 +46,19 @@ type Props = {
nodeId: string
schema: CredentialFormSchema
value: ResourceVarInputs
onChange: (value: any) => void
onChange: (value: ResourceVarInputs) => void
inPanel?: boolean
currentTool?: Tool | Event
currentProvider?: ToolWithProvider | TriggerWithProvider
showManageInputField?: boolean
onManageInputField?: () => void
extraParams?: Record<string, any>
extraParams?: Record<string, unknown>
providerType?: string
disableVariableInsertion?: boolean
}
type FormInputValue = string | number | boolean | string[] | Record<string, unknown> | null | undefined
const FormInputItem: FC<Props> = ({
readOnly,
nodeId,
@ -195,22 +196,25 @@ const FormInputItem: FC<Props> = ({
}
}
const handleValueChange = (newValue: any) => {
const handleValueChange = (newValue: FormInputValue) => {
const nextType = getVarKindType(formState) ?? varInput?.type ?? VarKindType.constant
onChange({
...value,
[variable]: {
...varInput,
type: getVarKindType(formState),
value: isNumber ? Number.parseFloat(newValue) : newValue,
type: nextType,
value: isNumber ? Number.parseFloat(String(newValue ?? '')) : newValue,
},
})
}
const handleAppOrModelSelect = (newValue: any) => {
const handleAppOrModelSelect = (newValue: Record<string, unknown>) => {
const nextType = getVarKindType(formState) ?? varInput?.type ?? VarKindType.constant
onChange({
...value,
[variable]: {
...varInput,
type: nextType,
value: newValue,
},
})
@ -271,6 +275,8 @@ const FormInputItem: FC<Props> = ({
},
})
}
const selectedStaticOption = staticSelectItems.find(item => item.value === (varInput?.value as string | undefined)) ?? null
const selectedDynamicOption = dynamicSelectItems.find(item => item.value === (varInput?.value as string | undefined)) ?? null
return (
<div className={cn('gap-1', !(isShowJSONEditor && isConstant) && 'flex')}>
@ -315,24 +321,26 @@ const FormInputItem: FC<Props> = ({
/>
)}
{isSelect && isConstant && !isMultipleSelect && (
<SimpleSelect
wrapperClassName="h-8 grow"
<Select
value={selectedStaticOption?.value ?? null}
disabled={readOnly}
defaultValue={varInput?.value as string | undefined}
items={staticSelectItems}
onSelect={item => handleValueChange(item.value as string)}
placeholder={placeholder?.[language] || placeholder?.en_US}
renderOption={hasOptionIcon(visibleSelectOptions)
? ({ item }) => (
<div className="flex items-center">
{item.icon && (
<img src={item.icon} alt="" className="mr-2 h-4 w-4" />
)}
<span>{item.name}</span>
</div>
)
: undefined}
/>
onValueChange={value => value && handleValueChange(value)}
>
<SelectTrigger className="h-8 grow">
{selectedStaticOption?.name ?? placeholder?.[language] ?? placeholder?.en_US}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{staticSelectItems.map(item => (
<SelectItem key={item.value} value={item.value}>
{item.icon && (
<img src={item.icon} alt="" className="mr-2 h-4 w-4 shrink-0" />
)}
<SelectItemText>{item.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)}
{isSelect && isConstant && isMultipleSelect && (
<MultiSelectField
@ -345,22 +353,26 @@ const FormInputItem: FC<Props> = ({
/>
)}
{isDynamicSelect && !isMultipleSelect && (
<SimpleSelect
wrapperClassName="h-8 grow"
<Select
value={selectedDynamicOption?.value ?? null}
disabled={readOnly || isLoadingOptions}
defaultValue={varInput?.value as string | undefined}
items={dynamicSelectItems}
onSelect={item => handleValueChange(item.value as string)}
placeholder={isLoadingOptions ? 'Loading...' : (placeholder?.[language] || placeholder?.en_US)}
renderOption={({ item }) => (
<div className="flex items-center">
{item.icon && (
<img src={item.icon} alt="" className="mr-2 h-4 w-4" />
)}
<span>{item.name}</span>
</div>
)}
/>
onValueChange={value => value && handleValueChange(value)}
>
<SelectTrigger className="h-8 grow">
{selectedDynamicOption?.name ?? (isLoadingOptions ? 'Loading...' : (placeholder?.[language] ?? placeholder?.en_US))}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{dynamicSelectItems.map(item => (
<SelectItem key={item.value} value={item.value}>
{item.icon && (
<img src={item.icon} alt="" className="mr-2 h-4 w-4 shrink-0" />
)}
<SelectItemText>{item.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)}
{isDynamicSelect && isMultipleSelect && (
<MultiSelectField

View File

@ -36,6 +36,16 @@ type NodeHandleProps = {
showExceptionStatus?: boolean
} & Pick<Node, 'id' | 'data'>
const canAutoOpenStartNodeSelector = (nodeType: BlockEnum, isChatMode: boolean) => {
if (isChatMode)
return false
return nodeType === BlockEnum.Start
|| nodeType === BlockEnum.TriggerSchedule
|| nodeType === BlockEnum.TriggerWebhook
|| nodeType === BlockEnum.TriggerPlugin
}
export const NodeTargetHandle = memo(({
id,
data,
@ -103,11 +113,11 @@ export const NodeTargetHandle = memo(({
asChild
placement="left"
triggerClassName={open => `
hidden absolute left-0 top-0 pointer-events-none
absolute left-0 top-0 opacity-0 pointer-events-none transition-opacity duration-150
${nodeSelectorClassName}
group-hover:flex!
${data.selected && 'flex!'}
${open && 'flex!'}
group-hover:opacity-100 group-hover:pointer-events-auto
${data.selected && 'opacity-100 pointer-events-auto'}
${open && 'opacity-100 pointer-events-auto'}
`}
availableBlocksTypes={availablePrevBlocks}
/>
@ -132,12 +142,13 @@ export const NodeSourceHandle = memo(({
const setShouldAutoOpenStartNodeSelector = useStore(s => s.setShouldAutoOpenStartNodeSelector)
const setHasSelectedStartNode = useStore(s => s.setHasSelectedStartNode)
const workflowStoreApi = useWorkflowStore()
const [open, setOpen] = useState(false)
const { handleNodeAdd } = useNodesInteractions()
const { getNodesReadOnly } = useNodesReadOnly()
const { availableNextBlocks } = useAvailableBlocks(data.type, data.isInIteration || data.isInLoop)
const isConnectable = !!availableNextBlocks.length
const isChatMode = useIsChatMode()
const shouldAutoOpen = shouldAutoOpenStartNodeSelector && canAutoOpenStartNodeSelector(data.type, isChatMode)
const [open, setOpen] = useState(() => shouldAutoOpen)
const connected = data._connectedSourceHandleIds?.includes(handleId)
const handleOpenChange = useCallback((v: boolean) => {
@ -169,8 +180,7 @@ export const NodeSourceHandle = memo(({
return
}
if (data.type === BlockEnum.Start || data.type === BlockEnum.TriggerSchedule || data.type === BlockEnum.TriggerWebhook || data.type === BlockEnum.TriggerPlugin) {
setOpen(true)
if (canAutoOpenStartNodeSelector(data.type, false)) {
if (setShouldAutoOpenStartNodeSelector)
setShouldAutoOpenStartNodeSelector(false)
else
@ -221,11 +231,11 @@ export const NodeSourceHandle = memo(({
onSelect={handleSelect}
asChild
triggerClassName={open => `
hidden absolute top-0 left-0 pointer-events-none
absolute top-0 left-0 opacity-0 pointer-events-none transition-opacity duration-150
${nodeSelectorClassName}
group-hover:flex!
${data.selected && 'flex!'}
${open && 'flex!'}
group-hover:opacity-100 group-hover:pointer-events-auto
${data.selected && 'opacity-100 pointer-events-auto'}
${open && 'opacity-100 pointer-events-auto'}
`}
availableBlocksTypes={availableNextBlocks}
/>

View File

@ -2,9 +2,9 @@
import type { FC } from 'react'
import type { CredentialFormSchema, CredentialFormSchemaNumberInput, CredentialFormSchemaSelect } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { Var } from '@/app/components/workflow/types'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import * as React from 'react'
import { useCallback } from 'react'
import { SimpleSelect } from '@/app/components/base/select'
import { useCallback, useMemo } from 'react'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types'
@ -30,6 +30,18 @@ const ConstantField: FC<Props> = ({
}) => {
const language = useLanguage()
const placeholder = (schema as CredentialFormSchemaSelect).placeholder
const selectOptions = useMemo(() => {
if (schema.type !== FormTypeEnum.select && schema.type !== FormTypeEnum.dynamicSelect)
return []
return (schema as CredentialFormSchemaSelect).options.map(option => ({
value: String(option.value),
name: option.label[language] || option.label.en_US,
}))
}, [language, schema])
const selectedOption = useMemo(() => {
return selectOptions.find(option => option.value === String(value)) ?? null
}, [selectOptions, value])
const handleStaticChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value === '' ? '' : Number.parseFloat(e.target.value)
onChange(value, VarKindType.constant)
@ -42,17 +54,27 @@ const ConstantField: FC<Props> = ({
return (
<>
{(schema.type === FormTypeEnum.select || schema.type === FormTypeEnum.dynamicSelect) && (
<SimpleSelect
wrapperClassName="w-full h-8!"
className="flex items-center"
disabled={readonly}
defaultValue={value}
items={(schema as CredentialFormSchemaSelect).options.map(option => ({ value: option.value, name: option.label[language] || option.label.en_US }))}
onSelect={item => handleSelectChange(item.value)}
placeholder={placeholder?.[language] || placeholder?.en_US}
<Select
value={selectedOption?.value ?? null}
disabled={readonly || isLoading}
onValueChange={nextValue => nextValue && handleSelectChange(nextValue)}
onOpenChange={onOpenChange}
isLoading={isLoading}
/>
>
<SelectTrigger
className="h-8 w-full"
disabled={readonly || isLoading}
>
{selectedOption?.name ?? placeholder?.[language] ?? placeholder?.en_US}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{selectOptions.map(option => (
<SelectItem key={option.value} value={option.value}>
<SelectItemText>{option.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)}
{schema.type === FormTypeEnum.textNumber && (
<input

View File

@ -15,6 +15,7 @@ import type {
Var,
} from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { RiDeleteBinLine } from '@remixicon/react'
import { produce } from 'immer'
import {
@ -24,7 +25,6 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
import { SimpleSelect as Select } from '@/app/components/base/select'
import { useIsChatMode } from '@/app/components/workflow/hooks/use-workflow'
import { getVarType } from '@/app/components/workflow/nodes/_base/components/variable/utils'
import BoolValue from '@/app/components/workflow/panel/chat-variable-panel/components/bool-value'
@ -164,7 +164,7 @@ const ConditionItem = ({
}, [condition, doUpdateCondition, isArrayValue])
const isSelect = condition.comparison_operator && [ComparisonOperator.in, ComparisonOperator.notIn].includes(condition.comparison_operator)
const selectOptions = useMemo(() => {
const selectOptions = useMemo<Array<{ name: string, value: string }>>(() => {
if (isSelect) {
if (fileAttr?.key === 'type' || condition.comparison_operator === ComparisonOperator.allOf) {
return FILE_TYPE_OPTIONS.map(item => ({
@ -190,6 +190,7 @@ const ConditionItem = ({
name: item,
value: item,
}))
const selectedSubVarOption = subVarOptions.find(item => item.value === condition.key) ?? null
const handleSubVarKeyChange = useCallback((key: string) => {
const newCondition = produce(condition, (draft) => {
@ -257,6 +258,9 @@ const ConditionItem = ({
return true
return false
}, [condition])
const selectedSelectValue = isArrayValue ? (condition.value as string[])?.[0] : (condition.value as string)
const selectedSelectOption = selectOptions.find(item => item.value === selectedSelectValue) ?? null
return (
<div className={cn('mb-1 flex last-of-type:mb-0', className)}>
<div className={cn(
@ -269,26 +273,38 @@ const ConditionItem = ({
{isSubVarSelect
? (
<Select
wrapperClassName="h-6"
className="pl-0 text-xs"
optionWrapClassName="w-[165px] max-h-none"
defaultValue={condition.key}
items={subVarOptions}
onSelect={item => handleSubVarKeyChange(item.value as string)}
renderTrigger={item => (
item
value={selectedSubVarOption?.value ?? null}
onValueChange={value => value && handleSubVarKeyChange(value)}
>
<SelectTrigger
render={<div />}
nativeButton={false}
className="h-6 border-0 bg-transparent p-0 hover:bg-transparent focus-visible:bg-transparent [&>*:last-child]:hidden"
>
{selectedSubVarOption
? (
<div className="flex cursor-pointer justify-start">
<div className="inline-flex h-6 max-w-full items-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark px-1.5 text-text-accent shadow-xs">
<Variable02 className="h-3.5 w-3.5 shrink-0 text-text-accent" />
<div className="ml-0.5 truncate system-xs-medium">{item?.name}</div>
<div className="ml-0.5 truncate system-xs-medium">{selectedSubVarOption.name}</div>
</div>
</div>
)
: <div className="text-left system-sm-regular text-components-input-text-placeholder">{t('placeholder.select', { ns: 'common' })}</div>
)}
hideChecked
/>
: <div className="text-left system-sm-regular text-components-input-text-placeholder">{t('placeholder.select', { ns: 'common' })}</div>}
</SelectTrigger>
<SelectContent popupClassName="w-[165px]" listClassName="max-h-none p-1">
{subVarOptions.map(option => (
<SelectItem key={option.value} value={option.value} className="h-8 py-0 pr-5 pl-1">
<div className="flex h-6 items-center justify-between">
<div className="flex h-full items-center">
<Variable02 className="mr-[5px] h-3.5 w-3.5 text-text-accent" />
<SelectItemText className="mr-0 px-0 system-sm-medium text-text-secondary">{option.name}</SelectItemText>
</div>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
)
: (
<ConditionVarSelector
@ -353,15 +369,18 @@ const ConditionItem = ({
{
!comparisonOperatorNotRequireValue(condition.comparison_operator) && isSelect && (
<div className="border-t border-t-divider-subtle">
<Select
wrapperClassName="h-8"
className="rounded-t-none px-2 text-xs"
defaultValue={isArrayValue ? (condition.value as string[])?.[0] : (condition.value as string)}
items={selectOptions}
onSelect={item => handleUpdateConditionValue(item.value as string)}
hideChecked
notClearable
/>
<Select value={selectedSelectOption?.value ?? null} onValueChange={value => value && handleUpdateConditionValue(value)}>
<SelectTrigger className="h-8 rounded-t-none border-0 px-2 text-xs hover:bg-components-input-bg-normal focus-visible:bg-components-input-bg-normal">
{selectedSelectOption?.name ?? t('placeholder.select', { ns: 'common' })}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{selectOptions.map(option => (
<SelectItem key={option.value} value={option.value} className="text-xs">
<SelectItemText>{option.name}</SelectItemText>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}

View File

@ -4,6 +4,7 @@ import type { Node, NodeOutPutVar, Var } from '../../../types'
import type { CaseItem, HandleAddCondition, HandleAddSubVariableCondition, HandleRemoveCondition, handleRemoveSubVariableCondition, HandleToggleConditionLogicalOperator, HandleToggleSubVariableConditionLogicalOperator, HandleUpdateCondition, HandleUpdateSubVariableCondition } from '../types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import {
RiAddLine,
RiDeleteBinLine,
@ -14,7 +15,6 @@ import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ReactSortable } from 'react-sortablejs'
import { PortalSelect as Select } from '@/app/components/base/select'
import { VarType } from '../../../types'
import { SUB_VARIABLES } from '../../constants'
import { useGetAvailableVars } from '../../variable-assigner/hooks'
@ -167,11 +167,15 @@ const ConditionWrap: FC<Props> = ({
{isSubVariable
? (
<Select
popupInnerClassName="w-[165px] max-h-none"
onSelect={value => handleAddSubVariableCondition?.(caseId!, conditionId!, value.value as string)}
items={subVarOptions}
value=""
renderTrigger={() => (
value={null}
disabled={readOnly}
onValueChange={value => value && handleAddSubVariableCondition?.(caseId!, conditionId!, value)}
>
<SelectTrigger
render={<div />}
nativeButton={false}
className="border-0 bg-transparent p-0 hover:bg-transparent focus-visible:bg-transparent [&>*:last-child]:hidden"
>
<Button
size="small"
disabled={readOnly}
@ -179,9 +183,15 @@ const ConditionWrap: FC<Props> = ({
<RiAddLine className="mr-1 h-3.5 w-3.5" />
{t('nodes.ifElse.addSubVariable', { ns: 'workflow' })}
</Button>
)}
hideChecked
/>
</SelectTrigger>
<SelectContent popupClassName="w-[165px]" listClassName="max-h-none p-1">
{subVarOptions.map(option => (
<SelectItem key={option.value} value={option.value}>
<SelectItemText>{option.name}</SelectItemText>
</SelectItem>
))}
</SelectContent>
</Select>
)
: (
<ConditionAdd

View File

@ -237,8 +237,8 @@ describe('iteration path', () => {
await user.click(screen.getByRole('button', { name: 'pick-output-var' }))
await user.click(screen.getAllByRole('switch')[0]!)
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '7' } })
await user.click(screen.getByRole('button', { name: /workflow.nodes.iteration.ErrorMethod.operationTerminated/i }))
await user.click(screen.getByText('workflow.nodes.iteration.ErrorMethod.continueOnError'))
await user.click(screen.getByRole('combobox'))
await user.click(screen.getByRole('option', { name: 'workflow.nodes.iteration.ErrorMethod.continueOnError' }))
await user.click(screen.getAllByRole('switch')[1]!)
expect(handleInputChange).toHaveBeenCalledWith(['node-1', 'items'], 'variable', { type: VarType.arrayString })

View File

@ -1,5 +1,4 @@
import type { IterationNodeType } from '../types'
import type { Item } from '@/app/components/base/select'
import type { Var } from '@/app/components/workflow/types'
import { act, renderHook } from '@testing-library/react'
import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types'
@ -73,6 +72,11 @@ const createVar = (type: VarType, variable = 'test.variable'): Var => ({
type,
})
type SelectOption = {
name: string
value: string | number
}
describe('iteration/use-config', () => {
const mockSetInputs = vi.fn()
const mockDeleteNodeInspectorVars = vi.fn()
@ -148,7 +152,7 @@ describe('iteration/use-config', () => {
it('should update parallel, error-mode, and flatten options', () => {
const { result } = renderHook(() => useConfig('iteration-node', currentInputs))
const item: Item = { name: 'Continue', value: ErrorHandleMode.ContinueOnError }
const item: SelectOption = { name: 'Continue', value: ErrorHandleMode.ContinueOnError }
act(() => {
result.current.changeParallel(true)
@ -170,4 +174,52 @@ describe('iteration/use-config', () => {
flatten_output: true,
}))
})
it('should fall back to empty selectors and empty plugin lists when metadata is missing', () => {
mockUseStore.mockReturnValue(undefined)
mockUseAllBuiltInTools.mockReturnValue({ data: undefined })
mockUseAllCustomTools.mockReturnValue({ data: undefined })
mockUseAllWorkflowTools.mockReturnValue({ data: undefined })
mockUseAllMCPTools.mockReturnValue({ data: undefined })
const { result } = renderHook(() => useConfig('iteration-node', currentInputs))
expect(mockToNodeOutputVars).toHaveBeenCalledWith(
[{ id: 'child-node' }],
false,
undefined,
[],
[],
[],
{
buildInTools: [],
customTools: [],
workflowTools: [],
mcpTools: [],
dataSourceList: [],
},
)
act(() => {
result.current.handleInputChange('', VarKindType.variable)
})
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
iterator_selector: [],
iterator_input_type: VarType.arrayString,
}))
mockSetInputs.mockClear()
mockDeleteNodeInspectorVars.mockClear()
act(() => {
result.current.handleOutputVarChange('', VarKindType.variable, createVar(VarType.boolean, 'child.flag'))
})
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
output_selector: [],
output_type: VarType.arrayString,
}))
expect(mockDeleteNodeInspectorVars).toHaveBeenCalledWith('iteration-node')
})
})

View File

@ -1,12 +1,12 @@
import type { FC } from 'react'
import type { IterationNodeType } from './types'
import type { NodePanelProps } from '@/app/components/workflow/types'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { Slider } from '@langgenius/dify-ui/slider'
import { Switch } from '@langgenius/dify-ui/switch'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import Select from '@/app/components/base/select'
import Field from '@/app/components/workflow/nodes/_base/components/field'
import { ErrorHandleMode } from '@/app/components/workflow/types'
import { MAX_PARALLEL_LIMIT } from '@/config'
@ -49,6 +49,7 @@ const Panel: FC<NodePanelProps<IterationNodeType>> = ({
changeParallelNums,
changeFlattenOutput,
} = useConfig(id, data)
const selectedResponseMethod = responseMethod.find(item => item.value === inputs.error_handle_mode)
return (
<div className="pt-2 pb-2">
@ -119,7 +120,28 @@ const Panel: FC<NodePanelProps<IterationNodeType>> = ({
<div className="px-4 py-2">
<Field title={t(`${i18nPrefix}.errorResponseMethod`, { ns: 'workflow' })}>
<Select items={responseMethod} defaultValue={inputs.error_handle_mode} onSelect={changeErrorResponseMode} allowSearch={false} />
<Select
value={selectedResponseMethod ? String(selectedResponseMethod.value) : null}
onValueChange={(nextValue) => {
if (!nextValue)
return
const nextItem = responseMethod.find(item => String(item.value) === nextValue)
if (nextItem)
changeErrorResponseMode(nextItem)
}}
>
<SelectTrigger className="w-full">
{selectedResponseMethod?.name ?? t('placeholder.select', { ns: 'common' })}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{responseMethod.map(item => (
<SelectItem key={item.value} value={String(item.value)}>
<SelectItemText>{item.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</div>

View File

@ -1,6 +1,5 @@
import type { ErrorHandleMode, ValueSelector, Var } from '../../types'
import type { IterationNodeType } from './types'
import type { Item } from '@/app/components/base/select'
import type { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types'
import { isEqual } from 'es-toolkit/predicate'
import { produce } from 'immer'
@ -22,6 +21,11 @@ import { VarType } from '../../types'
import { toNodeOutputVars } from '../_base/components/variable/utils'
import useNodeCrud from '../_base/hooks/use-node-crud'
type SelectOption = {
value: string | number
name: string
}
const useConfig = (id: string, payload: IterationNodeType) => {
const {
deleteNodeInspectorVars,
@ -92,7 +96,7 @@ const useConfig = (id: string, payload: IterationNodeType) => {
setInputs(newInputs)
}, [inputs, setInputs])
const changeErrorResponseMode = useCallback((item: Item) => {
const changeErrorResponseMode = useCallback((item: SelectOption) => {
const newInputs = produce(inputs, (draft) => {
draft.error_handle_mode = item.value as ErrorHandleMode
})

View File

@ -1,7 +1,7 @@
/* eslint-disable ts/no-explicit-any, style/jsx-one-expression-per-line */
import type { ListFilterNodeType } from '../types'
import type { PanelProps } from '@/types/workflow'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
@ -160,6 +160,7 @@ describe('list-operator path', () => {
})
it('should change the selected sub variable', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const { unmount } = render(
<SubVariablePicker
@ -168,16 +169,8 @@ describe('list-operator path', () => {
/>,
)
const trigger = screen.getByRole('button')
await act(async () => {
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
})
const option = await screen.findByText('name')
await act(async () => {
fireEvent.click(option)
})
await user.click(screen.getByRole('combobox'))
await user.click(screen.getByRole('option', { name: 'name' }))
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith('name')

View File

@ -112,7 +112,7 @@ describe('FilterCondition', () => {
expect(screen.getByText(/operator:/)).toBeInTheDocument()
expect(screen.getByText(/sub-variable:/)).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'workflow.nodes.ifElse.optionName.doc' }))
await user.click(screen.getByRole('combobox'))
await user.click(screen.getByText('workflow.nodes.ifElse.optionName.image'))
expect(onChange).toHaveBeenCalledWith({
@ -282,7 +282,7 @@ describe('FilterCondition', () => {
/>,
)
await user.click(screen.getByRole('button', { name: 'workflow.nodes.ifElse.optionName.localUpload' }))
await user.click(screen.getByRole('combobox'))
await user.click(screen.getByText('workflow.nodes.ifElse.optionName.url'))
expect(onChange).toHaveBeenCalledWith({
key: 'transfer_method',
@ -305,6 +305,6 @@ describe('FilterCondition', () => {
/>,
)
expect(screen.getByRole('button', { name: 'Select value' })).toBeInTheDocument()
expect(screen.getByText('Select value')).toBeInTheDocument()
})
})

View File

@ -20,7 +20,7 @@ describe('list-operator/sub-variable-picker', () => {
expect(screen.getByText('common.placeholder.select')).toBeInTheDocument()
await user.click(screen.getByRole('button'))
await user.click(screen.getByRole('combobox'))
await user.click(screen.getByRole('option', { name: 'name' }))
expect(handleChange).toHaveBeenCalledWith('name')
@ -41,7 +41,7 @@ describe('list-operator/sub-variable-picker', () => {
expect(container.firstChild).toHaveClass('custom-sub-variable')
expect(screen.getByText('size')).toBeInTheDocument()
await user.click(screen.getByRole('button'))
await user.click(screen.getByRole('combobox'))
await user.click(screen.getByRole('option', { name: 'type' }))
expect(handleChange).toHaveBeenCalledWith('type')

View File

@ -2,10 +2,10 @@
import type { FC } from 'react'
import type { Condition } from '../types'
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 { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SimpleSelect as Select } from '@/app/components/base/select'
import Input from '@/app/components/workflow/nodes/_base/components/input-support-select-var'
import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list'
import { FILE_TYPE_OPTIONS, TRANSFER_METHOD } from '@/app/components/workflow/nodes/constants'
@ -126,15 +126,23 @@ const ValueInput = ({
return null
if (isSelect) {
const selectedValue = isArrayValue ? (condition.value as string[])?.[0] : condition.value as string
const selectedOption = selectOptions.find(option => option.value === selectedValue) ?? null
return (
<Select
items={selectOptions}
defaultValue={isArrayValue ? (condition.value as string[])[0] : condition.value as string}
onSelect={item => onChange(item.value)}
className="text-[13px]!"
wrapperClassName="grow h-8"
placeholder="Select value"
/>
<Select value={selectedOption?.value ?? null} disabled={readOnly} onValueChange={value => value && onChange(value)}>
<SelectTrigger className="h-8 grow text-[13px]">
{selectedOption?.name ?? 'Select value'}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{selectOptions.map(option => (
<SelectItem key={option.value} value={option.value}>
<SelectItemText>{option.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)
}

View File

@ -1,12 +1,11 @@
'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, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import * as React from 'react'
import { useCallback } from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
import { SimpleSelect as Select } from '@/app/components/base/select'
import { SUB_VARIABLES } from '../../constants'
type Props = {
@ -15,51 +14,36 @@ type Props = {
className?: string
}
type SubVariableOption = {
value: string
name: string
}
const SubVariablePicker: FC<Props> = ({
value,
onChange,
className,
}) => {
const { t } = useTranslation()
const subVarOptions = SUB_VARIABLES.map(item => ({
const subVarOptions = useMemo<SubVariableOption[]>(() => SUB_VARIABLES.map(item => ({
value: item,
name: item,
}))
const renderOption = ({ item }: { item: Record<string, any> }) => {
return (
<div className="flex h-6 items-center justify-between">
<div className="flex h-full items-center">
<Variable02 className="mr-[5px] h-3.5 w-3.5 text-text-accent" />
<span className="system-sm-medium text-text-secondary">{item.name}</span>
</div>
<span className="system-xs-regular text-text-tertiary">{item.type}</span>
</div>
)
}
const handleChange = useCallback(({ value }: Item) => {
onChange(value as string)
}, [onChange])
})), [])
const selectedOption = useMemo(() => {
return subVarOptions.find(option => option.value === value) ?? null
}, [subVarOptions, value])
return (
<div className={cn(className)}>
<Select
items={subVarOptions}
defaultValue={value}
onSelect={handleChange}
className="text-[13px]!"
placeholder={t('nodes.listFilter.selectVariableKeyPlaceholder', { ns: 'workflow' })!}
optionClassName="pl-1 pr-5 py-0"
renderOption={renderOption}
renderTrigger={item => (
<Select value={selectedOption?.value ?? null} onValueChange={nextValue => nextValue && onChange(nextValue)}>
<SelectTrigger className="h-8 border-0 bg-transparent p-0 hover:bg-transparent focus-visible:bg-transparent [&>*:last-child]:hidden">
<div className="group/sub-variable-picker flex h-8 items-center rounded-lg bg-components-input-bg-normal pl-1 hover:bg-state-base-hover-alt">
{item
{selectedOption
? (
<div className="flex cursor-pointer justify-start">
<div className="inline-flex h-6 max-w-full items-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark px-1.5 text-text-accent shadow-xs">
<Variable02 className="h-3.5 w-3.5 shrink-0 text-text-accent" />
<div className="ml-0.5 truncate system-xs-medium">{item?.name}</div>
<div className="ml-0.5 truncate system-xs-medium">{selectedOption.name}</div>
</div>
</div>
)
@ -70,8 +54,20 @@ const SubVariablePicker: FC<Props> = ({
</div>
)}
</div>
)}
/>
</SelectTrigger>
<SelectContent popupClassName="w-[165px]" listClassName="max-h-none p-1">
{subVarOptions.map(option => (
<SelectItem key={option.value} value={option.value} className="h-8 py-0 pr-5 pl-1">
<div className="flex h-6 items-center justify-between">
<div className="flex h-full items-center">
<Variable02 className="mr-[5px] h-3.5 w-3.5 text-text-accent" />
<SelectItemText className="mr-0 px-0 system-sm-medium text-text-secondary">{option.name}</SelectItemText>
</div>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}

View File

@ -15,6 +15,7 @@ import type {
Var,
} from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { RiDeleteBinLine } from '@remixicon/react'
import { produce } from 'immer'
import {
@ -24,7 +25,6 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
import { SimpleSelect as Select } from '@/app/components/base/select'
import BoolValue from '@/app/components/workflow/panel/chat-variable-panel/components/bool-value'
import { VarType } from '@/app/components/workflow/types'
import {
@ -141,7 +141,7 @@ const ConditionItem = ({
}, [condition, doUpdateCondition, isArrayValue])
const isSelect = condition.comparison_operator && [ComparisonOperator.in, ComparisonOperator.notIn].includes(condition.comparison_operator)
const selectOptions = useMemo(() => {
const selectOptions = useMemo<Array<{ name: string, value: string }>>(() => {
if (isSelect) {
if (fileAttr?.key === 'type' || condition.comparison_operator === ComparisonOperator.allOf) {
return FILE_TYPE_OPTIONS.map(item => ({
@ -167,6 +167,7 @@ const ConditionItem = ({
name: item,
value: item,
}))
const selectedSubVarOption = subVarOptions.find(item => item.value === condition.key) ?? null
const handleSubVarKeyChange = useCallback((key: string) => {
const newCondition = produce(condition, (draft) => {
@ -203,6 +204,8 @@ const ConditionItem = ({
doUpdateCondition(newCondition)
setOpen(false)
}, [condition, doUpdateCondition])
const selectedSelectValue = isArrayValue ? (condition.value as string[])?.[0] : (condition.value as string)
const selectedSelectOption = selectOptions.find(item => item.value === selectedSelectValue) ?? null
return (
<div className={cn('mb-1 flex last-of-type:mb-0', className)}>
@ -216,26 +219,38 @@ const ConditionItem = ({
{isSubVarSelect
? (
<Select
wrapperClassName="h-6"
className="pl-0 text-xs"
optionWrapClassName="w-[165px] max-h-none"
defaultValue={condition.key}
items={subVarOptions}
onSelect={item => handleSubVarKeyChange(item.value as string)}
renderTrigger={item => (
item
value={selectedSubVarOption?.value ?? null}
onValueChange={value => value && handleSubVarKeyChange(value)}
>
<SelectTrigger
render={<div />}
nativeButton={false}
className="h-6 border-0 bg-transparent p-0 hover:bg-transparent focus-visible:bg-transparent [&>*:last-child]:hidden"
>
{selectedSubVarOption
? (
<div className="flex cursor-pointer justify-start">
<div className="inline-flex h-6 max-w-full items-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark px-1.5 text-text-accent shadow-xs">
<Variable02 className="h-3.5 w-3.5 shrink-0 text-text-accent" />
<div className="ml-0.5 truncate system-xs-medium">{item?.name}</div>
<div className="ml-0.5 truncate system-xs-medium">{selectedSubVarOption.name}</div>
</div>
</div>
)
: <div className="text-left system-sm-regular text-components-input-text-placeholder">{t('placeholder.select', { ns: 'common' })}</div>
)}
hideChecked
/>
: <div className="text-left system-sm-regular text-components-input-text-placeholder">{t('placeholder.select', { ns: 'common' })}</div>}
</SelectTrigger>
<SelectContent popupClassName="w-[165px]" listClassName="max-h-none p-1">
{subVarOptions.map(option => (
<SelectItem key={option.value} value={option.value} className="h-8 py-0 pr-5 pl-1">
<div className="flex h-6 items-center justify-between">
<div className="flex h-full items-center">
<Variable02 className="mr-[5px] h-3.5 w-3.5 text-text-accent" />
<SelectItemText className="mr-0 px-0 system-sm-medium text-text-secondary">{option.name}</SelectItemText>
</div>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
)
: (
<ConditionVarSelector
@ -298,15 +313,18 @@ const ConditionItem = ({
{
!comparisonOperatorNotRequireValue(condition.comparison_operator) && isSelect && (
<div className="border-t border-t-divider-subtle">
<Select
wrapperClassName="h-8"
className="rounded-t-none px-2 text-xs"
defaultValue={isArrayValue ? (condition.value as string[])?.[0] : (condition.value as string)}
items={selectOptions}
onSelect={item => handleUpdateConditionValue(item.value as string)}
hideChecked
notClearable
/>
<Select value={selectedSelectOption?.value ?? null} onValueChange={value => value && handleUpdateConditionValue(value)}>
<SelectTrigger className="h-8 rounded-t-none border-0 px-2 text-xs hover:bg-components-input-bg-normal focus-visible:bg-components-input-bg-normal">
{selectedSelectOption?.name ?? t('placeholder.select', { ns: 'common' })}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{selectOptions.map(option => (
<SelectItem key={option.value} value={option.value} className="text-xs">
<SelectItemText>{option.name}</SelectItemText>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}

View File

@ -4,13 +4,13 @@ import type { Node, NodeOutPutVar, Var } from '../../../types'
import type { Condition, HandleAddCondition, HandleAddSubVariableCondition, HandleRemoveCondition, handleRemoveSubVariableCondition, HandleToggleConditionLogicalOperator, HandleToggleSubVariableConditionLogicalOperator, HandleUpdateCondition, HandleUpdateSubVariableCondition, LogicalOperator } from '../types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import {
RiAddLine,
} from '@remixicon/react'
import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { PortalSelect as Select } from '@/app/components/base/select'
import { VarType } from '../../../types'
import { useGetAvailableVars } from '../../variable-assigner/hooks'
import { SUB_VARIABLES } from './../default'
@ -115,11 +115,15 @@ const ConditionWrap: FC<Props> = ({
{isSubVariable
? (
<Select
popupInnerClassName="w-[165px] max-h-none"
onSelect={value => handleAddSubVariableCondition?.(conditionId!, value.value as string)}
items={subVarOptions}
value=""
renderTrigger={() => (
value={null}
disabled={readOnly}
onValueChange={value => value && handleAddSubVariableCondition?.(conditionId!, value)}
>
<SelectTrigger
render={<div />}
nativeButton={false}
className="border-0 bg-transparent p-0 hover:bg-transparent focus-visible:bg-transparent [&>*:last-child]:hidden"
>
<Button
size="small"
disabled={readOnly}
@ -127,9 +131,15 @@ const ConditionWrap: FC<Props> = ({
<RiAddLine className="mr-1 h-3.5 w-3.5" />
{t('nodes.ifElse.addSubVariable', { ns: 'workflow' })}
</Button>
)}
hideChecked
/>
</SelectTrigger>
<SelectContent popupClassName="w-[165px]" listClassName="max-h-none p-1">
{subVarOptions.map(option => (
<SelectItem key={option.value} value={option.value}>
<SelectItemText>{option.name}</SelectItemText>
</SelectItem>
))}
</SelectContent>
</Select>
)
: (
<ConditionAdd

View File

@ -1,5 +1,5 @@
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { useTranslation } from 'react-i18next'
import PureSelect from '@/app/components/base/select/pure'
type InputModeSelectProps = {
value?: string
@ -20,17 +20,22 @@ const InputModeSelect = ({
value: 'constant',
},
]
const selectedOption = options.find(option => option.value === value) ?? null
return (
<PureSelect
options={options}
value={value}
onChange={onChange}
popupProps={{
title: t('nodes.loop.inputMode', { ns: 'workflow' }),
className: 'w-[132px]',
}}
/>
<Select value={selectedOption?.value ?? null} onValueChange={nextValue => nextValue && onChange(nextValue)}>
<SelectTrigger className="w-full">
{selectedOption?.label ?? t('nodes.loop.inputMode', { ns: 'workflow' })}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{options.map(option => (
<SelectItem key={option.value} value={option.value}>
<SelectItemText>{option.label}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)
}

View File

@ -1,4 +1,4 @@
import PureSelect from '@/app/components/base/select/pure'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { VarType } from '@/app/components/workflow/types'
type VariableTypeSelectProps = {
@ -43,16 +43,22 @@ const VariableTypeSelect = ({
value: VarType.arrayBoolean,
},
]
const selectedOption = options.find(option => option.value === value) ?? null
return (
<PureSelect
options={options}
value={value}
onChange={onChange}
popupProps={{
className: 'w-[132px]',
}}
/>
<Select value={selectedOption?.value ?? null} onValueChange={nextValue => nextValue && onChange(nextValue)}>
<SelectTrigger className="w-full">
{selectedOption?.label}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{options.map(option => (
<SelectItem key={option.value} value={option.value}>
<SelectItemText>{option.label}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)
}

View File

@ -3,6 +3,7 @@ import type { FC } from 'react'
import type { Param } from '../../types'
import type { MoreInfo } from '@/app/components/workflow/types'
import { Button } from '@langgenius/dify-ui/button'
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 { useBoolean } from 'ahooks'
@ -13,7 +14,6 @@ import Field from '@/app/components/app/configuration/config-var/config-modal/fi
import ConfigSelect from '@/app/components/app/configuration/config-var/config-select'
import Input from '@/app/components/base/input'
import Modal from '@/app/components/base/modal'
import Select from '@/app/components/base/select'
import Textarea from '@/app/components/base/textarea'
import { ChangeType } from '@/app/components/workflow/types'
import { checkKeys } from '@/utils/var'
@ -141,18 +141,21 @@ const AddExtractParameter: FC<Props> = ({
</Field>
<Field title={t(`${i18nPrefix}.addExtractParameterContent.type`, { ns: 'workflow' })}>
<Select
defaultValue={param.type}
allowSearch={false}
// bgClassName='bg-gray-100'
onSelect={v => handleParamChange('type')(v.value)}
optionClassName="capitalize"
items={
TYPES.map(type => ({
value: type,
name: type,
}))
}
/>
value={param.type}
onValueChange={value => value && handleParamChange('type')(value)}
>
<SelectTrigger className="w-full capitalize">
{param.type}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{TYPES.map(type => (
<SelectItem key={type} value={type} className="capitalize">
<SelectItemText className="capitalize">{type}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
{param.type === ParamType.select && (
<Field title={t('variableConfig.options', { ns: 'appDebug' })}>

View File

@ -14,7 +14,7 @@ describe('trigger-schedule/frequency-selector', () => {
/>,
)
const trigger = screen.getByRole('button', { name: 'workflow.nodes.triggerSchedule.frequency.daily' })
const trigger = screen.getByRole('combobox')
await user.click(trigger)
await waitFor(() => {

View File

@ -43,7 +43,7 @@ describe('trigger-schedule components', () => {
/>,
)
const trigger = screen.getByRole('button', { name: 'workflow.nodes.triggerSchedule.frequency.daily' })
const trigger = screen.getByRole('combobox')
await user.click(trigger)
await user.keyboard('{ArrowDown}')
await user.keyboard('{Enter}')

View File

@ -1,8 +1,22 @@
import type { ScheduleFrequency } from '../types'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectLabel,
SelectTrigger,
} from '@langgenius/dify-ui/select'
import * as React from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { SimpleSelect } from '@/app/components/base/select'
type FrequencyOption = {
value: ScheduleFrequency
name: string
}
type FrequencySelectorProps = {
frequency: ScheduleFrequency
@ -11,28 +25,37 @@ type FrequencySelectorProps = {
const FrequencySelector = ({ frequency, onChange }: FrequencySelectorProps) => {
const { t } = useTranslation()
const groupLabel = t('nodes.triggerSchedule.frequency.label', { ns: 'workflow' })
const frequencies = useMemo(() => [
{ value: 'frequency-header', name: t('nodes.triggerSchedule.frequency.label', { ns: 'workflow' }), isGroup: true },
const frequencies = useMemo<FrequencyOption[]>(() => [
{ value: 'hourly', name: t('nodes.triggerSchedule.frequency.hourly', { ns: 'workflow' }) },
{ value: 'daily', name: t('nodes.triggerSchedule.frequency.daily', { ns: 'workflow' }) },
{ value: 'weekly', name: t('nodes.triggerSchedule.frequency.weekly', { ns: 'workflow' }) },
{ value: 'monthly', name: t('nodes.triggerSchedule.frequency.monthly', { ns: 'workflow' }) },
], [t])
const selectedFrequency = frequencies.find(item => item.value === frequency)
return (
<SimpleSelect
key={`${frequency}-${frequencies[0]?.name}`} // Include translation in key to force re-render
items={frequencies}
defaultValue={frequency}
onSelect={item => onChange(item.value as ScheduleFrequency)}
placeholder={t('nodes.triggerSchedule.selectFrequency', { ns: 'workflow' })}
className="w-full py-2"
wrapperClassName="h-auto"
optionWrapClassName="min-w-40"
notClearable={true}
allowSearch={false}
/>
<Select
key={`${frequency}-${groupLabel}`}
value={frequency}
onValueChange={value => value && onChange(value as ScheduleFrequency)}
>
<SelectTrigger className="w-full py-2">
{selectedFrequency?.name ?? t('nodes.triggerSchedule.selectFrequency', { ns: 'workflow' })}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
<SelectGroup>
<SelectLabel>{groupLabel}</SelectLabel>
{frequencies.map(item => (
<SelectItem key={item.value} value={item.value}>
<SelectItemText>{item.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)
}

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