From afec528f513cba7bcc4c929b5f1ab1980a2971d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=9E=E6=B3=95=E6=93=8D=E4=BD=9C?= Date: Wed, 22 Apr 2026 16:55:16 +0800 Subject: [PATCH] feat: improve follow-up settings (#35442) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- README.md | 13 - api/.env.example | 16 -- .../manager.py | 31 ++- api/core/llm_generator/llm_generator.py | 103 +++++-- .../suggested_questions_after_answer.py | 12 +- api/core/llm_generator/prompts.py | 15 +- api/models/model.py | 24 +- api/services/message_service.py | 47 +++- .../test_additional_feature_managers.py | 32 +++ .../core/llm_generator/test_llm_generator.py | 100 +++++++ .../services/test_message_service.py | 125 +++++++++ docs/suggested-questions-configuration.md | 253 ------------------ .../follow-up-setting-modal.spec.tsx | 97 +++++++ .../__tests__/follow-up.spec.tsx | 89 +++++- .../follow-up-setting-modal.tsx | 241 +++++++++++++++++ .../features/new-feature-panel/follow-up.tsx | 93 ++++++- web/app/components/base/features/types.ts | 12 +- web/i18n/ar-TN/app-debug.json | 5 + web/i18n/de-DE/app-debug.json | 5 + web/i18n/en-US/app-debug.json | 9 + web/i18n/es-ES/app-debug.json | 5 + web/i18n/fa-IR/app-debug.json | 5 + web/i18n/fr-FR/app-debug.json | 5 + web/i18n/hi-IN/app-debug.json | 5 + web/i18n/id-ID/app-debug.json | 5 + web/i18n/it-IT/app-debug.json | 5 + web/i18n/ja-JP/app-debug.json | 5 + web/i18n/ko-KR/app-debug.json | 5 + web/i18n/nl-NL/app-debug.json | 5 + web/i18n/pl-PL/app-debug.json | 5 + web/i18n/pt-BR/app-debug.json | 5 + web/i18n/ro-RO/app-debug.json | 5 + web/i18n/ru-RU/app-debug.json | 5 + web/i18n/sl-SI/app-debug.json | 5 + web/i18n/th-TH/app-debug.json | 5 + web/i18n/tr-TR/app-debug.json | 5 + web/i18n/uk-UA/app-debug.json | 5 + web/i18n/vi-VN/app-debug.json | 5 + web/i18n/zh-Hans/app-debug.json | 9 + web/i18n/zh-Hant/app-debug.json | 5 + web/models/debug.ts | 7 +- web/types/app.ts | 2 + 42 files changed, 1086 insertions(+), 349 deletions(-) delete mode 100644 docs/suggested-questions-configuration.md create mode 100644 web/app/components/base/features/new-feature-panel/__tests__/follow-up-setting-modal.spec.tsx create mode 100644 web/app/components/base/features/new-feature-panel/follow-up-setting-modal.tsx diff --git a/README.md b/README.md index d9848a6c78..c87472ace3 100644 --- a/README.md +++ b/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. diff --git a/api/.env.example b/api/.env.example index 7455d4a0e9..6cfe0266c2 100644 --- a/api/.env.example +++ b/api/.env.example @@ -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 diff --git a/api/core/app/app_config/features/suggested_questions_after_answer/manager.py b/api/core/app/app_config/features/suggested_questions_after_answer/manager.py index 2dddce349c..0c36992c77 100644 --- a/api/core/app/app_config/features/suggested_questions_after_answer/manager.py +++ b/api/core/app/app_config/features/suggested_questions_after_answer/manager.py @@ -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"] diff --git a/api/core/llm_generator/llm_generator.py b/api/core/llm_generator/llm_generator.py index 348526b0ef..6454f4f0dc 100644 --- a/api/core/llm_generator/llm_generator.py +++ b/api/core/llm_generator/llm_generator.py @@ -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 = [] diff --git a/api/core/llm_generator/output_parser/suggested_questions_after_answer.py b/api/core/llm_generator/output_parser/suggested_questions_after_answer.py index eec771181f..c030802c79 100644 --- a/api/core/llm_generator/output_parser/suggested_questions_after_answer.py +++ b/api/core/llm_generator/output_parser/suggested_questions_after_answer.py @@ -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 diff --git a/api/core/llm_generator/prompts.py b/api/core/llm_generator/prompts.py index ee9a016c95..855a00c9cd 100644 --- a/api/core/llm_generator/prompts.py +++ b/api/core/llm_generator/prompts.py @@ -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 = ( " The user will send a long text. Generate a Question and Answer pairs only using the knowledge" diff --git a/api/models/model.py b/api/models/model.py index a1117fc43a..a632735f39 100644 --- a/api/models/model.py +++ b/api/models/model.py @@ -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: diff --git a/api/services/message_service.py b/api/services/message_service.py index 98f24dd6a6..8f5e028d4d 100644 --- a/api/services/message_service.py +++ b/api/services/message_service.py @@ -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) diff --git a/api/tests/unit_tests/core/app/app_config/features/test_additional_feature_managers.py b/api/tests/unit_tests/core/app/app_config/features/test_additional_feature_managers.py index dd00c3defc..0a0ffe657c 100644 --- a/api/tests/unit_tests/core/app/app_config/features/test_additional_feature_managers.py +++ b/api/tests/unit_tests/core/app/app_config/features/test_additional_feature_managers.py @@ -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}}) diff --git a/api/tests/unit_tests/core/llm_generator/test_llm_generator.py b/api/tests/unit_tests/core/llm_generator/test_llm_generator.py index 2716f4712c..3b64ce6b5c 100644 --- a/api/tests/unit_tests/core/llm_generator/test_llm_generator.py +++ b/api/tests/unit_tests/core/llm_generator/test_llm_generator.py @@ -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 diff --git a/api/tests/unit_tests/services/test_message_service.py b/api/tests/unit_tests/services/test_message_service.py index 969132cfd8..7adc15d63e 100644 --- a/api/tests/unit_tests/services/test_message_service.py +++ b/api/tests/unit_tests/services/test_message_service.py @@ -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") diff --git a/docs/suggested-questions-configuration.md b/docs/suggested-questions-configuration.md deleted file mode 100644 index c726d3b157..0000000000 --- a/docs/suggested-questions-configuration.md +++ /dev/null @@ -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. diff --git a/web/app/components/base/features/new-feature-panel/__tests__/follow-up-setting-modal.spec.tsx b/web/app/components/base/features/new-feature-panel/__tests__/follow-up-setting-modal.spec.tsx new file mode 100644 index 0000000000..9437a19824 --- /dev/null +++ b/web/app/components/base/features/new-feature-panel/__tests__/follow-up-setting-modal.spec.tsx @@ -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 }) => ( +
{`${provider}:${modelId}`}
+ ), +})) + +const renderModal = (data: SuggestedQuestionsAfterAnswer = { enabled: true }) => { + const onSave = vi.fn() + const onCancel = vi.fn() + + render( + , + ) + + 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() + }) + }) +}) diff --git a/web/app/components/base/features/new-feature-panel/__tests__/follow-up.spec.tsx b/web/app/components/base/features/new-feature-panel/__tests__/follow-up.spec.tsx index 0e7c6aa558..323032249d 100644 --- a/web/app/components/base/features/new-feature-panel/__tests__/follow-up.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/__tests__/follow-up.spec.tsx @@ -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 }) => ( +
+ + +
+ ), +})) + +const renderWithProvider = ( + props: { + disabled?: boolean + onChange?: OnFeaturesChange + suggested?: SuggestedQuestionsAfterAnswer + } = {}, +) => { return render( - + , ) @@ -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', + }), + }), + })) + }) }) diff --git a/web/app/components/base/features/new-feature-panel/follow-up-setting-modal.tsx b/web/app/components/base/features/new-feature-panel/follow-up-setting-modal.tsx new file mode 100644 index 0000000000..24e89c3517 --- /dev/null +++ b/web/app/components/base/features/new-feature-panel/follow-up-setting-modal.tsx @@ -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(() => getInitialModel(data.model)) + const [prompt, setPrompt] = useState(data.prompt || '') + const [promptMode, setPromptMode] = useState( + data.prompt ? PROMPT_MODE.custom : PROMPT_MODE.default, + ) + const { defaultModel } = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textGeneration) + const selectedModel = useMemo(() => { + 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), + }, + }) + }, [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 ( + { + if (!open) + onCancel() + }} + > + + + + {t('feature.suggestedQuestionsAfterAnswer.modal.title', { ns: 'appDebug' })} + +
+
+
+ {t('feature.suggestedQuestionsAfterAnswer.modal.modelLabel', { ns: 'appDebug' })} +
+ +
+
+
+ {t('feature.suggestedQuestionsAfterAnswer.modal.promptLabel', { ns: 'appDebug' })} +
+
+ +