Compare commits

..

3 Commits

Author SHA1 Message Date
9d1d76055a test 2026-01-30 16:29:46 +08:00
a34d3ffc28 update 2026-01-30 16:26:20 +08:00
1d2ed8da2a test: fix failed test 2026-01-30 16:16:50 +08:00
38 changed files with 1425 additions and 1669 deletions

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

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

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

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

View File

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

View File

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

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", {})

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

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,

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:

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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] = {}

View File

@ -43,3 +43,4 @@ exclude = [
"controllers/web/workflow_events.py",
"tasks/app_generate/workflow_execute_task.py",
]

View File

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

View File

@ -1,8 +1,9 @@
import type { ListChildComponentProps } from 'react-window'
import type { DataSourceNotionPage, DataSourceNotionPageMap } from '@/models/common'
import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { memo, useCallback, useMemo, useRef, useState } from 'react'
import { memo, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { areEqual, FixedSizeList as List } from 'react-window'
import { cn } from '@/utils/classnames'
import Checkbox from '../../checkbox'
import NotionIcon from '../../notion-icon'
@ -31,22 +32,6 @@ type NotionPageItem = {
depth: number
} & DataSourceNotionPage
type ItemProps = {
virtualStart: number
virtualSize: number
current: NotionPageItem
onToggle: (pageId: string) => void
checkedIds: Set<string>
disabledCheckedIds: Set<string>
onCheck: (pageId: string) => void
canPreview?: boolean
onPreview: (pageId: string) => void
listMapWithChildrenAndDescendants: NotionPageTreeMap
searchValue: string
previewPageId: string
pagesMap: DataSourceNotionPageMap
}
const recursivePushInParentDescendants = (
pagesMap: DataSourceNotionPageMap,
listTreeMap: NotionPageTreeMap,
@ -84,22 +69,34 @@ const recursivePushInParentDescendants = (
}
}
const ItemComponent = ({
virtualStart,
virtualSize,
current,
onToggle,
checkedIds,
disabledCheckedIds,
onCheck,
canPreview,
onPreview,
listMapWithChildrenAndDescendants,
searchValue,
previewPageId,
pagesMap,
}: ItemProps) => {
const ItemComponent = ({ index, style, data }: ListChildComponentProps<{
dataList: NotionPageItem[]
handleToggle: (index: number) => void
checkedIds: Set<string>
disabledCheckedIds: Set<string>
handleCheck: (index: number) => void
canPreview?: boolean
handlePreview: (index: number) => void
listMapWithChildrenAndDescendants: NotionPageTreeMap
searchValue: string
previewPageId: string
pagesMap: DataSourceNotionPageMap
}>) => {
const { t } = useTranslation()
const {
dataList,
handleToggle,
checkedIds,
disabledCheckedIds,
handleCheck,
canPreview,
handlePreview,
listMapWithChildrenAndDescendants,
searchValue,
previewPageId,
pagesMap,
} = data
const current = dataList[index]
const currentWithChildrenAndDescendants = listMapWithChildrenAndDescendants[current.page_id]
const hasChild = currentWithChildrenAndDescendants.descendants.size > 0
const ancestors = currentWithChildrenAndDescendants.ancestors
@ -112,7 +109,7 @@ const ItemComponent = ({
<div
className="mr-1 flex h-5 w-5 shrink-0 items-center justify-center rounded-md hover:bg-components-button-ghost-bg-hover"
style={{ marginLeft: current.depth * 8 }}
onClick={() => onToggle(current.page_id)}
onClick={() => handleToggle(index)}
>
{
current.expand
@ -135,21 +132,15 @@ const ItemComponent = ({
return (
<div
className={cn('group flex cursor-pointer items-center rounded-md pl-2 pr-[2px] hover:bg-state-base-hover', previewPageId === current.page_id && 'bg-state-base-hover')}
style={{
position: 'absolute',
top: 0,
left: 8,
right: 8,
width: 'calc(100% - 16px)',
height: virtualSize,
transform: `translateY(${virtualStart + 8}px)`,
}}
style={{ ...style, top: style.top as number + 8, left: 8, right: 8, width: 'calc(100% - 16px)' }}
>
<Checkbox
className="mr-2 shrink-0"
checked={checkedIds.has(current.page_id)}
disabled={disabled}
onCheck={() => onCheck(current.page_id)}
onCheck={() => {
handleCheck(index)
}}
/>
{!searchValue && renderArrow()}
<NotionIcon
@ -169,7 +160,7 @@ const ItemComponent = ({
className="ml-1 hidden h-6 shrink-0 cursor-pointer items-center rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-2 text-xs
font-medium leading-4 text-components-button-secondary-text shadow-xs shadow-shadow-shadow-3 backdrop-blur-[10px]
hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover group-hover:flex"
onClick={() => onPreview(current.page_id)}
onClick={() => handlePreview(index)}
>
{t('dataSource.notion.selector.preview', { ns: 'common' })}
</div>
@ -188,7 +179,7 @@ const ItemComponent = ({
</div>
)
}
const Item = memo(ItemComponent)
const Item = memo(ItemComponent, areEqual)
const PageSelector = ({
value,
@ -202,10 +193,31 @@ const PageSelector = ({
onPreview,
}: PageSelectorProps) => {
const { t } = useTranslation()
const parentRef = useRef<HTMLDivElement>(null)
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set())
const [dataList, setDataList] = useState<NotionPageItem[]>([])
const [localPreviewPageId, setLocalPreviewPageId] = useState('')
useEffect(() => {
setDataList(list.filter(item => item.parent_id === 'root' || !pagesMap[item.parent_id]).map((item) => {
return {
...item,
expand: false,
depth: 0,
}
}))
}, [list])
const searchDataList = list.filter((item) => {
return item.page_name.includes(searchValue)
}).map((item) => {
return {
...item,
expand: false,
depth: 0,
}
})
const currentDataList = searchValue ? searchDataList : dataList
const currentPreviewPageId = previewPageId === undefined ? localPreviewPageId : previewPageId
const listMapWithChildrenAndDescendants = useMemo(() => {
return list.reduce((prev: NotionPageTreeMap, next: DataSourceNotionPage) => {
const pageId = next.page_id
@ -217,89 +229,47 @@ const PageSelector = ({
}, {})
}, [list, pagesMap])
const childrenByParent = useMemo(() => {
const map = new Map<string | null, DataSourceNotionPage[]>()
for (const item of list) {
const isRoot = item.parent_id === 'root' || !pagesMap[item.parent_id]
const parentKey = isRoot ? null : item.parent_id
const children = map.get(parentKey) || []
children.push(item)
map.set(parentKey, children)
}
return map
}, [list, pagesMap])
const dataList = useMemo(() => {
const result: NotionPageItem[] = []
const buildVisibleList = (parentId: string | null, depth: number) => {
const items = childrenByParent.get(parentId) || []
for (const item of items) {
const isExpanded = expandedIds.has(item.page_id)
result.push({
...item,
expand: isExpanded,
depth,
})
if (isExpanded) {
buildVisibleList(item.page_id, depth + 1)
}
}
}
buildVisibleList(null, 0)
return result
}, [childrenByParent, expandedIds])
const searchDataList = useMemo(() => list.filter((item) => {
return item.page_name.includes(searchValue)
}).map((item) => {
return {
...item,
expand: false,
depth: 0,
}
}), [list, searchValue])
const currentDataList = searchValue ? searchDataList : dataList
const currentPreviewPageId = previewPageId === undefined ? localPreviewPageId : previewPageId
const virtualizer = useVirtualizer({
count: currentDataList.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 28,
overscan: 5,
getItemKey: index => currentDataList[index].page_id,
})
const handleToggle = useCallback((pageId: string) => {
setExpandedIds((prev) => {
const next = new Set(prev)
if (prev.has(pageId)) {
next.delete(pageId)
const descendants = listMapWithChildrenAndDescendants[pageId]?.descendants
if (descendants) {
for (const descendantId of descendants)
next.delete(descendantId)
}
}
else {
next.add(pageId)
}
return next
})
}, [listMapWithChildrenAndDescendants])
const handleCheck = useCallback((pageId: string) => {
const handleToggle = (index: number) => {
const current = dataList[index]
const pageId = current.page_id
const currentWithChildrenAndDescendants = listMapWithChildrenAndDescendants[pageId]
const descendantsIds = Array.from(currentWithChildrenAndDescendants.descendants)
const childrenIds = Array.from(currentWithChildrenAndDescendants.children)
let newDataList = []
if (current.expand) {
current.expand = false
newDataList = dataList.filter(item => !descendantsIds.includes(item.page_id))
}
else {
current.expand = true
newDataList = [
...dataList.slice(0, index + 1),
...childrenIds.map(item => ({
...pagesMap[item],
expand: false,
depth: listMapWithChildrenAndDescendants[item].depth,
})),
...dataList.slice(index + 1),
]
}
setDataList(newDataList)
}
const copyValue = new Set(value)
const handleCheck = (index: number) => {
const current = currentDataList[index]
const pageId = current.page_id
const currentWithChildrenAndDescendants = listMapWithChildrenAndDescendants[pageId]
const copyValue = new Set(value)
if (copyValue.has(pageId)) {
if (!searchValue) {
for (const item of currentWithChildrenAndDescendants.descendants)
copyValue.delete(item)
}
copyValue.delete(pageId)
}
else {
@ -307,17 +277,22 @@ const PageSelector = ({
for (const item of currentWithChildrenAndDescendants.descendants)
copyValue.add(item)
}
copyValue.add(pageId)
}
onSelect(copyValue)
}, [listMapWithChildrenAndDescendants, onSelect, searchValue, value])
onSelect(new Set(copyValue))
}
const handlePreview = (index: number) => {
const current = currentDataList[index]
const pageId = current.page_id
const handlePreview = useCallback((pageId: string) => {
setLocalPreviewPageId(pageId)
if (onPreview)
onPreview(pageId)
}, [onPreview])
}
if (!currentDataList.length) {
return (
@ -328,41 +303,29 @@ const PageSelector = ({
}
return (
<div
ref={parentRef}
<List
className="py-2"
style={{ height: 296, width: '100%', overflow: 'auto' }}
height={296}
itemCount={currentDataList.length}
itemSize={28}
width="100%"
itemKey={(index, data) => data.dataList[index].page_id}
itemData={{
dataList: currentDataList,
handleToggle,
checkedIds: value,
disabledCheckedIds: disabledValue,
handleCheck,
canPreview,
handlePreview,
listMapWithChildrenAndDescendants,
searchValue,
previewPageId: currentPreviewPageId,
pagesMap,
}}
>
<div
style={{
height: virtualizer.getTotalSize(),
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const current = currentDataList[virtualRow.index]
return (
<Item
key={virtualRow.key}
virtualStart={virtualRow.start}
virtualSize={virtualRow.size}
current={current}
onToggle={handleToggle}
checkedIds={value}
disabledCheckedIds={disabledValue}
onCheck={handleCheck}
canPreview={canPreview}
onPreview={handlePreview}
listMapWithChildrenAndDescendants={listMapWithChildrenAndDescendants}
searchValue={searchValue}
previewPageId={currentPreviewPageId}
pagesMap={pagesMap}
/>
)
})}
</div>
</div>
{Item}
</List>
)
}

View File

@ -11,18 +11,21 @@ import { recursivePushInParentDescendants } from './utils'
// Note: react-i18next uses global mock from web/vitest.setup.ts
// Mock @tanstack/react-virtual useVirtualizer hook - renders items directly for testing
vi.mock('@tanstack/react-virtual', () => ({
useVirtualizer: ({ count, getItemKey }: { count: number, getItemKey?: (index: number) => string }) => ({
getVirtualItems: () =>
Array.from({ length: count }).map((_, index) => ({
index,
key: getItemKey ? getItemKey(index) : index,
start: index * 28,
size: 28,
})),
getTotalSize: () => count * 28,
}),
// Mock react-window FixedSizeList - renders items directly for testing
vi.mock('react-window', () => ({
FixedSizeList: ({ children: ItemComponent, itemCount, itemData, itemKey }: any) => (
<div data-testid="virtual-list">
{Array.from({ length: itemCount }).map((_, index) => (
<ItemComponent
key={itemKey?.(index, itemData) || index}
index={index}
style={{ top: index * 28, left: 0, right: 0, width: '100%', position: 'absolute' }}
data={itemData}
/>
))}
</div>
),
areEqual: (prevProps: any, nextProps: any) => prevProps === nextProps,
}))
// Note: NotionIcon from @/app/components/base/ is NOT mocked - using real component per testing guidelines
@ -116,7 +119,7 @@ describe('PageSelector', () => {
render(<PageSelector {...props} />)
// Assert
expect(screen.getByText('Test Page')).toBeInTheDocument()
expect(screen.getByTestId('virtual-list')).toBeInTheDocument()
})
it('should render empty state when list is empty', () => {
@ -131,7 +134,7 @@ describe('PageSelector', () => {
// Assert
expect(screen.getByText('common.dataSource.notion.selector.noSearchResult')).toBeInTheDocument()
expect(screen.queryByText('Test Page')).not.toBeInTheDocument()
expect(screen.queryByTestId('virtual-list')).not.toBeInTheDocument()
})
it('should render items using FixedSizeList', () => {
@ -1163,7 +1166,7 @@ describe('PageSelector', () => {
render(<PageSelector {...props} />)
// Assert
expect(screen.getByText('Test Page')).toBeInTheDocument()
expect(screen.getByTestId('virtual-list')).toBeInTheDocument()
})
it('should handle special characters in page name', () => {
@ -1337,7 +1340,7 @@ describe('PageSelector', () => {
render(<PageSelector {...props} />)
// Assert
expect(screen.getByText('Test Page')).toBeInTheDocument()
expect(screen.getByTestId('virtual-list')).toBeInTheDocument()
if (propVariation.canPreview)
expect(screen.getByText('common.dataSource.notion.selector.preview')).toBeInTheDocument()
else

View File

@ -1,7 +1,7 @@
import type { DataSourceNotionPage, DataSourceNotionPageMap } from '@/models/common'
import { useVirtualizer } from '@tanstack/react-virtual'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { FixedSizeList as List } from 'react-window'
import Item from './item'
import { recursivePushInParentDescendants } from './utils'
@ -45,16 +45,29 @@ const PageSelector = ({
currentCredentialId,
}: PageSelectorProps) => {
const { t } = useTranslation()
const parentRef = useRef<HTMLDivElement>(null)
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set())
const [dataList, setDataList] = useState<NotionPageItem[]>([])
const [currentPreviewPageId, setCurrentPreviewPageId] = useState('')
const prevCredentialIdRef = useRef(currentCredentialId)
// Reset expanded state when credential changes (render-time detection)
if (prevCredentialIdRef.current !== currentCredentialId) {
prevCredentialIdRef.current = currentCredentialId
setExpandedIds(new Set())
}
useEffect(() => {
setDataList(list.filter(item => item.parent_id === 'root' || !pagesMap[item.parent_id]).map((item) => {
return {
...item,
expand: false,
depth: 0,
}
}))
}, [currentCredentialId])
const searchDataList = list.filter((item) => {
return item.page_name.includes(searchValue)
}).map((item) => {
return {
...item,
expand: false,
depth: 0,
}
})
const currentDataList = searchValue ? searchDataList : dataList
const listMapWithChildrenAndDescendants = useMemo(() => {
return list.reduce((prev: NotionPageTreeMap, next: DataSourceNotionPage) => {
@ -67,86 +80,39 @@ const PageSelector = ({
}, {})
}, [list, pagesMap])
// Pre-build children index for O(1) lookup instead of O(n) filter
const childrenByParent = useMemo(() => {
const map = new Map<string | null, DataSourceNotionPage[]>()
for (const item of list) {
const isRoot = item.parent_id === 'root' || !pagesMap[item.parent_id]
const parentKey = isRoot ? null : item.parent_id
const children = map.get(parentKey) || []
children.push(item)
map.set(parentKey, children)
const handleToggle = useCallback((index: number) => {
const current = dataList[index]
const pageId = current.page_id
const currentWithChildrenAndDescendants = listMapWithChildrenAndDescendants[pageId]
const descendantsIds = Array.from(currentWithChildrenAndDescendants.descendants)
const childrenIds = Array.from(currentWithChildrenAndDescendants.children)
let newDataList = []
if (current.expand) {
current.expand = false
newDataList = dataList.filter(item => !descendantsIds.includes(item.page_id))
}
return map
}, [list, pagesMap])
else {
current.expand = true
// Compute visible data list based on expanded state
const dataList = useMemo(() => {
const result: NotionPageItem[] = []
const buildVisibleList = (parentId: string | null, depth: number) => {
const items = childrenByParent.get(parentId) || []
for (const item of items) {
const isExpanded = expandedIds.has(item.page_id)
result.push({
...item,
expand: isExpanded,
depth,
})
if (isExpanded) {
buildVisibleList(item.page_id, depth + 1)
}
}
newDataList = [
...dataList.slice(0, index + 1),
...childrenIds.map(item => ({
...pagesMap[item],
expand: false,
depth: listMapWithChildrenAndDescendants[item].depth,
})),
...dataList.slice(index + 1),
]
}
setDataList(newDataList)
}, [dataList, listMapWithChildrenAndDescendants, pagesMap])
buildVisibleList(null, 0)
return result
}, [childrenByParent, expandedIds])
const searchDataList = useMemo(() => list.filter((item) => {
return item.page_name.includes(searchValue)
}).map((item) => {
return {
...item,
expand: false,
depth: 0,
}
}), [list, searchValue])
const currentDataList = searchValue ? searchDataList : dataList
const virtualizer = useVirtualizer({
count: currentDataList.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 28,
overscan: 5,
getItemKey: index => currentDataList[index].page_id,
})
// Stable callback - no dependencies on dataList
const handleToggle = useCallback((pageId: string) => {
setExpandedIds((prev) => {
const next = new Set(prev)
if (prev.has(pageId)) {
// Collapse: remove current and all descendants
next.delete(pageId)
const descendants = listMapWithChildrenAndDescendants[pageId]?.descendants
if (descendants) {
for (const descendantId of descendants)
next.delete(descendantId)
}
}
else {
next.add(pageId)
}
return next
})
}, [listMapWithChildrenAndDescendants])
// Stable callback - uses pageId parameter instead of index
const handleCheck = useCallback((pageId: string) => {
const handleCheck = useCallback((index: number) => {
const copyValue = new Set(checkedIds)
const current = currentDataList[index]
const pageId = current.page_id
const currentWithChildrenAndDescendants = listMapWithChildrenAndDescendants[pageId]
if (copyValue.has(pageId)) {
@ -154,6 +120,7 @@ const PageSelector = ({
for (const item of currentWithChildrenAndDescendants.descendants)
copyValue.delete(item)
}
copyValue.delete(pageId)
}
else {
@ -171,15 +138,18 @@ const PageSelector = ({
}
}
onSelect(copyValue)
}, [checkedIds, isMultipleChoice, listMapWithChildrenAndDescendants, onSelect, searchValue])
onSelect(new Set(copyValue))
}, [currentDataList, isMultipleChoice, listMapWithChildrenAndDescendants, onSelect, searchValue, checkedIds])
const handlePreview = useCallback((index: number) => {
const current = currentDataList[index]
const pageId = current.page_id
// Stable callback
const handlePreview = useCallback((pageId: string) => {
setCurrentPreviewPageId(pageId)
if (onPreview)
onPreview(pageId)
}, [onPreview])
}, [currentDataList, onPreview])
if (!currentDataList.length) {
return (
@ -190,42 +160,30 @@ const PageSelector = ({
}
return (
<div
ref={parentRef}
<List
className="py-2"
style={{ height: 296, width: '100%', overflow: 'auto' }}
height={296}
itemCount={currentDataList.length}
itemSize={28}
width="100%"
itemKey={(index, data) => data.dataList[index].page_id}
itemData={{
dataList: currentDataList,
handleToggle,
checkedIds,
disabledCheckedIds: disabledValue,
handleCheck,
canPreview,
handlePreview,
listMapWithChildrenAndDescendants,
searchValue,
previewPageId: currentPreviewPageId,
pagesMap,
isMultipleChoice,
}}
>
<div
style={{
height: virtualizer.getTotalSize(),
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const current = currentDataList[virtualRow.index]
return (
<Item
key={virtualRow.key}
virtualStart={virtualRow.start}
virtualSize={virtualRow.size}
current={current}
onToggle={handleToggle}
checkedIds={checkedIds}
disabledCheckedIds={disabledValue}
onCheck={handleCheck}
canPreview={canPreview}
onPreview={handlePreview}
listMapWithChildrenAndDescendants={listMapWithChildrenAndDescendants}
searchValue={searchValue}
previewPageId={currentPreviewPageId}
pagesMap={pagesMap}
isMultipleChoice={isMultipleChoice}
/>
)
})}
</div>
</div>
{Item}
</List>
)
}

View File

@ -1,7 +1,9 @@
import type { ListChildComponentProps } from 'react-window'
import type { DataSourceNotionPage, DataSourceNotionPageMap } from '@/models/common'
import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react'
import { memo } from 'react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { areEqual } from 'react-window'
import Checkbox from '@/app/components/base/checkbox'
import NotionIcon from '@/app/components/base/notion-icon'
import Radio from '@/app/components/base/radio/ui'
@ -21,40 +23,36 @@ type NotionPageItem = {
depth: number
} & DataSourceNotionPage
type ItemProps = {
virtualStart: number
virtualSize: number
current: NotionPageItem
onToggle: (pageId: string) => void
const Item = ({ index, style, data }: ListChildComponentProps<{
dataList: NotionPageItem[]
handleToggle: (index: number) => void
checkedIds: Set<string>
disabledCheckedIds: Set<string>
onCheck: (pageId: string) => void
handleCheck: (index: number) => void
canPreview?: boolean
onPreview: (pageId: string) => void
handlePreview: (index: number) => void
listMapWithChildrenAndDescendants: NotionPageTreeMap
searchValue: string
previewPageId: string
pagesMap: DataSourceNotionPageMap
isMultipleChoice?: boolean
}
const Item = ({
virtualStart,
virtualSize,
current,
onToggle,
checkedIds,
disabledCheckedIds,
onCheck,
canPreview,
onPreview,
listMapWithChildrenAndDescendants,
searchValue,
previewPageId,
pagesMap,
isMultipleChoice,
}: ItemProps) => {
}>) => {
const { t } = useTranslation()
const {
dataList,
handleToggle,
checkedIds,
disabledCheckedIds,
handleCheck,
canPreview,
handlePreview,
listMapWithChildrenAndDescendants,
searchValue,
previewPageId,
pagesMap,
isMultipleChoice,
} = data
const current = dataList[index]
const currentWithChildrenAndDescendants = listMapWithChildrenAndDescendants[current.page_id]
const hasChild = currentWithChildrenAndDescendants.descendants.size > 0
const ancestors = currentWithChildrenAndDescendants.ancestors
@ -67,7 +65,7 @@ const Item = ({
<div
className="mr-1 flex h-5 w-5 shrink-0 items-center justify-center rounded-md hover:bg-components-button-ghost-bg-hover"
style={{ marginLeft: current.depth * 8 }}
onClick={() => onToggle(current.page_id)}
onClick={() => handleToggle(index)}
>
{
current.expand
@ -90,15 +88,7 @@ const Item = ({
return (
<div
className={cn('group flex cursor-pointer items-center rounded-md pl-2 pr-[2px] hover:bg-state-base-hover', previewPageId === current.page_id && 'bg-state-base-hover')}
style={{
position: 'absolute',
top: 0,
left: 8,
right: 8,
width: 'calc(100% - 16px)',
height: virtualSize,
transform: `translateY(${virtualStart + 8}px)`,
}}
style={{ ...style, top: style.top as number + 8, left: 8, right: 8, width: 'calc(100% - 16px)' }}
>
{isMultipleChoice
? (
@ -106,7 +96,9 @@ const Item = ({
className="mr-2 shrink-0"
checked={checkedIds.has(current.page_id)}
disabled={disabled}
onCheck={() => onCheck(current.page_id)}
onCheck={() => {
handleCheck(index)
}}
/>
)
: (
@ -114,7 +106,9 @@ const Item = ({
className="mr-2 shrink-0"
isChecked={checkedIds.has(current.page_id)}
disabled={disabled}
onCheck={() => onCheck(current.page_id)}
onCheck={() => {
handleCheck(index)
}}
/>
)}
{!searchValue && renderArrow()}
@ -135,7 +129,7 @@ const Item = ({
className="ml-1 hidden h-6 shrink-0 cursor-pointer items-center rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-2 text-xs
font-medium leading-4 text-components-button-secondary-text shadow-xs shadow-shadow-shadow-3 backdrop-blur-[10px]
hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover group-hover:flex"
onClick={() => onPreview(current.page_id)}
onClick={() => handlePreview(index)}
>
{t('dataSource.notion.selector.preview', { ns: 'common' })}
</div>
@ -155,4 +149,4 @@ const Item = ({
)
}
export default memo(Item)
export default React.memo(Item, areEqual)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@ -1409,6 +1409,11 @@
"count": 2
}
},
"app/components/base/notion-page-selector/page-selector/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
}
},
"app/components/base/pagination/index.tsx": {
"unicorn/prefer-number-properties": {
"count": 1
@ -1868,7 +1873,12 @@
},
"app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/index.spec.tsx": {
"ts/no-explicit-any": {
"count": 2
"count": 5
}
},
"app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
}
},
"app/components/datasets/documents/create-from-pipeline/data-source/online-drive/connect/index.spec.tsx": {

View File

@ -84,7 +84,6 @@
"@tailwindcss/typography": "0.5.19",
"@tanstack/react-form": "1.23.7",
"@tanstack/react-query": "5.90.5",
"@tanstack/react-virtual": "3.13.18",
"abcjs": "6.5.2",
"ahooks": "3.9.5",
"class-variance-authority": "0.7.1",
@ -143,6 +142,7 @@
"react-sortablejs": "6.1.4",
"react-syntax-highlighter": "15.6.6",
"react-textarea-autosize": "8.5.9",
"react-window": "1.8.11",
"reactflow": "11.11.4",
"rehype-katex": "7.0.1",
"rehype-raw": "7.0.0",
@ -199,6 +199,7 @@
"@types/react-dom": "19.2.3",
"@types/react-slider": "1.3.6",
"@types/react-syntax-highlighter": "15.5.13",
"@types/react-window": "1.8.8",
"@types/semver": "7.7.1",
"@types/sortablejs": "1.15.8",
"@types/uuid": "10.0.0",

1905
web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

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)

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'