mirror of
https://github.com/langgenius/dify.git
synced 2026-05-06 18:27:19 +08:00
Merge branch 'main' into jzh
This commit is contained in:
commit
b371dd2cdf
@ -367,7 +367,7 @@ For each extraction:
|
||||
┌────────────────────────────────────────┐
|
||||
│ 1. Extract code │
|
||||
│ 2. Run: pnpm lint:fix │
|
||||
│ 3. Run: pnpm type-check:tsgo │
|
||||
│ 3. Run: pnpm type-check │
|
||||
│ 4. Run: pnpm test │
|
||||
│ 5. Test functionality manually │
|
||||
│ 6. PASS? → Next extraction │
|
||||
|
||||
@ -127,7 +127,7 @@ For the current file being tested:
|
||||
- [ ] Run full directory test: `pnpm test path/to/directory/`
|
||||
- [ ] Check coverage report: `pnpm test:coverage`
|
||||
- [ ] Run `pnpm lint:fix` on all test files
|
||||
- [ ] Run `pnpm type-check:tsgo`
|
||||
- [ ] Run `pnpm type-check`
|
||||
|
||||
## Common Issues to Watch
|
||||
|
||||
|
||||
@ -30,7 +30,7 @@ The codebase is split into:
|
||||
## Language Style
|
||||
|
||||
- **Python**: Keep type hints on functions and attributes, and implement relevant special methods (e.g., `__repr__`, `__str__`). Prefer `TypedDict` over `dict` or `Mapping` for type safety and better code documentation.
|
||||
- **TypeScript**: Use the strict config, rely on ESLint (`pnpm lint:fix` preferred) plus `pnpm type-check:tsgo`, and avoid `any` types.
|
||||
- **TypeScript**: Use the strict config, rely on ESLint (`pnpm lint:fix` preferred) plus `pnpm type-check`, and avoid `any` types.
|
||||
|
||||
## General Practices
|
||||
|
||||
|
||||
13
README.md
13
README.md
@ -139,19 +139,6 @@ Star Dify on GitHub and be instantly notified of new releases.
|
||||
|
||||
If you need to customize the configuration, please refer to the comments in our [.env.example](docker/.env.example) file and update the corresponding values in your `.env` file. Additionally, you might need to make adjustments to the `docker-compose.yaml` file itself, such as changing image versions, port mappings, or volume mounts, based on your specific deployment environment and requirements. After making any changes, please re-run `docker compose up -d`. You can find the full list of available environment variables [here](https://docs.dify.ai/getting-started/install-self-hosted/environments).
|
||||
|
||||
#### Customizing Suggested Questions
|
||||
|
||||
You can now customize the "Suggested Questions After Answer" feature to better fit your use case. For example, to generate longer, more technical questions:
|
||||
|
||||
```bash
|
||||
# In your .env file
|
||||
SUGGESTED_QUESTIONS_PROMPT='Please help me predict the five most likely technical follow-up questions a developer would ask. Focus on implementation details, best practices, and architecture considerations. Keep each question between 40-60 characters. Output must be JSON array: ["question1","question2","question3","question4","question5"]'
|
||||
SUGGESTED_QUESTIONS_MAX_TOKENS=512
|
||||
SUGGESTED_QUESTIONS_TEMPERATURE=0.3
|
||||
```
|
||||
|
||||
See the [Suggested Questions Configuration Guide](docs/suggested-questions-configuration.md) for detailed examples and usage instructions.
|
||||
|
||||
### Metrics Monitoring with Grafana
|
||||
|
||||
Import the dashboard to Grafana, using Dify's PostgreSQL database as data source, to monitor metrics in granularity of apps, tenants, messages, and more.
|
||||
|
||||
@ -709,22 +709,6 @@ SWAGGER_UI_PATH=/swagger-ui.html
|
||||
# Set to false to export dataset IDs as plain text for easier cross-environment import
|
||||
DSL_EXPORT_ENCRYPT_DATASET_ID=true
|
||||
|
||||
# Suggested Questions After Answer Configuration
|
||||
# These environment variables allow customization of the suggested questions feature
|
||||
#
|
||||
# Custom prompt for generating suggested questions (optional)
|
||||
# If not set, uses the default prompt that generates 3 questions under 20 characters each
|
||||
# Example: "Please help me predict the five most likely technical follow-up questions a developer would ask. Focus on implementation details, best practices, and architecture considerations. Keep each question between 40-60 characters. Output must be JSON array: [\"question1\",\"question2\",\"question3\",\"question4\",\"question5\"]"
|
||||
# SUGGESTED_QUESTIONS_PROMPT=
|
||||
|
||||
# Maximum number of tokens for suggested questions generation (default: 256)
|
||||
# Adjust this value for longer questions or more questions
|
||||
# SUGGESTED_QUESTIONS_MAX_TOKENS=256
|
||||
|
||||
# Temperature for suggested questions generation (default: 0.0)
|
||||
# Higher values (0.5-1.0) produce more creative questions, lower values (0.0-0.3) produce more focused questions
|
||||
# SUGGESTED_QUESTIONS_TEMPERATURE=0
|
||||
|
||||
# Tenant isolated task queue configuration
|
||||
TENANT_ISOLATED_TASK_CONCURRENCY=1
|
||||
|
||||
|
||||
@ -1,3 +1,11 @@
|
||||
"""Console workspace endpoint controllers.
|
||||
|
||||
This module exposes workspace-scoped plugin endpoint management APIs. The
|
||||
canonical write routes follow resource-oriented paths, while the historical
|
||||
verb-based aliases stay available as deprecated resources so OpenAPI metadata
|
||||
marks only the legacy paths as deprecated.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from flask import request
|
||||
@ -25,7 +33,12 @@ class EndpointIdPayload(BaseModel):
|
||||
endpoint_id: str
|
||||
|
||||
|
||||
class EndpointUpdatePayload(EndpointIdPayload):
|
||||
class EndpointUpdatePayload(BaseModel):
|
||||
settings: dict[str, Any]
|
||||
name: str = Field(min_length=1)
|
||||
|
||||
|
||||
class LegacyEndpointUpdatePayload(EndpointIdPayload):
|
||||
settings: dict[str, Any]
|
||||
name: str = Field(min_length=1)
|
||||
|
||||
@ -76,6 +89,7 @@ register_schema_models(
|
||||
EndpointCreatePayload,
|
||||
EndpointIdPayload,
|
||||
EndpointUpdatePayload,
|
||||
LegacyEndpointUpdatePayload,
|
||||
EndpointListQuery,
|
||||
EndpointListForPluginQuery,
|
||||
EndpointCreateResponse,
|
||||
@ -88,8 +102,60 @@ register_schema_models(
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/endpoints/create")
|
||||
class EndpointCreateApi(Resource):
|
||||
def _create_endpoint() -> dict[str, bool]:
|
||||
"""Create a plugin endpoint for the current workspace."""
|
||||
user, tenant_id = current_account_with_tenant()
|
||||
|
||||
args = EndpointCreatePayload.model_validate(console_ns.payload)
|
||||
|
||||
try:
|
||||
return {
|
||||
"success": EndpointService.create_endpoint(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user.id,
|
||||
plugin_unique_identifier=args.plugin_unique_identifier,
|
||||
name=args.name,
|
||||
settings=args.settings,
|
||||
)
|
||||
}
|
||||
except PluginPermissionDeniedError as e:
|
||||
raise ValueError(e.description) from e
|
||||
|
||||
|
||||
def _update_endpoint(endpoint_id: str) -> dict[str, bool]:
|
||||
"""Update a plugin endpoint identified by the canonical path parameter."""
|
||||
user, tenant_id = current_account_with_tenant()
|
||||
|
||||
args = EndpointUpdatePayload.model_validate(console_ns.payload)
|
||||
|
||||
return {
|
||||
"success": EndpointService.update_endpoint(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user.id,
|
||||
endpoint_id=endpoint_id,
|
||||
name=args.name,
|
||||
settings=args.settings,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def _delete_endpoint(endpoint_id: str) -> dict[str, bool]:
|
||||
"""Delete a plugin endpoint identified by the canonical path parameter."""
|
||||
user, tenant_id = current_account_with_tenant()
|
||||
|
||||
return {
|
||||
"success": EndpointService.delete_endpoint(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user.id,
|
||||
endpoint_id=endpoint_id,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/endpoints")
|
||||
class EndpointCollectionApi(Resource):
|
||||
"""Canonical collection resource for endpoint creation."""
|
||||
|
||||
@console_ns.doc("create_endpoint")
|
||||
@console_ns.doc(description="Create a new plugin endpoint")
|
||||
@console_ns.expect(console_ns.models[EndpointCreatePayload.__name__])
|
||||
@ -104,22 +170,33 @@ class EndpointCreateApi(Resource):
|
||||
@is_admin_or_owner_required
|
||||
@account_initialization_required
|
||||
def post(self):
|
||||
user, tenant_id = current_account_with_tenant()
|
||||
return _create_endpoint()
|
||||
|
||||
args = EndpointCreatePayload.model_validate(console_ns.payload)
|
||||
|
||||
try:
|
||||
return {
|
||||
"success": EndpointService.create_endpoint(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user.id,
|
||||
plugin_unique_identifier=args.plugin_unique_identifier,
|
||||
name=args.name,
|
||||
settings=args.settings,
|
||||
)
|
||||
}
|
||||
except PluginPermissionDeniedError as e:
|
||||
raise ValueError(e.description) from e
|
||||
@console_ns.route("/workspaces/current/endpoints/create")
|
||||
class DeprecatedEndpointCreateApi(Resource):
|
||||
"""Deprecated verb-based alias for endpoint creation."""
|
||||
|
||||
@console_ns.doc("create_endpoint_deprecated")
|
||||
@console_ns.doc(deprecated=True)
|
||||
@console_ns.doc(
|
||||
description=(
|
||||
"Deprecated legacy alias for creating a plugin endpoint. Use POST /workspaces/current/endpoints instead."
|
||||
)
|
||||
)
|
||||
@console_ns.expect(console_ns.models[EndpointCreatePayload.__name__])
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Endpoint created successfully",
|
||||
console_ns.models[EndpointCreateResponse.__name__],
|
||||
)
|
||||
@console_ns.response(403, "Admin privileges required")
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@account_initialization_required
|
||||
def post(self):
|
||||
return _create_endpoint()
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/endpoints/list")
|
||||
@ -190,10 +267,56 @@ class EndpointListForSinglePluginApi(Resource):
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/endpoints/delete")
|
||||
class EndpointDeleteApi(Resource):
|
||||
@console_ns.route("/workspaces/current/endpoints/<string:id>")
|
||||
class EndpointItemApi(Resource):
|
||||
"""Canonical item resource for endpoint updates and deletion."""
|
||||
|
||||
@console_ns.doc("delete_endpoint")
|
||||
@console_ns.doc(description="Delete a plugin endpoint")
|
||||
@console_ns.doc(params={"id": {"description": "Endpoint ID", "type": "string", "required": True}})
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Endpoint deleted successfully",
|
||||
console_ns.models[EndpointDeleteResponse.__name__],
|
||||
)
|
||||
@console_ns.response(403, "Admin privileges required")
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@account_initialization_required
|
||||
def delete(self, id: str):
|
||||
return _delete_endpoint(endpoint_id=id)
|
||||
|
||||
@console_ns.doc("update_endpoint")
|
||||
@console_ns.doc(description="Update a plugin endpoint")
|
||||
@console_ns.expect(console_ns.models[EndpointUpdatePayload.__name__])
|
||||
@console_ns.doc(params={"id": {"description": "Endpoint ID", "type": "string", "required": True}})
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Endpoint updated successfully",
|
||||
console_ns.models[EndpointUpdateResponse.__name__],
|
||||
)
|
||||
@console_ns.response(403, "Admin privileges required")
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@account_initialization_required
|
||||
def patch(self, id: str):
|
||||
return _update_endpoint(endpoint_id=id)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/endpoints/delete")
|
||||
class DeprecatedEndpointDeleteApi(Resource):
|
||||
"""Deprecated verb-based alias for endpoint deletion."""
|
||||
|
||||
@console_ns.doc("delete_endpoint_deprecated")
|
||||
@console_ns.doc(deprecated=True)
|
||||
@console_ns.doc(
|
||||
description=(
|
||||
"Deprecated legacy alias for deleting a plugin endpoint. "
|
||||
"Use DELETE /workspaces/current/endpoints/{id} instead."
|
||||
)
|
||||
)
|
||||
@console_ns.expect(console_ns.models[EndpointIdPayload.__name__])
|
||||
@console_ns.response(
|
||||
200,
|
||||
@ -206,22 +329,23 @@ class EndpointDeleteApi(Resource):
|
||||
@is_admin_or_owner_required
|
||||
@account_initialization_required
|
||||
def post(self):
|
||||
user, tenant_id = current_account_with_tenant()
|
||||
|
||||
args = EndpointIdPayload.model_validate(console_ns.payload)
|
||||
|
||||
return {
|
||||
"success": EndpointService.delete_endpoint(
|
||||
tenant_id=tenant_id, user_id=user.id, endpoint_id=args.endpoint_id
|
||||
)
|
||||
}
|
||||
return _delete_endpoint(endpoint_id=args.endpoint_id)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/endpoints/update")
|
||||
class EndpointUpdateApi(Resource):
|
||||
@console_ns.doc("update_endpoint")
|
||||
@console_ns.doc(description="Update a plugin endpoint")
|
||||
@console_ns.expect(console_ns.models[EndpointUpdatePayload.__name__])
|
||||
class DeprecatedEndpointUpdateApi(Resource):
|
||||
"""Deprecated verb-based alias for endpoint updates."""
|
||||
|
||||
@console_ns.doc("update_endpoint_deprecated")
|
||||
@console_ns.doc(deprecated=True)
|
||||
@console_ns.doc(
|
||||
description=(
|
||||
"Deprecated legacy alias for updating a plugin endpoint. "
|
||||
"Use PATCH /workspaces/current/endpoints/{id} instead."
|
||||
)
|
||||
)
|
||||
@console_ns.expect(console_ns.models[LegacyEndpointUpdatePayload.__name__])
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Endpoint updated successfully",
|
||||
@ -233,19 +357,8 @@ class EndpointUpdateApi(Resource):
|
||||
@is_admin_or_owner_required
|
||||
@account_initialization_required
|
||||
def post(self):
|
||||
user, tenant_id = current_account_with_tenant()
|
||||
|
||||
args = EndpointUpdatePayload.model_validate(console_ns.payload)
|
||||
|
||||
return {
|
||||
"success": EndpointService.update_endpoint(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user.id,
|
||||
endpoint_id=args.endpoint_id,
|
||||
name=args.name,
|
||||
settings=args.settings,
|
||||
)
|
||||
}
|
||||
args = LegacyEndpointUpdatePayload.model_validate(console_ns.payload)
|
||||
return _update_endpoint(endpoint_id=args.endpoint_id)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/endpoints/enable")
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
from typing import Any
|
||||
|
||||
CUSTOM_FOLLOW_UP_PROMPT_MAX_LENGTH = 1000
|
||||
|
||||
|
||||
class SuggestedQuestionsAfterAnswerConfigManager:
|
||||
@classmethod
|
||||
@ -20,7 +22,11 @@ class SuggestedQuestionsAfterAnswerConfigManager:
|
||||
@classmethod
|
||||
def validate_and_set_defaults(cls, config: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
|
||||
"""
|
||||
Validate and set defaults for suggested questions feature
|
||||
Validate and set defaults for suggested questions feature.
|
||||
|
||||
Optional fields:
|
||||
- prompt: custom instruction prompt.
|
||||
- model: provider/model configuration for suggested question generation.
|
||||
|
||||
:param config: app model config args
|
||||
"""
|
||||
@ -39,4 +45,27 @@ class SuggestedQuestionsAfterAnswerConfigManager:
|
||||
if not isinstance(config["suggested_questions_after_answer"]["enabled"], bool):
|
||||
raise ValueError("enabled in suggested_questions_after_answer must be of boolean type")
|
||||
|
||||
prompt = config["suggested_questions_after_answer"].get("prompt")
|
||||
if prompt is not None and not isinstance(prompt, str):
|
||||
raise ValueError("prompt in suggested_questions_after_answer must be of string type")
|
||||
if isinstance(prompt, str) and len(prompt) > CUSTOM_FOLLOW_UP_PROMPT_MAX_LENGTH:
|
||||
raise ValueError(
|
||||
f"prompt in suggested_questions_after_answer must be less than or equal to "
|
||||
f"{CUSTOM_FOLLOW_UP_PROMPT_MAX_LENGTH} characters"
|
||||
)
|
||||
|
||||
if "model" in config["suggested_questions_after_answer"]:
|
||||
model_config = config["suggested_questions_after_answer"]["model"]
|
||||
if not isinstance(model_config, dict):
|
||||
raise ValueError("model in suggested_questions_after_answer must be of object type")
|
||||
|
||||
if "provider" not in model_config or not isinstance(model_config["provider"], str):
|
||||
raise ValueError("provider in suggested_questions_after_answer.model must be of string type")
|
||||
|
||||
if "name" not in model_config or not isinstance(model_config["name"], str):
|
||||
raise ValueError("name in suggested_questions_after_answer.model must be of string type")
|
||||
|
||||
if "completion_params" in model_config and not isinstance(model_config["completion_params"], dict):
|
||||
raise ValueError("completion_params in suggested_questions_after_answer.model must be of object type")
|
||||
|
||||
return config, ["suggested_questions_after_answer"]
|
||||
|
||||
@ -2,7 +2,7 @@ import json
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
from typing import Any, Protocol, TypedDict, cast
|
||||
from typing import Any, NotRequired, Protocol, TypedDict, cast
|
||||
|
||||
import json_repair
|
||||
from sqlalchemy import select
|
||||
@ -13,13 +13,13 @@ from core.llm_generator.output_parser.rule_config_generator import RuleConfigGen
|
||||
from core.llm_generator.output_parser.suggested_questions_after_answer import SuggestedQuestionsAfterAnswerOutputParser
|
||||
from core.llm_generator.prompts import (
|
||||
CONVERSATION_TITLE_PROMPT,
|
||||
DEFAULT_SUGGESTED_QUESTIONS_MAX_TOKENS,
|
||||
DEFAULT_SUGGESTED_QUESTIONS_TEMPERATURE,
|
||||
GENERATOR_QA_PROMPT,
|
||||
JAVASCRIPT_CODE_GENERATOR_PROMPT_TEMPLATE,
|
||||
LLM_MODIFY_CODE_SYSTEM,
|
||||
LLM_MODIFY_PROMPT_SYSTEM,
|
||||
PYTHON_CODE_GENERATOR_PROMPT_TEMPLATE,
|
||||
SUGGESTED_QUESTIONS_MAX_TOKENS,
|
||||
SUGGESTED_QUESTIONS_TEMPERATURE,
|
||||
SYSTEM_STRUCTURED_OUTPUT_GENERATE,
|
||||
WORKFLOW_RULE_CONFIG_PROMPT_GENERATE_TEMPLATE,
|
||||
)
|
||||
@ -41,6 +41,36 @@ from models.workflow import Workflow
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SuggestedQuestionsModelConfig(TypedDict):
|
||||
provider: str
|
||||
name: str
|
||||
completion_params: NotRequired[dict[str, object]]
|
||||
|
||||
|
||||
def _normalize_completion_params(completion_params: dict[str, object]) -> tuple[dict[str, object], list[str]]:
|
||||
"""
|
||||
Normalize raw completion params into invocation parameters and stop sequences.
|
||||
|
||||
This mirrors the app-model access path by separating ``stop`` from provider
|
||||
parameters before invocation, then drops non-positive token limits because
|
||||
some plugin-backed models reject ``0`` after mapping ``max_tokens`` to their
|
||||
provider-specific output-token field.
|
||||
"""
|
||||
normalized_parameters = dict(completion_params)
|
||||
stop_value = normalized_parameters.pop("stop", [])
|
||||
if isinstance(stop_value, list) and all(isinstance(item, str) for item in stop_value):
|
||||
stop = stop_value
|
||||
else:
|
||||
stop = []
|
||||
|
||||
for token_limit_key in ("max_tokens", "max_output_tokens"):
|
||||
token_limit = normalized_parameters.get(token_limit_key)
|
||||
if isinstance(token_limit, int | float) and token_limit <= 0:
|
||||
normalized_parameters.pop(token_limit_key, None)
|
||||
|
||||
return normalized_parameters, stop
|
||||
|
||||
|
||||
class WorkflowServiceInterface(Protocol):
|
||||
def get_draft_workflow(self, app_model: App, workflow_id: str | None = None) -> Workflow | None:
|
||||
pass
|
||||
@ -123,8 +153,15 @@ class LLMGenerator:
|
||||
return name
|
||||
|
||||
@classmethod
|
||||
def generate_suggested_questions_after_answer(cls, tenant_id: str, histories: str) -> Sequence[str]:
|
||||
output_parser = SuggestedQuestionsAfterAnswerOutputParser()
|
||||
def generate_suggested_questions_after_answer(
|
||||
cls,
|
||||
tenant_id: str,
|
||||
histories: str,
|
||||
*,
|
||||
instruction_prompt: str | None = None,
|
||||
model_config: object | None = None,
|
||||
) -> Sequence[str]:
|
||||
output_parser = SuggestedQuestionsAfterAnswerOutputParser(instruction_prompt=instruction_prompt)
|
||||
format_instructions = output_parser.get_format_instructions()
|
||||
|
||||
prompt_template = PromptTemplateParser(template="{{histories}}\n{{format_instructions}}\nquestions:\n")
|
||||
@ -133,10 +170,36 @@ class LLMGenerator:
|
||||
|
||||
try:
|
||||
model_manager = ModelManager.for_tenant(tenant_id=tenant_id)
|
||||
model_instance = model_manager.get_default_model_instance(
|
||||
tenant_id=tenant_id,
|
||||
model_type=ModelType.LLM,
|
||||
)
|
||||
configured_model = cast(dict[str, object], model_config) if isinstance(model_config, dict) else {}
|
||||
provider = configured_model.get("provider")
|
||||
model_name = configured_model.get("name")
|
||||
use_configured_model = False
|
||||
|
||||
if isinstance(provider, str) and provider and isinstance(model_name, str) and model_name:
|
||||
try:
|
||||
model_instance = model_manager.get_model_instance(
|
||||
tenant_id=tenant_id,
|
||||
model_type=ModelType.LLM,
|
||||
provider=provider,
|
||||
model=model_name,
|
||||
)
|
||||
use_configured_model = True
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to use configured suggested-questions model %s/%s, fallback to default model",
|
||||
provider,
|
||||
model_name,
|
||||
exc_info=True,
|
||||
)
|
||||
model_instance = model_manager.get_default_model_instance(
|
||||
tenant_id=tenant_id,
|
||||
model_type=ModelType.LLM,
|
||||
)
|
||||
else:
|
||||
model_instance = model_manager.get_default_model_instance(
|
||||
tenant_id=tenant_id,
|
||||
model_type=ModelType.LLM,
|
||||
)
|
||||
except InvokeAuthorizationError:
|
||||
return []
|
||||
|
||||
@ -145,19 +208,29 @@ class LLMGenerator:
|
||||
questions: Sequence[str] = []
|
||||
|
||||
try:
|
||||
configured_completion_params = configured_model.get("completion_params")
|
||||
if use_configured_model and isinstance(configured_completion_params, dict):
|
||||
model_parameters, stop = _normalize_completion_params(configured_completion_params)
|
||||
elif use_configured_model:
|
||||
model_parameters = {}
|
||||
stop = []
|
||||
else:
|
||||
# Default-model generation keeps the built-in suggested-questions tuning.
|
||||
model_parameters = {
|
||||
"max_tokens": DEFAULT_SUGGESTED_QUESTIONS_MAX_TOKENS,
|
||||
"temperature": DEFAULT_SUGGESTED_QUESTIONS_TEMPERATURE,
|
||||
}
|
||||
stop = []
|
||||
|
||||
response: LLMResult = model_instance.invoke_llm(
|
||||
prompt_messages=list(prompt_messages),
|
||||
model_parameters={
|
||||
"max_tokens": SUGGESTED_QUESTIONS_MAX_TOKENS,
|
||||
"temperature": SUGGESTED_QUESTIONS_TEMPERATURE,
|
||||
},
|
||||
model_parameters=model_parameters,
|
||||
stop=stop,
|
||||
stream=False,
|
||||
)
|
||||
|
||||
text_content = response.message.get_text_content()
|
||||
questions = output_parser.parse(text_content) if text_content else []
|
||||
except InvokeError:
|
||||
questions = []
|
||||
except Exception:
|
||||
logger.exception("Failed to generate suggested questions after answer")
|
||||
questions = []
|
||||
|
||||
@ -3,17 +3,21 @@ import logging
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
|
||||
from core.llm_generator.prompts import SUGGESTED_QUESTIONS_AFTER_ANSWER_INSTRUCTION_PROMPT
|
||||
from core.llm_generator.prompts import DEFAULT_SUGGESTED_QUESTIONS_AFTER_ANSWER_INSTRUCTION_PROMPT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SuggestedQuestionsAfterAnswerOutputParser:
|
||||
def __init__(self, instruction_prompt: str | None = None) -> None:
|
||||
self._instruction_prompt = instruction_prompt or DEFAULT_SUGGESTED_QUESTIONS_AFTER_ANSWER_INSTRUCTION_PROMPT
|
||||
|
||||
def get_format_instructions(self) -> str:
|
||||
return SUGGESTED_QUESTIONS_AFTER_ANSWER_INSTRUCTION_PROMPT
|
||||
return self._instruction_prompt
|
||||
|
||||
def parse(self, text: str) -> Sequence[str]:
|
||||
action_match = re.search(r"\[.*?\]", text.strip(), re.DOTALL)
|
||||
stripped_text = text.strip()
|
||||
action_match = re.search(r"\[.*?\]", stripped_text, re.DOTALL)
|
||||
questions: list[str] = []
|
||||
if action_match is not None:
|
||||
try:
|
||||
@ -23,4 +27,6 @@ class SuggestedQuestionsAfterAnswerOutputParser:
|
||||
else:
|
||||
if isinstance(json_obj, list):
|
||||
questions = [question for question in json_obj if isinstance(question, str)]
|
||||
elif stripped_text:
|
||||
logger.warning("Failed to find suggested questions payload array in text: %r", stripped_text[:200])
|
||||
return questions
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
# Written by YORKI MINAKO🤡, Edited by Xiaoyi, Edited by yasu-oh
|
||||
import os
|
||||
|
||||
CONVERSATION_TITLE_PROMPT = """You are asked to generate a concise chat title by decomposing the user’s input into two parts: “Intention” and “Subject”.
|
||||
|
||||
@ -96,8 +95,8 @@ JAVASCRIPT_CODE_GENERATOR_PROMPT_TEMPLATE = (
|
||||
)
|
||||
|
||||
|
||||
# Default prompt for suggested questions (can be overridden by environment variable)
|
||||
_DEFAULT_SUGGESTED_QUESTIONS_AFTER_ANSWER_PROMPT = (
|
||||
# Default prompt and model parameters for suggested questions.
|
||||
DEFAULT_SUGGESTED_QUESTIONS_AFTER_ANSWER_INSTRUCTION_PROMPT = (
|
||||
"Please help me predict the three most likely questions that human would ask, "
|
||||
"and keep each question under 20 characters.\n"
|
||||
"MAKE SURE your output is the SAME language as the Assistant's latest response. "
|
||||
@ -105,14 +104,8 @@ _DEFAULT_SUGGESTED_QUESTIONS_AFTER_ANSWER_PROMPT = (
|
||||
'["question1","question2","question3"]\n'
|
||||
)
|
||||
|
||||
# Environment variable override for suggested questions prompt
|
||||
SUGGESTED_QUESTIONS_AFTER_ANSWER_INSTRUCTION_PROMPT = os.getenv(
|
||||
"SUGGESTED_QUESTIONS_PROMPT", _DEFAULT_SUGGESTED_QUESTIONS_AFTER_ANSWER_PROMPT
|
||||
)
|
||||
|
||||
# Configurable LLM parameters for suggested questions (can be overridden by environment variables)
|
||||
SUGGESTED_QUESTIONS_MAX_TOKENS = int(os.getenv("SUGGESTED_QUESTIONS_MAX_TOKENS", "256"))
|
||||
SUGGESTED_QUESTIONS_TEMPERATURE = float(os.getenv("SUGGESTED_QUESTIONS_TEMPERATURE", "0"))
|
||||
DEFAULT_SUGGESTED_QUESTIONS_MAX_TOKENS = 256
|
||||
DEFAULT_SUGGESTED_QUESTIONS_TEMPERATURE = 0.0
|
||||
|
||||
GENERATOR_QA_PROMPT = (
|
||||
"<Task> The user will send a long text. Generate a Question and Answer pairs only using the knowledge"
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -173,7 +173,7 @@ dev = [
|
||||
# "locust>=2.40.4", # Temporarily removed due to compatibility issues. Uncomment when resolved.
|
||||
"pytest-timeout>=2.4.0",
|
||||
"pytest-xdist>=3.8.0",
|
||||
"pyrefly>=0.61.1",
|
||||
"pyrefly>=0.62.0",
|
||||
"xinference-client>=2.5.0",
|
||||
]
|
||||
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from typing import cast
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
from sqlalchemy import select
|
||||
@ -17,7 +19,16 @@ from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from libs.infinite_scroll_pagination import InfiniteScrollPagination
|
||||
from models import Account
|
||||
from models.enums import FeedbackFromSource, FeedbackRating
|
||||
from models.model import App, AppMode, AppModelConfig, AppModelConfigDict, EndUser, Message, MessageFeedback
|
||||
from models.model import (
|
||||
App,
|
||||
AppMode,
|
||||
AppModelConfig,
|
||||
AppModelConfigDict,
|
||||
EndUser,
|
||||
Message,
|
||||
MessageFeedback,
|
||||
SuggestedQuestionsAfterAnswerConfig,
|
||||
)
|
||||
from repositories.execution_extra_content_repository import ExecutionExtraContentRepository
|
||||
from repositories.sqlalchemy_execution_extra_content_repository import (
|
||||
SQLAlchemyExecutionExtraContentRepository,
|
||||
@ -32,6 +43,7 @@ from services.errors.message import (
|
||||
from services.workflow_service import WorkflowService
|
||||
|
||||
_app_model_config_adapter: TypeAdapter[AppModelConfigDict] = TypeAdapter(AppModelConfigDict)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _create_execution_extra_content_repository() -> ExecutionExtraContentRepository:
|
||||
@ -252,6 +264,7 @@ class MessageService:
|
||||
)
|
||||
|
||||
model_manager = ModelManager.for_tenant(tenant_id=app_model.tenant_id)
|
||||
suggested_questions_after_answer_config: SuggestedQuestionsAfterAnswerConfig = {"enabled": False}
|
||||
|
||||
if app_model.mode == AppMode.ADVANCED_CHAT:
|
||||
workflow_service = WorkflowService()
|
||||
@ -271,9 +284,11 @@ class MessageService:
|
||||
if not app_config.additional_features.suggested_questions_after_answer:
|
||||
raise SuggestedQuestionsAfterAnswerDisabledError()
|
||||
|
||||
model_instance = model_manager.get_default_model_instance(
|
||||
tenant_id=app_model.tenant_id, model_type=ModelType.LLM
|
||||
)
|
||||
suggested_questions_after_answer = workflow.features_dict.get("suggested_questions_after_answer")
|
||||
if isinstance(suggested_questions_after_answer, dict):
|
||||
suggested_questions_after_answer_config = cast(
|
||||
SuggestedQuestionsAfterAnswerConfig, suggested_questions_after_answer
|
||||
)
|
||||
else:
|
||||
if not conversation.override_model_configs:
|
||||
app_model_config = db.session.scalar(
|
||||
@ -293,16 +308,14 @@ class MessageService:
|
||||
if not app_model_config:
|
||||
raise ValueError("did not find app model config")
|
||||
|
||||
suggested_questions_after_answer = app_model_config.suggested_questions_after_answer_dict
|
||||
if suggested_questions_after_answer.get("enabled", False) is False:
|
||||
suggested_questions_after_answer_config = app_model_config.suggested_questions_after_answer_dict
|
||||
if suggested_questions_after_answer_config.get("enabled", False) is False:
|
||||
raise SuggestedQuestionsAfterAnswerDisabledError()
|
||||
|
||||
model_instance = model_manager.get_model_instance(
|
||||
tenant_id=app_model.tenant_id,
|
||||
provider=app_model_config.model_dict["provider"],
|
||||
model_type=ModelType.LLM,
|
||||
model=app_model_config.model_dict["name"],
|
||||
)
|
||||
model_instance = model_manager.get_default_model_instance(
|
||||
tenant_id=app_model.tenant_id,
|
||||
model_type=ModelType.LLM,
|
||||
)
|
||||
|
||||
# get memory of conversation (read-only)
|
||||
memory = TokenBufferMemory(conversation=conversation, model_instance=model_instance)
|
||||
@ -312,9 +325,17 @@ class MessageService:
|
||||
message_limit=3,
|
||||
)
|
||||
|
||||
instruction_prompt = suggested_questions_after_answer_config.get("prompt")
|
||||
if not isinstance(instruction_prompt, str) or not instruction_prompt.strip():
|
||||
instruction_prompt = None
|
||||
|
||||
configured_model = suggested_questions_after_answer_config.get("model")
|
||||
with measure_time() as timer:
|
||||
questions_sequence = LLMGenerator.generate_suggested_questions_after_answer(
|
||||
tenant_id=app_model.tenant_id, histories=histories
|
||||
tenant_id=app_model.tenant_id,
|
||||
histories=histories,
|
||||
instruction_prompt=instruction_prompt,
|
||||
model_config=configured_model,
|
||||
)
|
||||
questions: list[str] = list(questions_sequence)
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -77,6 +77,38 @@ class TestAdditionalFeatureManagers:
|
||||
SuggestedQuestionsAfterAnswerConfigManager.validate_and_set_defaults(
|
||||
{"suggested_questions_after_answer": {"enabled": "yes"}}
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
SuggestedQuestionsAfterAnswerConfigManager.validate_and_set_defaults(
|
||||
{"suggested_questions_after_answer": {"enabled": True, "prompt": 123}}
|
||||
)
|
||||
with pytest.raises(ValueError, match="must be less than or equal to 1000 characters"):
|
||||
SuggestedQuestionsAfterAnswerConfigManager.validate_and_set_defaults(
|
||||
{"suggested_questions_after_answer": {"enabled": True, "prompt": "a" * 1001}}
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
SuggestedQuestionsAfterAnswerConfigManager.validate_and_set_defaults(
|
||||
{"suggested_questions_after_answer": {"enabled": True, "model": "bad"}}
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
SuggestedQuestionsAfterAnswerConfigManager.validate_and_set_defaults(
|
||||
{"suggested_questions_after_answer": {"enabled": True, "model": {"provider": "openai"}}}
|
||||
)
|
||||
|
||||
validated_config, _ = SuggestedQuestionsAfterAnswerConfigManager.validate_and_set_defaults(
|
||||
{
|
||||
"suggested_questions_after_answer": {
|
||||
"enabled": True,
|
||||
"prompt": "custom prompt",
|
||||
"model": {
|
||||
"provider": "openai",
|
||||
"name": "gpt-4o-mini",
|
||||
"completion_params": {"max_tokens": 1024},
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
assert validated_config["suggested_questions_after_answer"]["prompt"] == "custom prompt"
|
||||
assert validated_config["suggested_questions_after_answer"]["model"]["name"] == "gpt-4o-mini"
|
||||
|
||||
assert (
|
||||
SuggestedQuestionsAfterAnswerConfigManager.convert({"suggested_questions_after_answer": {"enabled": True}})
|
||||
|
||||
@ -6,7 +6,12 @@ import pytest
|
||||
from core.app.app_config.entities import ModelConfig
|
||||
from core.llm_generator.entities import RuleCodeGeneratePayload, RuleGeneratePayload, RuleStructuredOutputPayload
|
||||
from core.llm_generator.llm_generator import LLMGenerator
|
||||
from core.llm_generator.prompts import (
|
||||
DEFAULT_SUGGESTED_QUESTIONS_MAX_TOKENS,
|
||||
DEFAULT_SUGGESTED_QUESTIONS_TEMPERATURE,
|
||||
)
|
||||
from graphon.model_runtime.entities.llm_entities import LLMMode, LLMResult
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from graphon.model_runtime.errors.invoke import InvokeAuthorizationError, InvokeError
|
||||
|
||||
|
||||
@ -96,6 +101,10 @@ class TestLLMGenerator:
|
||||
questions = LLMGenerator.generate_suggested_questions_after_answer("tenant_id", "histories")
|
||||
assert len(questions) == 2
|
||||
assert questions[0] == "Question 1?"
|
||||
assert mock_model_instance.invoke_llm.call_args.kwargs["model_parameters"] == {
|
||||
"max_tokens": DEFAULT_SUGGESTED_QUESTIONS_MAX_TOKENS,
|
||||
"temperature": DEFAULT_SUGGESTED_QUESTIONS_TEMPERATURE,
|
||||
}
|
||||
|
||||
def test_generate_suggested_questions_after_answer_auth_error(self, mock_model_instance):
|
||||
with patch("core.llm_generator.llm_generator.ModelManager.for_tenant") as mock_manager:
|
||||
@ -113,6 +122,97 @@ class TestLLMGenerator:
|
||||
questions = LLMGenerator.generate_suggested_questions_after_answer("tenant_id", "histories")
|
||||
assert questions == []
|
||||
|
||||
@patch("core.llm_generator.llm_generator.ModelManager.for_tenant")
|
||||
def test_generate_suggested_questions_after_answer_with_custom_model_and_prompt(self, mock_for_tenant):
|
||||
custom_model_instance = MagicMock()
|
||||
custom_response = MagicMock()
|
||||
custom_response.message.get_text_content.return_value = '["Question 1?"]'
|
||||
custom_model_instance.invoke_llm.return_value = custom_response
|
||||
|
||||
mock_for_tenant.return_value.get_model_instance.return_value = custom_model_instance
|
||||
|
||||
questions = LLMGenerator.generate_suggested_questions_after_answer(
|
||||
"tenant_id",
|
||||
"histories",
|
||||
instruction_prompt="custom prompt",
|
||||
model_config={
|
||||
"provider": "openai",
|
||||
"name": "gpt-4o",
|
||||
"completion_params": {"temperature": 0.2},
|
||||
},
|
||||
)
|
||||
|
||||
assert questions == ["Question 1?"]
|
||||
mock_for_tenant.return_value.get_model_instance.assert_called_once_with(
|
||||
tenant_id="tenant_id",
|
||||
model_type=ModelType.LLM,
|
||||
provider="openai",
|
||||
model="gpt-4o",
|
||||
)
|
||||
|
||||
invoke_kwargs = custom_model_instance.invoke_llm.call_args.kwargs
|
||||
assert invoke_kwargs["model_parameters"] == {"temperature": 0.2}
|
||||
assert invoke_kwargs["stop"] == []
|
||||
assert "custom prompt" in invoke_kwargs["prompt_messages"][0].content
|
||||
|
||||
@patch("core.llm_generator.llm_generator.ModelManager.for_tenant")
|
||||
def test_generate_suggested_questions_after_answer_fallback_to_default_model(self, mock_for_tenant):
|
||||
default_model_instance = MagicMock()
|
||||
default_response = MagicMock()
|
||||
default_response.message.get_text_content.return_value = '["Question 1?"]'
|
||||
default_model_instance.invoke_llm.return_value = default_response
|
||||
|
||||
mock_for_tenant.return_value.get_model_instance.side_effect = ValueError("invalid configured model")
|
||||
mock_for_tenant.return_value.get_default_model_instance.return_value = default_model_instance
|
||||
|
||||
questions = LLMGenerator.generate_suggested_questions_after_answer(
|
||||
"tenant_id",
|
||||
"histories",
|
||||
model_config={
|
||||
"provider": "openai",
|
||||
"name": "not-found-model",
|
||||
"completion_params": {"temperature": 0.2},
|
||||
},
|
||||
)
|
||||
|
||||
assert questions == ["Question 1?"]
|
||||
mock_for_tenant.return_value.get_default_model_instance.assert_called_once_with(
|
||||
tenant_id="tenant_id",
|
||||
model_type=ModelType.LLM,
|
||||
)
|
||||
assert default_model_instance.invoke_llm.call_args.kwargs["model_parameters"] == {
|
||||
"max_tokens": DEFAULT_SUGGESTED_QUESTIONS_MAX_TOKENS,
|
||||
"temperature": DEFAULT_SUGGESTED_QUESTIONS_TEMPERATURE,
|
||||
}
|
||||
assert default_model_instance.invoke_llm.call_args.kwargs["stop"] == []
|
||||
|
||||
@patch("core.llm_generator.llm_generator.ModelManager.for_tenant")
|
||||
def test_generate_suggested_questions_after_answer_drops_non_positive_max_tokens(self, mock_for_tenant):
|
||||
custom_model_instance = MagicMock()
|
||||
custom_response = MagicMock()
|
||||
custom_response.message.get_text_content.return_value = '["Question 1?"]'
|
||||
custom_model_instance.invoke_llm.return_value = custom_response
|
||||
mock_for_tenant.return_value.get_model_instance.return_value = custom_model_instance
|
||||
|
||||
questions = LLMGenerator.generate_suggested_questions_after_answer(
|
||||
"tenant_id",
|
||||
"histories",
|
||||
model_config={
|
||||
"provider": "openai",
|
||||
"name": "gpt-4o",
|
||||
"completion_params": {
|
||||
"temperature": 0.2,
|
||||
"max_tokens": 0,
|
||||
"stop": ["END"],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert questions == ["Question 1?"]
|
||||
invoke_kwargs = custom_model_instance.invoke_llm.call_args.kwargs
|
||||
assert invoke_kwargs["model_parameters"] == {"temperature": 0.2}
|
||||
assert invoke_kwargs["stop"] == ["END"]
|
||||
|
||||
def test_generate_rule_config_no_variable_success(self, mock_model_instance, model_config_entity):
|
||||
payload = RuleGeneratePayload(
|
||||
instruction="test instruction", model_config=model_config_entity, no_variable=True
|
||||
|
||||
@ -3,6 +3,7 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from libs.infinite_scroll_pagination import InfiniteScrollPagination
|
||||
from models.enums import FeedbackFromSource, FeedbackRating
|
||||
from models.model import App, AppMode, EndUser, Message
|
||||
@ -931,6 +932,130 @@ class TestMessageServiceSuggestedQuestions:
|
||||
assert result == ["Q1?"]
|
||||
mock_llm_gen.generate_suggested_questions_after_answer.assert_called_once()
|
||||
|
||||
@patch("services.message_service.db")
|
||||
@patch("services.message_service.ModelManager.for_tenant")
|
||||
@patch("services.message_service.TokenBufferMemory")
|
||||
@patch("services.message_service.LLMGenerator")
|
||||
@patch("services.message_service.TraceQueueManager")
|
||||
@patch.object(MessageService, "get_message")
|
||||
@patch("services.message_service.ConversationService")
|
||||
def test_get_suggested_questions_chat_app_uses_frontend_model_and_prompt(
|
||||
self,
|
||||
mock_conversation_service,
|
||||
mock_get_message,
|
||||
mock_trace_manager,
|
||||
mock_llm_gen,
|
||||
mock_memory,
|
||||
mock_model_manager,
|
||||
mock_db,
|
||||
factory,
|
||||
):
|
||||
"""Test suggested question generation uses frontend configured model and prompt."""
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
|
||||
app = factory.create_app_mock(mode=AppMode.CHAT.value)
|
||||
app.tenant_id = "tenant-123"
|
||||
user = factory.create_end_user_mock()
|
||||
message = factory.create_message_mock()
|
||||
mock_get_message.return_value = message
|
||||
|
||||
conversation = MagicMock()
|
||||
conversation.override_model_configs = None
|
||||
mock_conversation_service.get_conversation.return_value = conversation
|
||||
|
||||
app_model_config = MagicMock()
|
||||
app_model_config.suggested_questions_after_answer_dict = {
|
||||
"enabled": True,
|
||||
"prompt": "custom prompt",
|
||||
"model": {
|
||||
"provider": "openai",
|
||||
"name": "gpt-4o-mini",
|
||||
"completion_params": {"max_tokens": 2048, "temperature": 0.1},
|
||||
},
|
||||
}
|
||||
mock_db.session.scalar.return_value = app_model_config
|
||||
|
||||
mock_memory.return_value.get_history_prompt_text.return_value = "histories"
|
||||
mock_llm_gen.generate_suggested_questions_after_answer.return_value = ["Q1?"]
|
||||
|
||||
result = MessageService.get_suggested_questions_after_answer(
|
||||
app_model=app,
|
||||
user=user,
|
||||
message_id="msg-123",
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
)
|
||||
|
||||
assert result == ["Q1?"]
|
||||
mock_model_manager.return_value.get_default_model_instance.assert_called_once_with(
|
||||
tenant_id="tenant-123",
|
||||
model_type=ModelType.LLM,
|
||||
)
|
||||
mock_memory.assert_called_once_with(
|
||||
conversation=conversation,
|
||||
model_instance=mock_model_manager.return_value.get_default_model_instance.return_value,
|
||||
)
|
||||
mock_llm_gen.generate_suggested_questions_after_answer.assert_called_once_with(
|
||||
tenant_id="tenant-123",
|
||||
histories="histories",
|
||||
instruction_prompt="custom prompt",
|
||||
model_config={
|
||||
"provider": "openai",
|
||||
"name": "gpt-4o-mini",
|
||||
"completion_params": {"max_tokens": 2048, "temperature": 0.1},
|
||||
},
|
||||
)
|
||||
|
||||
@patch("services.message_service.db")
|
||||
@patch("services.message_service.ModelManager.for_tenant")
|
||||
@patch("services.message_service.TokenBufferMemory")
|
||||
@patch("services.message_service.LLMGenerator")
|
||||
@patch("services.message_service.TraceQueueManager")
|
||||
@patch.object(MessageService, "get_message")
|
||||
@patch("services.message_service.ConversationService")
|
||||
def test_get_suggested_questions_chat_app_invalid_frontend_model_fallback_to_default(
|
||||
self,
|
||||
mock_conversation_service,
|
||||
mock_get_message,
|
||||
mock_trace_manager,
|
||||
mock_llm_gen,
|
||||
mock_memory,
|
||||
mock_model_manager,
|
||||
mock_db,
|
||||
factory,
|
||||
):
|
||||
"""Test invalid frontend configured model falls back to tenant default model."""
|
||||
app = factory.create_app_mock(mode=AppMode.CHAT.value)
|
||||
app.tenant_id = "tenant-123"
|
||||
user = factory.create_end_user_mock()
|
||||
message = factory.create_message_mock()
|
||||
mock_get_message.return_value = message
|
||||
|
||||
conversation = MagicMock()
|
||||
conversation.override_model_configs = None
|
||||
mock_conversation_service.get_conversation.return_value = conversation
|
||||
|
||||
app_model_config = MagicMock()
|
||||
app_model_config.suggested_questions_after_answer_dict = {
|
||||
"enabled": True,
|
||||
"model": {"provider": "openai", "name": "invalid-model"},
|
||||
}
|
||||
mock_db.session.scalar.return_value = app_model_config
|
||||
|
||||
mock_model_manager.return_value.get_model_instance.side_effect = ValueError("invalid model")
|
||||
mock_memory.return_value.get_history_prompt_text.return_value = "histories"
|
||||
mock_llm_gen.generate_suggested_questions_after_answer.return_value = ["Q1?"]
|
||||
|
||||
result = MessageService.get_suggested_questions_after_answer(
|
||||
app_model=app, user=user, message_id="msg-123", invoke_from=MagicMock()
|
||||
)
|
||||
|
||||
assert result == ["Q1?"]
|
||||
mock_model_manager.return_value.get_default_model_instance.assert_called_once_with(
|
||||
tenant_id="tenant-123",
|
||||
model_type=ModelType.LLM,
|
||||
)
|
||||
mock_model_manager.return_value.get_model_instance.assert_not_called()
|
||||
|
||||
# Test 30: get_suggested_questions_after_answer - Disabled Error
|
||||
@patch("services.message_service.WorkflowService")
|
||||
@patch("services.message_service.AdvancedChatAppConfigManager")
|
||||
|
||||
64
api/uv.lock
generated
64
api/uv.lock
generated
@ -1627,7 +1627,7 @@ dev = [
|
||||
{ name = "lxml-stubs", specifier = ">=0.5.1" },
|
||||
{ name = "mypy", specifier = ">=1.20.1" },
|
||||
{ name = "pandas-stubs", specifier = ">=3.0.0" },
|
||||
{ name = "pyrefly", specifier = ">=0.61.1" },
|
||||
{ name = "pyrefly", specifier = ">=0.62.0" },
|
||||
{ name = "pytest", specifier = ">=9.0.3" },
|
||||
{ name = "pytest-benchmark", specifier = ">=5.2.3" },
|
||||
{ name = "pytest-cov", specifier = ">=7.1.0" },
|
||||
@ -3687,28 +3687,28 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "lxml"
|
||||
version = "6.0.2"
|
||||
version = "6.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/28/30/9abc9e34c657c33834eaf6cd02124c61bdf5944d802aa48e69be8da3585d/lxml-6.1.0.tar.gz", hash = "sha256:bfd57d8008c4965709a919c3e9a98f76c2c7cb319086b3d26858250620023b13", size = 4197006, upload-time = "2026-04-18T04:32:51.613Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/d4/9326838b59dc36dfae42eec9656b97520f9997eee1de47b8316aaeed169c/lxml-6.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d2f17a16cd8751e8eb233a7e41aecdf8e511712e00088bf9be455f604cd0d28d", size = 8570663, upload-time = "2026-04-18T04:27:48.253Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/a4/053745ce1f8303ccbb788b86c0db3a91b973675cefc42566a188637b7c40/lxml-6.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0cea5b1d3e6e77d71bd2b9972eb2446221a69dc52bb0b9c3c6f6e5700592d93", size = 4624024, upload-time = "2026-04-18T04:27:52.594Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/97/a517944b20f8fd0932ad2109482bee4e29fe721416387a363306667941f6/lxml-6.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fc46da94826188ed45cb53bd8e3fc076ae22675aea2087843d4735627f867c6d", size = 4930895, upload-time = "2026-04-18T04:32:56.29Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/7c/e08a970727d556caa040a44773c7b7e3ad0f0d73dedc863543e9a8b931f2/lxml-6.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9147d8e386ec3b82c3b15d88927f734f565b0aaadef7def562b853adca45784a", size = 5093820, upload-time = "2026-04-18T04:32:58.94Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/ee/2a5c2aa2c32016a226ca25d3e1056a8102ea6e1fe308bf50213586635400/lxml-6.1.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5715e0e28736a070f3f34a7ccc09e2fdcba0e3060abbcf61a1a5718ff6d6b105", size = 5005790, upload-time = "2026-04-18T04:33:01.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/38/a0db9be8f38ad6043ab9429487c128dd1d30f07956ef43040402f8da49e8/lxml-6.1.0-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4937460dc5df0cdd2f06a86c285c28afda06aefa3af949f9477d3e8df430c485", size = 5630827, upload-time = "2026-04-18T04:33:04.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/ba/3c13d3fc24b7cacf675f808a3a1baabf43a30d0cd24c98f94548e9aa58eb/lxml-6.1.0-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bc783ee3147e60a25aa0445ea82b3e8aabb83b240f2b95d32cb75587ff781814", size = 5240445, upload-time = "2026-04-18T04:33:06.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/ba/eeef4ccba09b2212fe239f46c1692a98db1878e0872ae320756488878a94/lxml-6.1.0-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:40d9189f80075f2e1f88db21ef815a2b17b28adf8e50aaf5c789bfe737027f32", size = 5350121, upload-time = "2026-04-18T04:33:09.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/01/1da87c7b587c38d0cbe77a01aae3b9c1c49ed47d76918ef3db8fc151b1ca/lxml-6.1.0-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:05b9b8787e35bec69e68daf4952b2e6dfcfb0db7ecf1a06f8cdfbbac4eb71aad", size = 4694949, upload-time = "2026-04-18T04:33:11.628Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/88/7db0fe66d5aaf128443ee1623dec3db1576f3e4c17751ec0ef5866468590/lxml-6.1.0-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0f08beb0182e3e9a86fae124b3c47a7b41b7b69b225e1377db983802404e54", size = 5243901, upload-time = "2026-04-18T04:33:13.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/a8/1346726af7d1f6fca1f11223ba34001462b0a3660416986d37641708d57c/lxml-6.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73becf6d8c81d4c76b1014dbd3584cb26d904492dcf73ca85dc8bff08dcd6d2d", size = 5048054, upload-time = "2026-04-18T04:33:16.965Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/b7/85057012f035d1a0c87e02f8c723ca3c3e6e0728bcf4cb62080b21b1c1e3/lxml-6.1.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1ae225f66e5938f4fa29d37e009a3bb3b13032ac57eb4eb42afa44f6e4054e69", size = 4777324, upload-time = "2026-04-18T04:33:19.832Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/6c/ad2f94a91073ef570f33718040e8e160d5fb93331cf1ab3ca1323f939e2d/lxml-6.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:690022c7fae793b0489aa68a658822cea83e0d5933781811cabbf5ea3bcfe73d", size = 5645702, upload-time = "2026-04-18T04:33:22.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/89/0bb6c0bd549c19004c60eea9dc554dd78fd647b72314ef25d460e0d208c6/lxml-6.1.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:63aeafc26aac0be8aff14af7871249e87ea1319be92090bfd632ec68e03b16a5", size = 5232901, upload-time = "2026-04-18T04:33:26.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/d9/d609a11fb567da9399f525193e2b49847b5a409cdebe737f06a8b7126bdc/lxml-6.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:264c605ab9c0e4aa1a679636f4582c4d3313700009fac3ec9c3412ed0d8f3e1d", size = 5261333, upload-time = "2026-04-18T04:33:28.984Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/3a/ac3f99ec8ac93089e7dd556f279e0d14c24de0a74a507e143a2e4b496e7c/lxml-6.1.0-cp312-cp312-win32.whl", hash = "sha256:56971379bc5ee8037c5a0f09fa88f66cdb7d37c3e38af3e45cf539f41131ac1f", size = 3596289, upload-time = "2026-04-18T04:27:42.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/a7/0a915557538593cb1bbeedcd40e13c7a261822c26fecbbdb71dad0c2f540/lxml-6.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:bba078de0031c219e5dd06cf3e6bf8fb8e6e64a77819b358f53bb132e3e03366", size = 3997059, upload-time = "2026-04-18T04:27:46.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/96/a5dc078cf0126fbfbc35611d77ecd5da80054b5893e28fb213a5613b9e1d/lxml-6.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:c3592631e652afa34999a088f98ba7dfc7d6aff0d535c410bea77a71743f3819", size = 3659552, upload-time = "2026-04-18T04:27:51.133Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -5357,19 +5357,19 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pyrefly"
|
||||
version = "0.61.1"
|
||||
version = "0.62.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b0/c8/52fce3f0e3718d9ff71d16af41cef925e58613741328004d3aa3fe585057/pyrefly-0.61.1.tar.gz", hash = "sha256:2a871320b7d2b28b8635064b620097d7091e84c49e4808d915ad31dad685d0f5", size = 5535788, upload-time = "2026-04-17T18:47:33.958Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/8874ed25781e7dd561c6d75fb4a7becf10a18d75b074f25b845cc334f781/pyrefly-0.62.0.tar.gz", hash = "sha256:da1fbe1075dc1e6c8e3134e9370b0a0e7a296061d782cca5bf83dbb8e4c10d7c", size = 5537672, upload-time = "2026-04-20T17:12:15.718Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/61/38/e94ff401405a05fbf81c9bbfa993a34ffd03be84812b545063c8efb56b44/pyrefly-0.61.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6e3ed857b99291fc4aa3b54ce22deb086c0174cf3a3775eccea7439efd16d925", size = 12969301, upload-time = "2026-04-17T18:47:06.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/be/53c7f9400696e46633c8cee8b6fd32ce7ab4a965ddf9ac4f4ea9e2034647/pyrefly-0.61.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cf6335c1baf9470ca8113f7ea8bdbd0b96081c82a911157c576cdfc8a67a9a87", size = 12475413, upload-time = "2026-04-17T18:47:08.863Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/68/83cc3267620b14f81fa596a84efc7ebcf5c49f79b521499e85d1a4fca6d8/pyrefly-0.61.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:844b5baddc2a631f69648a4756c54c97d86e4b9c07e335b216668e24390b77b6", size = 36074785, upload-time = "2026-04-17T18:47:11.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/00/e8d437995b8dcea022f5310bc873f5de1dcc71da4876d5be917ee9a93fef/pyrefly-0.61.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eaa294f90622c5b3743af8e9de4263447f22bb0e8b60c80cf83292adb4f2d14b", size = 38802979, upload-time = "2026-04-17T18:47:16.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/3f/f1cbc58e8875608ae740d9575de95c8bc6d4dce202f82b4fe90005727618/pyrefly-0.61.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a8d8c3fe08b9593dce23ad4bc7c393891a379c2d580aa1f263182567721bd6f", size = 37029339, upload-time = "2026-04-17T18:47:19.601Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/8c/0ff67041c88c28f48b10ce15758831d1e4e60f11db5bfc09dcffd5edb6ba/pyrefly-0.61.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:305f2086f4d7d796244b337884d96cf0d32435420336a77840ca369cf6fa06fd", size = 41595667, upload-time = "2026-04-17T18:47:23.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/9e/62b8139b140931593a6b29334802ea6b86d033c0bfd9794950279732253b/pyrefly-0.61.1-py3-none-win32.whl", hash = "sha256:3271a019885a72c8dd064e928bb445af807771506842f5f2faaac17d8e6e73a5", size = 11963660, upload-time = "2026-04-17T18:47:25.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/6e/73280243d12bec28f55b6edd4e70c5cf11e3d7de2395ecb4eb36cca7dab4/pyrefly-0.61.1-py3-none-win_amd64.whl", hash = "sha256:3e3763d5d76f505c5b8897db1446bde8e138d50a67751f2aa76d6c6034254836", size = 12804056, upload-time = "2026-04-17T18:47:28.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/32/38ac5af84d96167412024abf5e2f49f15b777987a1942e7a442e8e5fef82/pyrefly-0.61.1-py3-none-win_arm64.whl", hash = "sha256:cef5631e2ab09702274ec2eaaafee28a114891cf85f2d31568b329727e3ff735", size = 12302467, upload-time = "2026-04-17T18:47:31.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/ea/09bd9da7d5df294db800312fb415be2fefbaa5594178e9e49f44fa071aea/pyrefly-0.62.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d78ec4f126dee1fa76215b193b964490ce10e62a32d2787a72c51623658b803", size = 13020414, upload-time = "2026-04-20T17:11:43.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/f0/f84afac4f220c4c8c801b779ee2ff28ad3f7731f4283c2e1b6ee9012e8c2/pyrefly-0.62.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2a41a34902d20756264486f9e309f22633d100261bd960feea6e858a098d985d", size = 12515659, upload-time = "2026-04-20T17:11:46.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/0b/620c39cefa9ae1b25ee7a2da9d8d3c278b095649cb8435c5e01ea64f7c17/pyrefly-0.62.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4666c6b65aea662e5f77b64dc91c091b7ea5cede6aa66c0f4cbae26480403583", size = 36228332, upload-time = "2026-04-20T17:11:50.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/fb/47b8b76438c12761e509a3666cd5a99d4af7f21976ba8385feb475cbfe30/pyrefly-0.62.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1aefab798f47d37c13ded791192fee9b39a6d2b12e31f38ae06a1f80c4b26e22", size = 38995741, upload-time = "2026-04-20T17:11:54.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/d2/03bd17673f61147cd5609cd7d6a1455eeccc17a07a7e141ed9931b0c42c0/pyrefly-0.62.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8fa986b50d56740da1d7ae7c660a505143cb9d286fa98cc7e5f4a759cc6eaa5d", size = 37205321, upload-time = "2026-04-20T17:11:58.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/14/20ba7b7f2d182f9b7c1e24a3041dac9b5730ae28cfe1614a2c98706650f2/pyrefly-0.62.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32e9b175805c82ffb967e4708f4910bace7e1a12736907380cc9afdbaabb0efb", size = 41786834, upload-time = "2026-04-20T17:12:03.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/c8/5a7ba88c4fa1b5090d877f70fa1b742b921b9e7d8d3f4b6b9b1ba1820850/pyrefly-0.62.0-py3-none-win32.whl", hash = "sha256:1cd98edc20cab5bac8016c9220ee66080e39bd22e7f0e9bb3e2c4e2be1555eed", size = 12010170, upload-time = "2026-04-20T17:12:06.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/78/d8f810de010ff2ed594c630c724fd817ef430963249e9eb396ce8f785e9d/pyrefly-0.62.0-py3-none-win_amd64.whl", hash = "sha256:6994f8ee7d6720325ee52207fbdaca98a799a1efe462bb5ba90c47160f7f3e6e", size = 12861816, upload-time = "2026-04-20T17:12:09.689Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/a9/ac824ef6a3f50b7c0ec5974471f8f2cb205cd1edd53a5abbcf7ba37feb5d/pyrefly-0.62.0-py3-none-win_arm64.whl", hash = "sha256:362a5d47a5ac5aaa5258091e878a1759ff8b687d8cf462af1c516144f7b0108a", size = 12352977, upload-time = "2026-04-20T17:12:12.736Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@ -1,253 +0,0 @@
|
||||
# Configurable Suggested Questions After Answer
|
||||
|
||||
This document explains how to configure the "Suggested Questions After Answer" feature in Dify using environment variables.
|
||||
|
||||
## Overview
|
||||
|
||||
The suggested questions feature generates follow-up questions after each AI response to help users continue the conversation. By default, Dify generates 3 short questions (under 20 characters each), but you can customize this behavior to better fit your specific use case.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### `SUGGESTED_QUESTIONS_PROMPT`
|
||||
|
||||
**Description**: Custom prompt template for generating suggested questions.
|
||||
|
||||
**Default**:
|
||||
|
||||
```
|
||||
Please help me predict the three most likely questions that human would ask, and keep each question under 20 characters.
|
||||
MAKE SURE your output is the SAME language as the Assistant's latest response.
|
||||
The output must be an array in JSON format following the specified schema:
|
||||
["question1","question2","question3"]
|
||||
```
|
||||
|
||||
**Usage Examples**:
|
||||
|
||||
1. **Technical/Developer Questions (Your Use Case)**:
|
||||
|
||||
```bash
|
||||
export SUGGESTED_QUESTIONS_PROMPT='Please help me predict the five most likely technical follow-up questions a developer would ask. Focus on implementation details, best practices, and architecture considerations. Keep each question between 40-60 characters. Output must be JSON array: ["question1","question2","question3","question4","question5"]'
|
||||
```
|
||||
|
||||
1. **Customer Support**:
|
||||
|
||||
```bash
|
||||
export SUGGESTED_QUESTIONS_PROMPT='Generate 3 helpful follow-up questions that guide customers toward solving their own problems. Focus on troubleshooting steps and common issues. Keep questions under 30 characters. JSON format: ["q1","q2","q3"]'
|
||||
```
|
||||
|
||||
1. **Educational Content**:
|
||||
|
||||
```bash
|
||||
export SUGGESTED_QUESTIONS_PROMPT='Create 4 thought-provoking questions that help students deeper understand the topic. Focus on concepts, relationships, and applications. Questions should be 25-40 characters. JSON: ["question1","question2","question3","question4"]'
|
||||
```
|
||||
|
||||
1. **Multilingual Support**:
|
||||
|
||||
```bash
|
||||
export SUGGESTED_QUESTIONS_PROMPT='Generate exactly 3 follow-up questions in the same language as the conversation. Adapt question length appropriately for the language (Chinese: 10-15 chars, English: 20-30 chars, Arabic: 25-35 chars). Always output valid JSON array.'
|
||||
```
|
||||
|
||||
**Important Notes**:
|
||||
|
||||
- The prompt must request JSON array output format
|
||||
- Include language matching instructions for multilingual support
|
||||
- Specify clear character limits or question count requirements
|
||||
- Focus on your specific domain or use case
|
||||
|
||||
### `SUGGESTED_QUESTIONS_MAX_TOKENS`
|
||||
|
||||
**Description**: Maximum number of tokens for the LLM response.
|
||||
|
||||
**Default**: `256`
|
||||
|
||||
**Usage**:
|
||||
|
||||
```bash
|
||||
export SUGGESTED_QUESTIONS_MAX_TOKENS=512 # For longer questions or more questions
|
||||
```
|
||||
|
||||
**Recommended Values**:
|
||||
|
||||
- `256`: Default, good for 3-4 short questions
|
||||
- `384`: Medium, good for 4-5 medium-length questions
|
||||
- `512`: High, good for 5+ longer questions or complex prompts
|
||||
- `1024`: Maximum, for very complex question generation
|
||||
|
||||
### `SUGGESTED_QUESTIONS_TEMPERATURE`
|
||||
|
||||
**Description**: Temperature parameter for LLM creativity.
|
||||
|
||||
**Default**: `0.0`
|
||||
|
||||
**Usage**:
|
||||
|
||||
```bash
|
||||
export SUGGESTED_QUESTIONS_TEMPERATURE=0.3 # Balanced creativity
|
||||
```
|
||||
|
||||
**Recommended Values**:
|
||||
|
||||
- `0.0-0.2`: Very focused, predictable questions (good for technical support)
|
||||
- `0.3-0.5`: Balanced creativity and relevance (good for general use)
|
||||
- `0.6-0.8`: More creative, diverse questions (good for brainstorming)
|
||||
- `0.9-1.0`: Maximum creativity (good for educational exploration)
|
||||
|
||||
## Configuration Examples
|
||||
|
||||
### Example 1: Developer Documentation Chatbot
|
||||
|
||||
```bash
|
||||
# .env file
|
||||
SUGGESTED_QUESTIONS_PROMPT='Generate exactly 5 technical follow-up questions that developers would ask after reading code documentation. Focus on implementation details, edge cases, performance considerations, and best practices. Each question should be 40-60 characters long. Output as JSON array: ["question1","question2","question3","question4","question5"]'
|
||||
SUGGESTED_QUESTIONS_MAX_TOKENS=512
|
||||
SUGGESTED_QUESTIONS_TEMPERATURE=0.3
|
||||
```
|
||||
|
||||
### Example 2: Customer Service Bot
|
||||
|
||||
```bash
|
||||
# .env file
|
||||
SUGGESTED_QUESTIONS_PROMPT='Create 3 actionable follow-up questions that help customers resolve their own issues. Focus on common problems, troubleshooting steps, and product features. Keep questions simple and under 25 characters. JSON: ["q1","q2","q3"]'
|
||||
SUGGESTED_QUESTIONS_MAX_TOKENS=256
|
||||
SUGGESTED_QUESTIONS_TEMPERATURE=0.1
|
||||
```
|
||||
|
||||
### Example 3: Educational Tutor
|
||||
|
||||
```bash
|
||||
# .env file
|
||||
SUGGESTED_QUESTIONS_PROMPT='Generate 4 thought-provoking questions that help students deepen their understanding of the topic. Focus on relationships between concepts, practical applications, and critical thinking. Questions should be 30-45 characters. Output: ["question1","question2","question3","question4"]'
|
||||
SUGGESTED_QUESTIONS_MAX_TOKENS=384
|
||||
SUGGESTED_QUESTIONS_TEMPERATURE=0.6
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Environment Variable Loading**: The system checks for environment variables at startup
|
||||
1. **Fallback to Defaults**: If no environment variables are set, original behavior is preserved
|
||||
1. **Prompt Template**: The custom prompt is used as-is, allowing full control over question generation
|
||||
1. **LLM Parameters**: Custom max_tokens and temperature are passed to the LLM API
|
||||
1. **JSON Parsing**: The system expects JSON array output and parses it accordingly
|
||||
|
||||
### File Changes
|
||||
|
||||
The implementation modifies these files:
|
||||
|
||||
- `api/core/llm_generator/prompts.py`: Environment variable support
|
||||
- `api/core/llm_generator/llm_generator.py`: Custom LLM parameters
|
||||
- `api/.env.example`: Documentation of new variables
|
||||
|
||||
### Backward Compatibility
|
||||
|
||||
- ✅ **Zero Breaking Changes**: Works exactly as before if no environment variables are set
|
||||
- ✅ **Default Behavior Preserved**: Original prompt and parameters used as fallbacks
|
||||
- ✅ **No Database Changes**: Pure environment variable configuration
|
||||
- ✅ **No UI Changes Required**: Configuration happens at deployment level
|
||||
|
||||
## Testing Your Configuration
|
||||
|
||||
### Local Testing
|
||||
|
||||
1. Set environment variables:
|
||||
|
||||
```bash
|
||||
export SUGGESTED_QUESTIONS_PROMPT='Your test prompt...'
|
||||
export SUGGESTED_QUESTIONS_MAX_TOKENS=300
|
||||
export SUGGESTED_QUESTIONS_TEMPERATURE=0.4
|
||||
```
|
||||
|
||||
1. Start Dify API:
|
||||
|
||||
```bash
|
||||
cd api
|
||||
python -m flask run --host 0.0.0.0 --port=5001 --debug
|
||||
```
|
||||
|
||||
1. Test the feature in your chat application and verify the questions match your expectations.
|
||||
|
||||
### Monitoring
|
||||
|
||||
Monitor the following when testing:
|
||||
|
||||
- **Question Quality**: Are questions relevant and helpful?
|
||||
- **Language Matching**: Do questions match the conversation language?
|
||||
- **JSON Format**: Is output properly formatted as JSON array?
|
||||
- **Length Constraints**: Do questions follow your length requirements?
|
||||
- **Response Time**: Are the custom parameters affecting performance?
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Invalid JSON Output**:
|
||||
|
||||
- **Problem**: LLM doesn't return valid JSON
|
||||
- **Solution**: Make sure your prompt explicitly requests JSON array format
|
||||
|
||||
1. **Questions Too Long/Short**:
|
||||
|
||||
- **Problem**: Questions don't follow length constraints
|
||||
- **Solution**: Be more specific about character limits in your prompt
|
||||
|
||||
1. **Too Few/Many Questions**:
|
||||
|
||||
- **Problem**: Wrong number of questions generated
|
||||
- **Solution**: Clearly specify the exact number in your prompt
|
||||
|
||||
1. **Language Mismatch**:
|
||||
|
||||
- **Problem**: Questions in wrong language
|
||||
- **Solution**: Include explicit language matching instructions in prompt
|
||||
|
||||
1. **Performance Issues**:
|
||||
|
||||
- **Problem**: Slow response times
|
||||
- **Solution**: Reduce `SUGGESTED_QUESTIONS_MAX_TOKENS` or simplify prompt
|
||||
|
||||
### Debug Logging
|
||||
|
||||
To debug your configuration, you can temporarily add logging to see the actual prompt and parameters being used:
|
||||
|
||||
```python
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# In llm_generator.py
|
||||
logger.info(f"Suggested questions prompt: {prompt}")
|
||||
logger.info(f"Max tokens: {SUGGESTED_QUESTIONS_MAX_TOKENS}")
|
||||
logger.info(f"Temperature: {SUGGESTED_QUESTIONS_TEMPERATURE}")
|
||||
```
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### From Default Configuration
|
||||
|
||||
If you're currently using the default configuration and want to customize:
|
||||
|
||||
1. **Assess Your Needs**: Determine what aspects need customization (question count, length, domain focus)
|
||||
1. **Design Your Prompt**: Write a custom prompt that addresses your specific use case
|
||||
1. **Choose Parameters**: Select appropriate max_tokens and temperature values
|
||||
1. **Test Incrementally**: Start with small changes and test thoroughly
|
||||
1. **Deploy Gradually**: Roll out to production after successful testing
|
||||
|
||||
### Best Practices
|
||||
|
||||
1. **Start Simple**: Begin with minimal changes to the default prompt
|
||||
1. **Test Thoroughly**: Test with various conversation types and languages
|
||||
1. **Monitor Performance**: Watch for impact on response times and costs
|
||||
1. **Get User Feedback**: Collect feedback on question quality and relevance
|
||||
1. **Iterate**: Refine your configuration based on real-world usage
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
This environment variable approach provides immediate customization while maintaining backward compatibility. Future enhancements could include:
|
||||
|
||||
1. **App-Level Configuration**: Different apps with different suggested question settings
|
||||
1. **Dynamic Prompts**: Context-aware prompts based on conversation content
|
||||
1. **Multi-Model Support**: Different models for different types of questions
|
||||
1. **Analytics Dashboard**: Insights into question effectiveness and usage patterns
|
||||
1. **A/B Testing**: Built-in testing of different prompt configurations
|
||||
|
||||
For now, the environment variable approach offers a simple, reliable way to customize the suggested questions feature for your specific needs.
|
||||
@ -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:",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -88,7 +88,7 @@ See `[web/docs/overlay-migration.md](../../web/docs/overlay-migration.md)` for t
|
||||
|
||||
- `pnpm -C packages/dify-ui test` — Vitest unit tests for primitives.
|
||||
- `pnpm -C packages/dify-ui storybook` — Storybook on the default port. Each primitive has `index.stories.tsx`.
|
||||
- `pnpm -C packages/dify-ui type-check` — `tsc --noEmit` for this package only.
|
||||
- `pnpm -C packages/dify-ui type-check` — `tsgo --noEmit` for this package only.
|
||||
|
||||
See `[AGENTS.md](./AGENTS.md)` for:
|
||||
|
||||
|
||||
@ -83,7 +83,7 @@
|
||||
"storybook:build": "storybook build",
|
||||
"test": "vp test",
|
||||
"test:watch": "vp test --watch",
|
||||
"type-check": "tsc"
|
||||
"type-check": "tsgo"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@base-ui/react": "catalog:",
|
||||
@ -109,6 +109,7 @@
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"@vitejs/plugin-react": "catalog:",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"class-variance-authority": "catalog:",
|
||||
|
||||
@ -2,5 +2,7 @@
|
||||
"extends": "@dify/tsconfig/react.json",
|
||||
"compilerOptions": {
|
||||
"types": ["vite-plus/test/globals"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts", "tailwind.config.ts"],
|
||||
"exclude": ["node_modules", "dist", "storybook-static", "coverage"]
|
||||
}
|
||||
|
||||
@ -8,9 +8,10 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vp pack",
|
||||
"type-check": "tsc"
|
||||
"type-check": "tsgo"
|
||||
},
|
||||
"dependencies": {
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -117,17 +117,17 @@ async function runTypeCheck(
|
||||
|
||||
await fs.mkdir(TYPECHECK_CACHE_DIR, { recursive: true })
|
||||
|
||||
const tscArgs = ['exec', 'tsc', '--noEmit', '--pretty', 'false']
|
||||
const tsgoArgs = ['exec', 'tsgo', '--noEmit', '--pretty', 'false']
|
||||
if (incremental) {
|
||||
tscArgs.push('--incremental', '--tsBuildInfoFile', buildInfoPath)
|
||||
tsgoArgs.push('--incremental', '--tsBuildInfoFile', buildInfoPath)
|
||||
}
|
||||
else {
|
||||
tscArgs.push('--incremental', 'false')
|
||||
tsgoArgs.push('--incremental', 'false')
|
||||
}
|
||||
tscArgs.push('--project', projectPath)
|
||||
tsgoArgs.push('--project', projectPath)
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync('pnpm', tscArgs, {
|
||||
const { stdout, stderr } = await execFileAsync('pnpm', tsgoArgs, {
|
||||
cwd: projectDirectory,
|
||||
env: {
|
||||
...process.env,
|
||||
|
||||
2247
pnpm-lock.yaml
generated
2247
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -47,12 +47,12 @@ overrides:
|
||||
yaml@>=2.0.0 <2.8.3: 2.8.3
|
||||
yauzl@<3.2.1: 3.2.1
|
||||
catalog:
|
||||
'@amplitude/analytics-browser': 2.39.0
|
||||
'@amplitude/plugin-session-replay-browser': 1.27.7
|
||||
'@amplitude/analytics-browser': 2.41.0
|
||||
'@amplitude/plugin-session-replay-browser': 1.27.10
|
||||
'@antfu/eslint-config': 8.2.0
|
||||
'@base-ui/react': 1.4.1
|
||||
'@chromatic-com/storybook': 5.1.2
|
||||
'@cucumber/cucumber': 12.8.0
|
||||
'@cucumber/cucumber': 12.8.1
|
||||
'@egoist/tailwindcss-icons': 1.9.2
|
||||
'@emoji-mart/data': 1.2.1
|
||||
'@eslint-react/eslint-plugin': 3.0.0
|
||||
@ -75,8 +75,8 @@ catalog:
|
||||
'@mdx-js/react': 3.1.1
|
||||
'@mdx-js/rollup': 3.1.1
|
||||
'@monaco-editor/react': 4.7.0
|
||||
'@next/eslint-plugin-next': 16.2.3
|
||||
'@next/mdx': 16.2.3
|
||||
'@next/eslint-plugin-next': 16.2.4
|
||||
'@next/mdx': 16.2.4
|
||||
'@orpc/client': 1.13.14
|
||||
'@orpc/contract': 1.13.14
|
||||
'@orpc/openapi-client': 1.13.14
|
||||
@ -84,7 +84,7 @@ catalog:
|
||||
'@playwright/test': 1.59.1
|
||||
'@remixicon/react': 4.9.0
|
||||
'@rgrove/parse-xml': 4.2.0
|
||||
'@sentry/react': 10.48.0
|
||||
'@sentry/react': 10.49.0
|
||||
'@storybook/addon-docs': 10.3.5
|
||||
'@storybook/addon-links': 10.3.5
|
||||
'@storybook/addon-onboarding': 10.3.5
|
||||
@ -95,23 +95,23 @@ catalog:
|
||||
'@streamdown/math': 1.0.2
|
||||
'@svgdotjs/svg.js': 3.2.5
|
||||
'@t3-oss/env-nextjs': 0.13.11
|
||||
'@tailwindcss/postcss': 4.2.2
|
||||
'@tailwindcss/postcss': 4.2.4
|
||||
'@tailwindcss/typography': 0.5.19
|
||||
'@tailwindcss/vite': 4.2.2
|
||||
'@tanstack/eslint-plugin-query': 5.99.0
|
||||
'@tailwindcss/vite': 4.2.4
|
||||
'@tanstack/eslint-plugin-query': 5.99.2
|
||||
'@tanstack/react-devtools': 0.10.2
|
||||
'@tanstack/react-form': 1.29.0
|
||||
'@tanstack/react-form-devtools': 0.2.21
|
||||
'@tanstack/react-query': 5.99.0
|
||||
'@tanstack/react-query-devtools': 5.99.0
|
||||
'@tanstack/react-virtual': 3.13.23
|
||||
'@tanstack/react-form': 1.29.1
|
||||
'@tanstack/react-form-devtools': 0.2.22
|
||||
'@tanstack/react-query': 5.99.2
|
||||
'@tanstack/react-query-devtools': 5.99.2
|
||||
'@tanstack/react-virtual': 3.13.24
|
||||
'@testing-library/dom': 10.4.1
|
||||
'@testing-library/jest-dom': 6.9.1
|
||||
'@testing-library/react': 16.3.2
|
||||
'@testing-library/user-event': 14.6.1
|
||||
'@tsslint/cli': 3.0.3
|
||||
'@tsslint/compat-eslint': 3.0.3
|
||||
'@tsslint/config': 3.0.3
|
||||
'@tsslint/cli': 3.0.4
|
||||
'@tsslint/compat-eslint': 3.0.4
|
||||
'@tsslint/config': 3.0.4
|
||||
'@types/js-cookie': 3.0.6
|
||||
'@types/js-yaml': 4.0.9
|
||||
'@types/negotiator': 0.6.4
|
||||
@ -120,12 +120,12 @@ catalog:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3
|
||||
'@types/sortablejs': 1.15.9
|
||||
'@typescript-eslint/eslint-plugin': 8.58.2
|
||||
'@typescript-eslint/parser': 8.58.2
|
||||
'@typescript/native-preview': 7.0.0-dev.20260413.1
|
||||
'@typescript-eslint/eslint-plugin': 8.59.0
|
||||
'@typescript-eslint/parser': 8.59.0
|
||||
'@typescript/native-preview': 7.0.0-dev.20260422.1
|
||||
'@vitejs/plugin-react': 6.0.1
|
||||
'@vitejs/plugin-rsc': 0.5.24
|
||||
'@vitest/coverage-v8': 4.1.4
|
||||
'@vitest/coverage-v8': 4.1.5
|
||||
abcjs: 6.6.2
|
||||
agentation: 3.0.2
|
||||
ahooks: 3.9.7
|
||||
@ -138,22 +138,22 @@ catalog:
|
||||
cron-parser: 5.5.0
|
||||
dayjs: 1.11.20
|
||||
decimal.js: 10.6.0
|
||||
dompurify: 3.4.0
|
||||
dompurify: 3.4.1
|
||||
echarts: 6.0.0
|
||||
echarts-for-react: 3.0.6
|
||||
elkjs: 0.11.1
|
||||
embla-carousel-autoplay: 8.6.0
|
||||
embla-carousel-react: 8.6.0
|
||||
emoji-mart: 5.6.0
|
||||
es-toolkit: 1.45.1
|
||||
eslint: 10.2.0
|
||||
es-toolkit: 1.46.0
|
||||
eslint: 10.2.1
|
||||
eslint-markdown: 0.6.1
|
||||
eslint-plugin-better-tailwindcss: 4.4.1
|
||||
eslint-plugin-hyoban: 0.14.1
|
||||
eslint-plugin-markdown-preferences: 0.41.1
|
||||
eslint-plugin-no-barrel-files: 1.3.1
|
||||
eslint-plugin-react-refresh: 0.5.2
|
||||
eslint-plugin-sonarjs: 4.0.2
|
||||
eslint-plugin-sonarjs: 4.0.3
|
||||
eslint-plugin-storybook: 10.3.5
|
||||
fast-deep-equal: 3.1.3
|
||||
happy-dom: 20.9.0
|
||||
@ -161,7 +161,7 @@ catalog:
|
||||
hono: 4.12.14
|
||||
html-entities: 2.6.0
|
||||
html-to-image: 1.11.13
|
||||
i18next: 26.0.4
|
||||
i18next: 26.0.6
|
||||
i18next-resources-to-backend: 1.2.1
|
||||
iconify-import-svg: 0.2.0
|
||||
immer: 11.1.4
|
||||
@ -171,21 +171,21 @@ catalog:
|
||||
js-yaml: 4.1.1
|
||||
jsonschema: 1.5.0
|
||||
katex: 0.16.45
|
||||
knip: 6.4.1
|
||||
ky: 2.0.0
|
||||
knip: 6.6.1
|
||||
ky: 2.0.2
|
||||
lamejs: 1.2.1
|
||||
lexical: 0.43.0
|
||||
loro-crdt: 1.10.8
|
||||
loro-crdt: 1.11.1
|
||||
mermaid: 11.14.0
|
||||
mime: 4.1.0
|
||||
mitt: 3.0.1
|
||||
negotiator: 1.0.0
|
||||
next: 16.2.3
|
||||
next: 16.2.4
|
||||
next-themes: 0.4.6
|
||||
nuqs: 2.8.9
|
||||
pinyin-pro: 3.28.1
|
||||
playwright: 1.59.1
|
||||
postcss: 8.5.9
|
||||
postcss: 8.5.10
|
||||
qrcode.react: 4.2.0
|
||||
qs: 6.15.1
|
||||
react: 19.2.5
|
||||
@ -213,10 +213,10 @@ catalog:
|
||||
streamdown: 2.5.0
|
||||
string-ts: 2.3.1
|
||||
tailwind-merge: 3.5.0
|
||||
tailwindcss: 4.2.2
|
||||
tailwindcss: 4.2.4
|
||||
tldts: 7.0.28
|
||||
tsx: 4.21.0
|
||||
typescript: 6.0.2
|
||||
typescript: 6.0.3
|
||||
uglify-js: 3.19.3
|
||||
unist-util-visit: 5.1.0
|
||||
use-context-selector: 2.0.0
|
||||
|
||||
@ -48,7 +48,7 @@
|
||||
"build": "vp pack",
|
||||
"lint": "eslint",
|
||||
"lint:fix": "eslint --fix",
|
||||
"type-check": "tsc",
|
||||
"type-check": "tsgo",
|
||||
"test": "vp test",
|
||||
"test:coverage": "vp test --coverage",
|
||||
"publish:check": "./scripts/publish.sh --dry-run",
|
||||
@ -60,6 +60,7 @@
|
||||
"@types/node": "catalog:",
|
||||
"@typescript-eslint/eslint-plugin": "catalog:",
|
||||
"@typescript-eslint/parser": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"eslint": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
|
||||
@ -1,14 +1,17 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { PeriodParams } from '@/app/components/app/overview/app-chart'
|
||||
import type { Item } from '@/app/components/base/select'
|
||||
import type { I18nKeysByPrefix } from '@/types/i18n'
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import dayjs from 'dayjs'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SimpleSelect } from '@/app/components/base/select'
|
||||
|
||||
type TimePeriodName = I18nKeysByPrefix<'appLog', 'filter.period.'>
|
||||
type TimePeriodOption = {
|
||||
value: string
|
||||
name: string
|
||||
}
|
||||
|
||||
type Props = {
|
||||
periodMapping: { [key: string]: { value: number, name: TimePeriodName } }
|
||||
@ -24,8 +27,18 @@ const LongTimeRangePicker: FC<Props> = ({
|
||||
queryDateFormat,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const items = React.useMemo<TimePeriodOption[]>(() => {
|
||||
return Object.entries(periodMapping).map(([key, period]) => ({
|
||||
value: key,
|
||||
name: t(`filter.period.${period.name}`, { ns: 'appLog' }),
|
||||
}))
|
||||
}, [periodMapping, t])
|
||||
const [value, setValue] = React.useState('2')
|
||||
const selectedItem = React.useMemo(() => {
|
||||
return items.find(item => item.value === value) ?? null
|
||||
}, [items, value])
|
||||
|
||||
const handleSelect = React.useCallback((item: Item) => {
|
||||
const handleSelect = React.useCallback((item: TimePeriodOption) => {
|
||||
const id = item.value
|
||||
const value = periodMapping[id]?.value ?? '-1'
|
||||
const name = item.name || t('filter.period.allTime', { ns: 'appLog' })
|
||||
@ -55,13 +68,30 @@ const LongTimeRangePicker: FC<Props> = ({
|
||||
}, [onSelect, periodMapping, queryDateFormat, t])
|
||||
|
||||
return (
|
||||
<SimpleSelect
|
||||
items={Object.entries(periodMapping).map(([k, v]) => ({ value: k, name: t(`filter.period.${v.name}`, { ns: 'appLog' }) }))}
|
||||
className="mt-0 w-40!"
|
||||
notClearable={true}
|
||||
onSelect={handleSelect}
|
||||
defaultValue="2"
|
||||
/>
|
||||
<Select
|
||||
value={selectedItem?.value ?? null}
|
||||
onValueChange={(nextValue) => {
|
||||
if (!nextValue)
|
||||
return
|
||||
const nextItem = items.find(item => item.value === nextValue)
|
||||
if (!nextItem)
|
||||
return
|
||||
setValue(nextValue)
|
||||
handleSelect(nextItem)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="mt-0 w-fit max-w-none">
|
||||
{selectedItem?.name ?? t('placeholder.select', { ns: 'common' })}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{items.map(item => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
<SelectItemText>{item.name}</SelectItemText>
|
||||
<SelectItemIndicator />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
export default React.memo(LongTimeRangePicker)
|
||||
|
||||
@ -1,19 +1,22 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { PeriodParamsWithTimeRange, TimeRange } from '@/app/components/app/overview/app-chart'
|
||||
import type { Item } from '@/app/components/base/select'
|
||||
import type { I18nKeysByPrefix } from '@/types/i18n'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RiArrowDownSLine, RiCheckLine } from '@remixicon/react'
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import dayjs from 'dayjs'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SimpleSelect } from '@/app/components/base/select'
|
||||
|
||||
const today = dayjs()
|
||||
|
||||
type TimePeriodName = I18nKeysByPrefix<'appLog', 'filter.period.'>
|
||||
type TimePeriodOption = {
|
||||
value: number
|
||||
name: string
|
||||
}
|
||||
|
||||
type Props = {
|
||||
isCustomRange: boolean
|
||||
@ -27,8 +30,19 @@ const RangeSelector: FC<Props> = ({
|
||||
onSelect,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const items = useMemo<TimePeriodOption[]>(() => {
|
||||
return ranges.map(range => ({
|
||||
...range,
|
||||
name: t(`filter.period.${range.name}`, { ns: 'appLog' }),
|
||||
}))
|
||||
}, [ranges, t])
|
||||
const [value, setValue] = useState('0')
|
||||
const selectedItem = useMemo(() => {
|
||||
return items.find(item => String(item.value) === value) ?? null
|
||||
}, [items, value])
|
||||
|
||||
const handleSelectRange = useCallback((item: Item) => {
|
||||
const handleSelectRange = useCallback((item: TimePeriodOption) => {
|
||||
const { name, value } = item
|
||||
let period: TimeRange | null = null
|
||||
if (value === 0) {
|
||||
@ -42,44 +56,38 @@ const RangeSelector: FC<Props> = ({
|
||||
onSelect({ query: period!, name })
|
||||
}, [onSelect])
|
||||
|
||||
const renderTrigger = useCallback((item: Item | null, isOpen: boolean) => {
|
||||
return (
|
||||
<div className={cn('flex h-8 cursor-pointer items-center space-x-1.5 rounded-lg bg-components-input-bg-normal pr-2 pl-3', isOpen && 'bg-state-base-hover-alt')}>
|
||||
<div className="system-sm-regular text-components-input-text-filled">{isCustomRange ? t('filter.period.custom', { ns: 'appLog' }) : item?.name}</div>
|
||||
<RiArrowDownSLine className={cn('size-4 text-text-quaternary', isOpen && 'text-text-secondary')} />
|
||||
</div>
|
||||
)
|
||||
}, [isCustomRange])
|
||||
|
||||
const renderOption = useCallback(({ item, selected }: { item: Item, selected: boolean }) => {
|
||||
return (
|
||||
<>
|
||||
{selected && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute top-[9px] left-2 flex items-center text-text-accent',
|
||||
)}
|
||||
>
|
||||
<RiCheckLine className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
<span className={cn('block truncate system-md-regular')}>{item.name}</span>
|
||||
</>
|
||||
)
|
||||
}, [])
|
||||
return (
|
||||
<SimpleSelect
|
||||
items={ranges.map(v => ({ ...v, name: t(`filter.period.${v.name}`, { ns: 'appLog' }) }))}
|
||||
className="mt-0 w-40!"
|
||||
notClearable={true}
|
||||
onSelect={handleSelectRange}
|
||||
defaultValue={0}
|
||||
wrapperClassName="h-8"
|
||||
optionWrapClassName="w-[200px] translate-x-[-24px]"
|
||||
renderTrigger={renderTrigger}
|
||||
optionClassName="flex items-center py-0 pl-7 pr-2 h-8"
|
||||
renderOption={renderOption}
|
||||
/>
|
||||
<Select
|
||||
value={selectedItem ? String(selectedItem.value) : null}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
onValueChange={(nextValue) => {
|
||||
if (!nextValue)
|
||||
return
|
||||
const nextItem = items.find(item => String(item.value) === nextValue)
|
||||
if (!nextItem)
|
||||
return
|
||||
setValue(nextValue)
|
||||
handleSelectRange(nextItem)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-auto w-fit max-w-none border-0 bg-transparent p-0 hover:bg-transparent focus-visible:bg-transparent [&>*:last-child]:hidden"
|
||||
>
|
||||
<div className={cn('flex h-8 cursor-pointer items-center space-x-1.5 rounded-lg bg-components-input-bg-normal pr-2 pl-3', open && 'bg-state-base-hover-alt')}>
|
||||
<div className="system-sm-regular text-components-input-text-filled">{isCustomRange ? t('filter.period.custom', { ns: 'appLog' }) : selectedItem?.name}</div>
|
||||
<RiArrowDownSLine className={cn('size-4 text-text-quaternary', open && 'text-text-secondary')} />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="translate-x-[-24px]" popupClassName="w-[200px]" listClassName="p-1">
|
||||
{items.map(item => (
|
||||
<SelectItem key={item.value} value={String(item.value)} className="h-8 py-0 pr-2 pl-7 system-md-regular">
|
||||
<SelectItemText className="px-0">{item.name}</SelectItemText>
|
||||
<SelectItemIndicator className="absolute top-[8px] left-2 ml-0" />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
export default React.memo(RangeSelector)
|
||||
|
||||
@ -5,16 +5,31 @@ import { InputVarType } from '@/app/components/workflow/types'
|
||||
import ConfigModalFormFields from '../form-fields'
|
||||
|
||||
vi.mock('@/app/components/base/file-uploader', () => ({
|
||||
FileUploaderInAttachmentWrapper: ({ onChange }: { onChange: (files: Array<Record<string, unknown>>) => void }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange([
|
||||
{ fileId: 'file-1', type: 'local_file', url: 'https://example.com/file.png' },
|
||||
{ fileId: 'file-2', type: 'remote_url', url: 'https://example.com/file-2.png' },
|
||||
])}
|
||||
>
|
||||
upload-file
|
||||
</button>
|
||||
FileUploaderInAttachmentWrapper: ({
|
||||
onChange,
|
||||
value,
|
||||
fileConfig,
|
||||
}: {
|
||||
onChange: (files?: Array<Record<string, unknown>>) => void
|
||||
value: Array<Record<string, unknown>>
|
||||
fileConfig: Record<string, unknown>
|
||||
}) => (
|
||||
<div>
|
||||
<span data-testid="file-uploader-value">{JSON.stringify(value)}</span>
|
||||
<span data-testid="file-uploader-config">{JSON.stringify(fileConfig)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange([
|
||||
{ fileId: 'file-1', type: 'local_file', url: 'https://example.com/file.png' },
|
||||
{ fileId: 'file-2', type: 'remote_url', url: 'https://example.com/file-2.png' },
|
||||
])}
|
||||
>
|
||||
upload-file
|
||||
</button>
|
||||
<button type="button" data-testid="upload-empty-file" onClick={() => onChange(undefined)}>
|
||||
upload-empty-file
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
@ -38,12 +53,6 @@ vi.mock('@/app/components/base/checkbox', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/select', () => ({
|
||||
default: ({ onSelect }: { onSelect: (item: { value: string }) => void }) => (
|
||||
<button type="button" onClick={() => onSelect({ value: 'beta' })}>legacy-select</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/select', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@langgenius/dify-ui/select')>()
|
||||
|
||||
@ -52,6 +61,7 @@ vi.mock('@langgenius/dify-ui/select', async (importOriginal) => {
|
||||
Select: ({ value, onValueChange, children }: { value: string, onValueChange: (value: string) => void, children: ReactNode }) => (
|
||||
<div>
|
||||
<button type="button" onClick={() => onValueChange(value === 'true' ? 'false' : 'beta')}>{`ui-select:${value}`}</button>
|
||||
<button type="button" onClick={() => onValueChange('__empty__')}>ui-select-empty</button>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
@ -86,8 +96,8 @@ vi.mock('../../config-select', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../../config-string', () => ({
|
||||
default: ({ onChange }: { onChange: (value: number) => void }) => (
|
||||
<button type="button" onClick={() => onChange(64)}>config-string</button>
|
||||
default: ({ onChange, maxLength }: { onChange: (value: number) => void, maxLength: number }) => (
|
||||
<button type="button" data-max-length={String(maxLength)} onClick={() => onChange(64)}>config-string</button>
|
||||
),
|
||||
}))
|
||||
|
||||
@ -211,4 +221,150 @@ describe('ConfigModalFormFields', () => {
|
||||
fireEvent.click(screen.getByText('json-editor'))
|
||||
expect(jsonProps.onJSONSchemaChange).toHaveBeenCalledWith('{\n "type": "object"\n}')
|
||||
})
|
||||
|
||||
it('should update text input metadata and clear empty defaults for string inputs', () => {
|
||||
const textProps = createBaseProps()
|
||||
textProps.isStringInput = true
|
||||
textProps.tempPayload = {
|
||||
...textProps.tempPayload,
|
||||
type: InputVarType.textInput,
|
||||
default: 'hello',
|
||||
}
|
||||
|
||||
render(<ConfigModalFormFields {...textProps} />)
|
||||
|
||||
const variableInput = screen.getByDisplayValue('question')
|
||||
|
||||
fireEvent.click(screen.getByText('type-selector'))
|
||||
fireEvent.change(variableInput, { target: { value: 'prompt' } })
|
||||
fireEvent.blur(variableInput)
|
||||
fireEvent.change(screen.getByDisplayValue('Question'), { target: { value: 'Prompt Label' } })
|
||||
fireEvent.click(screen.getByText('config-string'))
|
||||
fireEvent.change(screen.getByDisplayValue('hello'), { target: { value: '' } })
|
||||
|
||||
expect(textProps.onTypeChange).toHaveBeenCalledWith({ value: InputVarType.select })
|
||||
expect(textProps.onVarNameChange).toHaveBeenCalled()
|
||||
expect(textProps.onVarKeyBlur).toHaveBeenCalled()
|
||||
expect(textProps.payloadChangeHandlers.label).toHaveBeenCalledWith('Prompt Label')
|
||||
expect(textProps.payloadChangeHandlers.max_length).toHaveBeenCalledWith(64)
|
||||
expect(textProps.payloadChangeHandlers.default).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
|
||||
it('should clear select defaults and apply uploader fallback values', () => {
|
||||
const selectProps = createBaseProps()
|
||||
selectProps.tempPayload = { ...selectProps.tempPayload, type: InputVarType.select, default: 'alpha' }
|
||||
selectProps.options = ['alpha', ' ', 'beta']
|
||||
render(<ConfigModalFormFields {...selectProps} />)
|
||||
|
||||
fireEvent.click(screen.getByText('ui-select-empty'))
|
||||
expect(selectProps.payloadChangeHandlers.default).toHaveBeenCalledWith(undefined)
|
||||
|
||||
const singleFallbackProps = createBaseProps()
|
||||
singleFallbackProps.tempPayload = {
|
||||
...singleFallbackProps.tempPayload,
|
||||
type: InputVarType.singleFile,
|
||||
default: undefined,
|
||||
}
|
||||
render(<ConfigModalFormFields {...singleFallbackProps} />)
|
||||
|
||||
expect(screen.getAllByTestId('file-uploader-value')[0]).toHaveTextContent('[]')
|
||||
expect(screen.getAllByTestId('file-uploader-config')[0]).toHaveTextContent('"allowed_file_types":["document"]')
|
||||
expect(screen.getAllByTestId('file-uploader-config')[0]).toHaveTextContent('"allowed_file_upload_methods":["remote_url"]')
|
||||
expect(screen.getAllByTestId('file-uploader-config')[0]).toHaveTextContent('"number_limits":1')
|
||||
fireEvent.click(screen.getAllByTestId('upload-empty-file')[0]!)
|
||||
expect(singleFallbackProps.payloadChangeHandlers.default).toHaveBeenCalledWith(undefined)
|
||||
|
||||
const multiFallbackProps = createBaseProps()
|
||||
multiFallbackProps.tempPayload = {
|
||||
...multiFallbackProps.tempPayload,
|
||||
type: InputVarType.multiFiles,
|
||||
default: undefined,
|
||||
max_length: undefined,
|
||||
}
|
||||
render(<ConfigModalFormFields {...multiFallbackProps} />)
|
||||
|
||||
expect(screen.getAllByTestId('file-uploader-value')[1]).toHaveTextContent('[]')
|
||||
expect(screen.getAllByTestId('file-uploader-config')[1]).toHaveTextContent('"number_limits":5')
|
||||
fireEvent.click(screen.getAllByTestId('upload-empty-file')[1]!)
|
||||
expect(multiFallbackProps.payloadChangeHandlers.default).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
|
||||
it('should clear number defaults and skip rendering the default selector when options are missing', () => {
|
||||
const numberProps = createBaseProps()
|
||||
numberProps.tempPayload = { ...numberProps.tempPayload, type: InputVarType.number, default: '9' }
|
||||
render(<ConfigModalFormFields {...numberProps} />)
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('9'), { target: { value: '' } })
|
||||
expect(numberProps.payloadChangeHandlers.default).toHaveBeenCalledWith(undefined)
|
||||
|
||||
const selectWithoutOptionsProps = createBaseProps()
|
||||
selectWithoutOptionsProps.tempPayload = { ...selectWithoutOptionsProps.tempPayload, type: InputVarType.select }
|
||||
selectWithoutOptionsProps.options = undefined
|
||||
render(<ConfigModalFormFields {...selectWithoutOptionsProps} />)
|
||||
|
||||
expect(screen.getAllByText('config-select')).toHaveLength(1)
|
||||
expect(screen.queryByText('ui-select:__empty__')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should preserve existing select and file defaults when present', () => {
|
||||
const selectProps = createBaseProps()
|
||||
selectProps.tempPayload = { ...selectProps.tempPayload, type: InputVarType.select, default: undefined }
|
||||
selectProps.options = ['alpha', 'beta']
|
||||
render(<ConfigModalFormFields {...selectProps} />)
|
||||
|
||||
expect(screen.getByText('ui-select:__empty__')).toBeInTheDocument()
|
||||
|
||||
const existingFile = { fileId: 'existing-file', type: 'local_file', url: 'https://example.com/existing.png' }
|
||||
const singleFileProps = createBaseProps()
|
||||
singleFileProps.tempPayload = {
|
||||
...singleFileProps.tempPayload,
|
||||
type: InputVarType.singleFile,
|
||||
default: existingFile,
|
||||
}
|
||||
render(<ConfigModalFormFields {...singleFileProps} />)
|
||||
|
||||
expect(screen.getAllByTestId('file-uploader-value')[0]).toHaveTextContent('"fileId":"existing-file"')
|
||||
|
||||
const existingFiles = [
|
||||
{ fileId: 'file-1', type: 'local_file', url: 'https://example.com/1.png' },
|
||||
{ fileId: 'file-2', type: 'remote_url', url: 'https://example.com/2.png' },
|
||||
]
|
||||
const multiFileProps = createBaseProps()
|
||||
multiFileProps.tempPayload = {
|
||||
...multiFileProps.tempPayload,
|
||||
type: InputVarType.multiFiles,
|
||||
default: existingFiles,
|
||||
max_length: 2,
|
||||
}
|
||||
render(<ConfigModalFormFields {...multiFileProps} />)
|
||||
|
||||
expect(screen.getAllByTestId('file-uploader-value')[1]).toHaveTextContent('"fileId":"file-1"')
|
||||
expect(screen.getAllByTestId('file-uploader-config')[1]).toHaveTextContent('"number_limits":2')
|
||||
})
|
||||
|
||||
it('should render empty fallback values for text, paragraph, and number defaults', () => {
|
||||
const textProps = createBaseProps()
|
||||
textProps.isStringInput = true
|
||||
textProps.tempPayload = { ...textProps.tempPayload, type: InputVarType.textInput, default: undefined }
|
||||
const textView = render(<ConfigModalFormFields {...textProps} />)
|
||||
|
||||
expect(screen.getAllByPlaceholderText('variableConfig.inputPlaceholder')[2]).toHaveValue('')
|
||||
expect(screen.getByText('config-string')).toHaveAttribute('data-max-length', '256')
|
||||
textView.unmount()
|
||||
|
||||
const paragraphProps = createBaseProps()
|
||||
paragraphProps.isStringInput = true
|
||||
paragraphProps.tempPayload = { ...paragraphProps.tempPayload, type: InputVarType.paragraph, default: undefined }
|
||||
const paragraphView = render(<ConfigModalFormFields {...paragraphProps} />)
|
||||
|
||||
expect(screen.getByText('config-string')).toHaveAttribute('data-max-length', 'Infinity')
|
||||
expect(paragraphView.container.querySelector('textarea')).toHaveValue('')
|
||||
paragraphView.unmount()
|
||||
|
||||
const numberProps = createBaseProps()
|
||||
numberProps.tempPayload = { ...numberProps.tempPayload, type: InputVarType.number, default: undefined }
|
||||
render(<ConfigModalFormFields {...numberProps} />)
|
||||
|
||||
expect(screen.getByRole('spinbutton')).toHaveValue(null)
|
||||
})
|
||||
})
|
||||
|
||||
@ -40,28 +40,49 @@ vi.mock('@/app/components/base/input', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/select', () => ({
|
||||
default: ({ defaultValue, onSelect, items, disabled, className }: {
|
||||
defaultValue: string
|
||||
onSelect: (item: { value: string }) => void
|
||||
items: { name: string, value: string }[]
|
||||
allowSearch?: boolean
|
||||
vi.mock('@langgenius/dify-ui/select', async () => {
|
||||
const React = await import('react')
|
||||
const SelectContext = React.createContext<{
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}) => (
|
||||
<select
|
||||
data-testid="select-input"
|
||||
value={defaultValue}
|
||||
onChange={e => onSelect({ value: e.target.value })}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
>
|
||||
{items.map(item => (
|
||||
<option key={item.value} value={item.value}>{item.name}</option>
|
||||
))}
|
||||
</select>
|
||||
),
|
||||
}))
|
||||
onValueChange?: (value: string) => void
|
||||
}>({})
|
||||
|
||||
return {
|
||||
Select: ({ children, disabled, onValueChange }: {
|
||||
children: React.ReactNode
|
||||
disabled?: boolean
|
||||
onValueChange?: (value: string) => void
|
||||
}) => (
|
||||
<SelectContext.Provider value={{ disabled, onValueChange }}>
|
||||
<div>{children}</div>
|
||||
</SelectContext.Provider>
|
||||
),
|
||||
SelectTrigger: ({ children, className }: { children: React.ReactNode, className?: string }) => {
|
||||
const context = React.useContext(SelectContext)
|
||||
return (
|
||||
<div>
|
||||
<button data-testid="select-input" type="button" disabled={context.disabled} className={className}>
|
||||
{children}
|
||||
</button>
|
||||
<button data-testid="select-empty" type="button" onClick={() => context.onValueChange?.('')}>
|
||||
empty select value
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
SelectItem: ({ children, value }: { children: React.ReactNode, value: string }) => {
|
||||
const context = React.useContext(SelectContext)
|
||||
return (
|
||||
<button data-testid={`select-${value}`} type="button" role="option" onClick={() => context.onValueChange?.(value)}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
SelectItemText: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
SelectItemIndicator: () => null,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/base/textarea', () => ({
|
||||
default: ({ value, onChange, placeholder, readOnly, className }: {
|
||||
@ -410,11 +431,24 @@ describe('ChatUserInput', () => {
|
||||
}))
|
||||
|
||||
render(<ChatUserInput inputs={{ choice: 'A' }} />)
|
||||
fireEvent.change(screen.getByTestId('select-input'), { target: { value: 'B' } })
|
||||
fireEvent.click(screen.getByTestId('select-B'))
|
||||
|
||||
expect(mockSetInputs).toHaveBeenCalledWith({ choice: 'B' })
|
||||
})
|
||||
|
||||
it('should ignore empty select updates', () => {
|
||||
mockUseContext.mockReturnValue(createContextValue({
|
||||
modelConfig: createModelConfig([
|
||||
createPromptVariable({ key: 'choice', name: 'Choice', type: 'select', options: ['A', 'B', 'C'] }),
|
||||
]),
|
||||
}))
|
||||
|
||||
render(<ChatUserInput inputs={{}} />)
|
||||
fireEvent.click(screen.getByTestId('select-empty'))
|
||||
|
||||
expect(mockSetInputs).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call setInputs when number input changes', () => {
|
||||
mockUseContext.mockReturnValue(createContextValue({
|
||||
modelConfig: createModelConfig([
|
||||
@ -443,20 +477,30 @@ describe('ChatUserInput', () => {
|
||||
})
|
||||
|
||||
it('should not call setInputs for unknown keys', () => {
|
||||
const filteredPromptVariables = {
|
||||
length: 1,
|
||||
forEach: vi.fn(),
|
||||
map: (callback: (value: ExtendedPromptVariable, index: number) => unknown) => [
|
||||
callback(createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), 0),
|
||||
],
|
||||
}
|
||||
mockUseContext.mockReturnValue(createContextValue({
|
||||
modelConfig: createModelConfig([
|
||||
createPromptVariable({ key: 'name', name: 'Name', type: 'string' }),
|
||||
]),
|
||||
modelConfig: {
|
||||
...createModelConfig(),
|
||||
configs: {
|
||||
prompt_template: '',
|
||||
prompt_variables: {
|
||||
filter: () => filteredPromptVariables,
|
||||
} as unknown as PromptVariable[],
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
render(<ChatUserInput inputs={{}} />)
|
||||
|
||||
// The component filters by promptVariableObj, so unknown keys won't trigger updates
|
||||
// This is tested indirectly - only valid keys should trigger setInputs
|
||||
fireEvent.change(screen.getByTestId('input-Name'), { target: { value: 'Valid' } })
|
||||
|
||||
expect(mockSetInputs).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetInputs).toHaveBeenCalledWith({ name: 'Valid' })
|
||||
expect(mockSetInputs).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@ -652,7 +696,7 @@ describe('ChatUserInput', () => {
|
||||
render(<ChatUserInput inputs={{}} />)
|
||||
const select = screen.getByTestId('select-input')
|
||||
expect(select).toBeInTheDocument()
|
||||
expect(select.children).toHaveLength(0)
|
||||
expect(screen.queryAllByRole('option')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should handle select with undefined options', () => {
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import type { Inputs } from '@/models/debug'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import * as React from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Select from '@/app/components/base/select'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
|
||||
import ConfigContext from '@/context/debug-configuration'
|
||||
@ -102,13 +102,26 @@ const ChatUserInput = ({
|
||||
)}
|
||||
{type === 'select' && (
|
||||
<Select
|
||||
className="w-full"
|
||||
defaultValue={inputs[key] as string}
|
||||
onSelect={(i) => { handleInputValueChange(key, i.value as string) }}
|
||||
items={(options || []).map(i => ({ name: i, value: i }))}
|
||||
allowSearch={false}
|
||||
value={inputs[key] ? String(inputs[key]) : null}
|
||||
disabled={readonly}
|
||||
/>
|
||||
onValueChange={(nextValue) => {
|
||||
if (!nextValue)
|
||||
return
|
||||
handleInputValueChange(key, nextValue)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
{String(inputs[key] || t('placeholder.select', { ns: 'common' }))}
|
||||
</SelectTrigger>
|
||||
<SelectContent popupClassName="w-(--anchor-width)">
|
||||
{(options || []).map(option => (
|
||||
<SelectItem key={option} value={option}>
|
||||
<SelectItemText>{option}</SelectItemText>
|
||||
<SelectItemIndicator />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{type === 'number' && (
|
||||
<Input
|
||||
|
||||
@ -9,6 +9,29 @@ import PromptValuePanel from '../index'
|
||||
|
||||
const mockSetShowAppConfigureFeaturesModal = vi.fn()
|
||||
|
||||
vi.mock('@langgenius/dify-ui/button', () => ({
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick?: () => void
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
data-disabled={disabled ? 'true' : 'false'}
|
||||
className={className}
|
||||
onClick={() => onClick?.()}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: (selector: (state: { setShowAppConfigureFeaturesModal: typeof mockSetShowAppConfigureFeaturesModal }) => unknown) => selector({
|
||||
setShowAppConfigureFeaturesModal: mockSetShowAppConfigureFeaturesModal,
|
||||
@ -24,15 +47,51 @@ vi.mock('@/app/components/base/features/new-feature-panel/feature-bar', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/select', () => ({
|
||||
default: ({ onSelect }: { onSelect: (item: { value: string }) => void }) => (
|
||||
<button type="button" onClick={() => onSelect({ value: 'selected-option' })}>select-input</button>
|
||||
),
|
||||
}))
|
||||
vi.mock('@langgenius/dify-ui/select', async () => {
|
||||
const React = await import('react')
|
||||
const SelectContext = React.createContext<{
|
||||
onValueChange?: (value: string) => void
|
||||
}>({})
|
||||
|
||||
return {
|
||||
Select: ({ children, onValueChange }: {
|
||||
children: React.ReactNode
|
||||
onValueChange?: (value: string) => void
|
||||
}) => (
|
||||
<SelectContext.Provider value={{ onValueChange }}>
|
||||
<div>{children}</div>
|
||||
</SelectContext.Provider>
|
||||
),
|
||||
SelectTrigger: ({ children }: { children: React.ReactNode }) => {
|
||||
const context = React.useContext(SelectContext)
|
||||
return (
|
||||
<div>
|
||||
<button type="button">{children}</button>
|
||||
<button data-testid="select-empty" type="button" onClick={() => context.onValueChange?.('')}>
|
||||
empty select value
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
SelectItem: ({ children, value }: { children: React.ReactNode, value: string }) => {
|
||||
const context = React.useContext(SelectContext)
|
||||
return (
|
||||
<button type="button" onClick={() => context.onValueChange?.(value)}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
SelectItemText: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
SelectItemIndicator: () => null,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/before-run-form/bool-input', () => ({
|
||||
default: ({ onChange }: { onChange: (value: boolean) => void }) => (
|
||||
<button type="button" onClick={() => onChange(true)}>bool-input</button>
|
||||
default: ({ name, onChange }: { name: string, onChange: (value: boolean) => void }) => (
|
||||
<button type="button" data-testid={`bool-input-${name}`} onClick={() => onChange(true)}>
|
||||
bool-input
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
@ -121,7 +180,7 @@ describe('PromptValuePanel', () => {
|
||||
})
|
||||
|
||||
const runButton = screen.getByRole('button', { name: 'appDebug.inputs.run' })
|
||||
expect(runButton).not.toBeDisabled()
|
||||
expect(runButton).toHaveAttribute('data-disabled', 'false')
|
||||
fireEvent.click(runButton)
|
||||
await waitFor(() => expect(mockOnSend).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
@ -137,9 +196,22 @@ describe('PromptValuePanel', () => {
|
||||
})
|
||||
|
||||
const runButton = screen.getByRole('button', { name: 'appDebug.inputs.run' })
|
||||
expect(runButton).toBeDisabled()
|
||||
fireEvent.click(runButton)
|
||||
expect(mockOnSend).not.toHaveBeenCalled()
|
||||
expect(runButton).toHaveAttribute('data-disabled', 'true')
|
||||
})
|
||||
|
||||
it('invokes the tooltip-branch run handler when the click callback is triggered', () => {
|
||||
renderPanel({
|
||||
context: {
|
||||
mode: AppModeEnum.CHAT,
|
||||
},
|
||||
props: {
|
||||
appType: AppModeEnum.CHAT,
|
||||
},
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'appDebug.inputs.run' }))
|
||||
|
||||
expect(mockOnSend).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('hydrates default values, supports advanced prompt gating, and toggles the feature panel', () => {
|
||||
@ -163,12 +235,33 @@ describe('PromptValuePanel', () => {
|
||||
})
|
||||
|
||||
expect(mockSetInputs).toHaveBeenCalledWith({ textVar: 'default text' })
|
||||
expect(screen.getByRole('button', { name: 'appDebug.inputs.run' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'appDebug.inputs.run' })).toHaveAttribute('data-disabled', 'true')
|
||||
|
||||
fireEvent.click(screen.getByText('feature bar'))
|
||||
expect(mockSetShowAppConfigureFeaturesModal).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disables run for advanced completion mode when the completion prompt is empty', () => {
|
||||
renderPanel({
|
||||
context: {
|
||||
isAdvancedMode: true,
|
||||
modelModeType: ModelModeType.completion,
|
||||
completionPromptConfig: {
|
||||
prompt: { text: '' },
|
||||
conversation_histories_role: { user_prefix: 'user', assistant_prefix: 'assistant' },
|
||||
},
|
||||
modelConfig: {
|
||||
configs: {
|
||||
prompt_template: '',
|
||||
prompt_variables: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button', { name: 'appDebug.inputs.run' })).toHaveAttribute('data-disabled', 'true')
|
||||
})
|
||||
|
||||
it('renders paragraph, select, number, checkbox, and vision inputs', () => {
|
||||
const onVisionFilesChange = vi.fn()
|
||||
renderPanel({
|
||||
@ -203,13 +296,13 @@ describe('PromptValuePanel', () => {
|
||||
})
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Paragraph Var'), { target: { value: 'updated paragraph' } })
|
||||
fireEvent.click(screen.getByText('select-input'))
|
||||
fireEvent.click(screen.getByText('b'))
|
||||
fireEvent.change(screen.getByDisplayValue('1'), { target: { value: '2' } })
|
||||
fireEvent.click(screen.getByText('bool-input'))
|
||||
fireEvent.click(screen.getByText('image-uploader'))
|
||||
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ paragraphVar: 'updated paragraph' }))
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ selectVar: 'selected-option' }))
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ selectVar: 'b' }))
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ numberVar: '2' }))
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ boolVar: true }))
|
||||
expect(onVisionFilesChange).toHaveBeenCalledWith([
|
||||
@ -222,6 +315,127 @@ describe('PromptValuePanel', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('ignores empty select values when choosing prompt options', () => {
|
||||
renderPanel({
|
||||
context: {
|
||||
modelConfig: {
|
||||
configs: {
|
||||
prompt_template: 'prompt template',
|
||||
prompt_variables: [
|
||||
{ key: 'selectVar', name: 'Select Var', type: 'select', options: ['a', 'b'], required: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
props: {
|
||||
inputs: {
|
||||
selectVar: 'a',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('select-empty'))
|
||||
|
||||
expect(mockSetInputs).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores updates when the rendered field is not tracked in the prompt variable lookup', () => {
|
||||
const filteredPromptVariables = {
|
||||
length: 1,
|
||||
forEach: vi.fn(),
|
||||
map: (callback: (value: { key: string, name: string, type: string, required: boolean }, index: number) => unknown) => [
|
||||
callback({ key: 'textVar', name: 'Text Var', type: 'string', required: true }, 0),
|
||||
],
|
||||
}
|
||||
|
||||
renderPanel({
|
||||
context: {
|
||||
modelConfig: {
|
||||
configs: {
|
||||
prompt_template: 'prompt template',
|
||||
prompt_variables: {
|
||||
filter: () => filteredPromptVariables,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
props: {
|
||||
inputs: { textVar: '' },
|
||||
},
|
||||
})
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Text Var'), { target: { value: 'ignored' } })
|
||||
|
||||
expect(mockSetInputs).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders empty select and number placeholders when no value is provided', () => {
|
||||
renderPanel({
|
||||
context: {
|
||||
modelConfig: {
|
||||
configs: {
|
||||
prompt_template: 'prompt template',
|
||||
prompt_variables: [
|
||||
{ key: 'selectVar', name: 'Select Var', type: 'select', required: false },
|
||||
{ key: 'numberVar', name: 'Number Var', type: 'number', required: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
props: {
|
||||
inputs: {
|
||||
selectVar: '',
|
||||
numberVar: '',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText('common.placeholder.select')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('Number Var')).toHaveValue(null)
|
||||
expect(screen.queryAllByRole('option')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('falls back to the checkbox key when the label is missing from the rendered collection', () => {
|
||||
const filteredPromptVariables = {
|
||||
length: 1,
|
||||
forEach: vi.fn(),
|
||||
map: (callback: (value: { key: string, name: string, type: string, required: boolean }, index: number) => unknown) => [
|
||||
callback({ key: 'boolVar', name: '', type: 'checkbox', required: false }, 0),
|
||||
],
|
||||
}
|
||||
|
||||
renderPanel({
|
||||
context: {
|
||||
modelConfig: {
|
||||
configs: {
|
||||
prompt_template: 'prompt template',
|
||||
prompt_variables: {
|
||||
filter: () => filteredPromptVariables,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
props: {
|
||||
inputs: {
|
||||
boolVar: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('bool-input-boolVar')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('marks actions as disabled when readonly even if the prompt is runnable', () => {
|
||||
renderPanel({
|
||||
context: {
|
||||
readonly: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button', { name: 'common.operation.clear' })).toHaveAttribute('data-disabled', 'true')
|
||||
expect(screen.getByRole('button', { name: 'appDebug.inputs.run' })).toHaveAttribute('data-disabled', 'true')
|
||||
})
|
||||
|
||||
it('collapses the user input panel and hides the clear and run actions', () => {
|
||||
renderPanel()
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ import type { Inputs } from '@/models/debug'
|
||||
import type { VisionFile, VisionSettings } from '@/types/app'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiArrowRightSLine,
|
||||
@ -17,7 +18,6 @@ import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import FeatureBar from '@/app/components/base/features/new-feature-panel/feature-bar'
|
||||
import TextGenerationImageUploader from '@/app/components/base/image-uploader/text-generation-image-uploader'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Select from '@/app/components/base/select'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
|
||||
@ -156,14 +156,26 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({
|
||||
)}
|
||||
{type === 'select' && (
|
||||
<Select
|
||||
className="w-full"
|
||||
defaultValue={inputs[key] as string}
|
||||
onSelect={(i) => { handleInputValueChange(key, i.value as string) }}
|
||||
items={(options || []).map(i => ({ name: i, value: i }))}
|
||||
allowSearch={false}
|
||||
bgClassName="bg-gray-50"
|
||||
value={inputs[key] ? String(inputs[key]) : null}
|
||||
disabled={readonly}
|
||||
/>
|
||||
onValueChange={(nextValue) => {
|
||||
if (!nextValue)
|
||||
return
|
||||
handleInputValueChange(key, nextValue)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full bg-gray-50">
|
||||
{String(inputs[key] || t('placeholder.select', { ns: 'common' }))}
|
||||
</SelectTrigger>
|
||||
<SelectContent popupClassName="w-(--anchor-width)">
|
||||
{(options || []).map(option => (
|
||||
<SelectItem key={option} value={option}>
|
||||
<SelectItemText>{option}</SelectItemText>
|
||||
<SelectItemIndicator />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{type === 'number' && (
|
||||
<Input
|
||||
|
||||
@ -5,6 +5,7 @@ import type { AppDetailResponse } from '@/models/app'
|
||||
import type { AppIconType, AppSSO, Language } from '@/types/app'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import { Switch } from '@langgenius/dify-ui/switch'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { RiArrowRightSLine, RiCloseLine } from '@remixicon/react'
|
||||
@ -19,7 +20,6 @@ import { SparklesSoft } from '@/app/components/base/icons/src/public/common'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import PremiumBadge from '@/app/components/base/premium-badge'
|
||||
import { SimpleSelect } from '@/app/components/base/select'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
@ -57,6 +57,10 @@ export type ConfigParams = {
|
||||
}
|
||||
|
||||
const prefixSettings = 'overview.appInfo.settings'
|
||||
type SelectOption = {
|
||||
value: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const SettingsModal: FC<ISettingsModalProps> = ({
|
||||
isChat,
|
||||
@ -110,6 +114,8 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
||||
const { enableBilling, plan, webappCopyrightEnabled } = useProviderContext()
|
||||
const { setShowPricingModal, setShowAccountSettingModal } = useModalContext()
|
||||
const isFreePlan = plan.type === 'sandbox'
|
||||
const languageOptions: SelectOption[] = languages.filter(item => item.supported)
|
||||
const selectedLanguage = languageOptions.find(item => item.value === language)
|
||||
const handlePlanClick = useCallback(() => {
|
||||
if (isFreePlan)
|
||||
setShowPricingModal()
|
||||
@ -303,13 +309,26 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
||||
{/* language */}
|
||||
<div className="flex items-center">
|
||||
<div className={cn('grow py-1 system-sm-semibold text-text-secondary')}>{t(`${prefixSettings}.language`, { ns: 'appOverview' })}</div>
|
||||
<SimpleSelect
|
||||
wrapperClassName="w-[200px]"
|
||||
items={languages.filter(item => item.supported)}
|
||||
defaultValue={language}
|
||||
onSelect={item => setLanguage(item.value as Language)}
|
||||
notClearable
|
||||
/>
|
||||
<Select
|
||||
value={selectedLanguage?.value ?? null}
|
||||
onValueChange={(nextValue) => {
|
||||
if (!nextValue)
|
||||
return
|
||||
setLanguage(nextValue as Language)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="large" className="w-[200px]">
|
||||
{selectedLanguage?.name ?? t('placeholder.select', { ns: 'common' })}
|
||||
</SelectTrigger>
|
||||
<SelectContent popupClassName="w-(--anchor-width)">
|
||||
{languageOptions.map(item => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
<SelectItemText>{item.name}</SelectItemText>
|
||||
<SelectItemIndicator />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* theme color */}
|
||||
{isChat && (
|
||||
|
||||
@ -270,7 +270,7 @@ describe('InputsFormContent', () => {
|
||||
renderWithContext(<InputsFormContent />, context)
|
||||
const selNodes = screen.getAllByText('Sel')
|
||||
expect(selNodes.length).toBeGreaterThan(0)
|
||||
expect(screen.queryByText('existing')).toBeNull()
|
||||
expect(screen.getByText('existing')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles select input empty branches (no current value -> show placeholder)', () => {
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import * as React from 'react'
|
||||
import { memo, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { PortalSelect } from '@/app/components/base/select'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
|
||||
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
|
||||
@ -85,13 +85,22 @@ const InputsFormContent = ({ showTip }: Props) => {
|
||||
/>
|
||||
)}
|
||||
{form.type === InputVarType.select && (
|
||||
<PortalSelect
|
||||
popupClassName="z-[60] w-[200px]"
|
||||
value={inputsFormValue?.[form.variable] ?? form.default ?? ''}
|
||||
items={form.options.map((option: string) => ({ value: option, name: option }))}
|
||||
onSelect={item => handleFormChange(form.variable, item.value as string)}
|
||||
placeholder={form.label}
|
||||
/>
|
||||
<Select
|
||||
value={(inputsFormValue?.[form.variable] ?? form.default ?? '') || null}
|
||||
onValueChange={value => value && handleFormChange(form.variable, value)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
{String(inputsFormValue?.[form.variable] ?? form.default ?? form.label)}
|
||||
</SelectTrigger>
|
||||
<SelectContent popupClassName="z-[60] w-(--anchor-width)">
|
||||
{form.options.map((option: string) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
<SelectItemText>{option}</SelectItemText>
|
||||
<SelectItemIndicator />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{form.type === InputVarType.singleFile && (
|
||||
<FileUploaderInAttachmentWrapper
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import * as React from 'react'
|
||||
import { memo, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { PortalSelect } from '@/app/components/base/select'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
|
||||
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
|
||||
@ -85,13 +85,22 @@ const InputsFormContent = ({ showTip }: Props) => {
|
||||
/>
|
||||
)}
|
||||
{form.type === InputVarType.select && (
|
||||
<PortalSelect
|
||||
popupClassName="z-[60] w-[200px]"
|
||||
value={inputsFormValue?.[form.variable] ?? form.default ?? ''}
|
||||
items={form.options.map((option: string) => ({ value: option, name: option }))}
|
||||
onSelect={item => handleFormChange(form.variable, item.value as string)}
|
||||
placeholder={form.label}
|
||||
/>
|
||||
<Select
|
||||
value={(inputsFormValue?.[form.variable] ?? form.default ?? '') || null}
|
||||
onValueChange={value => value && handleFormChange(form.variable, value)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
{String(inputsFormValue?.[form.variable] ?? form.default ?? form.label)}
|
||||
</SelectTrigger>
|
||||
<SelectContent popupClassName="z-[60] w-(--anchor-width)">
|
||||
{form.options.map((option: string) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
<SelectItemText>{option}</SelectItemText>
|
||||
<SelectItemIndicator />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{form.type === InputVarType.singleFile && (
|
||||
<FileUploaderInAttachmentWrapper
|
||||
|
||||
@ -206,7 +206,7 @@ const TimePicker = ({
|
||||
>
|
||||
<PopoverTrigger
|
||||
nativeButton={false}
|
||||
className={triggerFullWidth ? 'block! w-full' : undefined}
|
||||
className={triggerFullWidth ? 'flex! w-full' : undefined}
|
||||
render={renderTrigger
|
||||
? renderTrigger({
|
||||
inputElem,
|
||||
|
||||
@ -0,0 +1,97 @@
|
||||
import type { SuggestedQuestionsAfterAnswer } from '@/app/components/base/features/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import FollowUpSettingModal from '../follow-up-setting-modal'
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useModelListAndDefaultModelAndCurrentProviderAndModel: () => ({
|
||||
defaultModel: {
|
||||
provider: {
|
||||
provider: 'openai',
|
||||
},
|
||||
model: 'gpt-4o-mini',
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({
|
||||
default: ({ provider, modelId }: { provider: string, modelId: string }) => (
|
||||
<div data-testid="model-parameter-modal">{`${provider}:${modelId}`}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const renderModal = (data: SuggestedQuestionsAfterAnswer = { enabled: true }) => {
|
||||
const onSave = vi.fn()
|
||||
const onCancel = vi.fn()
|
||||
|
||||
render(
|
||||
<FollowUpSettingModal
|
||||
data={data}
|
||||
onSave={onSave}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
|
||||
return {
|
||||
onSave,
|
||||
onCancel,
|
||||
}
|
||||
}
|
||||
|
||||
describe('FollowUpSettingModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Default Prompt', () => {
|
||||
it('should show the system default prompt and save without a custom prompt when no custom prompt is configured', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onSave } = renderModal()
|
||||
|
||||
expect(screen.getByText('appDebug.feature.suggestedQuestionsAfterAnswer.modal.defaultPromptOption')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Please predict the three most likely follow-up questions a user would ask/)).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText(/common\.operation\.save/))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
prompt: undefined,
|
||||
model: expect.objectContaining({
|
||||
provider: 'openai',
|
||||
name: 'gpt-4o-mini',
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Custom Prompt', () => {
|
||||
it('should enable custom prompt input and save the custom prompt when selected', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onSave } = renderModal()
|
||||
|
||||
await user.click(screen.getByText('appDebug.feature.suggestedQuestionsAfterAnswer.modal.customPromptOption').closest('button')!)
|
||||
|
||||
const textarea = screen.getByPlaceholderText('appDebug.feature.suggestedQuestionsAfterAnswer.modal.promptPlaceholder')
|
||||
expect(textarea).toHaveAttribute('maxLength', '1000')
|
||||
|
||||
fireEvent.change(
|
||||
textarea,
|
||||
{ target: { value: 'Use a custom follow-up prompt.' } },
|
||||
)
|
||||
|
||||
await user.click(screen.getByText(/common\.operation\.save/))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
prompt: 'Use a custom follow-up prompt.',
|
||||
}))
|
||||
})
|
||||
|
||||
it('should disable save when custom prompt is selected but empty', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderModal()
|
||||
|
||||
await user.click(screen.getByText('appDebug.feature.suggestedQuestionsAfterAnswer.modal.customPromptOption').closest('button')!)
|
||||
|
||||
expect(screen.getByText(/common\.operation\.save/).closest('button')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,12 +1,55 @@
|
||||
import type { OnFeaturesChange } from '../../types'
|
||||
import type {
|
||||
OnFeaturesChange,
|
||||
SuggestedQuestionsAfterAnswer,
|
||||
} from '../../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { FeaturesProvider } from '../../context'
|
||||
import FollowUp from '../follow-up'
|
||||
|
||||
const renderWithProvider = (props: { disabled?: boolean, onChange?: OnFeaturesChange } = {}) => {
|
||||
vi.mock('../follow-up-setting-modal', () => ({
|
||||
default: ({ onSave, onCancel }: { onSave: (newState: unknown) => void, onCancel: () => void }) => (
|
||||
<div data-testid="follow-up-setting-modal">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSave({
|
||||
enabled: true,
|
||||
prompt: 'test prompt',
|
||||
model: {
|
||||
provider: 'openai',
|
||||
name: 'gpt-4o-mini',
|
||||
mode: 'chat',
|
||||
completion_params: {
|
||||
temperature: 0.7,
|
||||
max_tokens: 0,
|
||||
top_p: 0,
|
||||
echo: false,
|
||||
stop: [],
|
||||
presence_penalty: 0,
|
||||
frequency_penalty: 0,
|
||||
},
|
||||
},
|
||||
})}
|
||||
>
|
||||
save-settings
|
||||
</button>
|
||||
<button type="button" onClick={onCancel}>cancel-settings</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const renderWithProvider = (
|
||||
props: {
|
||||
disabled?: boolean
|
||||
onChange?: OnFeaturesChange
|
||||
suggested?: SuggestedQuestionsAfterAnswer
|
||||
} = {},
|
||||
) => {
|
||||
return render(
|
||||
<FeaturesProvider>
|
||||
<FeaturesProvider features={{
|
||||
suggested: props.suggested || { enabled: false },
|
||||
}}
|
||||
>
|
||||
<FollowUp disabled={props.disabled} onChange={props.onChange} />
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
@ -45,4 +88,44 @@ describe('FollowUp', () => {
|
||||
|
||||
expect(() => fireEvent.click(screen.getByRole('switch'))).not.toThrow()
|
||||
})
|
||||
|
||||
it('should render edit button when enabled and hovering', () => {
|
||||
renderWithProvider({
|
||||
suggested: {
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
|
||||
fireEvent.mouseEnter(screen.getByText(/feature\.suggestedQuestionsAfterAnswer\.title/).closest('[class]')!)
|
||||
|
||||
expect(screen.getByText(/operation\.settings/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open settings modal and save follow-up config', () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({
|
||||
onChange,
|
||||
suggested: {
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
|
||||
fireEvent.mouseEnter(screen.getByText(/feature\.suggestedQuestionsAfterAnswer\.title/).closest('[class]')!)
|
||||
fireEvent.click(screen.getByText(/operation\.settings/))
|
||||
|
||||
expect(screen.getByTestId('follow-up-setting-modal')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('save-settings'))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
|
||||
suggested: expect.objectContaining({
|
||||
enabled: true,
|
||||
prompt: 'test prompt',
|
||||
model: expect.objectContaining({
|
||||
provider: 'openai',
|
||||
name: 'gpt-4o-mini',
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
@ -0,0 +1,241 @@
|
||||
import type { SuggestedQuestionsAfterAnswer } from '@/app/components/base/features/types'
|
||||
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type {
|
||||
CompletionParams,
|
||||
Model,
|
||||
ModelModeType,
|
||||
} from '@/types/app'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { produce } from 'immer'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Radio from '@/app/components/base/radio/ui'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
|
||||
import { ModelModeType as ModelModeTypeEnum } from '@/types/app'
|
||||
|
||||
type FollowUpSettingModalProps = {
|
||||
data: SuggestedQuestionsAfterAnswer
|
||||
onSave: (newState: SuggestedQuestionsAfterAnswer) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const DEFAULT_COMPLETION_PARAMS: CompletionParams = {
|
||||
temperature: 0.7,
|
||||
max_tokens: 0,
|
||||
top_p: 0,
|
||||
echo: false,
|
||||
stop: [],
|
||||
presence_penalty: 0,
|
||||
frequency_penalty: 0,
|
||||
}
|
||||
|
||||
const DEFAULT_FOLLOW_UP_PROMPT = `Please predict the three most likely follow-up questions a user would ask, keep each question under 20 characters, use the same language as the assistant's latest response, and output a JSON array like ["question1", "question2", "question3"].`
|
||||
const CUSTOM_FOLLOW_UP_PROMPT_MAX_LENGTH = 1000
|
||||
|
||||
const getInitialModel = (model?: Model): Model => ({
|
||||
provider: model?.provider || '',
|
||||
name: model?.name || '',
|
||||
mode: model?.mode || ModelModeTypeEnum.chat,
|
||||
completion_params: {
|
||||
...DEFAULT_COMPLETION_PARAMS,
|
||||
...(model?.completion_params || {}),
|
||||
},
|
||||
})
|
||||
|
||||
const PROMPT_MODE = {
|
||||
default: 'default',
|
||||
custom: 'custom',
|
||||
} as const
|
||||
|
||||
type PromptMode = typeof PROMPT_MODE[keyof typeof PROMPT_MODE]
|
||||
|
||||
const FollowUpSettingModal = ({
|
||||
data,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: FollowUpSettingModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [model, setModel] = useState<Model>(() => getInitialModel(data.model))
|
||||
const [prompt, setPrompt] = useState(data.prompt || '')
|
||||
const [promptMode, setPromptMode] = useState<PromptMode>(
|
||||
data.prompt ? PROMPT_MODE.custom : PROMPT_MODE.default,
|
||||
)
|
||||
const { defaultModel } = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textGeneration)
|
||||
const selectedModel = useMemo<Model>(() => {
|
||||
if (model.provider && model.name)
|
||||
return model
|
||||
|
||||
if (!defaultModel)
|
||||
return model
|
||||
|
||||
return {
|
||||
...model,
|
||||
provider: defaultModel.provider.provider,
|
||||
name: defaultModel.model,
|
||||
}
|
||||
}, [defaultModel, model])
|
||||
|
||||
const handleModelChange = useCallback((newValue: { modelId: string, provider: string, mode?: string, features?: string[] }) => {
|
||||
setModel(prev => ({
|
||||
...prev,
|
||||
provider: newValue.provider,
|
||||
name: newValue.modelId,
|
||||
mode: (newValue.mode as ModelModeType) || prev.mode || ModelModeTypeEnum.chat,
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const handleCompletionParamsChange = useCallback((newParams: FormValue) => {
|
||||
setModel({
|
||||
...selectedModel,
|
||||
completion_params: {
|
||||
...DEFAULT_COMPLETION_PARAMS,
|
||||
...(newParams as Partial<CompletionParams>),
|
||||
},
|
||||
})
|
||||
}, [selectedModel])
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
const trimmedPrompt = prompt.trim()
|
||||
const nextFollowUpState = produce(data, (draft) => {
|
||||
if (selectedModel.provider && selectedModel.name)
|
||||
draft.model = selectedModel
|
||||
else
|
||||
draft.model = undefined
|
||||
|
||||
draft.prompt = promptMode === PROMPT_MODE.custom
|
||||
? (trimmedPrompt || undefined)
|
||||
: undefined
|
||||
})
|
||||
onSave(nextFollowUpState)
|
||||
}, [data, onSave, prompt, promptMode, selectedModel])
|
||||
|
||||
const isCustomPromptInvalid = promptMode === PROMPT_MODE.custom && !prompt.trim()
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open)
|
||||
onCancel()
|
||||
}}
|
||||
>
|
||||
<DialogContent className="w-[640px]! max-w-none! p-8! pb-6!">
|
||||
<DialogCloseButton className="top-8 right-8" />
|
||||
<DialogTitle className="pr-8 text-xl font-semibold text-text-primary">
|
||||
{t('feature.suggestedQuestionsAfterAnswer.modal.title', { ns: 'appDebug' })}
|
||||
</DialogTitle>
|
||||
<div className="mt-6 space-y-4">
|
||||
<div>
|
||||
<div className="mb-1.5 system-sm-semibold-uppercase text-text-secondary">
|
||||
{t('feature.suggestedQuestionsAfterAnswer.modal.modelLabel', { ns: 'appDebug' })}
|
||||
</div>
|
||||
<ModelParameterModal
|
||||
popupClassName="w-[520px]!"
|
||||
isAdvancedMode
|
||||
provider={selectedModel.provider}
|
||||
completionParams={selectedModel.completion_params}
|
||||
modelId={selectedModel.name}
|
||||
setModel={handleModelChange}
|
||||
onCompletionParamsChange={handleCompletionParamsChange}
|
||||
hideDebugWithMultipleModel
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1.5 system-sm-semibold-uppercase text-text-secondary">
|
||||
{t('feature.suggestedQuestionsAfterAnswer.modal.promptLabel', { ns: 'appDebug' })}
|
||||
</div>
|
||||
<div className="space-y-3" role="radiogroup" aria-label={t('feature.suggestedQuestionsAfterAnswer.modal.promptLabel', { ns: 'appDebug' }) || ''}>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={promptMode === PROMPT_MODE.default}
|
||||
className={cn(
|
||||
'w-full rounded-xl border p-4 text-left transition-colors',
|
||||
promptMode === PROMPT_MODE.default
|
||||
? 'border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg'
|
||||
: 'border-components-option-card-option-border bg-components-option-card-option-bg hover:bg-state-base-hover',
|
||||
)}
|
||||
onClick={() => setPromptMode(PROMPT_MODE.default)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="system-sm-semibold text-text-primary">
|
||||
{t('feature.suggestedQuestionsAfterAnswer.modal.defaultPromptOption', { ns: 'appDebug' })}
|
||||
</div>
|
||||
<div className="mt-1 system-xs-regular text-text-tertiary">
|
||||
{t('feature.suggestedQuestionsAfterAnswer.modal.defaultPromptOptionDescription', { ns: 'appDebug' })}
|
||||
</div>
|
||||
</div>
|
||||
<div aria-hidden="true">
|
||||
<Radio isChecked={promptMode === PROMPT_MODE.default} />
|
||||
</div>
|
||||
</div>
|
||||
{promptMode === PROMPT_MODE.default && (
|
||||
<div className="mt-3 rounded-lg border border-components-input-border-active bg-components-input-bg-normal px-3 py-2">
|
||||
<div className="system-sm-regular break-words whitespace-pre-wrap text-text-secondary">
|
||||
{DEFAULT_FOLLOW_UP_PROMPT}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={promptMode === PROMPT_MODE.custom}
|
||||
className={cn(
|
||||
'w-full rounded-xl border p-4 text-left transition-colors',
|
||||
promptMode === PROMPT_MODE.custom
|
||||
? 'border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg'
|
||||
: 'border-components-option-card-option-border bg-components-option-card-option-bg hover:bg-state-base-hover',
|
||||
)}
|
||||
onClick={() => setPromptMode(PROMPT_MODE.custom)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="system-sm-semibold text-text-primary">
|
||||
{t('feature.suggestedQuestionsAfterAnswer.modal.customPromptOption', { ns: 'appDebug' })}
|
||||
</div>
|
||||
<div className="mt-1 system-xs-regular text-text-tertiary">
|
||||
{t('feature.suggestedQuestionsAfterAnswer.modal.customPromptOptionDescription', { ns: 'appDebug' })}
|
||||
</div>
|
||||
</div>
|
||||
<div aria-hidden="true">
|
||||
<Radio isChecked={promptMode === PROMPT_MODE.custom} />
|
||||
</div>
|
||||
</div>
|
||||
{promptMode === PROMPT_MODE.custom && (
|
||||
<Textarea
|
||||
className="mt-3 min-h-32 resize-y border-components-input-border-active bg-components-input-bg-normal"
|
||||
value={prompt}
|
||||
onChange={e => setPrompt(e.target.value)}
|
||||
maxLength={CUSTOM_FOLLOW_UP_PROMPT_MAX_LENGTH}
|
||||
placeholder={t('feature.suggestedQuestionsAfterAnswer.modal.promptPlaceholder', { ns: 'appDebug' }) || ''}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex items-center justify-end gap-2">
|
||||
<Button onClick={onCancel}>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={isCustomPromptInvalid}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{t('operation.save', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default FollowUpSettingModal
|
||||
@ -1,10 +1,16 @@
|
||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
import type {
|
||||
OnFeaturesChange,
|
||||
SuggestedQuestionsAfterAnswer,
|
||||
} from '@/app/components/base/features/types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { RiEqualizer2Line } from '@remixicon/react'
|
||||
import { produce } from 'immer'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||
import FeatureCard from '@/app/components/base/features/new-feature-panel/feature-card'
|
||||
import FollowUpSettingModal from '@/app/components/base/features/new-feature-panel/follow-up-setting-modal'
|
||||
import { FeatureEnum } from '@/app/components/base/features/types'
|
||||
import { VirtualAssistant } from '@/app/components/base/icons/src/vender/features'
|
||||
|
||||
@ -18,8 +24,10 @@ const FollowUp = ({
|
||||
onChange,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const features = useFeatures(s => s.features)
|
||||
const suggested = useFeatures(s => s.features.suggested)
|
||||
const featuresStore = useFeaturesStore()
|
||||
const [isHovering, setIsHovering] = useState(false)
|
||||
const [isShowSettingModal, setIsShowSettingModal] = useState(false)
|
||||
|
||||
const handleChange = useCallback((type: FeatureEnum, enabled: boolean) => {
|
||||
const {
|
||||
@ -38,19 +46,76 @@ const FollowUp = ({
|
||||
onChange(newFeatures)
|
||||
}, [featuresStore, onChange])
|
||||
|
||||
const handleSave = useCallback((newSuggested: SuggestedQuestionsAfterAnswer) => {
|
||||
const {
|
||||
features,
|
||||
setFeatures,
|
||||
} = featuresStore!.getState()
|
||||
|
||||
const newFeatures = produce(features, (draft) => {
|
||||
draft.suggested = {
|
||||
...newSuggested,
|
||||
enabled: true,
|
||||
}
|
||||
})
|
||||
setFeatures(newFeatures)
|
||||
setIsShowSettingModal(false)
|
||||
if (onChange)
|
||||
onChange(newFeatures)
|
||||
}, [featuresStore, onChange])
|
||||
|
||||
const handleOpenSettingModal = useCallback(() => {
|
||||
if (disabled)
|
||||
return
|
||||
setIsShowSettingModal(true)
|
||||
}, [disabled])
|
||||
|
||||
return (
|
||||
<FeatureCard
|
||||
icon={(
|
||||
<div className="shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-light-blue-light-500 p-1 shadow-xs">
|
||||
<VirtualAssistant className="h-4 w-4 text-text-primary-on-surface" />
|
||||
</div>
|
||||
<>
|
||||
<FeatureCard
|
||||
icon={(
|
||||
<div className="shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-light-blue-light-500 p-1 shadow-xs">
|
||||
<VirtualAssistant className="h-4 w-4 text-text-primary-on-surface" />
|
||||
</div>
|
||||
)}
|
||||
title={t('feature.suggestedQuestionsAfterAnswer.title', { ns: 'appDebug' })}
|
||||
value={!!suggested?.enabled}
|
||||
onChange={state => handleChange(FeatureEnum.suggested, state)}
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<>
|
||||
{!suggested?.enabled && (
|
||||
<div className="line-clamp-2 min-h-8 system-xs-regular text-text-tertiary">
|
||||
{t('feature.suggestedQuestionsAfterAnswer.description', { ns: 'appDebug' })}
|
||||
</div>
|
||||
)}
|
||||
{!!suggested?.enabled && (
|
||||
<>
|
||||
{!isHovering && (
|
||||
<div className="line-clamp-2 min-h-8 system-xs-regular text-text-tertiary">
|
||||
{suggested.model?.name || t('feature.suggestedQuestionsAfterAnswer.modal.defaultModel', { ns: 'appDebug' })}
|
||||
</div>
|
||||
)}
|
||||
{isHovering && (
|
||||
<Button className="w-full" onClick={handleOpenSettingModal} disabled={disabled}>
|
||||
<RiEqualizer2Line className="mr-1 h-4 w-4" />
|
||||
{t('operation.settings', { ns: 'common' })}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</FeatureCard>
|
||||
{isShowSettingModal && (
|
||||
<FollowUpSettingModal
|
||||
data={suggested || { enabled: true }}
|
||||
onSave={handleSave}
|
||||
onCancel={() => setIsShowSettingModal(false)}
|
||||
/>
|
||||
)}
|
||||
title={t('feature.suggestedQuestionsAfterAnswer.title', { ns: 'appDebug' })}
|
||||
value={!!features.suggested?.enabled}
|
||||
description={t('feature.suggestedQuestionsAfterAnswer.description', { ns: 'appDebug' })!}
|
||||
onChange={state => handleChange(FeatureEnum.suggested, state)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -132,8 +132,8 @@ describe('FormGeneration', () => {
|
||||
})
|
||||
render(<FormGeneration forms={[form]} value={{}} onChange={onChange} />)
|
||||
|
||||
fireEvent.click(screen.getByText(/placeholder\.select/))
|
||||
fireEvent.click(screen.getByText('GPT-4'))
|
||||
fireEvent.click(screen.getByRole('combobox'))
|
||||
fireEvent.click(screen.getByRole('option', { name: 'GPT-4' }))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ model: 'gpt-4' })
|
||||
})
|
||||
@ -152,7 +152,7 @@ describe('FormGeneration', () => {
|
||||
render(<FormGeneration forms={[form]} value={{}} onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('模型')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText(/placeholder\.select/))
|
||||
expect(screen.getByText('智谱-4')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('combobox'))
|
||||
expect(screen.getByRole('option', { name: '智谱-4' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type { FC } from 'react'
|
||||
import type { CodeBasedExtensionForm } from '@/models/common'
|
||||
import type { ModerationConfig } from '@/models/debug'
|
||||
import { PortalSelect } from '@/app/components/base/select'
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
|
||||
@ -24,53 +24,65 @@ const FormGeneration: FC<FormGenerationProps> = ({
|
||||
return (
|
||||
<>
|
||||
{
|
||||
forms.map((form, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="py-2"
|
||||
>
|
||||
<div className="flex h-9 items-center text-sm font-medium text-text-primary">
|
||||
{locale === 'zh-Hans' ? form.label['zh-Hans'] : form.label['en-US']}
|
||||
</div>
|
||||
{
|
||||
form.type === 'text-input' && (
|
||||
<input
|
||||
value={value?.[form.variable] || ''}
|
||||
className="block h-9 w-full appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-text-primary outline-hidden"
|
||||
placeholder={form.placeholder}
|
||||
onChange={e => handleFormChange(form.variable, e.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
form.type === 'paragraph' && (
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
className="resize-none"
|
||||
forms.map((form, index) => {
|
||||
const selectOptions = form.type === 'select'
|
||||
? form.options.map(option => ({
|
||||
name: option.label[locale === 'zh-Hans' ? 'zh-Hans' : 'en-US'],
|
||||
value: option.value,
|
||||
}))
|
||||
: []
|
||||
const selectedOption = selectOptions.find(option => option.value === value?.[form.variable]) ?? null
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="py-2"
|
||||
>
|
||||
<div className="flex h-9 items-center text-sm font-medium text-text-primary">
|
||||
{locale === 'zh-Hans' ? form.label['zh-Hans'] : form.label['en-US']}
|
||||
</div>
|
||||
{
|
||||
form.type === 'text-input' && (
|
||||
<input
|
||||
value={value?.[form.variable] || ''}
|
||||
className="block h-9 w-full appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-text-primary outline-hidden"
|
||||
placeholder={form.placeholder}
|
||||
onChange={e => handleFormChange(form.variable, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
form.type === 'select' && (
|
||||
<PortalSelect
|
||||
value={value?.[form.variable]}
|
||||
items={form.options.map((option) => {
|
||||
return {
|
||||
name: option.label[locale === 'zh-Hans' ? 'zh-Hans' : 'en-US'],
|
||||
value: option.value,
|
||||
}
|
||||
})}
|
||||
onSelect={item => handleFormChange(form.variable, item.value as string)}
|
||||
popupClassName="w-[576px] z-102!"
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
))
|
||||
)
|
||||
}
|
||||
{
|
||||
form.type === 'paragraph' && (
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
className="resize-none"
|
||||
value={value?.[form.variable] || ''}
|
||||
placeholder={form.placeholder}
|
||||
onChange={e => handleFormChange(form.variable, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
form.type === 'select' && (
|
||||
<Select value={selectedOption?.value ?? null} onValueChange={nextValue => nextValue && handleFormChange(form.variable, nextValue)}>
|
||||
<SelectTrigger className="w-full">
|
||||
{selectedOption?.name ?? form.placeholder}
|
||||
</SelectTrigger>
|
||||
<SelectContent popupClassName="z-102 w-(--anchor-width)">
|
||||
{selectOptions.map(option => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<SelectItemText>{option.name}</SelectItemText>
|
||||
<SelectItemIndicator />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
</>
|
||||
)
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
import type { Item } from '@/app/components/base/select'
|
||||
import { Listbox, ListboxButton, ListboxOption, ListboxOptions, Transition } from '@headlessui/react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Switch } from '@langgenius/dify-ui/switch'
|
||||
@ -17,6 +16,11 @@ import { usePathname } from '@/next/navigation'
|
||||
import { useAppVoices } from '@/service/use-apps'
|
||||
import { TtsAutoPlay } from '@/types/app'
|
||||
|
||||
type SelectOption = {
|
||||
value: string | number
|
||||
name: string
|
||||
}
|
||||
|
||||
type VoiceParamConfigProps = {
|
||||
onClose: () => void
|
||||
onChange?: OnFeaturesChange
|
||||
@ -99,7 +103,7 @@ const VoiceParamConfig = ({
|
||||
</div>
|
||||
<Listbox
|
||||
value={languageItem}
|
||||
onChange={(value: Item) => {
|
||||
onChange={(value: SelectOption) => {
|
||||
handleChange({
|
||||
language: String(value.value),
|
||||
})
|
||||
@ -166,7 +170,7 @@ const VoiceParamConfig = ({
|
||||
<Listbox
|
||||
value={voiceItem}
|
||||
disabled={!languageItem}
|
||||
onChange={(value: Item) => {
|
||||
onChange={(value: SelectOption) => {
|
||||
handleChange({
|
||||
voice: String(value.value),
|
||||
})
|
||||
@ -195,7 +199,7 @@ const VoiceParamConfig = ({
|
||||
<ListboxOptions
|
||||
className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md border-[0.5px] border-components-panel-border bg-components-panel-bg px-1 py-1 text-base shadow-lg focus:outline-hidden sm:text-sm"
|
||||
>
|
||||
{voiceItems?.map((item: Item) => (
|
||||
{voiceItems?.map((item: SelectOption) => (
|
||||
<ListboxOption
|
||||
key={item.value}
|
||||
className="relative cursor-pointer rounded-lg py-2 pr-9 pl-3 text-text-secondary select-none hover:bg-state-base-hover data-active:bg-state-base-active"
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
import type { FileUploadConfigResponse } from '@/models/common'
|
||||
import type { Resolution, TransferMethod, TtsAutoPlay } from '@/types/app'
|
||||
import type {
|
||||
Model,
|
||||
Resolution,
|
||||
TransferMethod,
|
||||
TtsAutoPlay,
|
||||
} from '@/types/app'
|
||||
|
||||
export type EnabledOrDisabled = {
|
||||
enabled?: boolean
|
||||
@ -12,7 +17,10 @@ export type OpeningStatement = EnabledOrDisabled & {
|
||||
suggested_questions?: string[]
|
||||
}
|
||||
|
||||
export type SuggestedQuestionsAfterAnswer = EnabledOrDisabled
|
||||
export type SuggestedQuestionsAfterAnswer = EnabledOrDisabled & {
|
||||
model?: Model
|
||||
prompt?: string
|
||||
}
|
||||
|
||||
export type TextToSpeech = EnabledOrDisabled & {
|
||||
language?: string
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { Item } from '@/app/components/base/select'
|
||||
import type { BuiltInMetadataItem, MetadataItemWithValueLength } from '@/app/components/datasets/metadata/types'
|
||||
import type { SortType } from '@/service/datasets'
|
||||
import { PlusIcon } from '@heroicons/react/24/solid'
|
||||
@ -19,6 +18,11 @@ import { useDocLink } from '@/context/i18n'
|
||||
import { DataSourceType } from '@/models/datasets'
|
||||
import { useIndexStatus } from '../status-item/hooks'
|
||||
|
||||
type SelectOption = {
|
||||
value: string | number
|
||||
name: string
|
||||
}
|
||||
|
||||
type DocumentsHeaderProps = {
|
||||
// Dataset info
|
||||
datasetId: string
|
||||
@ -82,7 +86,7 @@ const DocumentsHeader: FC<DocumentsHeaderProps> = ({
|
||||
const isDataSourceNotion = dataSourceType === DataSourceType.NOTION
|
||||
const isDataSourceWeb = dataSourceType === DataSourceType.WEB
|
||||
|
||||
const statusFilterItems: Item[] = useMemo(() => [
|
||||
const statusFilterItems: SelectOption[] = useMemo(() => [
|
||||
{ value: 'all', name: t('list.index.all', { ns: 'datasetDocuments' }) as string },
|
||||
{ value: 'queuing', name: DOC_INDEX_STATUS_MAP.queuing.text },
|
||||
{ value: 'indexing', name: DOC_INDEX_STATUS_MAP.indexing.text },
|
||||
@ -94,7 +98,7 @@ const DocumentsHeader: FC<DocumentsHeaderProps> = ({
|
||||
{ value: 'archived', name: DOC_INDEX_STATUS_MAP.archived.text },
|
||||
], [DOC_INDEX_STATUS_MAP, t])
|
||||
|
||||
const sortItems: Item[] = useMemo(() => [
|
||||
const sortItems: SelectOption[] = useMemo(() => [
|
||||
{ value: 'created_at', name: t('list.sort.uploadTime', { ns: 'datasetDocuments' }) as string },
|
||||
{ value: 'hit_count', name: t('list.sort.hitCount', { ns: 'datasetDocuments' }) as string },
|
||||
], [t])
|
||||
|
||||
@ -82,7 +82,7 @@ describe('MenuBar', () => {
|
||||
it('should call renderOption for each item when dropdown is opened', async () => {
|
||||
render(<MenuBar {...defaultProps} />)
|
||||
|
||||
const selectButton = screen.getByRole('button', { name: /All/i })
|
||||
const selectButton = screen.getByRole('combobox')
|
||||
fireEvent.click(selectButton)
|
||||
|
||||
// After opening, renderOption is called for each item, rendering the mocked StatusItem
|
||||
|
||||
@ -1,14 +1,19 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { Item } from '@/app/components/base/select'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { SimpleSelect } from '@/app/components/base/select'
|
||||
import DisplayToggle from '../display-toggle'
|
||||
import StatusItem from '../status-item'
|
||||
import s from '../style.module.css'
|
||||
|
||||
type Item = {
|
||||
value: number | string
|
||||
name: string
|
||||
} & Record<string, unknown>
|
||||
|
||||
type MenuBarProps = {
|
||||
isAllSelected: boolean
|
||||
isSomeSelected: boolean
|
||||
@ -38,6 +43,8 @@ const MenuBar: FC<MenuBarProps> = ({
|
||||
isCollapsed,
|
||||
toggleCollapsed,
|
||||
}) => {
|
||||
const selectedStatus = statusList.find(item => item.value === selectDefaultValue) ?? null
|
||||
|
||||
return (
|
||||
<div className={s.docSearchWrapper}>
|
||||
<Checkbox
|
||||
@ -48,17 +55,29 @@ const MenuBar: FC<MenuBarProps> = ({
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<div className="flex-1 pl-5 system-sm-semibold-uppercase text-text-secondary">{totalText}</div>
|
||||
<SimpleSelect
|
||||
onSelect={onChangeStatus}
|
||||
items={statusList}
|
||||
defaultValue={selectDefaultValue}
|
||||
className={s.select}
|
||||
wrapperClassName="h-fit mr-2"
|
||||
optionWrapClassName="w-[160px]"
|
||||
optionClassName="p-0"
|
||||
renderOption={({ item, selected }) => <StatusItem item={item} selected={selected} />}
|
||||
notClearable
|
||||
/>
|
||||
<Select
|
||||
value={selectedStatus ? String(selectedStatus.value) : null}
|
||||
onValueChange={(nextValue) => {
|
||||
if (!nextValue)
|
||||
return
|
||||
const nextItem = statusList.find(item => String(item.value) === nextValue)
|
||||
if (nextItem)
|
||||
onChangeStatus(nextItem)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={cn(s.select, 'mr-2 h-fit')}>
|
||||
{selectedStatus?.name ?? ''}
|
||||
</SelectTrigger>
|
||||
<SelectContent popupClassName="w-[160px]">
|
||||
{statusList.map(item => (
|
||||
<SelectItem key={item.value} value={String(item.value)} className="h-auto p-0">
|
||||
<SelectItemText className="sr-only m-0 p-0">{item.name}</SelectItemText>
|
||||
<StatusItem item={item} selected={item.value === selectDefaultValue} />
|
||||
{item.value === selectDefaultValue && <SelectItemIndicator className="hidden" />}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
showLeftIcon
|
||||
showClearIcon
|
||||
|
||||
@ -1,16 +1,20 @@
|
||||
import type { Item } from '@/app/components/base/select'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type SelectOption = {
|
||||
value: string | number
|
||||
name: string
|
||||
}
|
||||
|
||||
type UseSearchFilterReturn = {
|
||||
inputValue: string
|
||||
searchValue: string
|
||||
selectedStatus: boolean | 'all'
|
||||
statusList: Item[]
|
||||
statusList: SelectOption[]
|
||||
selectDefaultValue: 'all' | 0 | 1
|
||||
handleInputChange: (value: string) => void
|
||||
onChangeStatus: (item: Item) => void
|
||||
onChangeStatus: (item: SelectOption) => void
|
||||
onClearFilter: () => void
|
||||
resetPage: () => void
|
||||
}
|
||||
@ -27,7 +31,7 @@ export const useSearchFilter = (options: UseSearchFilterOptions): UseSearchFilte
|
||||
const [searchValue, setSearchValue] = useState<string>('')
|
||||
const [selectedStatus, setSelectedStatus] = useState<boolean | 'all'>('all')
|
||||
|
||||
const statusList = useRef<Item[]>([
|
||||
const statusList = useRef<SelectOption[]>([
|
||||
{ value: 'all', name: t('list.index.all', { ns: 'datasetDocuments' }) },
|
||||
{ value: 0, name: t('list.status.disabled', { ns: 'datasetDocuments' }) },
|
||||
{ value: 1, name: t('list.status.enabled', { ns: 'datasetDocuments' }) },
|
||||
@ -43,7 +47,7 @@ export const useSearchFilter = (options: UseSearchFilterOptions): UseSearchFilte
|
||||
handleSearch()
|
||||
}, [handleSearch])
|
||||
|
||||
const onChangeStatus = useCallback(({ value }: Item) => {
|
||||
const onChangeStatus = useCallback(({ value }: SelectOption) => {
|
||||
setSelectedStatus(value === 'all' ? 'all' : !!value)
|
||||
onPageChange(1)
|
||||
}, [onPageChange])
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
import type { FC } from 'react'
|
||||
import type { Item } from '@/app/components/base/select'
|
||||
import { RiCheckLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
|
||||
type StatusOption = {
|
||||
value: string | number
|
||||
name: string
|
||||
}
|
||||
|
||||
type IStatusItemProps = {
|
||||
item: Item
|
||||
item: StatusOption
|
||||
selected: boolean
|
||||
}
|
||||
|
||||
|
||||
@ -480,7 +480,7 @@ describe('FieldInfo', () => {
|
||||
|
||||
// Assert - SimpleSelect should be rendered
|
||||
// Assert - SimpleSelect should be rendered
|
||||
expect(screen.getByRole('button'))!.toBeInTheDocument()
|
||||
expect(screen.getByRole('combobox'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render textarea when showEdit is true and inputType is textarea', () => {
|
||||
|
||||
@ -2,10 +2,10 @@
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import type { inputType } from '@/hooks/use-metadata'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AutoHeightTextarea from '@/app/components/base/auto-height-textarea'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { SimpleSelect } from '@/app/components/base/select'
|
||||
import { getTextWidthWithCanvas } from '@/utils'
|
||||
import s from '../style.module.css'
|
||||
|
||||
@ -36,6 +36,7 @@ const FieldInfo: FC<FieldInfoProps> = ({
|
||||
const textNeedWrap = getTextWidthWithCanvas(displayedValue) > 190
|
||||
const editAlignTop = showEdit && inputType === 'textarea'
|
||||
const readAlignTop = !showEdit && textNeedWrap
|
||||
const selectedOption = selectOptions.find(option => option.value === value)
|
||||
|
||||
const renderContent = () => {
|
||||
if (!showEdit)
|
||||
@ -43,14 +44,26 @@ const FieldInfo: FC<FieldInfoProps> = ({
|
||||
|
||||
if (inputType === 'select') {
|
||||
return (
|
||||
<SimpleSelect
|
||||
onSelect={({ value }) => onUpdate?.(value as string)}
|
||||
items={selectOptions}
|
||||
defaultValue={value}
|
||||
className={s.select}
|
||||
wrapperClassName={s.selectWrapper}
|
||||
placeholder={`${t('metadata.placeholder.select', { ns: 'datasetDocuments' })}${label}`}
|
||||
/>
|
||||
<Select
|
||||
value={selectedOption?.value ?? null}
|
||||
onValueChange={(nextValue) => {
|
||||
if (!nextValue)
|
||||
return
|
||||
onUpdate?.(nextValue)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={cn(s.select, s.selectWrapper)}>
|
||||
{selectedOption?.name ?? `${t('metadata.placeholder.select', { ns: 'datasetDocuments' })}${label}`}
|
||||
</SelectTrigger>
|
||||
<SelectContent popupClassName="w-(--anchor-width)">
|
||||
{selectOptions.map(option => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<SelectItemText>{option.name}</SelectItemText>
|
||||
<SelectItemIndicator />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -11,53 +11,56 @@ const mockMutateUserProfile = vi.fn()
|
||||
let mockLocale: string | undefined = 'en-US'
|
||||
let mockUserProfile: UserProfileResponse
|
||||
|
||||
vi.mock('@/app/components/base/select', async () => {
|
||||
vi.mock('@langgenius/dify-ui/select', async () => {
|
||||
const React = await import('react')
|
||||
const SelectContext = React.createContext<{
|
||||
disabled?: boolean
|
||||
onValueChange?: (value: string) => void
|
||||
}>({})
|
||||
|
||||
return {
|
||||
SimpleSelect: ({
|
||||
items = [],
|
||||
defaultValue,
|
||||
onSelect,
|
||||
Select: ({
|
||||
children,
|
||||
disabled,
|
||||
onValueChange,
|
||||
}: {
|
||||
items?: Array<{ value: string | number, name: string }>
|
||||
defaultValue?: string | number
|
||||
onSelect: (item: { value: string | number, name: string }) => void
|
||||
children: React.ReactNode
|
||||
disabled?: boolean
|
||||
onValueChange?: (value: string) => void
|
||||
}) => {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [selectedValue, setSelectedValue] = React.useState<string | number | undefined>(defaultValue)
|
||||
const selected = items.find(item => item.value === selectedValue)
|
||||
?? items.find(item => item.value === defaultValue)
|
||||
?? null
|
||||
|
||||
return (
|
||||
<SelectContext.Provider value={{ disabled, onValueChange }}>
|
||||
<div>{children}</div>
|
||||
</SelectContext.Provider>
|
||||
)
|
||||
},
|
||||
SelectTrigger: ({ children }: { children: React.ReactNode }) => {
|
||||
const context = React.useContext(SelectContext)
|
||||
return (
|
||||
<div>
|
||||
<button type="button" disabled={disabled} onClick={() => setOpen(prev => !prev)}>
|
||||
{selected?.name ?? ''}
|
||||
<button type="button" disabled={context.disabled}>
|
||||
{children}
|
||||
</button>
|
||||
<button data-testid="select-empty" type="button" onClick={() => context.onValueChange?.('')}>
|
||||
empty value
|
||||
</button>
|
||||
<button data-testid="select-invalid" type="button" onClick={() => context.onValueChange?.('__missing__')}>
|
||||
invalid value
|
||||
</button>
|
||||
{open && (
|
||||
<div>
|
||||
{items.map(item => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
role="option"
|
||||
onClick={() => {
|
||||
setSelectedValue(item.value)
|
||||
onSelect(item)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
SelectItem: ({ children, value }: { children: React.ReactNode, value: string }) => {
|
||||
const context = React.useContext(SelectContext)
|
||||
return (
|
||||
<button type="button" role="option" onClick={() => context.onValueChange?.(value)}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
SelectItemText: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
SelectItemIndicator: () => null,
|
||||
}
|
||||
})
|
||||
|
||||
@ -118,7 +121,7 @@ const getSectionByLabel = (sectionLabel: string) => {
|
||||
const selectOption = async (sectionLabel: string, optionName: string) => {
|
||||
const section = getSectionByLabel(sectionLabel)
|
||||
await act(async () => {
|
||||
fireEvent.click(within(section).getByRole('button'))
|
||||
fireEvent.click(within(section).getAllByRole('button')[0]!)
|
||||
})
|
||||
await act(async () => {
|
||||
fireEvent.click(await within(section).findByRole('option', { name: optionName }))
|
||||
@ -164,6 +167,18 @@ describe('LanguagePage - Rendering', () => {
|
||||
expect(screen.getByRole('button', { name: english.name })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: niueTimezone.name })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render placeholders when the current locale or timezone is unsupported', () => {
|
||||
mockLocale = 'unsupported-locale'
|
||||
mockUserProfile = createUserProfile({
|
||||
interface_language: 'unsupported-locale',
|
||||
timezone: 'Unsupported/Timezone',
|
||||
})
|
||||
|
||||
renderPage()
|
||||
|
||||
expect(screen.getAllByRole('button', { name: 'common.placeholder.select' })).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
// Interactions
|
||||
@ -206,7 +221,12 @@ describe('LanguagePage - Interactions', () => {
|
||||
await selectOption('common.language.timezone', midwayTimezone.name)
|
||||
|
||||
expect(await screen.findByText('common.actionMsg.modifiedSuccessfully')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: midwayTimezone.name })).toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
expect(updateUserProfileMock).toHaveBeenCalledWith({
|
||||
url: '/account/timezone',
|
||||
body: { timezone: midwayTimezone.value },
|
||||
})
|
||||
})
|
||||
}, 15000)
|
||||
|
||||
it('should show error toast when timezone update fails', async () => {
|
||||
@ -219,4 +239,30 @@ describe('LanguagePage - Interactions', () => {
|
||||
|
||||
expect(await screen.findByText('Timezone failed')).toBeInTheDocument()
|
||||
}, 15000)
|
||||
|
||||
it('should ignore empty and unknown language selections', async () => {
|
||||
renderPage()
|
||||
|
||||
const section = getSectionByLabel('common.language.displayLanguage')
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(within(section).getByTestId('select-empty'))
|
||||
fireEvent.click(within(section).getByTestId('select-invalid'))
|
||||
})
|
||||
|
||||
expect(updateUserProfileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should ignore empty and unknown timezone selections', async () => {
|
||||
renderPage()
|
||||
|
||||
const section = getSectionByLabel('common.language.timezone')
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(within(section).getByTestId('select-empty'))
|
||||
fireEvent.click(within(section).getByTestId('select-invalid'))
|
||||
})
|
||||
|
||||
expect(updateUserProfileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
'use client'
|
||||
import type { Item } from '@/app/components/base/select'
|
||||
import type { Locale } from '@/i18n-config'
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SimpleSelect } from '@/app/components/base/select'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { setLocaleOnClient } from '@/i18n-config'
|
||||
@ -13,6 +12,16 @@ import { useRouter } from '@/next/navigation'
|
||||
import { updateUserProfile } from '@/service/common'
|
||||
import { timezones } from '@/utils/timezone'
|
||||
|
||||
type SelectOption = {
|
||||
value: string
|
||||
name: string
|
||||
}
|
||||
|
||||
type TimezoneOption = {
|
||||
value: string | number
|
||||
name: string
|
||||
}
|
||||
|
||||
const titleClassName = `
|
||||
mb-2 system-sm-semibold text-text-secondary
|
||||
`
|
||||
@ -22,7 +31,10 @@ export default function LanguagePage() {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const handleSelectLanguage = async (item: Item) => {
|
||||
const languageOptions: SelectOption[] = languages.filter(item => item.supported)
|
||||
const selectedLanguage = languageOptions.find(item => item.value === (locale || userProfile.interface_language))
|
||||
const selectedTimezone = timezones.find(item => item.value === userProfile.timezone)
|
||||
const handleSelectLanguage = async (item: SelectOption) => {
|
||||
const url = '/account/interface-language'
|
||||
const bodyKey = 'interface_language'
|
||||
setEditing(true)
|
||||
@ -39,7 +51,7 @@ export default function LanguagePage() {
|
||||
setEditing(false)
|
||||
}
|
||||
}
|
||||
const handleSelectTimezone = async (item: Item) => {
|
||||
const handleSelectTimezone = async (item: TimezoneOption) => {
|
||||
const url = '/account/timezone'
|
||||
const bodyKey = 'timezone'
|
||||
setEditing(true)
|
||||
@ -59,11 +71,55 @@ export default function LanguagePage() {
|
||||
<>
|
||||
<div className="mb-8">
|
||||
<div className={titleClassName}>{t('language.displayLanguage', { ns: 'common' })}</div>
|
||||
<SimpleSelect defaultValue={locale || userProfile.interface_language} items={languages.filter(item => item.supported)} onSelect={item => handleSelectLanguage(item)} disabled={editing} notClearable={true} />
|
||||
<Select
|
||||
value={selectedLanguage?.value ?? null}
|
||||
disabled={editing}
|
||||
onValueChange={(nextValue) => {
|
||||
if (!nextValue)
|
||||
return
|
||||
const nextItem = languageOptions.find(item => item.value === nextValue)
|
||||
if (nextItem)
|
||||
handleSelectLanguage(nextItem)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="large">
|
||||
{selectedLanguage?.name ?? t('placeholder.select', { ns: 'common' })}
|
||||
</SelectTrigger>
|
||||
<SelectContent popupClassName="w-(--anchor-width)">
|
||||
{languageOptions.map(item => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
<SelectItemText>{item.name}</SelectItemText>
|
||||
<SelectItemIndicator />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="mb-8">
|
||||
<div className={titleClassName}>{t('language.timezone', { ns: 'common' })}</div>
|
||||
<SimpleSelect defaultValue={userProfile.timezone} items={timezones} onSelect={item => handleSelectTimezone(item)} disabled={editing} notClearable={true} />
|
||||
<Select
|
||||
value={selectedTimezone ? String(selectedTimezone.value) : null}
|
||||
disabled={editing}
|
||||
onValueChange={(nextValue) => {
|
||||
if (!nextValue)
|
||||
return
|
||||
const nextItem = timezones.find(item => String(item.value) === nextValue)
|
||||
if (nextItem)
|
||||
handleSelectTimezone(nextItem)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="large">
|
||||
{selectedTimezone?.name ?? t('placeholder.select', { ns: 'common' })}
|
||||
</SelectTrigger>
|
||||
<SelectContent popupClassName="w-(--anchor-width)">
|
||||
{timezones.map(item => (
|
||||
<SelectItem key={item.value} value={String(item.value)}>
|
||||
<SelectItemText>{item.name}</SelectItemText>
|
||||
<SelectItemIndicator />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@ -13,10 +13,10 @@ import type {
|
||||
NodeOutPutVar,
|
||||
} from '@/app/components/workflow/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import { useCallback, useState } from 'react'
|
||||
import Radio from '@/app/components/base/radio'
|
||||
import RadioE from '@/app/components/base/radio/ui'
|
||||
import { SimpleSelect } from '@/app/components/base/select'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import AppSelector from '@/app/components/plugins/plugin-detail-panel/app-selector'
|
||||
import ModelParameterModal from '@/app/components/plugins/plugin-detail-panel/model-selector'
|
||||
@ -253,6 +253,17 @@ function Form<
|
||||
if (show_on.length && !show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value))
|
||||
return null
|
||||
|
||||
const filteredOptions = options.filter((option) => {
|
||||
if (option.show_on.length)
|
||||
return option.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value)
|
||||
|
||||
return true
|
||||
}).map(option => ({ value: option.value, name: option.label[language] || option.label.en_US }))
|
||||
const currentValue = (isShowDefaultValue && ((value[variable] as string) === '' || value[variable] === undefined || value[variable] === null))
|
||||
? formSchema.default
|
||||
: value[variable]
|
||||
const selectedOption = filteredOptions.find(option => option.value === currentValue)
|
||||
|
||||
return (
|
||||
<div key={variable} className={cn(itemClassName, 'py-3')}>
|
||||
<div className={cn(fieldLabelClassName, 'flex items-center py-2 system-sm-semibold text-text-secondary')}>
|
||||
@ -263,20 +274,27 @@ function Form<
|
||||
)}
|
||||
{tooltipContent}
|
||||
</div>
|
||||
<SimpleSelect
|
||||
wrapperClassName="h-8"
|
||||
className={cn(inputClassName)}
|
||||
<Select
|
||||
disabled={readonly}
|
||||
defaultValue={(isShowDefaultValue && ((value[variable] as string) === '' || value[variable] === undefined || value[variable] === null)) ? formSchema.default : value[variable]}
|
||||
items={options.filter((option) => {
|
||||
if (option.show_on.length)
|
||||
return option.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value)
|
||||
|
||||
return true
|
||||
}).map(option => ({ value: option.value, name: option.label[language] || option.label.en_US }))}
|
||||
onSelect={item => handleFormChange(variable, item.value as string)}
|
||||
placeholder={placeholder?.[language] || placeholder?.en_US}
|
||||
/>
|
||||
value={selectedOption?.value ?? null}
|
||||
onValueChange={(nextValue) => {
|
||||
if (!nextValue)
|
||||
return
|
||||
handleFormChange(variable, nextValue)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="medium" className={cn(inputClassName)}>
|
||||
{selectedOption?.name ?? placeholder?.[language] ?? placeholder?.en_US}
|
||||
</SelectTrigger>
|
||||
<SelectContent popupClassName="w-(--anchor-width)">
|
||||
{filteredOptions.map(option => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<SelectItemText>{option.name}</SelectItemText>
|
||||
<SelectItemIndicator />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{fieldMoreInfo?.(formSchema)}
|
||||
{validating && changeKey === variable && <ValidatingTip />}
|
||||
</div>
|
||||
|
||||
@ -10,6 +10,7 @@ import type {
|
||||
} from '../../declarations'
|
||||
import type { NodeOutPutVar } from '@/app/components/workflow/types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { FormTypeEnum } from '../../declarations'
|
||||
import Form from '../Form'
|
||||
|
||||
@ -288,7 +289,8 @@ describe('Form', () => {
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should render select and checkbox fields and update checkbox value', () => {
|
||||
it('should render select and checkbox fields and update checkbox value', async () => {
|
||||
const user = userEvent.setup()
|
||||
const formSchemas: AnyFormSchema[] = [
|
||||
createSelectSchema({
|
||||
variable: 'model',
|
||||
@ -339,10 +341,10 @@ describe('Form', () => {
|
||||
)
|
||||
|
||||
expect(screen.getByText('Select A'))!.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('Select A'))
|
||||
fireEvent.click(screen.getByText('Select B'))
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
await user.click(screen.getByRole('option', { name: 'Select B' }))
|
||||
|
||||
fireEvent.click(screen.getByText('True'))
|
||||
await user.click(screen.getByText('True'))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ model: 'b', agree: false, toggle: 'on' })
|
||||
expect(onChange).toHaveBeenCalledWith({ model: 'a', agree: true, toggle: 'on' })
|
||||
@ -989,9 +991,8 @@ describe('Form', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
const selectTrigger = screen.getByRole('button', { name: 'Select A' })
|
||||
fireEvent.click(selectTrigger)
|
||||
expect(screen.queryByText('Select B')).not.toBeInTheDocument()
|
||||
const selectTrigger = screen.getByRole('combobox')
|
||||
expect(selectTrigger).toBeDisabled()
|
||||
})
|
||||
|
||||
// isShowDefaultValue=false: value used even if empty
|
||||
@ -1899,7 +1900,8 @@ describe('Form', () => {
|
||||
expect(screen.getByText('Select Tools'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show ValidatingTip for select field being validated', () => {
|
||||
it('should show ValidatingTip for select field being validated', async () => {
|
||||
const user = userEvent.setup()
|
||||
// Arrange: value 'a' is pre-selected so 'Select A' text appears in the trigger button
|
||||
const formSchemas: AnyFormSchema[] = [
|
||||
createSelectSchema({
|
||||
@ -1923,14 +1925,14 @@ describe('Form', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// First click opens the dropdown (Select A is the trigger button text)
|
||||
fireEvent.click(screen.getByText('Select A'))
|
||||
// Then click on 'Select B' option in the open dropdown
|
||||
fireEvent.click(screen.getByText('Select B'))
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
await user.click(screen.getByRole('option', { name: 'Select B' }))
|
||||
|
||||
// Assert: ValidatingTip shows for the select field
|
||||
// Assert: ValidatingTip shows for the select field
|
||||
expect(screen.getByText('Validating...'))!.toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Validating...'))!.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show ValidatingTip for toolSelector field being validated', () => {
|
||||
|
||||
@ -295,15 +295,15 @@ const ModelModal: FC<ModelModalProps> = ({
|
||||
<Dialog open onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
backdropProps={{ forceRender: true }}
|
||||
className="w-[640px] max-w-[640px] overflow-hidden p-0"
|
||||
className="flex w-[640px] max-w-[640px] flex-col overflow-hidden p-0"
|
||||
>
|
||||
<DialogCloseButton className="top-5 right-5 h-8 w-8" />
|
||||
<div className="p-6 pb-3">
|
||||
<div className="shrink-0 p-6 pb-3">
|
||||
{modalTitle}
|
||||
{modalDesc}
|
||||
{modalModel}
|
||||
</div>
|
||||
<div className="max-h-[calc(100vh-320px)] overflow-y-auto px-6 py-3">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-3">
|
||||
{
|
||||
mode === ModelModalModeEnum.configCustomModel && (
|
||||
<AuthForm
|
||||
@ -365,7 +365,7 @@ const ModelModal: FC<ModelModalProps> = ({
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div className="flex justify-between p-6 pt-5">
|
||||
<div className="flex shrink-0 justify-between p-6 pt-5">
|
||||
{
|
||||
(provider.help && (provider.help.title || provider.help.url))
|
||||
? (
|
||||
@ -410,7 +410,7 @@ const ModelModal: FC<ModelModalProps> = ({
|
||||
</div>
|
||||
{
|
||||
(mode === ModelModalModeEnum.configCustomModel || mode === ModelModalModeEnum.configProviderCredential) && (
|
||||
<div className="border-t-[0.5px] border-t-divider-regular">
|
||||
<div className="shrink-0 border-t-[0.5px] border-t-divider-regular">
|
||||
<div className="flex items-center justify-center rounded-b-2xl bg-background-section-burn py-3 text-xs text-text-tertiary">
|
||||
<Lock01 className="mr-1 h-3 w-3 text-text-tertiary" />
|
||||
{t('modelProvider.encrypted.front', { ns: 'common' })}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import type { PluginDeclaration, UpdateFromGitHubPayload } from '../../types'
|
||||
import type { Item } from '@/app/components/base/select'
|
||||
import type { InstallState } from '@/app/components/plugins/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
@ -22,6 +21,11 @@ import SetURL from './steps/setURL'
|
||||
|
||||
const i18nPrefix = 'installFromGitHub'
|
||||
|
||||
type SelectOption = {
|
||||
value: string | number
|
||||
name: string
|
||||
}
|
||||
|
||||
type InstallFromGitHubProps = {
|
||||
updatePayload?: UpdateFromGitHubPayload
|
||||
onClose: () => void
|
||||
@ -53,12 +57,12 @@ const InstallFromGitHub: React.FC<InstallFromGitHubProps> = ({ updatePayload, on
|
||||
const [manifest, setManifest] = useState<PluginDeclaration | null>(null)
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null)
|
||||
|
||||
const versions: Item[] = state.releases.map(release => ({
|
||||
const versions: SelectOption[] = state.releases.map(release => ({
|
||||
value: release.tag_name,
|
||||
name: release.tag_name,
|
||||
}))
|
||||
|
||||
const packages: Item[] = state.selectedVersion
|
||||
const packages: SelectOption[] = state.selectedVersion
|
||||
? (state.releases
|
||||
.find(release => release.tag_name === state.selectedVersion)
|
||||
?.assets
|
||||
@ -198,10 +202,10 @@ const InstallFromGitHub: React.FC<InstallFromGitHubProps> = ({ updatePayload, on
|
||||
repoUrl={state.repoUrl}
|
||||
selectedVersion={state.selectedVersion}
|
||||
versions={versions}
|
||||
onSelectVersion={item => setState(prevState => ({ ...prevState, selectedVersion: item.value as string }))}
|
||||
onSelectVersion={item => setState(prevState => ({ ...prevState, selectedVersion: String(item.value) }))}
|
||||
selectedPackage={state.selectedPackage}
|
||||
packages={packages}
|
||||
onSelectPackage={item => setState(prevState => ({ ...prevState, selectedPackage: item.value as string }))}
|
||||
onSelectPackage={item => setState(prevState => ({ ...prevState, selectedPackage: String(item.value) }))}
|
||||
onUploaded={handleUploaded}
|
||||
onFailed={handleUploadFail}
|
||||
onBack={handleBack}
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
import type { PluginDeclaration, UpdateFromGitHubPayload } from '../../../../types'
|
||||
import type { Item } from '@/app/components/base/select'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PluginCategoryEnum } from '../../../../types'
|
||||
import SelectPackage from '../selectPackage'
|
||||
|
||||
type SelectOption = {
|
||||
value: string | number
|
||||
name: string
|
||||
}
|
||||
|
||||
// Mock upload helper from hooks module
|
||||
const { mockHandleUpload } = vi.hoisted(() => ({
|
||||
mockHandleUpload: vi.fn(),
|
||||
@ -17,6 +21,53 @@ vi.mock('../../../hooks', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@langgenius/dify-ui/select', async () => {
|
||||
const React = await import('react')
|
||||
const SelectContext = React.createContext<{
|
||||
readOnly?: boolean
|
||||
onValueChange?: (value: string) => void
|
||||
}>({})
|
||||
|
||||
return {
|
||||
Select: ({ children, readOnly, onValueChange }: {
|
||||
children: React.ReactNode
|
||||
readOnly?: boolean
|
||||
onValueChange?: (value: string) => void
|
||||
}) => (
|
||||
<SelectContext.Provider value={{ readOnly, onValueChange }}>
|
||||
<div>{children}</div>
|
||||
</SelectContext.Provider>
|
||||
),
|
||||
SelectTrigger: ({ children }: { children: React.ReactNode }) => {
|
||||
const context = React.useContext(SelectContext)
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="select-trigger" className={context.readOnly ? 'cursor-not-allowed' : 'cursor-pointer'}>
|
||||
{children}
|
||||
</div>
|
||||
<button data-testid="select-empty" type="button" onClick={() => context.onValueChange?.('')}>
|
||||
empty select value
|
||||
</button>
|
||||
<button data-testid="select-invalid" type="button" onClick={() => context.onValueChange?.('__missing__')}>
|
||||
invalid select value
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
SelectItem: ({ children, value }: { children: React.ReactNode, value: string }) => {
|
||||
const context = React.useContext(SelectContext)
|
||||
return (
|
||||
<button type="button" onClick={() => context.onValueChange?.(value)}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
SelectItemText: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
SelectItemIndicator: () => null,
|
||||
}
|
||||
})
|
||||
|
||||
// Factory functions
|
||||
const createMockManifest = (): PluginDeclaration => ({
|
||||
plugin_unique_identifier: 'test-uid',
|
||||
@ -39,12 +90,12 @@ const createMockManifest = (): PluginDeclaration => ({
|
||||
trigger: {} as PluginDeclaration['trigger'],
|
||||
})
|
||||
|
||||
const createVersions = (): Item[] => [
|
||||
const createVersions = (): SelectOption[] => [
|
||||
{ value: 'v1.0.0', name: 'v1.0.0' },
|
||||
{ value: 'v0.9.0', name: 'v0.9.0' },
|
||||
]
|
||||
|
||||
const createPackages = (): Item[] => [
|
||||
const createPackages = (): SelectOption[] => [
|
||||
{ value: 'plugin.zip', name: 'plugin.zip' },
|
||||
{ value: 'plugin.tar.gz', name: 'plugin.tar.gz' },
|
||||
]
|
||||
@ -64,11 +115,11 @@ type TestProps = {
|
||||
updatePayload?: UpdateFromGitHubPayload
|
||||
repoUrl?: string
|
||||
selectedVersion?: string
|
||||
versions?: Item[]
|
||||
onSelectVersion?: (item: Item) => void
|
||||
versions?: SelectOption[]
|
||||
onSelectVersion?: (item: SelectOption) => void
|
||||
selectedPackage?: string
|
||||
packages?: Item[]
|
||||
onSelectPackage?: (item: Item) => void
|
||||
packages?: SelectOption[]
|
||||
onSelectPackage?: (item: SelectOption) => void
|
||||
onUploaded?: (result: { uniqueIdentifier: string, manifest: PluginDeclaration }) => void
|
||||
onFailed?: (errorMsg: string) => void
|
||||
onBack?: () => void
|
||||
@ -80,10 +131,10 @@ describe('SelectPackage', () => {
|
||||
repoUrl: 'https://github.com/owner/repo',
|
||||
selectedVersion: '',
|
||||
versions: createVersions(),
|
||||
onSelectVersion: vi.fn() as (item: Item) => void,
|
||||
onSelectVersion: vi.fn() as (item: SelectOption) => void,
|
||||
selectedPackage: '',
|
||||
packages: createPackages(),
|
||||
onSelectPackage: vi.fn() as (item: Item) => void,
|
||||
onSelectPackage: vi.fn() as (item: SelectOption) => void,
|
||||
onUploaded: vi.fn() as (result: { uniqueIdentifier: string, manifest: PluginDeclaration }) => void,
|
||||
onFailed: vi.fn() as (errorMsg: string) => void,
|
||||
onBack: vi.fn() as () => void,
|
||||
@ -96,6 +147,14 @@ describe('SelectPackage', () => {
|
||||
return render(<SelectPackage {...(props as Parameters<typeof SelectPackage>[0])} />)
|
||||
}
|
||||
|
||||
const getSection = (label: string): HTMLElement => {
|
||||
const labelElement = screen.getByText(label)
|
||||
const section = labelElement.closest('label')?.nextElementSibling
|
||||
if (!(section instanceof HTMLElement))
|
||||
throw new Error(`Missing section for ${label}`)
|
||||
return section
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockHandleUpload.mockReset()
|
||||
@ -144,13 +203,13 @@ describe('SelectPackage', () => {
|
||||
renderSelectPackage({ selectedVersion: 'v1.0.0' })
|
||||
|
||||
// PortalSelect should display the selected version
|
||||
expect(screen.getByText('v1.0.0')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('v1.0.0').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should pass selectedPackage to PortalSelect', () => {
|
||||
renderSelectPackage({ selectedPackage: 'plugin.zip' })
|
||||
|
||||
expect(screen.getByText('plugin.zip')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('plugin.zip').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should show installed version badge when updatePayload version differs', () => {
|
||||
@ -231,6 +290,54 @@ describe('SelectPackage', () => {
|
||||
|
||||
expect(mockHandleUpload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should ignore empty and unknown version selections', () => {
|
||||
const onSelectVersion = vi.fn()
|
||||
renderSelectPackage({ onSelectVersion })
|
||||
|
||||
const section = getSection('plugin.installFromGitHub.selectVersion')
|
||||
fireEvent.click(within(section).getByTestId('select-empty'))
|
||||
fireEvent.click(within(section).getByTestId('select-invalid'))
|
||||
|
||||
expect(onSelectVersion).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should select a valid version option', () => {
|
||||
const onSelectVersion = vi.fn()
|
||||
renderSelectPackage({ onSelectVersion })
|
||||
|
||||
const section = getSection('plugin.installFromGitHub.selectVersion')
|
||||
fireEvent.click(within(section).getByRole('button', { name: 'v0.9.0' }))
|
||||
|
||||
expect(onSelectVersion).toHaveBeenCalledWith({ value: 'v0.9.0', name: 'v0.9.0' })
|
||||
})
|
||||
|
||||
it('should ignore empty and unknown package selections', () => {
|
||||
const onSelectPackage = vi.fn()
|
||||
renderSelectPackage({
|
||||
selectedVersion: 'v1.0.0',
|
||||
onSelectPackage,
|
||||
})
|
||||
|
||||
const section = getSection('plugin.installFromGitHub.selectPackage')
|
||||
fireEvent.click(within(section).getByTestId('select-empty'))
|
||||
fireEvent.click(within(section).getByTestId('select-invalid'))
|
||||
|
||||
expect(onSelectPackage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should select a valid package option', () => {
|
||||
const onSelectPackage = vi.fn()
|
||||
renderSelectPackage({
|
||||
selectedVersion: 'v1.0.0',
|
||||
onSelectPackage,
|
||||
})
|
||||
|
||||
const section = getSection('plugin.installFromGitHub.selectPackage')
|
||||
fireEvent.click(within(section).getByRole('button', { name: 'plugin.tar.gz' }))
|
||||
|
||||
expect(onSelectPackage).toHaveBeenCalledWith({ value: 'plugin.tar.gz', name: 'plugin.tar.gz' })
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
@ -424,8 +531,7 @@ describe('SelectPackage', () => {
|
||||
renderSelectPackage({ selectedVersion: '' })
|
||||
|
||||
// When no version is selected, package select should be readonly
|
||||
// This is tested by verifying the component renders correctly
|
||||
const trigger = screen.getByText('plugin.installFromGitHub.selectPackagePlaceholder').closest('div')
|
||||
const trigger = screen.getAllByTestId('select-trigger')[1]
|
||||
expect(trigger).toHaveClass('cursor-not-allowed')
|
||||
})
|
||||
|
||||
@ -433,7 +539,7 @@ describe('SelectPackage', () => {
|
||||
renderSelectPackage({ selectedVersion: 'v1.0.0' })
|
||||
|
||||
// When version is selected, package select should be active
|
||||
const trigger = screen.getByText('plugin.installFromGitHub.selectPackagePlaceholder').closest('div')
|
||||
const trigger = screen.getAllByTestId('select-trigger')[1]
|
||||
expect(trigger).toHaveClass('cursor-pointer')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,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
|
||||
&& (
|
||||
|
||||
@ -6,38 +6,82 @@ import AppInputsForm from '../app-inputs-form'
|
||||
vi.mock('@/app/components/base/file-uploader', () => ({
|
||||
FileUploaderInAttachmentWrapper: ({
|
||||
onChange,
|
||||
value,
|
||||
}: {
|
||||
onChange: (files: Array<Record<string, unknown>>) => void
|
||||
}) => (
|
||||
<button data-testid="file-uploader" onClick={() => onChange([{ id: 'file-1', name: 'demo.png' }])}>
|
||||
Upload
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/select', () => ({
|
||||
PortalSelect: ({
|
||||
items,
|
||||
onSelect,
|
||||
}: {
|
||||
items: Array<{ value: string, name: string }>
|
||||
onSelect: (item: { value: string }) => void
|
||||
value: Array<Record<string, unknown>>
|
||||
}) => (
|
||||
<div>
|
||||
{items.map(item => (
|
||||
<button key={item.value} data-testid={`select-${item.value}`} onClick={() => onSelect(item)}>
|
||||
{item.name}
|
||||
</button>
|
||||
))}
|
||||
<span data-testid="file-uploader-value">{JSON.stringify(value)}</span>
|
||||
<button data-testid="file-uploader" onClick={() => onChange([{ id: 'file-1', name: 'demo.png' }])}>
|
||||
Upload
|
||||
</button>
|
||||
<button data-testid="file-uploader-empty" onClick={() => onChange([])}>
|
||||
Upload Empty
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/select', async () => {
|
||||
const React = await import('react')
|
||||
const SelectContext = React.createContext<{
|
||||
onValueChange?: (value: string) => void
|
||||
}>({})
|
||||
|
||||
return {
|
||||
Select: ({ children, onValueChange }: {
|
||||
children: React.ReactNode
|
||||
onValueChange?: (value: string) => void
|
||||
}) => (
|
||||
<SelectContext.Provider value={{ onValueChange }}>
|
||||
<div>{children}</div>
|
||||
</SelectContext.Provider>
|
||||
),
|
||||
SelectTrigger: ({ children }: { children: React.ReactNode }) => {
|
||||
const context = React.useContext(SelectContext)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button type="button">{children}</button>
|
||||
<button data-testid="select-empty" type="button" onClick={() => context.onValueChange?.('')}>
|
||||
Empty Select
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
SelectItem: ({ children, value }: { children: React.ReactNode, value: string }) => {
|
||||
const context = React.useContext(SelectContext)
|
||||
return (
|
||||
<button key={value} data-testid={`select-${value}`} type="button" onClick={() => context.onValueChange?.(value)}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
SelectItemText: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
SelectItemIndicator: () => null,
|
||||
}
|
||||
})
|
||||
|
||||
describe('AppInputsForm', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return null when no form items are provided', () => {
|
||||
const { container } = render(
|
||||
<AppInputsForm
|
||||
inputsForms={[]}
|
||||
inputs={{}}
|
||||
inputsRef={{ current: {} }}
|
||||
onFormChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should update text input values', () => {
|
||||
const onFormChange = vi.fn()
|
||||
const inputsRef = { current: { question: '' } }
|
||||
@ -58,6 +102,26 @@ describe('AppInputsForm', () => {
|
||||
expect(onFormChange).toHaveBeenCalledWith({ question: 'hello' })
|
||||
})
|
||||
|
||||
it('should update number input values', () => {
|
||||
const onFormChange = vi.fn()
|
||||
const inputsRef = { current: { count: '' } }
|
||||
|
||||
render(
|
||||
<AppInputsForm
|
||||
inputsForms={[{ variable: 'count', label: 'Count', type: InputVarType.number, required: false }]}
|
||||
inputs={{ count: '' }}
|
||||
inputsRef={inputsRef}
|
||||
onFormChange={onFormChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Count'), {
|
||||
target: { value: '42' },
|
||||
})
|
||||
|
||||
expect(onFormChange).toHaveBeenCalledWith({ count: '42' })
|
||||
})
|
||||
|
||||
it('should update select values', () => {
|
||||
const onFormChange = vi.fn()
|
||||
const inputsRef = { current: { tone: '' } }
|
||||
@ -76,6 +140,25 @@ describe('AppInputsForm', () => {
|
||||
expect(onFormChange).toHaveBeenCalledWith({ tone: 'formal' })
|
||||
})
|
||||
|
||||
it('should ignore empty select values and render the placeholder when there is no current selection', () => {
|
||||
const onFormChange = vi.fn()
|
||||
const inputsRef = { current: { tone: '' } }
|
||||
|
||||
render(
|
||||
<AppInputsForm
|
||||
inputsForms={[{ variable: 'tone', label: 'Tone', type: InputVarType.select, options: ['friendly', 'formal'], required: false }]}
|
||||
inputs={{ tone: '' }}
|
||||
inputsRef={inputsRef}
|
||||
onFormChange={onFormChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getAllByText('Tone').length).toBeGreaterThan(0)
|
||||
fireEvent.click(screen.getByTestId('select-empty'))
|
||||
|
||||
expect(onFormChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should update uploaded single file values', () => {
|
||||
const onFormChange = vi.fn()
|
||||
const inputsRef = { current: { attachment: null } }
|
||||
@ -103,4 +186,83 @@ describe('AppInputsForm', () => {
|
||||
attachment: { id: 'file-1', name: 'demo.png' },
|
||||
})
|
||||
})
|
||||
|
||||
it('should update paragraph fields and preserve sibling input values', () => {
|
||||
const onFormChange = vi.fn()
|
||||
const inputsRef = { current: { description: 'old', topic: 'existing' } }
|
||||
|
||||
render(
|
||||
<AppInputsForm
|
||||
inputsForms={[{ variable: 'description', label: 'Description', type: InputVarType.paragraph, required: false }]}
|
||||
inputs={{ description: '' }}
|
||||
inputsRef={inputsRef}
|
||||
onFormChange={onFormChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Description'), {
|
||||
target: { value: 'updated paragraph' },
|
||||
})
|
||||
|
||||
expect(onFormChange).toHaveBeenCalledWith({
|
||||
description: 'updated paragraph',
|
||||
topic: 'existing',
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep multi-file values and forward empty multi-file uploads', () => {
|
||||
const onFormChange = vi.fn()
|
||||
const existingFiles = [{ id: 'existing-file', name: 'existing.png' }]
|
||||
|
||||
render(
|
||||
<AppInputsForm
|
||||
inputsForms={[{
|
||||
variable: 'files',
|
||||
label: 'Files',
|
||||
type: InputVarType.multiFiles,
|
||||
required: true,
|
||||
max_length: 3,
|
||||
allowed_file_types: ['image'],
|
||||
allowed_file_extensions: ['.png'],
|
||||
allowed_file_upload_methods: ['local_file'],
|
||||
}]}
|
||||
inputs={{ files: existingFiles }}
|
||||
inputsRef={{ current: { files: existingFiles } }}
|
||||
onFormChange={onFormChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('file-uploader-value')).toHaveTextContent('"existing-file"')
|
||||
expect(screen.queryByText('workflow.panel.optional')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('file-uploader-empty'))
|
||||
expect(onFormChange).toHaveBeenCalledWith({ files: [] })
|
||||
})
|
||||
|
||||
it('should preserve existing single-file values and forward empty single-file uploads as undefined', () => {
|
||||
const onFormChange = vi.fn()
|
||||
const existingFile = { id: 'existing-file', name: 'existing.png' }
|
||||
|
||||
render(
|
||||
<AppInputsForm
|
||||
inputsForms={[{
|
||||
variable: 'attachment',
|
||||
label: 'Attachment',
|
||||
type: InputVarType.singleFile,
|
||||
required: false,
|
||||
allowed_file_types: ['image'],
|
||||
allowed_file_extensions: ['.png'],
|
||||
allowed_file_upload_methods: ['local_file'],
|
||||
}]}
|
||||
inputs={{ attachment: existingFile }}
|
||||
inputsRef={{ current: { attachment: existingFile } }}
|
||||
onFormChange={onFormChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('file-uploader-value')).toHaveTextContent('"existing-file"')
|
||||
|
||||
fireEvent.click(screen.getByTestId('file-uploader-empty'))
|
||||
expect(onFormChange).toHaveBeenCalledWith({ attachment: undefined })
|
||||
})
|
||||
})
|
||||
|
||||
@ -244,28 +244,42 @@ vi.mock('@/app/components/base/file-uploader', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock PortalSelect for testing select field interactions
|
||||
vi.mock('@/app/components/base/select', () => ({
|
||||
PortalSelect: ({ onSelect, value, placeholder, items }: {
|
||||
onSelect: (item: { value: string }) => void
|
||||
value: string
|
||||
placeholder: string
|
||||
items: Array<{ value: string, name: string }>
|
||||
}) => (
|
||||
<div data-testid="portal-select">
|
||||
<span data-testid="select-value">{value || placeholder}</span>
|
||||
{items?.map((item: { value: string, name: string }) => (
|
||||
// Mock Select for testing select field interactions
|
||||
vi.mock('@langgenius/dify-ui/select', async () => {
|
||||
const React = await import('react')
|
||||
const SelectContext = React.createContext<{
|
||||
onValueChange?: (value: string) => void
|
||||
}>({})
|
||||
|
||||
return {
|
||||
Select: ({ children, onValueChange }: {
|
||||
children: React.ReactNode
|
||||
onValueChange?: (value: string) => void
|
||||
}) => (
|
||||
<SelectContext.Provider value={{ onValueChange }}>
|
||||
<div data-testid="portal-select">{children}</div>
|
||||
</SelectContext.Provider>
|
||||
),
|
||||
SelectTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<span data-testid="select-value">{children}</span>
|
||||
),
|
||||
SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
SelectItem: ({ children, value }: { children: React.ReactNode, value: string }) => {
|
||||
const context = React.useContext(SelectContext)
|
||||
return (
|
||||
<button
|
||||
key={item.value}
|
||||
data-testid={`select-option-${item.value}`}
|
||||
onClick={() => onSelect(item)}
|
||||
key={value}
|
||||
data-testid={`select-option-${value}`}
|
||||
onClick={() => context.onValueChange?.(value)}
|
||||
>
|
||||
{item.name}
|
||||
{children}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
)
|
||||
},
|
||||
SelectItemText: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
SelectItemIndicator: () => null,
|
||||
}
|
||||
})
|
||||
|
||||
// Mock Input component with onClear support
|
||||
vi.mock('@/app/components/base/input', () => ({
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { PortalSelect } from '@/app/components/base/select'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
|
||||
@ -62,14 +62,23 @@ const AppInputsForm = ({
|
||||
)
|
||||
}
|
||||
if (form.type === InputVarType.select) {
|
||||
const selectOptions: Array<{ value: string, name: string }> = options.map((option: string) => ({ value: option, name: option }))
|
||||
const selectedOption = selectOptions.find(option => option.value === (inputs[variable] || '')) ?? null
|
||||
|
||||
return (
|
||||
<PortalSelect
|
||||
popupClassName="w-[356px] z-1050"
|
||||
value={inputs[variable] || ''}
|
||||
items={options.map((option: string) => ({ value: option, name: option }))}
|
||||
onSelect={item => handleFormChange(variable, item.value as string)}
|
||||
placeholder={label}
|
||||
/>
|
||||
<Select value={selectedOption?.value ?? null} onValueChange={value => value && handleFormChange(variable, value)}>
|
||||
<SelectTrigger className="w-full">
|
||||
{selectedOption?.name ?? label}
|
||||
</SelectTrigger>
|
||||
<SelectContent popupClassName="z-1050 w-(--anchor-width)">
|
||||
{selectOptions.map(option => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<SelectItemText>{option.name}</SelectItemText>
|
||||
<SelectItemIndicator />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
if (form.type === InputVarType.singleFile) {
|
||||
|
||||
@ -126,36 +126,77 @@ vi.mock('../oauth-client', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/select/custom', () => ({
|
||||
default: ({ options, value, onChange, CustomTrigger, CustomOption, containerProps }: {
|
||||
options: Array<{ value: string, label: string, show: boolean, extra?: React.ReactNode, tag?: React.ReactNode }>
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
CustomTrigger: () => React.ReactNode
|
||||
CustomOption: (option: { label: string, tag?: React.ReactNode, extra?: React.ReactNode }) => React.ReactNode
|
||||
containerProps?: { open?: boolean }
|
||||
}) => (
|
||||
<div
|
||||
data-testid="custom-select"
|
||||
data-value={value}
|
||||
data-options-count={options?.length || 0}
|
||||
data-container-open={containerProps?.open}
|
||||
>
|
||||
<div data-testid="custom-trigger">{CustomTrigger()}</div>
|
||||
<div data-testid="options-container">
|
||||
{options?.map(option => (
|
||||
vi.mock('@langgenius/dify-ui/select', async () => {
|
||||
const React = await import('react')
|
||||
|
||||
const SelectContext = React.createContext<{
|
||||
onValueChange?: (value: string) => void
|
||||
}>({})
|
||||
|
||||
const countOptions = (children: React.ReactNode): number => {
|
||||
return React.Children.toArray(children).reduce<number>((count, child) => {
|
||||
if (!React.isValidElement<{ children?: React.ReactNode }>(child))
|
||||
return count
|
||||
|
||||
return count + React.Children.toArray(child.props.children).filter((nestedChild) => {
|
||||
return React.isValidElement<{ value?: string }>(nestedChild) && 'value' in nestedChild.props
|
||||
}).length
|
||||
}, 0)
|
||||
}
|
||||
|
||||
return {
|
||||
Select: ({
|
||||
children,
|
||||
value,
|
||||
open,
|
||||
onValueChange,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
value: string | null
|
||||
open?: boolean
|
||||
onValueChange?: (value: string) => void
|
||||
}) => {
|
||||
const currentValue = value ?? DEFAULT_METHOD
|
||||
const optionsCount = countOptions(children)
|
||||
const containerOpen
|
||||
= currentValue === DEFAULT_METHOD || (currentValue === SupportedCreationMethods.OAUTH && optionsCount === 1)
|
||||
? undefined
|
||||
: String(open ?? false)
|
||||
|
||||
return (
|
||||
<SelectContext.Provider value={{ onValueChange }}>
|
||||
<div
|
||||
key={option.value}
|
||||
data-testid={`option-${option.value}`}
|
||||
onClick={() => onChange(option.value)}
|
||||
data-testid="custom-select"
|
||||
data-value={currentValue}
|
||||
data-options-count={optionsCount}
|
||||
data-container-open={containerOpen}
|
||||
>
|
||||
{CustomOption(option)}
|
||||
{children}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
</SelectContext.Provider>
|
||||
)
|
||||
},
|
||||
SelectTrigger: ({ children, className }: { children: React.ReactNode, render?: React.ReactNode, className?: string }) => {
|
||||
return <div data-testid="custom-trigger" className={className}>{children}</div>
|
||||
},
|
||||
SelectContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="options-container">{children}</div>
|
||||
),
|
||||
SelectItem: ({ children, value }: { children: React.ReactNode, value: string }) => {
|
||||
const context = React.useContext(SelectContext)
|
||||
return (
|
||||
<div
|
||||
data-testid={`option-${value}`}
|
||||
onClick={() => context.onValueChange?.(value)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
SelectItemText: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
SelectItemIndicator: () => null,
|
||||
}
|
||||
})
|
||||
|
||||
const createProviderInfo = (overrides: Partial<TriggerProviderApiEntity> = {}): TriggerProviderApiEntity => ({
|
||||
author: 'test-author',
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type { Option } from '@/app/components/base/select/custom'
|
||||
import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { RiAddLine, RiEqualizer2Line } from '@remixicon/react'
|
||||
import { useBoolean } from 'ahooks'
|
||||
@ -9,7 +9,6 @@ import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ActionButton, ActionButtonState } from '@/app/components/base/action-button'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import CustomSelect from '@/app/components/base/select/custom'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { openOAuthPopup } from '@/hooks/use-oauth'
|
||||
import { useInitiateTriggerOAuth, useTriggerOAuthConfig, useTriggerProviderInfo } from '@/service/use-triggers'
|
||||
@ -28,6 +27,14 @@ type Props = {
|
||||
|
||||
const MAX_COUNT = 10
|
||||
|
||||
type CreateTypeOption = {
|
||||
value: SupportedCreationMethods
|
||||
label: string
|
||||
show: boolean
|
||||
extra?: React.ReactNode
|
||||
tag?: React.ReactNode
|
||||
}
|
||||
|
||||
export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BUTTON, shape = 'square' }: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const { subscriptions } = useSubscriptionList()
|
||||
@ -35,6 +42,7 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU
|
||||
const [selectedCreateInfo, setSelectedCreateInfo] = useState<{ type: SupportedCreationMethods, builder?: TriggerSubscriptionBuilder } | null>(null)
|
||||
|
||||
const detail = usePluginStore(state => state.detail)
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false)
|
||||
|
||||
const { data: providerInfo } = useTriggerProviderInfo(detail?.provider || '')
|
||||
const supportedMethods = useMemo(() => providerInfo?.supported_creation_methods || [], [providerInfo?.supported_creation_methods])
|
||||
@ -63,7 +71,7 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU
|
||||
showClientSettingsModal()
|
||||
}, [showClientSettingsModal])
|
||||
|
||||
const allOptions = useMemo(() => {
|
||||
const allOptions = useMemo<CreateTypeOption[]>(() => {
|
||||
const showCustomBadge = oauthConfig?.custom_enabled && oauthConfig?.custom_configured
|
||||
|
||||
return [
|
||||
@ -99,6 +107,10 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU
|
||||
},
|
||||
]
|
||||
}, [t, oauthConfig, supportedMethods, methodType, onClickClientSettings])
|
||||
const visibleOptions = useMemo(() => {
|
||||
return allOptions.filter(option => option.show)
|
||||
}, [allOptions])
|
||||
const shouldAllowSelect = methodType === DEFAULT_METHOD || (methodType === SupportedCreationMethods.OAUTH && supportedMethods.length === 1)
|
||||
|
||||
const onChooseCreateType = async (type: SupportedCreationMethods) => {
|
||||
if (type === SupportedCreationMethods.OAUTH) {
|
||||
@ -145,24 +157,23 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU
|
||||
|
||||
return (
|
||||
<>
|
||||
<CustomSelect<Option & { show: boolean, extra?: React.ReactNode, tag?: React.ReactNode }>
|
||||
options={allOptions.filter(option => option.show)}
|
||||
value={methodType}
|
||||
onChange={value => onChooseCreateType(value as SupportedCreationMethods)}
|
||||
containerProps={{
|
||||
open: (methodType === DEFAULT_METHOD || (methodType === SupportedCreationMethods.OAUTH && supportedMethods.length === 1)) ? undefined : false,
|
||||
placement: 'bottom-start',
|
||||
offset: 4,
|
||||
triggerPopupSameWidth: buttonType === CreateButtonType.FULL_BUTTON,
|
||||
<Select
|
||||
value={methodType === DEFAULT_METHOD ? null : methodType}
|
||||
open={shouldAllowSelect ? isMenuOpen : false}
|
||||
onOpenChange={setIsMenuOpen}
|
||||
onValueChange={(value) => {
|
||||
if (!value)
|
||||
return
|
||||
setIsMenuOpen(false)
|
||||
void onChooseCreateType(value as SupportedCreationMethods)
|
||||
}}
|
||||
triggerProps={{
|
||||
className: cn('h-8 bg-transparent px-0 hover:bg-transparent', methodType !== DEFAULT_METHOD && supportedMethods.length > 1 && 'pointer-events-none', buttonType === CreateButtonType.FULL_BUTTON && 'grow'),
|
||||
}}
|
||||
popupProps={{
|
||||
wrapperClassName: 'z-1000',
|
||||
}}
|
||||
CustomTrigger={() => {
|
||||
return buttonType === CreateButtonType.FULL_BUTTON
|
||||
>
|
||||
<SelectTrigger
|
||||
render={<div />}
|
||||
nativeButton={false}
|
||||
className={cn('h-8 border-0 bg-transparent px-0 hover:bg-transparent focus-visible:bg-transparent [&>*:last-child]:hidden', buttonType === CreateButtonType.FULL_BUTTON && 'grow')}
|
||||
>
|
||||
{buttonType === CreateButtonType.FULL_BUTTON
|
||||
? (
|
||||
<Button
|
||||
variant="primary"
|
||||
@ -210,18 +221,21 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU
|
||||
<RiAddLine className="size-4" />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}}
|
||||
CustomOption={option => (
|
||||
<>
|
||||
<div className="mr-8 flex grow items-center gap-1 truncate px-1">
|
||||
{option.label}
|
||||
{option.tag}
|
||||
</div>
|
||||
{option.extra}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent placement="bottom-start" sideOffset={4} popupClassName={cn('z-1000', buttonType === CreateButtonType.FULL_BUTTON && 'min-w-(--anchor-width)')}>
|
||||
{visibleOptions.map(option => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="mr-8 flex grow items-center gap-1 truncate px-1">
|
||||
{option.label}
|
||||
{option.tag}
|
||||
</div>
|
||||
{option.extra}
|
||||
<SelectItemIndicator />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedCreateInfo && (
|
||||
<CommonCreateModal
|
||||
createType={selectedCreateInfo.type}
|
||||
|
||||
@ -153,8 +153,8 @@ vi.mock('@/app/components/plugins/plugin-auth', () => ({
|
||||
}))
|
||||
|
||||
// Portal components need mocking for controlled positioning in tests
|
||||
vi.mock('@langgenius/dify-ui/popover', () => ({
|
||||
Popover: ({
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
||||
PortalToFollowElem: ({
|
||||
children,
|
||||
open,
|
||||
}: {
|
||||
@ -165,7 +165,7 @@ vi.mock('@langgenius/dify-ui/popover', () => ({
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
PopoverTrigger: ({
|
||||
PortalToFollowElemTrigger: ({
|
||||
children,
|
||||
render,
|
||||
onClick,
|
||||
@ -178,7 +178,7 @@ vi.mock('@langgenius/dify-ui/popover', () => ({
|
||||
{render ?? children}
|
||||
</div>
|
||||
),
|
||||
PopoverContent: ({ children }: { children: ReactNode }) => (
|
||||
PortalToFollowElemContent: ({ children }: { children: ReactNode }) => (
|
||||
<div data-testid="portal-content">{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ToolFormSchema } from '@/app/components/tools/utils/to-form-schema'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@ -7,28 +8,42 @@ import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/typ
|
||||
import ReasoningConfigForm from '../reasoning-config-form'
|
||||
|
||||
vi.mock('@/app/components/base/input', () => ({
|
||||
default: ({ value, onChange }: { value?: string, onChange: (e: { target: { value: string } }) => void }) => (
|
||||
<input data-testid="number-input" value={value} onChange={e => onChange({ target: { value: e.target.value } })} />
|
||||
default: ({ value, onChange, placeholder }: { value?: string, onChange: (e: { target: { value: string } }) => void, placeholder?: string }) => (
|
||||
<input data-testid="number-input" placeholder={placeholder} value={value} onChange={e => onChange({ target: { value: e.target.value } })} />
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/select', () => ({
|
||||
SimpleSelect: ({
|
||||
items,
|
||||
onSelect,
|
||||
}: {
|
||||
items: Array<{ value: string, name: string }>
|
||||
onSelect: (item: { value: string }) => void
|
||||
}) => (
|
||||
<div>
|
||||
{items.map(item => (
|
||||
<button key={item.value} data-testid={`select-${item.value}`} onClick={() => onSelect({ value: item.value })}>
|
||||
{item.name}
|
||||
vi.mock('@langgenius/dify-ui/select', async () => {
|
||||
const React = await import('react')
|
||||
const SelectContext = React.createContext<{
|
||||
onValueChange?: (value: string) => void
|
||||
}>({})
|
||||
|
||||
return {
|
||||
Select: ({ children, onValueChange }: {
|
||||
children: React.ReactNode
|
||||
onValueChange?: (value: string) => void
|
||||
}) => (
|
||||
<SelectContext.Provider value={{ onValueChange }}>
|
||||
<div>{children}</div>
|
||||
</SelectContext.Provider>
|
||||
),
|
||||
SelectTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<button type="button">{children}</button>
|
||||
),
|
||||
SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
SelectItem: ({ children, value }: { children: React.ReactNode, value: string }) => {
|
||||
const context = React.useContext(SelectContext)
|
||||
return (
|
||||
<button key={value} data-testid={`select-${value}`} type="button" onClick={() => context.onValueChange?.(value)}>
|
||||
{children}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
)
|
||||
},
|
||||
SelectItemText: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
SelectItemIndicator: () => null,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@langgenius/dify-ui/switch', () => ({
|
||||
Switch: ({ checked, onCheckedChange }: { checked: boolean, onCheckedChange: (checked: boolean) => void }) => (
|
||||
@ -47,9 +62,10 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', ()
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/plugin-detail-panel/app-selector', () => ({
|
||||
default: ({ onSelect }: { onSelect: (value: Record<string, unknown>) => void }) => (
|
||||
default: ({ onSelect, scope }: { onSelect: (value: Record<string, unknown>) => void, scope?: string }) => (
|
||||
<button
|
||||
data-testid="app-selector"
|
||||
data-scope={scope}
|
||||
onClick={() => onSelect({ app_id: 'app-1', inputs: { topic: 'hello' } })}
|
||||
>
|
||||
Select App
|
||||
@ -66,10 +82,13 @@ vi.mock('@/app/components/plugins/plugin-detail-panel/model-selector', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
|
||||
default: ({ onChange }: { onChange: (value: string) => void }) => (
|
||||
<button data-testid="code-editor" onClick={() => onChange('{"foo":"bar"}')}>
|
||||
Update JSON
|
||||
</button>
|
||||
default: ({ onChange, placeholder }: { onChange: (value: string) => void, placeholder?: ReactNode }) => (
|
||||
<div>
|
||||
<div data-testid="code-editor-placeholder">{placeholder}</div>
|
||||
<button data-testid="code-editor" onClick={() => onChange('{"foo":"bar"}')}>
|
||||
Update JSON
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
@ -90,8 +109,8 @@ vi.mock('@/app/components/workflow/nodes/_base/components/form-input-type-switch
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
|
||||
default: ({ onChange }: { onChange: (value: string) => void }) => (
|
||||
<button data-testid="var-picker" onClick={() => onChange(['node', 'field'] as unknown as string)}>
|
||||
default: ({ onChange, value }: { onChange: (value: string) => void, value: string | string[] }) => (
|
||||
<button data-testid="var-picker" data-value={JSON.stringify(value)} onClick={() => onChange(['node', 'field'] as unknown as string)}>
|
||||
Pick Variable
|
||||
</button>
|
||||
),
|
||||
@ -337,4 +356,198 @@ describe('ReasoningConfigForm', () => {
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should update number, boolean, and select fields', () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<ReasoningConfigForm
|
||||
value={{
|
||||
count: {
|
||||
auto: 0,
|
||||
value: { type: VarKindType.constant, value: '' },
|
||||
},
|
||||
enabled: {
|
||||
auto: 0,
|
||||
value: { type: VarKindType.constant, value: false },
|
||||
},
|
||||
choice: {
|
||||
auto: 0,
|
||||
value: { type: VarKindType.constant, value: '' },
|
||||
},
|
||||
}}
|
||||
onChange={onChange}
|
||||
schemas={[
|
||||
createSchema({
|
||||
variable: 'count',
|
||||
type: FormTypeEnum.textNumber,
|
||||
label: { en_US: 'Count', zh_Hans: '数量' },
|
||||
placeholder: { en_US: 'Enter count', zh_Hans: '输入数量' },
|
||||
}),
|
||||
createSchema({
|
||||
variable: 'enabled',
|
||||
type: FormTypeEnum.checkbox,
|
||||
label: { en_US: 'Enabled', zh_Hans: '启用' },
|
||||
}),
|
||||
createSchema({
|
||||
variable: 'choice',
|
||||
type: FormTypeEnum.select,
|
||||
label: { en_US: 'Choice', zh_Hans: '选择' },
|
||||
placeholder: { en_US: 'Pick one', zh_Hans: '选择一个' },
|
||||
options: [
|
||||
{
|
||||
value: 'alpha',
|
||||
label: { en_US: 'Alpha', zh_Hans: 'Alpha' },
|
||||
show_on: [],
|
||||
},
|
||||
{
|
||||
value: 'beta',
|
||||
label: { en_US: 'Beta', zh_Hans: 'Beta' },
|
||||
show_on: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
]}
|
||||
nodeOutputVars={[]}
|
||||
availableNodes={[]}
|
||||
nodeId="node-1"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Pick one')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('Enter count')).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(screen.getByTestId('number-input'), { target: { value: '7' } })
|
||||
fireEvent.click(screen.getByTestId('boolean-input'))
|
||||
fireEvent.click(screen.getByTestId('select-beta'))
|
||||
|
||||
expect(onChange).toHaveBeenNthCalledWith(1, expect.objectContaining({
|
||||
count: {
|
||||
auto: 0,
|
||||
value: { type: VarKindType.constant, value: '7' },
|
||||
},
|
||||
}))
|
||||
expect(onChange).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
||||
enabled: {
|
||||
auto: 0,
|
||||
value: { type: VarKindType.constant, value: true },
|
||||
},
|
||||
}))
|
||||
expect(onChange).toHaveBeenNthCalledWith(3, expect.objectContaining({
|
||||
choice: {
|
||||
auto: 0,
|
||||
value: { type: VarKindType.constant, value: 'beta' },
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
it('should render selected select values and update object json fields', () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<ReasoningConfigForm
|
||||
value={{
|
||||
config: {
|
||||
auto: 0,
|
||||
value: { type: VarKindType.constant, value: '{}' },
|
||||
},
|
||||
choice: {
|
||||
auto: 0,
|
||||
value: { type: VarKindType.constant, value: 'alpha' },
|
||||
},
|
||||
}}
|
||||
onChange={onChange}
|
||||
schemas={[
|
||||
createSchema({
|
||||
variable: 'config',
|
||||
type: FormTypeEnum.object,
|
||||
input_schema: { type: Type.object, properties: {}, additionalProperties: false },
|
||||
placeholder: { en_US: '{\n "foo": "bar"\n}', zh_Hans: '{\n "foo": "bar"\n}' },
|
||||
}),
|
||||
createSchema({
|
||||
variable: 'choice',
|
||||
type: FormTypeEnum.select,
|
||||
placeholder: { en_US: 'Pick one', zh_Hans: '选择一个' },
|
||||
options: [
|
||||
{
|
||||
value: 'alpha',
|
||||
label: { en_US: 'Alpha', zh_Hans: 'Alpha' },
|
||||
show_on: [],
|
||||
},
|
||||
{
|
||||
value: 'beta',
|
||||
label: { en_US: 'Beta', zh_Hans: 'Beta' },
|
||||
show_on: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
]}
|
||||
nodeOutputVars={[]}
|
||||
availableNodes={[]}
|
||||
nodeId="node-1"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getAllByText('Alpha').length).toBeGreaterThan(0)
|
||||
expect(screen.getByTestId('code-editor-placeholder')).toHaveTextContent('"foo": "bar"')
|
||||
|
||||
fireEvent.click(screen.getByTestId('code-editor'))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
|
||||
config: {
|
||||
auto: 0,
|
||||
value: { type: VarKindType.constant, value: '{"foo":"bar"}' },
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
it('should render json placeholders, default app scope, variable links, and helper urls', () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<ReasoningConfigForm
|
||||
value={{
|
||||
config: {
|
||||
auto: 0,
|
||||
value: { type: VarKindType.constant, value: '{}' },
|
||||
},
|
||||
app: {
|
||||
auto: 0,
|
||||
value: { type: VarKindType.constant, value: null },
|
||||
},
|
||||
files: {
|
||||
auto: 0,
|
||||
value: { type: VarKindType.variable, value: '' },
|
||||
},
|
||||
}}
|
||||
onChange={onChange}
|
||||
schemas={[
|
||||
createSchema({
|
||||
variable: 'config',
|
||||
type: FormTypeEnum.object,
|
||||
input_schema: { type: Type.object, properties: {}, additionalProperties: false },
|
||||
placeholder: { en_US: '{\n "foo": "bar"\n}', zh_Hans: '{\n "foo": "bar"\n}' },
|
||||
}),
|
||||
createSchema({
|
||||
variable: 'app',
|
||||
type: FormTypeEnum.appSelector,
|
||||
scope: '' as never,
|
||||
}),
|
||||
createSchema({
|
||||
variable: 'files',
|
||||
type: FormTypeEnum.files,
|
||||
url: 'https://example.com/help',
|
||||
}),
|
||||
]}
|
||||
nodeOutputVars={[]}
|
||||
availableNodes={[]}
|
||||
nodeId="node-1"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('code-editor-placeholder')).toHaveTextContent('"foo": "bar"')
|
||||
expect(screen.getByTestId('app-selector')).toHaveAttribute('data-scope', 'all')
|
||||
expect(screen.getByTestId('var-picker')).toHaveAttribute('data-value', '[]')
|
||||
expect(screen.getByRole('link', { name: 'tools.howToGet' })).toHaveAttribute('href', 'https://example.com/help')
|
||||
})
|
||||
})
|
||||
|
||||
@ -7,6 +7,7 @@ import type {
|
||||
ValueSelector,
|
||||
} from '@/app/components/workflow/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import { Switch } from '@langgenius/dify-ui/switch'
|
||||
import {
|
||||
RiArrowRightUpLine,
|
||||
@ -16,7 +17,7 @@ import { useBoolean } from 'ahooks'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { SimpleSelect } from '@/app/components/base/select'
|
||||
// eslint-disable-next-line no-restricted-imports -- legacy tooltip migration is handled separately from this change
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
@ -156,6 +157,9 @@ const ReasoningConfigForm: React.FC<Props> = ({
|
||||
language,
|
||||
schema,
|
||||
})
|
||||
const selectedOption = isSelect && options
|
||||
? pickerProps.selectItems.find(item => item.value === (varInput?.value as string | number | undefined)) ?? null
|
||||
: null
|
||||
|
||||
return (
|
||||
<div key={variable} className="space-y-0.5">
|
||||
@ -225,13 +229,19 @@ const ReasoningConfigForm: React.FC<Props> = ({
|
||||
/>
|
||||
)}
|
||||
{isSelect && options && (
|
||||
<SimpleSelect
|
||||
wrapperClassName="h-8 grow"
|
||||
defaultValue={varInput?.value as string | number | undefined}
|
||||
items={pickerProps.selectItems}
|
||||
onSelect={item => handleValueChange(variable, type)(item.value as string)}
|
||||
placeholder={placeholder?.[language] || placeholder?.en_US}
|
||||
/>
|
||||
<Select value={selectedOption ? String(selectedOption.value) : null} onValueChange={value => value && handleValueChange(variable, type)(value)}>
|
||||
<SelectTrigger className="h-8 grow">
|
||||
{selectedOption?.name ?? placeholder?.[language] ?? placeholder?.en_US}
|
||||
</SelectTrigger>
|
||||
<SelectContent popupClassName="w-(--anchor-width)">
|
||||
{pickerProps.selectItems.map(item => (
|
||||
<SelectItem key={item.value} value={String(item.value)}>
|
||||
<SelectItemText>{item.name}</SelectItemText>
|
||||
<SelectItemIndicator />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{isShowJSONEditor && isConstant && (
|
||||
<div className="mt-1 w-full">
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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'))
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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
|
||||
})
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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' })}>
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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}')
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user