Compare commits

..
Author SHA1 Message Date
Stephen Zhou 9d1d76055a test 2026-01-30 16:29:46 +08:00
Stephen Zhou a34d3ffc28 update 2026-01-30 16:26:20 +08:00
Stephen Zhou 1d2ed8da2a test: fix failed test 2026-01-30 16:16:50 +08:00
38 changed files with 508 additions and 473 deletions
+2 -2
View File
@@ -93,9 +93,9 @@ class AppExecutionConfig(BaseSettings):
default=0,
)
HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS: PositiveInt = Field(
HITL_GLOBAL_TIMEOUT_SECONDS: PositiveInt = Field(
description="Maximum seconds a workflow run can stay paused waiting for human input before global timeout.",
default=int(timedelta(days=7).total_seconds()),
default=int(timedelta(days=3).total_seconds()),
ge=1,
)
+37 -22
View File
@@ -1,4 +1,5 @@
from collections.abc import Sequence
from typing import Any
from flask_restx import Resource
from pydantic import BaseModel, Field
@@ -11,12 +12,10 @@ from controllers.console.app.error import (
ProviderQuotaExceededError,
)
from controllers.console.wraps import account_initialization_required, setup_required
from core.app.app_config.entities import ModelConfig
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from core.helper.code_executor.code_node_provider import CodeNodeProvider
from core.helper.code_executor.javascript.javascript_code_provider import JavascriptCodeProvider
from core.helper.code_executor.python3.python3_code_provider import Python3CodeProvider
from core.llm_generator.entities import RuleCodeGeneratePayload, RuleGeneratePayload, RuleStructuredOutputPayload
from core.llm_generator.llm_generator import LLMGenerator
from core.model_runtime.errors.invoke import InvokeError
from extensions.ext_database import db
@@ -27,13 +26,28 @@ from services.workflow_service import WorkflowService
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
class RuleGeneratePayload(BaseModel):
instruction: str = Field(..., description="Rule generation instruction")
model_config_data: dict[str, Any] = Field(..., alias="model_config", description="Model configuration")
no_variable: bool = Field(default=False, description="Whether to exclude variables")
class RuleCodeGeneratePayload(RuleGeneratePayload):
code_language: str = Field(default="javascript", description="Programming language for code generation")
class RuleStructuredOutputPayload(BaseModel):
instruction: str = Field(..., description="Structured output generation instruction")
model_config_data: dict[str, Any] = Field(..., alias="model_config", description="Model configuration")
class InstructionGeneratePayload(BaseModel):
flow_id: str = Field(..., description="Workflow/Flow ID")
node_id: str = Field(default="", description="Node ID for workflow context")
current: str = Field(default="", description="Current instruction text")
language: str = Field(default="javascript", description="Programming language (javascript/python)")
instruction: str = Field(..., description="Instruction for generation")
model_config_data: ModelConfig = Field(..., alias="model_config", description="Model configuration")
model_config_data: dict[str, Any] = Field(..., alias="model_config", description="Model configuration")
ideal_output: str = Field(default="", description="Expected ideal output")
@@ -50,7 +64,6 @@ reg(RuleCodeGeneratePayload)
reg(RuleStructuredOutputPayload)
reg(InstructionGeneratePayload)
reg(InstructionTemplatePayload)
reg(ModelConfig)
@console_ns.route("/rule-generate")
@@ -69,7 +82,12 @@ class RuleGenerateApi(Resource):
_, current_tenant_id = current_account_with_tenant()
try:
rules = LLMGenerator.generate_rule_config(tenant_id=current_tenant_id, args=args)
rules = LLMGenerator.generate_rule_config(
tenant_id=current_tenant_id,
instruction=args.instruction,
model_config=args.model_config_data,
no_variable=args.no_variable,
)
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
@@ -100,7 +118,9 @@ class RuleCodeGenerateApi(Resource):
try:
code_result = LLMGenerator.generate_code(
tenant_id=current_tenant_id,
args=args,
instruction=args.instruction,
model_config=args.model_config_data,
code_language=args.code_language,
)
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
@@ -132,7 +152,8 @@ class RuleStructuredOutputGenerateApi(Resource):
try:
structured_output = LLMGenerator.generate_structured_output(
tenant_id=current_tenant_id,
args=args,
instruction=args.instruction,
model_config=args.model_config_data,
)
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
@@ -183,29 +204,23 @@ class InstructionGenerateApi(Resource):
case "llm":
return LLMGenerator.generate_rule_config(
current_tenant_id,
args=RuleGeneratePayload(
instruction=args.instruction,
model_config=args.model_config_data,
no_variable=True,
),
instruction=args.instruction,
model_config=args.model_config_data,
no_variable=True,
)
case "agent":
return LLMGenerator.generate_rule_config(
current_tenant_id,
args=RuleGeneratePayload(
instruction=args.instruction,
model_config=args.model_config_data,
no_variable=True,
),
instruction=args.instruction,
model_config=args.model_config_data,
no_variable=True,
)
case "code":
return LLMGenerator.generate_code(
tenant_id=current_tenant_id,
args=RuleCodeGeneratePayload(
instruction=args.instruction,
model_config=args.model_config_data,
code_language=args.language,
),
instruction=args.instruction,
model_config=args.model_config_data,
code_language=args.language,
)
case _:
return {"error": f"invalid node type: {node_type}"}
-20
View File
@@ -1,20 +0,0 @@
"""Shared payload models for LLM generator helpers and controllers."""
from pydantic import BaseModel, Field
from core.app.app_config.entities import ModelConfig
class RuleGeneratePayload(BaseModel):
instruction: str = Field(..., description="Rule generation instruction")
model_config_data: ModelConfig = Field(..., alias="model_config", description="Model configuration")
no_variable: bool = Field(default=False, description="Whether to exclude variables")
class RuleCodeGeneratePayload(RuleGeneratePayload):
code_language: str = Field(default="javascript", description="Programming language for code generation")
class RuleStructuredOutputPayload(BaseModel):
instruction: str = Field(..., description="Structured output generation instruction")
model_config_data: ModelConfig = Field(..., alias="model_config", description="Model configuration")
+37 -46
View File
@@ -6,8 +6,6 @@ from typing import Protocol, cast
import json_repair
from core.app.app_config.entities import ModelConfig
from core.llm_generator.entities import RuleCodeGeneratePayload, RuleGeneratePayload, RuleStructuredOutputPayload
from core.llm_generator.output_parser.rule_config_generator import RuleConfigGeneratorOutputParser
from core.llm_generator.output_parser.suggested_questions_after_answer import SuggestedQuestionsAfterAnswerOutputParser
from core.llm_generator.prompts import (
@@ -153,19 +151,19 @@ class LLMGenerator:
return questions
@classmethod
def generate_rule_config(cls, tenant_id: str, args: RuleGeneratePayload):
def generate_rule_config(cls, tenant_id: str, instruction: str, model_config: dict, no_variable: bool):
output_parser = RuleConfigGeneratorOutputParser()
error = ""
error_step = ""
rule_config = {"prompt": "", "variables": [], "opening_statement": "", "error": ""}
model_parameters = args.model_config_data.completion_params
if args.no_variable:
model_parameters = model_config.get("completion_params", {})
if no_variable:
prompt_template = PromptTemplateParser(WORKFLOW_RULE_CONFIG_PROMPT_GENERATE_TEMPLATE)
prompt_generate = prompt_template.format(
inputs={
"TASK_DESCRIPTION": args.instruction,
"TASK_DESCRIPTION": instruction,
},
remove_template_variables=False,
)
@@ -177,8 +175,8 @@ class LLMGenerator:
model_instance = model_manager.get_model_instance(
tenant_id=tenant_id,
model_type=ModelType.LLM,
provider=args.model_config_data.provider,
model=args.model_config_data.name,
provider=model_config.get("provider", ""),
model=model_config.get("name", ""),
)
try:
@@ -192,7 +190,7 @@ class LLMGenerator:
error = str(e)
error_step = "generate rule config"
except Exception as e:
logger.exception("Failed to generate rule config, model: %s", args.model_config_data.name)
logger.exception("Failed to generate rule config, model: %s", model_config.get("name"))
rule_config["error"] = str(e)
rule_config["error"] = f"Failed to {error_step}. Error: {error}" if error else ""
@@ -211,7 +209,7 @@ class LLMGenerator:
# format the prompt_generate_prompt
prompt_generate_prompt = prompt_template.format(
inputs={
"TASK_DESCRIPTION": args.instruction,
"TASK_DESCRIPTION": instruction,
},
remove_template_variables=False,
)
@@ -222,8 +220,8 @@ class LLMGenerator:
model_instance = model_manager.get_model_instance(
tenant_id=tenant_id,
model_type=ModelType.LLM,
provider=args.model_config_data.provider,
model=args.model_config_data.name,
provider=model_config.get("provider", ""),
model=model_config.get("name", ""),
)
try:
@@ -252,7 +250,7 @@ class LLMGenerator:
# the second step to generate the task_parameter and task_statement
statement_generate_prompt = statement_template.format(
inputs={
"TASK_DESCRIPTION": args.instruction,
"TASK_DESCRIPTION": instruction,
"INPUT_TEXT": prompt_content.message.get_text_content(),
},
remove_template_variables=False,
@@ -278,7 +276,7 @@ class LLMGenerator:
error_step = "generate conversation opener"
except Exception as e:
logger.exception("Failed to generate rule config, model: %s", args.model_config_data.name)
logger.exception("Failed to generate rule config, model: %s", model_config.get("name"))
rule_config["error"] = str(e)
rule_config["error"] = f"Failed to {error_step}. Error: {error}" if error else ""
@@ -286,20 +284,16 @@ class LLMGenerator:
return rule_config
@classmethod
def generate_code(
cls,
tenant_id: str,
args: RuleCodeGeneratePayload,
):
if args.code_language == "python":
def generate_code(cls, tenant_id: str, instruction: str, model_config: dict, code_language: str = "javascript"):
if code_language == "python":
prompt_template = PromptTemplateParser(PYTHON_CODE_GENERATOR_PROMPT_TEMPLATE)
else:
prompt_template = PromptTemplateParser(JAVASCRIPT_CODE_GENERATOR_PROMPT_TEMPLATE)
prompt = prompt_template.format(
inputs={
"INSTRUCTION": args.instruction,
"CODE_LANGUAGE": args.code_language,
"INSTRUCTION": instruction,
"CODE_LANGUAGE": code_language,
},
remove_template_variables=False,
)
@@ -308,28 +302,28 @@ class LLMGenerator:
model_instance = model_manager.get_model_instance(
tenant_id=tenant_id,
model_type=ModelType.LLM,
provider=args.model_config_data.provider,
model=args.model_config_data.name,
provider=model_config.get("provider", ""),
model=model_config.get("name", ""),
)
prompt_messages = [UserPromptMessage(content=prompt)]
model_parameters = args.model_config_data.completion_params
model_parameters = model_config.get("completion_params", {})
try:
response: LLMResult = model_instance.invoke_llm(
prompt_messages=list(prompt_messages), model_parameters=model_parameters, stream=False
)
generated_code = response.message.get_text_content()
return {"code": generated_code, "language": args.code_language, "error": ""}
return {"code": generated_code, "language": code_language, "error": ""}
except InvokeError as e:
error = str(e)
return {"code": "", "language": args.code_language, "error": f"Failed to generate code. Error: {error}"}
return {"code": "", "language": code_language, "error": f"Failed to generate code. Error: {error}"}
except Exception as e:
logger.exception(
"Failed to invoke LLM model, model: %s, language: %s", args.model_config_data.name, args.code_language
"Failed to invoke LLM model, model: %s, language: %s", model_config.get("name"), code_language
)
return {"code": "", "language": args.code_language, "error": f"An unexpected error occurred: {str(e)}"}
return {"code": "", "language": code_language, "error": f"An unexpected error occurred: {str(e)}"}
@classmethod
def generate_qa_document(cls, tenant_id: str, query, document_language: str):
@@ -359,20 +353,20 @@ class LLMGenerator:
return answer.strip()
@classmethod
def generate_structured_output(cls, tenant_id: str, args: RuleStructuredOutputPayload):
def generate_structured_output(cls, tenant_id: str, instruction: str, model_config: dict):
model_manager = ModelManager()
model_instance = model_manager.get_model_instance(
tenant_id=tenant_id,
model_type=ModelType.LLM,
provider=args.model_config_data.provider,
model=args.model_config_data.name,
provider=model_config.get("provider", ""),
model=model_config.get("name", ""),
)
prompt_messages = [
SystemPromptMessage(content=SYSTEM_STRUCTURED_OUTPUT_GENERATE),
UserPromptMessage(content=args.instruction),
UserPromptMessage(content=instruction),
]
model_parameters = args.model_config_data.completion_params
model_parameters = model_config.get("model_parameters", {})
try:
response: LLMResult = model_instance.invoke_llm(
@@ -396,17 +390,12 @@ class LLMGenerator:
error = str(e)
return {"output": "", "error": f"Failed to generate JSON Schema. Error: {error}"}
except Exception as e:
logger.exception("Failed to invoke LLM model, model: %s", args.model_config_data.name)
logger.exception("Failed to invoke LLM model, model: %s", model_config.get("name"))
return {"output": "", "error": f"An unexpected error occurred: {str(e)}"}
@staticmethod
def instruction_modify_legacy(
tenant_id: str,
flow_id: str,
current: str,
instruction: str,
model_config: ModelConfig,
ideal_output: str | None,
tenant_id: str, flow_id: str, current: str, instruction: str, model_config: dict, ideal_output: str | None
):
last_run: Message | None = (
db.session.query(Message).where(Message.app_id == flow_id).order_by(Message.created_at.desc()).first()
@@ -445,7 +434,7 @@ class LLMGenerator:
node_id: str,
current: str,
instruction: str,
model_config: ModelConfig,
model_config: dict,
ideal_output: str | None,
workflow_service: WorkflowServiceInterface,
):
@@ -516,7 +505,7 @@ class LLMGenerator:
@staticmethod
def __instruction_modify_common(
tenant_id: str,
model_config: ModelConfig,
model_config: dict,
last_run: dict | None,
current: str | None,
error_message: str | None,
@@ -537,8 +526,8 @@ class LLMGenerator:
model_instance = ModelManager().get_model_instance(
tenant_id=tenant_id,
model_type=ModelType.LLM,
provider=model_config.provider,
model=model_config.name,
provider=model_config.get("provider", ""),
model=model_config.get("name", ""),
)
match node_type:
case "llm" | "agent":
@@ -581,5 +570,7 @@ class LLMGenerator:
error = str(e)
return {"error": f"Failed to generate code. Error: {error}"}
except Exception as e:
logger.exception("Failed to invoke LLM model, model: %s", json.dumps(model_config.name), exc_info=True)
logger.exception(
"Failed to invoke LLM model, model: %s", json.dumps(model_config.get("name")), exc_info=True
)
return {"error": f"An unexpected error occurred: {str(e)}"}
@@ -92,10 +92,6 @@ def _build_llm_result_from_first_chunk(
Build a single `LLMResult` from the first returned chunk.
This is used for `stream=False` because the plugin side may still implement the response via a chunked stream.
Note:
This function always drains the `chunks` iterator after reading the first chunk to ensure any underlying
streaming resources are released (e.g., HTTP connections owned by the plugin runtime).
"""
content = ""
content_list: list[PromptMessageContentUnionTypes] = []
@@ -103,25 +99,18 @@ def _build_llm_result_from_first_chunk(
system_fingerprint: str | None = None
tools_calls: list[AssistantPromptMessage.ToolCall] = []
try:
first_chunk = next(chunks, None)
if first_chunk is not None:
if isinstance(first_chunk.delta.message.content, str):
content += first_chunk.delta.message.content
elif isinstance(first_chunk.delta.message.content, list):
content_list.extend(first_chunk.delta.message.content)
first_chunk = next(chunks, None)
if first_chunk is not None:
if isinstance(first_chunk.delta.message.content, str):
content += first_chunk.delta.message.content
elif isinstance(first_chunk.delta.message.content, list):
content_list.extend(first_chunk.delta.message.content)
if first_chunk.delta.message.tool_calls:
_increase_tool_call(first_chunk.delta.message.tool_calls, tools_calls)
if first_chunk.delta.message.tool_calls:
_increase_tool_call(first_chunk.delta.message.tool_calls, tools_calls)
usage = first_chunk.delta.usage or LLMUsage.empty_usage()
system_fingerprint = first_chunk.system_fingerprint
finally:
try:
for _ in chunks:
pass
except Exception:
logger.debug("Failed to drain non-stream plugin chunk iterator.", exc_info=True)
usage = first_chunk.delta.usage or LLMUsage.empty_usage()
system_fingerprint = first_chunk.system_fingerprint
return LLMResult(
model=model,
@@ -12,7 +12,6 @@ from core.app.apps.chat.app_generator import ChatAppGenerator
from core.app.apps.completion.app_generator import CompletionAppGenerator
from core.app.apps.workflow.app_generator import WorkflowAppGenerator
from core.app.entities.app_invoke_entities import InvokeFrom
from core.app.layers.pause_state_persist_layer import PauseStateLayerConfig
from core.plugin.backwards_invocation.base import BaseBackwardsInvocation
from extensions.ext_database import db
from models import Account
@@ -103,11 +102,6 @@ class PluginAppBackwardsInvocation(BaseBackwardsInvocation):
if not workflow:
raise ValueError("unexpected app type")
pause_config = PauseStateLayerConfig(
session_factory=db.engine,
state_owner_user_id=workflow.created_by,
)
return AdvancedChatAppGenerator().generate(
app_model=app,
workflow=workflow,
@@ -121,7 +115,6 @@ class PluginAppBackwardsInvocation(BaseBackwardsInvocation):
invoke_from=InvokeFrom.SERVICE_API,
workflow_run_id=str(uuid.uuid4()),
streaming=stream,
pause_state_config=pause_config,
)
elif app.mode == AppMode.AGENT_CHAT:
return AgentChatAppGenerator().generate(
@@ -168,11 +161,6 @@ class PluginAppBackwardsInvocation(BaseBackwardsInvocation):
if not workflow:
raise ValueError("unexpected app type")
pause_config = PauseStateLayerConfig(
session_factory=db.engine,
state_owner_user_id=workflow.created_by,
)
return WorkflowAppGenerator().generate(
app_model=app,
workflow=workflow,
@@ -181,7 +169,6 @@ class PluginAppBackwardsInvocation(BaseBackwardsInvocation):
invoke_from=InvokeFrom.SERVICE_API,
streaming=stream,
call_depth=1,
pause_state_config=pause_config,
)
@classmethod
-4
View File
@@ -98,10 +98,6 @@ class WorkflowTool(Tool):
invoke_from=self.runtime.invoke_from,
streaming=False,
call_depth=self.workflow_call_depth + 1,
# NOTE(QuantumGhost): We explicitly set `pause_state_config` to `None`
# because workflow pausing mechanisms (such as HumanInput) are not
# supported within WorkflowTool execution context.
pause_state_config=None,
)
assert isinstance(result, dict)
data = result.get("data", {})
+1 -21
View File
@@ -40,7 +40,7 @@ dependencies = [
"numpy~=1.26.4",
"openpyxl~=3.1.5",
"opik~=1.8.72",
"litellm==1.77.1", # Pinned to avoid madoka dependency issue
"litellm==1.77.1", # Pinned to avoid madoka dependency issue
"opentelemetry-api==1.27.0",
"opentelemetry-distro==0.48b0",
"opentelemetry-exporter-otlp==1.27.0",
@@ -230,23 +230,3 @@ vdb = [
"mo-vector~=0.1.13",
"mysql-connector-python>=9.3.0",
]
[tool.mypy]
[[tool.mypy.overrides]]
# targeted ignores for current type-check errors
# TODO(QuantumGhost): suppress type errors in HITL related code.
# fix the type error later
module = [
"configs.middleware.cache.redis_pubsub_config",
"extensions.ext_redis",
"tasks.workflow_execution_tasks",
"core.workflow.nodes.base.node",
"services.human_input_delivery_test_service",
"core.app.apps.advanced_chat.app_generator",
"controllers.console.human_input_form",
"controllers.console.app.workflow_run",
"repositories.sqlalchemy_api_workflow_node_execution_repository",
"extensions.logstore.repositories.logstore_api_workflow_run_repository",
]
ignore_errors = true
-7
View File
@@ -16,8 +16,6 @@ from core.app.apps.workflow.app_generator import WorkflowAppGenerator
from core.app.entities.app_invoke_entities import InvokeFrom
from core.app.features.rate_limiting import RateLimit
from core.app.features.rate_limiting.rate_limit import rate_limit_context
from core.app.layers.pause_state_persist_layer import PauseStateLayerConfig
from core.db import session_factory
from enums.quota_type import QuotaType, unlimited
from extensions.otel import AppGenerateHandler, trace_span
from models.model import Account, App, AppMode, EndUser
@@ -191,10 +189,6 @@ class AppGenerateService:
request_id,
)
pause_config = PauseStateLayerConfig(
session_factory=session_factory.get_session_maker(),
state_owner_user_id=workflow.created_by,
)
return rate_limit.generate(
WorkflowAppGenerator.convert_to_event_stream(
WorkflowAppGenerator().generate(
@@ -206,7 +200,6 @@ class AppGenerateService:
streaming=False,
root_node_id=root_node_id,
call_depth=0,
pause_state_config=pause_config,
),
),
request_id,
+1 -1
View File
@@ -239,7 +239,7 @@ class HumanInputService:
logger.warning("App mode %s does not support resume for workflow run %s", app.mode, workflow_run_id)
def _is_globally_expired(self, form: Form, *, now: datetime | None = None) -> bool:
global_timeout_seconds = dify_config.HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS
global_timeout_seconds = dify_config.HITL_GLOBAL_TIMEOUT_SECONDS
if global_timeout_seconds <= 0:
return False
if form.workflow_run_id is None:
+1 -1
View File
@@ -61,7 +61,7 @@ def check_and_handle_human_input_timeouts(limit: int = 100) -> None:
form_repo = HumanInputFormSubmissionRepository(session_factory)
service = HumanInputService(session_factory, form_repository=form_repo)
now = naive_utc_now()
global_timeout_seconds = dify_config.HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS
global_timeout_seconds = dify_config.HITL_GLOBAL_TIMEOUT_SECONDS
with session_factory() as session:
global_deadline = now - timedelta(seconds=global_timeout_seconds) if global_timeout_seconds > 0 else None
@@ -101,26 +101,3 @@ def test__normalize_non_stream_plugin_result__empty_iterator_defaults():
assert result.message.tool_calls == []
assert result.usage == LLMUsage.empty_usage()
assert result.system_fingerprint is None
def test__normalize_non_stream_plugin_result__closes_chunk_iterator():
prompt_messages = [UserPromptMessage(content="hi")]
chunk = _make_chunk(content="hello", usage=LLMUsage.empty_usage())
closed: list[bool] = []
def _chunk_iter():
try:
yield chunk
yield _make_chunk(content="ignored", usage=LLMUsage.empty_usage())
finally:
closed.append(True)
result = _normalize_non_stream_plugin_result(
model="test-model",
prompt_messages=prompt_messages,
result=_chunk_iter(),
)
assert result.message.content == "hello"
assert closed == [True]
@@ -1,72 +0,0 @@
from types import SimpleNamespace
from unittest.mock import MagicMock
from core.app.layers.pause_state_persist_layer import PauseStateLayerConfig
from core.plugin.backwards_invocation.app import PluginAppBackwardsInvocation
from models.model import AppMode
def test_invoke_chat_app_advanced_chat_injects_pause_state_config(mocker):
workflow = MagicMock()
workflow.created_by = "owner-id"
app = MagicMock()
app.mode = AppMode.ADVANCED_CHAT
app.workflow = workflow
mocker.patch(
"core.plugin.backwards_invocation.app.db",
SimpleNamespace(engine=MagicMock()),
)
generator_spy = mocker.patch(
"core.plugin.backwards_invocation.app.AdvancedChatAppGenerator.generate",
return_value={"result": "ok"},
)
result = PluginAppBackwardsInvocation.invoke_chat_app(
app=app,
user=MagicMock(),
conversation_id="conv-1",
query="hello",
stream=False,
inputs={"k": "v"},
files=[],
)
assert result == {"result": "ok"}
call_kwargs = generator_spy.call_args.kwargs
pause_state_config = call_kwargs.get("pause_state_config")
assert isinstance(pause_state_config, PauseStateLayerConfig)
assert pause_state_config.state_owner_user_id == "owner-id"
def test_invoke_workflow_app_injects_pause_state_config(mocker):
workflow = MagicMock()
workflow.created_by = "owner-id"
app = MagicMock()
app.mode = AppMode.WORKFLOW
app.workflow = workflow
mocker.patch(
"core.plugin.backwards_invocation.app.db",
SimpleNamespace(engine=MagicMock()),
)
generator_spy = mocker.patch(
"core.plugin.backwards_invocation.app.WorkflowAppGenerator.generate",
return_value={"result": "ok"},
)
result = PluginAppBackwardsInvocation.invoke_workflow_app(
app=app,
user=MagicMock(),
stream=False,
inputs={"k": "v"},
files=[],
)
assert result == {"result": "ok"}
call_kwargs = generator_spy.call_args.kwargs
pause_state_config = call_kwargs.get("pause_state_config")
assert isinstance(pause_state_config, PauseStateLayerConfig)
assert pause_state_config.state_owner_user_id == "owner-id"
@@ -55,43 +55,6 @@ def test_workflow_tool_should_raise_tool_invoke_error_when_result_has_error_fiel
assert exc_info.value.args == ("oops",)
def test_workflow_tool_does_not_use_pause_state_config(monkeypatch: pytest.MonkeyPatch):
entity = ToolEntity(
identity=ToolIdentity(author="test", name="test tool", label=I18nObject(en_US="test tool"), provider="test"),
parameters=[],
description=None,
has_runtime_parameters=False,
)
runtime = ToolRuntime(tenant_id="test_tool", invoke_from=InvokeFrom.EXPLORE)
tool = WorkflowTool(
workflow_app_id="",
workflow_as_tool_id="",
version="1",
workflow_entities={},
workflow_call_depth=1,
entity=entity,
runtime=runtime,
)
monkeypatch.setattr(tool, "_get_app", lambda *args, **kwargs: None)
monkeypatch.setattr(tool, "_get_workflow", lambda *args, **kwargs: None)
from unittest.mock import MagicMock, Mock
mock_user = Mock()
monkeypatch.setattr(tool, "_resolve_user", lambda *args, **kwargs: mock_user)
generate_mock = MagicMock(return_value={"data": {}})
monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate", generate_mock)
monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None)
list(tool.invoke("test_user", {}))
call_kwargs = generate_mock.call_args.kwargs
assert "pause_state_config" in call_kwargs
assert call_kwargs["pause_state_config"] is None
def test_workflow_tool_should_generate_variable_messages_for_outputs(monkeypatch: pytest.MonkeyPatch):
"""Test that WorkflowTool should generate variable messages when there are outputs"""
entity = ToolEntity(
@@ -1,65 +0,0 @@
from unittest.mock import MagicMock
import services.app_generate_service as app_generate_service_module
from models.model import AppMode
from services.app_generate_service import AppGenerateService
class _DummyRateLimit:
def __init__(self, client_id: str, max_active_requests: int) -> None:
self.client_id = client_id
self.max_active_requests = max_active_requests
@staticmethod
def gen_request_key() -> str:
return "dummy-request-id"
def enter(self, request_id: str | None = None) -> str:
return request_id or "dummy-request-id"
def exit(self, request_id: str) -> None:
return None
def generate(self, generator, request_id: str):
return generator
def test_workflow_blocking_injects_pause_state_config(mocker, monkeypatch):
monkeypatch.setattr(app_generate_service_module.dify_config, "BILLING_ENABLED", False)
mocker.patch("services.app_generate_service.RateLimit", _DummyRateLimit)
workflow = MagicMock()
workflow.id = "workflow-id"
workflow.created_by = "owner-id"
mocker.patch.object(AppGenerateService, "_get_workflow", return_value=workflow)
generator_spy = mocker.patch(
"services.app_generate_service.WorkflowAppGenerator.generate",
return_value={"result": "ok"},
)
app_model = MagicMock()
app_model.mode = AppMode.WORKFLOW
app_model.id = "app-id"
app_model.tenant_id = "tenant-id"
app_model.max_active_requests = 0
app_model.is_agent = False
user = MagicMock()
user.id = "user-id"
result = AppGenerateService.generate(
app_model=app_model,
user=user,
args={"inputs": {"k": "v"}},
invoke_from=MagicMock(),
streaming=False,
)
assert result == {"result": "ok"}
call_kwargs = generator_spy.call_args.kwargs
pause_state_config = call_kwargs.get("pause_state_config")
assert pause_state_config is not None
assert pause_state_config.state_owner_user_id == "owner-id"
@@ -100,7 +100,7 @@ def test_ensure_form_active_respects_global_timeout(monkeypatch, sample_form_rec
created_at=datetime.utcnow() - timedelta(hours=2),
expiration_time=datetime.utcnow() + timedelta(hours=2),
)
monkeypatch.setattr(human_input_service_module.dify_config, "HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS", 3600)
monkeypatch.setattr(human_input_service_module.dify_config, "HITL_GLOBAL_TIMEOUT_SECONDS", 3600)
with pytest.raises(FormExpiredError):
service.ensure_form_active(Form(expired_record))
@@ -115,7 +115,7 @@ def test_is_global_timeout_uses_created_at():
def test_check_and_handle_human_input_timeouts_marks_and_routes(monkeypatch: pytest.MonkeyPatch):
now = datetime(2025, 1, 1, 12, 0, 0)
monkeypatch.setattr(task_module, "naive_utc_now", lambda: now)
monkeypatch.setattr(task_module.dify_config, "HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS", 3600)
monkeypatch.setattr(task_module.dify_config, "HITL_GLOBAL_TIMEOUT_SECONDS", 3600)
monkeypatch.setattr(task_module, "db", SimpleNamespace(engine=object()))
forms = [
@@ -193,7 +193,7 @@ def test_check_and_handle_human_input_timeouts_marks_and_routes(monkeypatch: pyt
def test_check_and_handle_human_input_timeouts_omits_global_filter_when_disabled(monkeypatch: pytest.MonkeyPatch):
now = datetime(2025, 1, 1, 12, 0, 0)
monkeypatch.setattr(task_module, "naive_utc_now", lambda: now)
monkeypatch.setattr(task_module.dify_config, "HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS", 0)
monkeypatch.setattr(task_module.dify_config, "HITL_GLOBAL_TIMEOUT_SECONDS", 0)
monkeypatch.setattr(task_module, "db", SimpleNamespace(engine=object()))
capture: dict[str, Any] = {}
+1
View File
@@ -43,3 +43,4 @@ exclude = [
"controllers/web/workflow_events.py",
"tasks/app_generate/workflow_execute_task.py",
]
@@ -145,7 +145,10 @@ export default function MailAndPasswordAuth({ isEmailSetup }: MailAndPasswordAut
value={password}
onChange={e => setPassword(e.target.value)}
id="password"
onPressEnter={() => handleEmailPasswordLogin()}
onKeyDown={(e) => {
if (e.key === 'Enter')
handleEmailPasswordLogin()
}}
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
placeholder={t('passwordPlaceholder', { ns: 'login' }) || ''}
@@ -124,7 +124,7 @@ describe('CreateAppModal', () => {
const nameInput = screen.getByPlaceholderText('app.newApp.appNamePlaceholder')
fireEvent.change(nameInput, { target: { value: 'My App' } })
fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Create' }))
fireEvent.click(screen.getByRole('button', { name: /app\.newApp\.Create/ }))
await waitFor(() => expect(mockCreateApp).toHaveBeenCalledWith({
name: 'My App',
@@ -152,7 +152,7 @@ describe('CreateAppModal', () => {
const nameInput = screen.getByPlaceholderText('app.newApp.appNamePlaceholder')
fireEvent.change(nameInput, { target: { value: 'My App' } })
fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Create' }))
fireEvent.click(screen.getByRole('button', { name: /app\.newApp\.Create/ }))
await waitFor(() => expect(mockCreateApp).toHaveBeenCalled())
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'boom' })
+1 -25
View File
@@ -1,5 +1,5 @@
import type { VariantProps } from 'class-variance-authority'
import type { ChangeEventHandler, CSSProperties, FocusEventHandler, KeyboardEventHandler } from 'react'
import type { ChangeEventHandler, CSSProperties, FocusEventHandler } from 'react'
import { RiCloseCircleFill, RiErrorWarningLine, RiSearchLine } from '@remixicon/react'
import { cva } from 'class-variance-authority'
import { noop } from 'es-toolkit/function'
@@ -33,7 +33,6 @@ export type InputProps = {
wrapperClassName?: string
styleCss?: CSSProperties
unit?: string
onPressEnter?: KeyboardEventHandler<HTMLInputElement>
} & Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> & VariantProps<typeof inputVariants>
const removeLeadingZeros = (value: string) => value.replace(/^(-?)0+(?=\d)/, '$1')
@@ -53,30 +52,10 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({
placeholder,
onChange = noop,
onBlur = noop,
onKeyDown,
onCompositionStart,
onCompositionEnd,
onPressEnter,
unit,
...props
}, ref) => {
const { t } = useTranslation()
const isComposingRef = React.useRef(false)
const handleCompositionStart: React.CompositionEventHandler<HTMLInputElement> = (e) => {
isComposingRef.current = true
onCompositionStart?.(e)
}
const handleCompositionEnd: React.CompositionEventHandler<HTMLInputElement> = (e) => {
setTimeout(() => {
isComposingRef.current = false
}, 50)
onCompositionEnd?.(e)
}
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (e) => {
if (onPressEnter && e.key === 'Enter' && !e.nativeEvent.isComposing && !isComposingRef.current)
onPressEnter(e)
onKeyDown?.(e)
}
const handleNumberChange: ChangeEventHandler<HTMLInputElement> = (e) => {
if (value === 0) {
// remove leading zeros
@@ -129,9 +108,6 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({
onBlur={props.type === 'number' ? handleNumberBlur : onBlur}
disabled={disabled}
{...props}
onKeyDown={handleKeyDown}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
/>
{!!(showClearIcon && value && !disabled && !destructive) && (
<div
+5 -6
View File
@@ -71,12 +71,12 @@ const CustomizedPagination: FC<Props> = ({
setShowInput(false)
}
const handleInputPressEnter = (e: React.KeyboardEvent<HTMLInputElement>) => {
e.preventDefault()
handleInputConfirm()
}
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Escape') {
if (e.key === 'Enter') {
e.preventDefault()
handleInputConfirm()
}
else if (e.key === 'Escape') {
e.preventDefault()
setInputValue(current + 1)
setShowInput(false)
@@ -132,7 +132,6 @@ const CustomizedPagination: FC<Props> = ({
autoFocus
value={inputValue}
onChange={handleInputChange}
onPressEnter={handleInputPressEnter}
onKeyDown={handleInputKeyDown}
onBlur={handleInputBlur}
/>
@@ -138,7 +138,7 @@ describe('CreateAppModal', () => {
setup({ appName: 'My App', isEditModal: false })
expect(screen.getByText('explore.appCustomize.title:{"name":"My App"}')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.create' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /common\.operation\.create/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument()
})
@@ -146,7 +146,7 @@ describe('CreateAppModal', () => {
setup({ isEditModal: true, appMode: AppModeEnum.CHAT, max_active_requests: 5 })
expect(screen.getByText('app.editAppTitle')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /common\.operation\.save/ })).toBeInTheDocument()
expect(screen.getByRole('switch')).toBeInTheDocument()
expect((screen.getByRole('spinbutton') as HTMLInputElement).value).toBe('5')
})
@@ -166,7 +166,7 @@ describe('CreateAppModal', () => {
it('should not render modal content when hidden', () => {
setup({ show: false })
expect(screen.queryByRole('button', { name: 'common.operation.create' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /common\.operation\.create/ })).not.toBeInTheDocument()
})
})
@@ -175,13 +175,13 @@ describe('CreateAppModal', () => {
it('should disable confirm action when confirmDisabled is true', () => {
setup({ confirmDisabled: true })
expect(screen.getByRole('button', { name: 'common.operation.create' })).toBeDisabled()
expect(screen.getByRole('button', { name: /common\.operation\.create/ })).toBeDisabled()
})
it('should disable confirm action when appName is empty', () => {
setup({ appName: ' ' })
expect(screen.getByRole('button', { name: 'common.operation.create' })).toBeDisabled()
expect(screen.getByRole('button', { name: /common\.operation\.create/ })).toBeDisabled()
})
})
@@ -245,7 +245,7 @@ describe('CreateAppModal', () => {
setup({ isEditModal: false })
expect(screen.getByText('billing.apps.fullTip2')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.create' })).toBeDisabled()
expect(screen.getByRole('button', { name: /common\.operation\.create/ })).toBeDisabled()
})
it('should allow saving when apps quota is reached in edit mode', () => {
@@ -257,7 +257,7 @@ describe('CreateAppModal', () => {
setup({ isEditModal: true })
expect(screen.queryByText('billing.apps.fullTip2')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeEnabled()
expect(screen.getByRole('button', { name: /common\.operation\.save/ })).toBeEnabled()
})
})
@@ -384,7 +384,7 @@ describe('CreateAppModal', () => {
fireEvent.click(screen.getByRole('button', { name: 'app.iconPicker.ok' }))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ }))
act(() => {
vi.advanceTimersByTime(300)
})
@@ -433,7 +433,7 @@ describe('CreateAppModal', () => {
expect(screen.queryByRole('button', { name: 'app.iconPicker.cancel' })).not.toBeInTheDocument()
// Submit and verify the payload uses the original icon (cancel reverts to props)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ }))
act(() => {
vi.advanceTimersByTime(300)
})
@@ -471,7 +471,7 @@ describe('CreateAppModal', () => {
appIconBackground: '#000000',
})
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ }))
act(() => {
vi.advanceTimersByTime(300)
})
@@ -495,7 +495,7 @@ describe('CreateAppModal', () => {
const { onConfirm } = setup({ appDescription: 'Old description' })
fireEvent.change(screen.getByPlaceholderText('app.newApp.appDescriptionPlaceholder'), { target: { value: 'Updated description' } })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ }))
act(() => {
vi.advanceTimersByTime(300)
})
@@ -512,7 +512,7 @@ describe('CreateAppModal', () => {
appIconBackground: null,
})
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ }))
act(() => {
vi.advanceTimersByTime(300)
})
@@ -536,7 +536,7 @@ describe('CreateAppModal', () => {
fireEvent.click(screen.getByRole('switch'))
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '12' } })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/ }))
act(() => {
vi.advanceTimersByTime(300)
})
@@ -551,7 +551,7 @@ describe('CreateAppModal', () => {
it('should omit max_active_requests when input is empty', () => {
const { onConfirm } = setup({ isEditModal: true, max_active_requests: null })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/ }))
act(() => {
vi.advanceTimersByTime(300)
})
@@ -564,7 +564,7 @@ describe('CreateAppModal', () => {
const { onConfirm } = setup({ isEditModal: true, max_active_requests: null })
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: 'abc' } })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/ }))
act(() => {
vi.advanceTimersByTime(300)
})
@@ -576,7 +576,7 @@ describe('CreateAppModal', () => {
it('should show toast error and not submit when name becomes empty before debounced submit runs', () => {
const { onConfirm, onHide } = setup({ appName: 'My App' })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ }))
fireEvent.change(screen.getByPlaceholderText('app.newApp.appNamePlaceholder'), { target: { value: ' ' } })
act(() => {
@@ -2,7 +2,6 @@
import type { FC } from 'react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { useDocLink } from '@/context/i18n'
import useTheme from '@/hooks/use-theme'
import { Theme } from '@/types/app'
import { cn } from '@/utils/classnames'
@@ -13,13 +12,12 @@ const i18nPrefix = 'sidebar.noApps'
const NoApps: FC = () => {
const { t } = useTranslation()
const { theme } = useTheme()
const docLink = useDocLink()
return (
<div className="rounded-xl bg-background-default-subtle p-4">
<div className={cn('h-[35px] w-[86px] bg-contain bg-center bg-no-repeat', theme === Theme.dark ? s.dark : s.light)}></div>
<div className="system-sm-semibold mt-2 text-text-secondary">{t(`${i18nPrefix}.title`, { ns: 'explore' })}</div>
<div className="system-xs-regular my-1 text-text-tertiary">{t(`${i18nPrefix}.description`, { ns: 'explore' })}</div>
<a className="system-xs-regular text-text-accent" target="_blank" rel="noopener noreferrer" href={docLink('/use-dify/publish/README')}>{t(`${i18nPrefix}.learnMore`, { ns: 'explore' })}</a>
<a className="system-xs-regular text-text-accent" target="_blank" rel="noopener noreferrer" href="https://docs.dify.ai/en/guides/application-publishing/launch-your-webapp-quickly/README">{t(`${i18nPrefix}.learnMore`, { ns: 'explore' })}</a>
</div>
)
}
@@ -86,6 +86,7 @@ vi.mock('./actions/commands/registry', () => ({
vi.mock('@/app/components/workflow/utils/common', () => ({
getKeyboardKeyCodeBySystem: () => 'ctrl',
getKeyboardKeyNameBySystem: (key: string) => key,
isEventTargetInputArea: () => false,
isMac: () => false,
}))
+16 -11
View File
@@ -319,17 +319,22 @@ const GotoAnything: FC<Props> = ({
if (!e.target.value.startsWith('@') && !e.target.value.startsWith('/'))
clearSelection()
}}
onPressEnter={(e) => {
const query = searchQuery.trim()
if (query.startsWith('/')) {
const commandName = query.substring(1).split(' ')[0]
const handler = slashCommandRegistry.findCommand(commandName)
const isAvailable = handler?.isAvailable?.() ?? true
if (handler?.mode === 'direct' && handler.execute && isAvailable) {
e.preventDefault()
handler.execute()
setShow(false)
setSearchQuery('')
onKeyDown={(e) => {
if (e.key === 'Enter') {
const query = searchQuery.trim()
// Check if it's a complete slash command
if (query.startsWith('/')) {
const commandName = query.substring(1).split(' ')[0]
const handler = slashCommandRegistry.findCommand(commandName)
// If it's a direct mode command, execute immediately
const isAvailable = handler?.isAvailable?.() ?? true
if (handler?.mode === 'direct' && handler.execute && isAvailable) {
e.preventDefault()
handler.execute()
setShow(false)
setSearchQuery('')
}
}
}
}}
@@ -134,19 +134,20 @@ vi.mock('@/app/components/workflow/constants', () => ({
WORKFLOW_DATA_UPDATE: 'WORKFLOW_DATA_UPDATE',
}))
// Mock FileReader
// Mock FileReader - synchronous to avoid timing issues in tests
class MockFileReader {
result: string | null = null
onload: ((e: { target: { result: string | null } }) => void) | null = null
readAsText(_file: File) {
// Simulate async file reading
setTimeout(() => {
this.result = 'test file content'
if (this.onload) {
// Call onload synchronously to avoid race conditions in tests
this.result = 'test file content'
// Use queueMicrotask instead of setTimeout to ensure it runs before other timers
// but still allows React state updates to complete
queueMicrotask(() => {
if (this.onload)
this.onload({ target: { result: this.result } })
}
}, 0)
})
}
}
@@ -33,6 +33,11 @@ vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
}))
// Mock workflow utils for ShortcutsName component
vi.mock('@/app/components/workflow/utils', () => ({
getKeyboardKeyNameBySystem: (key: string) => key,
}))
// Mock StartNodeSelectionPanel (using real component would be better for integration,
// but for this test we'll mock to control behavior)
vi.mock('./start-node-selection-panel', () => ({
@@ -551,8 +556,10 @@ describe('WorkflowOnboardingModal', () => {
// Assert
const escKey = screen.getByText('workflow.onboarding.escTip.key')
expect(escKey.closest('kbd')).toBeInTheDocument()
expect(escKey.closest('kbd')).toHaveClass('system-kbd')
// ShortcutsName renders a div with system-kbd class, not a kbd element
const kbdContainer = escKey.closest('.system-kbd')
expect(kbdContainer).toBeInTheDocument()
expect(kbdContainer).toHaveClass('system-kbd')
})
it('should have descriptive text for ESC functionality', () => {
@@ -221,7 +221,7 @@ const buildOutputVars = (schema: Record<string, any>, schemaTypeDefinitions?: Sc
const metaData = genNodeMetaData({
sort: 1,
type: BlockEnum.TriggerPlugin,
helpLinkUri: 'trigger/plugin-trigger',
helpLinkUri: 'plugin-trigger',
isStart: true,
})
@@ -110,7 +110,7 @@ const validateVisualConfig = (payload: ScheduleTriggerNodeType, t: any): string
const metaData = genNodeMetaData({
sort: 2,
type: BlockEnum.TriggerSchedule,
helpLinkUri: 'trigger/schedule-trigger',
helpLinkUri: 'schedule-trigger',
isStart: true,
})
@@ -8,7 +8,7 @@ import { createWebhookRawVariable } from './utils/raw-variable'
const metaData = genNodeMetaData({
sort: 3,
type: BlockEnum.TriggerWebhook,
helpLinkUri: 'trigger/webhook-trigger',
helpLinkUri: 'webhook-trigger',
isStart: true,
})
@@ -1,5 +1,4 @@
import type { BlockEnum } from '@/app/components/workflow/types'
import type { UseDifyNodesPath } from '@/types/doc-paths'
import { BlockClassificationEnum } from '@/app/components/workflow/block-selector/types'
export type GenNodeMetaDataParams = {
@@ -8,7 +7,7 @@ export type GenNodeMetaDataParams = {
type: BlockEnum
title?: string
author?: string
helpLinkUri?: UseDifyNodesPath
helpLinkUri?: string
isRequired?: boolean
isUndeletable?: boolean
isStart?: boolean
@@ -139,7 +139,10 @@ export default function MailAndPasswordAuth({ isInvite, isEmailSetup, allowRegis
id="password"
value={password}
onChange={e => setPassword(e.target.value)}
onPressEnter={() => handleEmailPasswordLogin()}
onKeyDown={(e) => {
if (e.key === 'Enter')
handleEmailPasswordLogin()
}}
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
placeholder={t('passwordPlaceholder', { ns: 'login' }) || ''}
+6 -4
View File
@@ -103,10 +103,12 @@ export default function InviteSettingsPage() {
value={name}
onChange={e => setName(e.target.value)}
placeholder={t('namePlaceholder', { ns: 'login' }) || ''}
onPressEnter={(e) => {
e.preventDefault()
e.stopPropagation()
handleActivate()
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
e.stopPropagation()
handleActivate()
}
}}
/>
</div>
+14 -18
View File
@@ -1,7 +1,6 @@
import type { Locale } from '@/i18n-config/language'
import type { DocPathWithoutLang } from '@/types/doc-paths'
import { useTranslation } from '#i18n'
import { useCallback } from 'react'
import { getDocLanguage, getLanguage, getPricingPageLanguage } from '@/i18n-config/language'
import { apiReferencePathTranslations } from '@/types/doc-paths'
@@ -28,24 +27,21 @@ export const useDocLink = (baseUrl?: string): ((path?: DocPathWithoutLang, pathM
let baseDocUrl = baseUrl || defaultDocBaseUrl
baseDocUrl = (baseDocUrl.endsWith('/')) ? baseDocUrl.slice(0, -1) : baseDocUrl
const locale = useLocale()
return useCallback(
(path?: DocPathWithoutLang, pathMap?: DocPathMap): string => {
const docLanguage = getDocLanguage(locale)
const pathUrl = path || ''
let targetPath = (pathMap) ? pathMap[locale] || pathUrl : pathUrl
let languagePrefix = `/${docLanguage}`
const docLanguage = getDocLanguage(locale)
return (path?: DocPathWithoutLang, pathMap?: DocPathMap): string => {
const pathUrl = path || ''
let targetPath = (pathMap) ? pathMap[locale] || pathUrl : pathUrl
let languagePrefix = `/${docLanguage}`
// Translate API reference paths for non-English locales
if (targetPath.startsWith('/api-reference/') && docLanguage !== 'en') {
const translatedPath = apiReferencePathTranslations[targetPath]?.[docLanguage as 'zh' | 'ja']
if (translatedPath) {
targetPath = translatedPath
languagePrefix = ''
}
// Translate API reference paths for non-English locales
if (targetPath.startsWith('/api-reference/') && docLanguage !== 'en') {
const translatedPath = apiReferencePathTranslations[targetPath]?.[docLanguage as 'zh' | 'ja']
if (translatedPath) {
targetPath = translatedPath
languagePrefix = ''
}
}
return `${baseDocUrl}${languagePrefix}${targetPath}`
},
[baseDocUrl, locale],
)
return `${baseDocUrl}${languagePrefix}${targetPath}`
}
}
+323
View File
@@ -182,6 +182,11 @@
"count": 1
}
},
"app/components/app/annotation/add-annotation-modal/edit-item/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/app/annotation/batch-add-annotation-modal/csv-downloader.spec.tsx": {
"ts/no-explicit-any": {
"count": 2
@@ -191,6 +196,9 @@
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
},
"react-refresh/only-export-components": {
"count": 1
},
"ts/no-explicit-any": {
"count": 2
}
@@ -198,6 +206,9 @@
"app/components/app/annotation/edit-annotation-modal/edit-item/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
},
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/app/annotation/edit-annotation-modal/index.spec.tsx": {
@@ -254,6 +265,11 @@
"count": 6
}
},
"app/components/app/configuration/base/var-highlight/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/app/configuration/config-prompt/advanced-prompt-input.tsx": {
"ts/no-explicit-any": {
"count": 2
@@ -424,6 +440,11 @@
"count": 6
}
},
"app/components/app/configuration/debug/debug-with-multiple-model/context.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/app/configuration/debug/debug-with-multiple-model/index.spec.tsx": {
"ts/no-explicit-any": {
"count": 5
@@ -506,6 +527,11 @@
"count": 1
}
},
"app/components/app/create-app-dialog/app-list/sidebar.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/app/create-app-modal/index.spec.tsx": {
"ts/no-explicit-any": {
"count": 7
@@ -522,6 +548,14 @@
"app/components/app/create-from-dsl-modal/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 2
},
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/app/log/filter.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/app/log/index.tsx": {
@@ -590,6 +624,9 @@
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 3
},
"react-refresh/only-export-components": {
"count": 1
},
"ts/no-explicit-any": {
"count": 4
}
@@ -599,6 +636,11 @@
"count": 2
}
},
"app/components/app/workflow-log/filter.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/app/workflow-log/list.spec.tsx": {
"ts/no-explicit-any": {
"count": 1
@@ -650,6 +692,11 @@
"count": 1
}
},
"app/components/base/action-button/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/base/agent-log-modal/detail.tsx": {
"ts/no-explicit-any": {
"count": 1
@@ -678,6 +725,11 @@
"count": 2
}
},
"app/components/base/amplitude/AmplitudeProvider.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/base/amplitude/utils.ts": {
"ts/no-explicit-any": {
"count": 2
@@ -726,6 +778,9 @@
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
},
"react-refresh/only-export-components": {
"count": 1
},
"react/no-nested-component-definitions": {
"count": 1
}
@@ -735,11 +790,21 @@
"count": 1
}
},
"app/components/base/button/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/base/button/sync-button.stories.tsx": {
"no-console": {
"count": 1
}
},
"app/components/base/carousel/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/base/chat/chat-with-history/chat-wrapper.tsx": {
"ts/no-explicit-any": {
"count": 7
@@ -827,6 +892,11 @@
"count": 1
}
},
"app/components/base/chat/chat/context.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/base/chat/chat/hooks.ts": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 2
@@ -930,10 +1000,18 @@
}
},
"app/components/base/error-boundary/index.tsx": {
"react-refresh/only-export-components": {
"count": 3
},
"ts/no-explicit-any": {
"count": 2
}
},
"app/components/base/features/context.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/base/features/new-feature-panel/annotation-reply/index.tsx": {
"ts/no-explicit-any": {
"count": 3
@@ -999,6 +1077,11 @@
"count": 3
}
},
"app/components/base/file-uploader/store.tsx": {
"react-refresh/only-export-components": {
"count": 4
}
},
"app/components/base/file-uploader/utils.spec.ts": {
"test/no-identical-title": {
"count": 1
@@ -1095,6 +1178,11 @@
"count": 2
}
},
"app/components/base/ga/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/base/icons/utils.ts": {
"ts/no-explicit-any": {
"count": 3
@@ -1146,6 +1234,16 @@
"count": 1
}
},
"app/components/base/input/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/base/logo/dify-logo.tsx": {
"react-refresh/only-export-components": {
"count": 2
}
},
"app/components/base/markdown-blocks/audio-block.tsx": {
"ts/no-explicit-any": {
"count": 5
@@ -1296,6 +1394,11 @@
"count": 1
}
},
"app/components/base/node-status/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/base/notion-connector/index.stories.tsx": {
"no-console": {
"count": 1
@@ -1327,6 +1430,9 @@
}
},
"app/components/base/portal-to-follow-elem/index.tsx": {
"react-refresh/only-export-components": {
"count": 2
},
"ts/no-explicit-any": {
"count": 1
}
@@ -1508,6 +1614,16 @@
"count": 1
}
},
"app/components/base/textarea/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/base/toast/index.tsx": {
"react-refresh/only-export-components": {
"count": 2
}
},
"app/components/base/video-gallery/VideoPlayer.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
@@ -1551,6 +1667,16 @@
"count": 2
}
},
"app/components/billing/pricing/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/billing/pricing/plan-switcher/plan-range-switcher.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/billing/pricing/plans/cloud-plan-item/index.spec.tsx": {
"test/prefer-hooks-in-order": {
"count": 1
@@ -1601,6 +1727,11 @@
"count": 3
}
},
"app/components/datasets/common/image-uploader/store.tsx": {
"react-refresh/only-export-components": {
"count": 4
}
},
"app/components/datasets/common/image-uploader/utils.ts": {
"ts/no-explicit-any": {
"count": 2
@@ -1611,6 +1742,16 @@
"count": 1
}
},
"app/components/datasets/common/retrieval-method-info/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/datasets/create/file-preview/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
@@ -1641,6 +1782,11 @@
"count": 3
}
},
"app/components/datasets/create/step-two/preview-item/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/datasets/create/stop-embedding-modal/index.spec.tsx": {
"test/prefer-hooks-in-order": {
"count": 1
@@ -1698,6 +1844,9 @@
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 2
},
"react-refresh/only-export-components": {
"count": 1
},
"ts/no-explicit-any": {
"count": 2
}
@@ -1762,6 +1911,11 @@
"count": 2
}
},
"app/components/datasets/documents/create-from-pipeline/data-source/store/provider.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/datasets/documents/create-from-pipeline/data-source/store/slices/online-drive.ts": {
"ts/no-explicit-any": {
"count": 4
@@ -1807,6 +1961,11 @@
"count": 1
}
},
"app/components/datasets/documents/detail/completed/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/datasets/documents/detail/completed/new-child-segment.tsx": {
"ts/no-explicit-any": {
"count": 1
@@ -1830,6 +1989,11 @@
"count": 1
}
},
"app/components/datasets/documents/detail/segment-add/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/datasets/documents/detail/settings/pipeline-settings/index.tsx": {
"ts/no-explicit-any": {
"count": 6
@@ -1970,6 +2134,11 @@
"count": 1
}
},
"app/components/explore/try-app/tab.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/goto-anything/actions/commands/command-bus.ts": {
"ts/no-explicit-any": {
"count": 2
@@ -1981,6 +2150,9 @@
}
},
"app/components/goto-anything/actions/commands/slash.tsx": {
"react-refresh/only-export-components": {
"count": 3
},
"ts/no-explicit-any": {
"count": 1
}
@@ -1998,6 +2170,9 @@
"app/components/goto-anything/context.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 4
},
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/goto-anything/index.spec.tsx": {
@@ -2194,6 +2369,11 @@
"count": 4
}
},
"app/components/plugins/install-plugin/install-bundle/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/plugins/install-plugin/install-bundle/item/github-item.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
@@ -2270,6 +2450,11 @@
"count": 2
}
},
"app/components/plugins/plugin-auth/index.tsx": {
"react-refresh/only-export-components": {
"count": 3
}
},
"app/components/plugins/plugin-auth/plugin-auth-in-agent.tsx": {
"ts/no-explicit-any": {
"count": 1
@@ -2354,6 +2539,9 @@
}
},
"app/components/plugins/plugin-detail-panel/subscription-list/create/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
@@ -2394,6 +2582,9 @@
}
},
"app/components/plugins/plugin-page/context.tsx": {
"react-refresh/only-export-components": {
"count": 2
},
"ts/no-explicit-any": {
"count": 1
}
@@ -2758,6 +2949,11 @@
"count": 1
}
},
"app/components/workflow/block-selector/constants.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/workflow/block-selector/featured-tools.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 2
@@ -2779,6 +2975,11 @@
"count": 1
}
},
"app/components/workflow/block-selector/index-bar.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/workflow/block-selector/market-place-plugin/action.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
@@ -2817,11 +3018,26 @@
"count": 1
}
},
"app/components/workflow/block-selector/view-type-select.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/workflow/candidate-node-main.tsx": {
"ts/no-explicit-any": {
"count": 2
}
},
"app/components/workflow/context.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/workflow/datasets-detail-store/provider.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/workflow/header/run-mode.tsx": {
"no-console": {
"count": 1
@@ -2830,11 +3046,21 @@
"count": 1
}
},
"app/components/workflow/header/test-run-menu.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/workflow/header/view-workflow-history.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"app/components/workflow/hooks-store/provider.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/workflow/hooks-store/store.ts": {
"ts/no-explicit-any": {
"count": 6
@@ -2955,10 +3181,18 @@
}
},
"app/components/workflow/nodes/_base/components/editor/code-editor/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
},
"ts/no-explicit-any": {
"count": 6
}
},
"app/components/workflow/nodes/_base/components/entry-node-container.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/workflow/nodes/_base/components/error-handle/default-value.tsx": {
"ts/no-explicit-any": {
"count": 1
@@ -2984,6 +3218,16 @@
"count": 1
}
},
"app/components/workflow/nodes/_base/components/layout/index.tsx": {
"react-refresh/only-export-components": {
"count": 7
}
},
"app/components/workflow/nodes/_base/components/mcp-tool-availability.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/workflow/nodes/_base/components/memory-config.tsx": {
"unicorn/prefer-number-properties": {
"count": 1
@@ -3067,6 +3311,9 @@
}
},
"app/components/workflow/nodes/_base/components/workflow-panel/tab.tsx": {
"react-refresh/only-export-components": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
@@ -3120,6 +3367,9 @@
}
},
"app/components/workflow/nodes/agent/panel.tsx": {
"react-refresh/only-export-components": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
@@ -3295,6 +3545,9 @@
}
},
"app/components/workflow/nodes/human-input/components/variable-in-markdown.tsx": {
"react-refresh/only-export-components": {
"count": 2
},
"ts/no-explicit-any": {
"count": 8
}
@@ -3424,6 +3677,11 @@
"count": 2
}
},
"app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/context.tsx": {
"react-refresh/only-export-components": {
"count": 3
}
},
"app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/auto-width-input.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
@@ -3721,6 +3979,11 @@
"count": 1
}
},
"app/components/workflow/note-node/note-editor/toolbar/color-picker.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/workflow/note-node/note-editor/utils.ts": {
"regexp/no-useless-quantifier": {
"count": 1
@@ -3757,6 +4020,9 @@
}
},
"app/components/workflow/panel/chat-variable-panel/components/object-value-item.tsx": {
"react-refresh/only-export-components": {
"count": 1
},
"ts/no-explicit-any": {
"count": 5
},
@@ -4043,6 +4309,11 @@
"count": 8
}
},
"app/components/workflow/workflow-history-store.tsx": {
"react-refresh/only-export-components": {
"count": 2
}
},
"app/components/workflow/workflow-preview/components/nodes/constants.ts": {
"ts/no-explicit-any": {
"count": 1
@@ -4104,30 +4375,79 @@
}
},
"context/app-context.tsx": {
"react-refresh/only-export-components": {
"count": 2
},
"ts/no-explicit-any": {
"count": 1
}
},
"context/datasets-context.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"context/event-emitter.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"context/external-api-panel-context.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"context/external-knowledge-api-context.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"context/global-public-context.tsx": {
"react-refresh/only-export-components": {
"count": 4
}
},
"context/hooks/use-trigger-events-limit-modal.ts": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 3
}
},
"context/mitt-context.tsx": {
"react-refresh/only-export-components": {
"count": 3
}
},
"context/modal-context.test.tsx": {
"ts/no-explicit-any": {
"count": 3
}
},
"context/modal-context.tsx": {
"react-refresh/only-export-components": {
"count": 2
},
"ts/no-explicit-any": {
"count": 5
}
},
"context/provider-context.tsx": {
"react-refresh/only-export-components": {
"count": 3
},
"ts/no-explicit-any": {
"count": 1
}
},
"context/web-app-context.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"context/workspace-context.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"hooks/use-async-window-open.spec.ts": {
"ts/no-explicit-any": {
"count": 6
@@ -4164,6 +4484,9 @@
"hooks/use-pay.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 4
},
"react-refresh/only-export-components": {
"count": 3
}
},
"i18n-config/README.md": {
-9
View File
@@ -282,15 +282,6 @@ function generateTypeDefinitions(
}
lines.push('')
// Add UseDifyNodesPath helper type after UseDifyPath
if (section === 'use-dify') {
lines.push('// UseDify node paths (without prefix)')
// eslint-disable-next-line no-template-curly-in-string
lines.push('type ExtractNodesPath<T> = T extends `/use-dify/nodes/${infer Path}` ? Path : never')
lines.push('export type UseDifyNodesPath = ExtractNodesPath<UseDifyPath>')
lines.push('')
}
}
// Generate API reference type (English paths only)
+1 -5
View File
@@ -2,7 +2,7 @@
// DON NOT EDIT IT MANUALLY
//
// Generated from: https://raw.githubusercontent.com/langgenius/dify-docs/refs/heads/main/docs.json
// Generated at: 2026-01-30T09:14:29.304Z
// Generated at: 2026-01-21T07:24:02.413Z
// Language prefixes
export type DocLanguage = 'en' | 'zh' | 'ja'
@@ -104,10 +104,6 @@ export type UseDifyPath =
| '/use-dify/workspace/subscription-management'
| '/use-dify/workspace/team-members-management'
// UseDify node paths (without prefix)
type ExtractNodesPath<T> = T extends `/use-dify/nodes/${infer Path}` ? Path : never
export type UseDifyNodesPath = ExtractNodesPath<UseDifyPath>
// SelfHost paths
export type SelfHostPath =
| '/self-host/advanced-deployments/local-source-code'