diff --git a/.vscode/launch.json.template b/.vscode/launch.json.template index 2611b75c6c0..ff03d036744 100644 --- a/.vscode/launch.json.template +++ b/.vscode/launch.json.template @@ -5,7 +5,9 @@ "name": "Python: API (gevent)", "type": "debugpy", "request": "launch", - "program": "${workspaceFolder}/api/app.py", + "module": "gevent.monkey", + "args": ["--module", "app"], + "gevent": true, "jinja": true, "justMyCode": true, "cwd": "${workspaceFolder}/api", @@ -33,22 +35,6 @@ "justMyCode": false, "cwd": "${workspaceFolder}/api", "python": "${workspaceFolder}/api/.venv/bin/python" - }, - { - "name": "Next.js: debug full stack", - "type": "node", - "request": "launch", - "program": "${workspaceFolder}/web/node_modules/next/dist/bin/next", - "runtimeArgs": ["--inspect"], - "skipFiles": ["/**"], - "serverReadyAction": { - "action": "debugWithChrome", - "killOnServerStop": true, - "pattern": "- Local:.+(https?://.+)", - "uriFormat": "%s", - "webRoot": "${workspaceFolder}/web" - }, - "cwd": "${workspaceFolder}/web" - } + } ] } diff --git a/api/.env.example b/api/.env.example index 3ccf0ca0b4f..8c19466fb9e 100644 --- a/api/.env.example +++ b/api/.env.example @@ -561,6 +561,8 @@ WORKFLOW_MAX_EXECUTION_STEPS=500 WORKFLOW_MAX_EXECUTION_TIME=1200 WORKFLOW_CALL_MAX_DEPTH=5 MAX_VARIABLE_SIZE=204800 +# Maximum concurrent node-builder LLM calls per workflow generation request +WORKFLOW_GENERATOR_NODE_BUILDER_MAX_WORKERS=6 # GraphEngine Worker Pool Configuration # Minimum number of workers per GraphEngine instance (default: 1) diff --git a/api/app.py b/api/app.py index 7b7fa58c22f..7ca94d73459 100644 --- a/api/app.py +++ b/api/app.py @@ -1,5 +1,24 @@ from __future__ import annotations +# ``python -m app`` (docker DEBUG=true, or IDE debugging) serves through the +# gevent pywsgi server at the bottom of this file, so the stdlib must be +# monkey-patched BEFORE any other import pulls in sockets or locks. Without +# this, every request runs as a greenlet on one OS thread while blocking +# calls (LLM invokes, ``Future.result`` waits, DB I/O) pin that thread — the +# whole process freezes until the call returns. Gunicorn and Celery apply +# their own patching (see gunicorn.conf.py / celery_entrypoint.py), and +# ``flask run`` uses real Werkzeug threads, so both skip this branch. +if __name__ == "__main__": + from gevent import monkey + + monkey.patch_all() + + import psycogreen.gevent as psycogreen_gevent + from grpc.experimental import gevent as grpc_gevent + + grpc_gevent.init_gevent() + psycogreen_gevent.patch_psycopg() + import logging import sys from typing import TYPE_CHECKING, cast diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index 4c631d0180c..50be83eb6a9 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -784,6 +784,11 @@ class WorkflowConfig(BaseSettings): default=500, ) + WORKFLOW_GENERATOR_NODE_BUILDER_MAX_WORKERS: PositiveInt = Field( + description="Maximum concurrent node-builder LLM calls per workflow generation request", + default=6, + ) + WORKFLOW_MAX_EXECUTION_TIME: PositiveInt = Field( description="Maximum execution time in seconds for a single workflow", default=1200, diff --git a/api/core/llm_generator/llm_generator.py b/api/core/llm_generator/llm_generator.py index fe3240a2984..b685a5774bb 100644 --- a/api/core/llm_generator/llm_generator.py +++ b/api/core/llm_generator/llm_generator.py @@ -403,55 +403,6 @@ class LLMGenerator: return "" return "\n\n".join(sections) + "\n\n" - @classmethod - def classify_workflow_mode( - cls, - tenant_id: str, - instruction: str, - model_config: ModelConfig, - ) -> Literal["workflow", "advanced-chat"]: - """Classify a free-text instruction into a concrete app mode. - - One tiny LLM call using the model the user already picked (so no extra - provider setup is needed). Parsed leniently; defaults to - ``advanced-chat`` on anything unexpected or any error, so a - ``mode="auto"`` request never blocks generation. NEVER raises. - """ - default_mode: Literal["workflow", "advanced-chat"] = "advanced-chat" - try: - model_instance = ModelManager.for_tenant(tenant_id=tenant_id).get_model_instance( - tenant_id=tenant_id, - model_type=ModelType.LLM, - provider=model_config.provider, - model=model_config.name, - ) - prompt_messages: list[PromptMessage] = [ - UserPromptMessage( - content=( - "Reply with exactly one word: 'workflow' (one-shot automation, no chat) " - "or 'advanced-chat' (conversational multi-turn). " - f"Instruction: {instruction.strip()}" - ) - ), - ] - response: LLMResult = model_instance.invoke_llm( - prompt_messages=prompt_messages, - model_parameters={"max_tokens": 4, "temperature": 0}, - stream=False, - ) - text = (response.message.get_text_content() or "").strip().lower() - except Exception: - logger.info("Workflow mode classification failed; defaulting to %s", default_mode, exc_info=True) - return default_mode - - # Lenient parse: an affirmative "workflow" wins; everything else - # (including a truncated / empty / garbled reply) falls back to the - # conversational default. "advanced-chat" needs no positive match - # because it IS the default. - if "workflow" in text: - return "workflow" - return default_mode - @classmethod def generate_rule_config(cls, tenant_id: str, args: RuleGeneratePayload): output_parser = RuleConfigGeneratorOutputParser() diff --git a/api/core/workflow/generator/__init__.py b/api/core/workflow/generator/__init__.py index 9a885db948a..c919ea0c53c 100644 --- a/api/core/workflow/generator/__init__.py +++ b/api/core/workflow/generator/__init__.py @@ -4,12 +4,12 @@ Workflow generator package. Generates a Dify workflow graph (nodes, edges, viewport) from a natural-language instruction. Intended for the cmd+k `/create` slash command's preview/apply flow. -Pipeline (slim, single-shot variant): +Pipeline: runner.WorkflowGenerator.generate_workflow_graph(...) - ├── planner_prompts: short LLM call → high-level node plan - └── builder_prompts: structured-output LLM call → full graph JSON - └── postprocess: fill defaults, auto-layout viewport, sanity-check edges + ├── planner_prompts: short LLM call → node and edge plan + ├── node_builder_prompts: bounded parallel calls → semantic node configs + └── postprocess: assemble wrappers, auto-layout, validate graph The runner is pure domain logic; ``WorkflowGeneratorService`` (in ``services/``) owns the model-manager dependency and is what controllers call. diff --git a/api/core/workflow/generator/prompts/builder_prompts.py b/api/core/workflow/generator/prompts/builder_prompts.py index 85079739c15..b59f3bbae51 100644 --- a/api/core/workflow/generator/prompts/builder_prompts.py +++ b/api/core/workflow/generator/prompts/builder_prompts.py @@ -1,19 +1,4 @@ -""" -Builder prompts. - -The builder is the second step of the slim planner→builder pipeline. It takes -the planner's high-level node list and emits the *full* graph JSON consumed by -``WorkflowService.sync_draft_workflow``. - -The builder owns: node configuration (prompts, code, headers, etc.), edge wiring, -handle ids ("source"/"target"), positions, and the viewport. It is the only -prompt that needs to know the concrete shape of each node type — keep its -examples accurate or the LLM will invent fields. -""" - -import json -from collections.abc import Iterable -from typing import Any +"""Compact semantic configuration references for workflow node builders.""" # Per-node-type configuration cheatsheet. # @@ -23,50 +8,9 @@ from typing import Any # both ``WorkflowService.sync_draft_workflow``'s structural checks and the # runtime entity validation each node performs when the workflow runs. # -# The cheatsheet is assembled DYNAMICALLY per request: the planner decides -# which node types the workflow needs, and ``build_node_config_cheatsheet`` -# stitches together only the snippets for those types (plus the always-needed -# wrapper / shared-field / edge-handle preamble, and the containers section -# when an iteration / loop is planned). This keeps the builder prompt tight — -# a 3-node summariser no longer carries the schema for 12 unrelated node -# types — and lets each snippet document its FULL schema (e.g. a "file" start -# variable's required ``allowed_file_types``) without bloating every prompt. -# -# The postprocessor in ``runner.py`` fills missing wrapper fields (``type``, -# ``positionAbsolute``, ``width``, ``height``, ``sourcePosition`` / -# ``targetPosition``, edge ``data.sourceType`` / ``data.targetType``), so the -# LLM only needs to emit semantically meaningful fields. - -# Always-included preamble: the node/edge wrapper shape and the shared -# ``data`` fields that apply to every node type, plus the "## Per type" header -# the per-type snippets slot under. -_CHEATSHEET_PREAMBLE = """\ -## Node wrapper (every node, top-level) - - {"id": "node1" (digits + letters only — see "Node IDs" below), - "type": "custom", # ReactFlow renderer key. Iteration/loop - # *start* children use special types - # (see Containers below). - "position": {"x": , "y": }, - "data": { ... per-type fields ... }} - -Children of iteration / loop containers additionally need -``parentId``, ``zIndex: 1002`` and ``extent: "parent"`` — see Containers. - -## Shared "data" fields (every node) - - {"type": "", # e.g. "llm", "start", "if-else" - "title": "", - "desc": "", - "selected": false} - -## Per type — additional "data" fields (only the node types in your plan are shown)""" - - -# node_type → its per-type schema snippet. Keyed by the exact ``node_type`` -# string the planner emits so ``build_node_config_cheatsheet`` can look each -# one up directly. Iteration / loop are documented in the Containers section -# (they are subgraphs, not leaf nodes) rather than here. +# Each snippet mirrors the production node default closely enough for one +# model call to emit only meaningful ``data`` fields. The runner owns wrappers, +# topology, container metadata, layout, and validation. _NODE_SNIPPETS: dict[str, str] = { "start": """\ - start: @@ -248,484 +192,30 @@ _NODE_SNIPPETS: dict[str, str] = { Enable only the sub-features you need; ``conditions`` reuse the if-else condition shape (key / comparison_operator / value). Outputs: ``result`` (the processed array), ``first_record``, ``last_record``.""", + "assigner": """\ +- assigner (write to an existing conversation / loop variable): + {"version": "2", + "items": [{"variable_selector": ["", ""], + "input_type": "variable", + "operation": "over-write", + "value": ["", ""]}]} + ``input_type`` is "variable" (value is a selector) or "constant". + Operations: over-write | clear | append | extend | set | += | -= | *= | + /= | remove-first | remove-last.""", + "human-input": """\ +- human-input (pause for a person; use webapp delivery by default): + {"delivery_methods": [{"id": "webapp", "type": "webapp", "enabled": true}], + "form_content": "", + "inputs": [{"type": "paragraph", "output_variable_name": "comment", + "default": {"type": "constant", "selector": [], "value": ""}}], + "user_actions": [{"id": "approve", "title": "Approve", + "button_style": "primary"}], + "timeout": 3, "timeout_unit": "day"} + Each ``inputs[].output_variable_name`` is an output variable. Outgoing + edges use the matching user-action id as ``sourceHandle``.""", } -# Pulled into the cheatsheet only when an iteration / loop appears in the plan. -_CONTAINERS_SECTION = """\ -## Containers — iteration / loop - -These are SUBGRAPH nodes. To use one you MUST emit, in order: - -1. The container node itself, e.g. for iteration: - id: "nodeK" - type: "custom" - data: {"type": "iteration", - "title": "