chore: Improve workflow generator accessibility and API contracts (#38838)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Crazywoola 2026-07-13 17:15:54 +08:00 committed by GitHub
parent 8e0d10bf5e
commit 7865ffd429
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1869 additions and 460 deletions

View File

@ -3,7 +3,7 @@ from collections.abc import Generator, Sequence
from typing import Any, Literal
from flask_restx import Resource
from pydantic import BaseModel, Field, RootModel
from pydantic import BaseModel, ConfigDict, Field, RootModel
from sqlalchemy import select
from sqlalchemy.orm import Session
@ -26,9 +26,10 @@ from core.helper.code_executor.python3.python3_code_provider import Python3CodeP
from core.llm_generator.entities import RuleCodeGeneratePayload, RuleGeneratePayload, RuleStructuredOutputPayload
from core.llm_generator.llm_generator import LLMGenerator
from core.workflow.generator.types import WorkflowGenerateErrorCode
from fields.base import ResponseModel
from graphon.model_runtime.entities.llm_entities import LLMMode
from graphon.model_runtime.errors.invoke import InvokeError
from libs.helper import compact_generate_response
from libs.helper import compact_generate_response, dump_response
from libs.login import login_required
from models import App
from services.workflow_generator_service import WorkflowGeneratorService
@ -60,6 +61,48 @@ class InstructionTemplatePayload(BaseModel):
_MAX_INSTRUCTION_LENGTH = 10_000
class WorkflowGraphPosition(BaseModel):
x: float
y: float
class WorkflowGraphViewport(WorkflowGraphPosition):
zoom: float
class WorkflowGraphNode(BaseModel):
"""React Flow node shape accepted and returned by the generator.
Node-specific configuration lives under ``data`` and wrapper metadata
differs for container children, so unknown wrapper fields must survive
request validation and response serialization.
"""
model_config = ConfigDict(extra="allow", populate_by_name=True)
id: str
type: str
position: WorkflowGraphPosition
data: dict[str, Any]
class WorkflowGraphEdge(BaseModel):
"""React Flow edge shape with extensible renderer metadata."""
model_config = ConfigDict(extra="allow", populate_by_name=True)
id: str
source: str
target: str
type: str
class WorkflowGraph(BaseModel):
nodes: list[WorkflowGraphNode]
edges: list[WorkflowGraphEdge]
viewport: WorkflowGraphViewport
class WorkflowGeneratePayload(BaseModel):
"""Payload for the cmd+k `/create` and `/refine` workflow generator endpoint.
@ -79,7 +122,7 @@ class WorkflowGeneratePayload(BaseModel):
alias="model_config",
description="Model configuration",
)
current_graph: dict | None = Field(
current_graph: WorkflowGraph | None = Field(
default=None,
description="Existing draft graph to refine (cmd+k `/refine`); omit for create-from-scratch",
)
@ -98,6 +141,59 @@ class WorkflowInstructionSuggestionsPayload(BaseModel):
count: int = Field(default=4, ge=1, le=6, description="Number of suggestions to return (1-6)")
class WorkflowGenerateErrorResponse(ResponseModel):
code: WorkflowGenerateErrorCode
detail: str
node_id: str | None = None
class WorkflowGenerateResponse(ResponseModel):
graph: WorkflowGraph
message: str = ""
app_name: str = ""
icon: str = ""
error: str = ""
errors: list[WorkflowGenerateErrorResponse] = Field(default_factory=list)
mode: Literal["workflow", "advanced-chat"] | None = None
class WorkflowPlanNodeResponse(ResponseModel):
label: str
node_type: str
purpose: str = ""
class WorkflowPlanStartInputResponse(ResponseModel):
variable: str
label: str = ""
type: str = ""
class WorkflowGeneratePlanEventResponse(ResponseModel):
event: Literal["plan"] = "plan"
title: str = ""
description: str = ""
app_name: str = ""
icon: str = ""
mode: Literal["workflow", "advanced-chat"]
nodes: list[WorkflowPlanNodeResponse]
start_inputs: list[WorkflowPlanStartInputResponse] = Field(default_factory=list)
class WorkflowGenerateResultEventResponse(WorkflowGenerateResponse):
event: Literal["result"] = "result"
class WorkflowGenerateStreamEventResponse(
RootModel[WorkflowGeneratePlanEventResponse | WorkflowGenerateResultEventResponse]
):
"""Schema for each JSON object carried by an SSE ``data:`` frame."""
class WorkflowInstructionSuggestionsResponse(ResponseModel):
suggestions: list[str]
class GeneratorResponse(RootModel[Any]):
root: Any
@ -114,7 +210,16 @@ register_schema_models(
WorkflowInstructionSuggestionsPayload,
ModelConfig,
)
register_response_schema_models(console_ns, GeneratorResponse, SimpleDataResponse)
register_response_schema_models(
console_ns,
GeneratorResponse,
SimpleDataResponse,
WorkflowGenerateResponse,
WorkflowGeneratePlanEventResponse,
WorkflowGenerateResultEventResponse,
WorkflowGenerateStreamEventResponse,
WorkflowInstructionSuggestionsResponse,
)
@console_ns.route("/rule-generate")
@ -376,7 +481,11 @@ class WorkflowGenerateApi(Resource):
@console_ns.doc("generate_workflow_graph")
@console_ns.doc(description="Generate a Dify workflow graph from natural language")
@console_ns.expect(console_ns.models[WorkflowGeneratePayload.__name__])
@console_ns.response(200, "Workflow graph generated successfully", console_ns.models[GeneratorResponse.__name__])
@console_ns.response(
200,
"Workflow graph generated successfully",
console_ns.models[WorkflowGenerateResponse.__name__],
)
@console_ns.response(400, "Invalid request parameters")
@console_ns.response(402, "Provider quota exceeded")
@setup_required
@ -399,7 +508,9 @@ class WorkflowGenerateApi(Resource):
instruction=args.instruction,
model_config=args.model_config_data,
ideal_output=args.ideal_output,
current_graph=args.current_graph,
current_graph=args.current_graph.model_dump(by_alias=True, exclude_none=True)
if args.current_graph
else None,
)
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
@ -410,7 +521,7 @@ class WorkflowGenerateApi(Resource):
except InvokeError as e:
raise CompletionRequestError(e.description)
return result
return dump_response(WorkflowGenerateResponse, result)
@console_ns.route("/workflow-generate/suggestions")
@ -426,7 +537,11 @@ class WorkflowInstructionSuggestionsApi(Resource):
@console_ns.doc("generate_workflow_instruction_suggestions")
@console_ns.doc(description="Suggest example workflow-generator instructions for the tenant")
@console_ns.expect(console_ns.models[WorkflowInstructionSuggestionsPayload.__name__])
@console_ns.response(200, "Suggestions generated successfully", console_ns.models[GeneratorResponse.__name__])
@console_ns.response(
200,
"Suggestions generated successfully",
console_ns.models[WorkflowInstructionSuggestionsResponse.__name__],
)
@console_ns.response(400, "Invalid request parameters")
@setup_required
@login_required
@ -440,7 +555,7 @@ class WorkflowInstructionSuggestionsApi(Resource):
language=args.language,
count=args.count,
)
return {"suggestions": suggestions}
return dump_response(WorkflowInstructionSuggestionsResponse, {"suggestions": suggestions})
@console_ns.route("/workflow-generate/stream")
@ -458,7 +573,11 @@ class WorkflowGenerateStreamApi(Resource):
@console_ns.doc("generate_workflow_graph_stream")
@console_ns.doc(description="Stream a Dify workflow graph (plan then result) via SSE")
@console_ns.expect(console_ns.models[WorkflowGeneratePayload.__name__])
@console_ns.response(200, "Server-Sent Events stream of plan/result events")
@console_ns.response(
200,
"Server-Sent Events stream; each data frame matches this plan/result event schema",
console_ns.models[WorkflowGenerateStreamEventResponse.__name__],
)
@console_ns.response(400, "Invalid request parameters")
@setup_required
@login_required
@ -481,10 +600,17 @@ class WorkflowGenerateStreamApi(Resource):
instruction=args.instruction,
model_config=args.model_config_data,
ideal_output=args.ideal_output,
current_graph=args.current_graph,
current_graph=(
args.current_graph.model_dump(by_alias=True, exclude_none=True) if args.current_graph else None
),
):
body = {"event": event_name, **payload}
yield f"data: {json.dumps(body)}\n\n"
if event_name == "plan":
plan_event = WorkflowGeneratePlanEventResponse.model_validate(body)
yield f"data: {json.dumps(plan_event.model_dump(mode='json'))}\n\n"
else:
result_event = WorkflowGenerateResultEventResponse.model_validate(body)
yield f"data: {json.dumps(result_event.model_dump(mode='json'))}\n\n"
except (ProviderTokenNotInitError, QuotaExceededError, ModelCurrentlyNotSupportError, InvokeError) as e:
# The model instance is resolved inside the service (lazily, on
# first iteration), so a provider / init error surfaces here.
@ -498,6 +624,7 @@ class WorkflowGenerateStreamApi(Resource):
"error": detail,
"errors": [{"code": WorkflowGenerateErrorCode.MODEL_ERROR, "detail": detail}],
}
yield f"data: {json.dumps(error_body)}\n\n"
error_event = WorkflowGenerateResultEventResponse.model_validate(error_body)
yield f"data: {json.dumps(error_event.model_dump(mode='json'))}\n\n"
return compact_generate_response(generate())

View File

@ -440,7 +440,26 @@ correct.
copy each entry verbatim into ``start.data.variables`` so the
downstream references resolve.
- In Advanced-Chat mode you may also reference ``sys.query`` and
``sys.files`` without declaring them.
``sys.files`` without declaring them. In selector fields, spell these as
exactly ``["sys", "query"]`` and ``["sys", "files"]`` never as a
one-item array such as ``["sys,query"]`` or ``["sys.query"]``.
10. MULTIPLE KNOWLEDGE-RETRIEVAL INPUTS TO ONE LLM require a template fan-in.
``context.variable_selector accepts only one selector`` and therefore
cannot carry two retrieval outputs. When an LLM must synthesize two or
more retrieval results:
- Run the retrieval nodes as parallel siblings from the same query input.
- Add one ``template-transform`` node after them. Give it one variable per
retrieval, such as ``value_selector: ["node2", "result"]`` and
``value_selector: ["node3", "result"]``, and render every source's
content into one labelled text output.
- Add an edge from EACH retrieval node to the template, then one edge from
the template to the LLM. The LLM must NOT receive direct retrieval edges.
- Enable the LLM's context, set ``context.variable_selector`` to the
template's ``["<template-node-id>", "output"]``, and put
``{{#context#}}`` in its prompt.
- Do not use ``variable-aggregator`` for this: it selects the first value
produced by mutually exclusive branches; it does not concatenate two
retrieval results that both ran.
"""

View File

@ -51,6 +51,11 @@ minimum set of Dify workflow nodes needed to fulfil it, in execution order.
"question-classifier" (semantic / intent routing)
- "for each item in a list" "iteration"
- "keep going until condition" "loop"
- synthesize multiple independent knowledge sources one
"knowledge-retrieval" node per source, then one "template-transform"
node that combines every result, then one "llm" node that consumes the
template output as context; the retrievals are parallel inputs to the
template, not mutually exclusive branches
5. PREFER "tool" over "http-request" or "code" whenever an installed tool from the
"Available tools" section below covers the task (e.g. web search, time lookup,
scraping, audio, translation, etc.). Only fall back to "http-request" for

View File

@ -15,7 +15,7 @@ Pipeline:
Intentionally NOT here (deferred to a future iteration):
- Mermaid rendering
- Heuristic node/edge auto-repair beyond default fill
- Broad semantic auto-repair when multiple valid graph interpretations exist
- Multi-step validation engine with classification of fixable vs. user-required errors
- Tool / model catalogue filtering
@ -419,6 +419,11 @@ class WorkflowGenerator:
yield "result", cast(dict[str, Any], empty_plan)
return
# A single LLM cannot select multiple retrieval outputs as context.
# Make the required template fan-in explicit in the plan so the
# builder receives its schema and assigns stable sequential ids.
cls._insert_multi_retrieval_template_plan(plan_nodes)
# Planner-supplied user-input declarations. The builder uses these to
# populate ``start.data.variables`` so downstream ``{#start.<var>#}``
# references resolve at run time. Optional field — older prompts may
@ -650,6 +655,38 @@ class WorkflowGenerator:
return cast(PlannerResultDict, parsed)
# ------------------------------------------------------------------
# Plan normalization
# ------------------------------------------------------------------
@staticmethod
def _insert_multi_retrieval_template_plan(plan_nodes: list[dict[str, Any]]) -> None:
"""Insert the unambiguous multi-retrieval template step when omitted.
Multiple LLM nodes make ownership ambiguous, so that case remains in
the planner's hands. With exactly one LLM, every independent retrieval
result can safely fan into one template immediately before that LLM.
"""
node_types = [str(node.get("node_type") or "") for node in plan_nodes]
if node_types.count(BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL) < 2:
return
if node_types.count(BuiltinNodeTypes.LLM) != 1:
return
if BuiltinNodeTypes.TEMPLATE_TRANSFORM in node_types:
return
llm_index = node_types.index(BuiltinNodeTypes.LLM)
retrievals_before_llm = node_types[:llm_index].count(BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL)
if retrievals_before_llm < 2:
return
plan_nodes.insert(
llm_index,
{
"label": "Combine Knowledge",
"node_type": BuiltinNodeTypes.TEMPLATE_TRANSFORM,
"purpose": "Combine every knowledge retrieval result into one labelled context for the LLM.",
},
)
# ------------------------------------------------------------------
# Builder
# ------------------------------------------------------------------
@ -719,7 +756,7 @@ class WorkflowGenerator:
# ------------------------------------------------------------------
@classmethod
def _postprocess_graph(cls, *, graph: GraphDict, mode: WorkflowGenerationMode) -> GraphDict:
"""Fill safe defaults, normalise positions and dedupe edges."""
"""Fill safe defaults and apply only deterministic graph repairs."""
# Internally treat nodes/edges as untyped dicts — TypedDicts forbid the
# arbitrary-key setdefault writes we need here, but the caller only sees
@ -737,6 +774,12 @@ class WorkflowGenerator:
# of the postprocess pass touches them.
cls._sanitize_node_ids(nodes=nodes, edges=edges)
# An LLM context accepts one selector. If the builder wires multiple
# retrieval nodes straight into an LLM but selects only one result,
# insert a template-transform fan-in that renders every result into
# one string and point the context at that output.
cls._insert_multi_retrieval_context_templates(nodes=nodes, edges=edges)
# Container-child nodes carry their own relative positions inside the
# parent and have a special ``type`` (custom-iteration-start /
# custom-loop-start). We must not override their positions or wrapper
@ -835,7 +878,10 @@ class WorkflowGenerator:
# fails at run time with "variable not found". The dominant failure
# mode is a prompt that references ``{#start.url#}`` when the start
# node has ``variables: []``, so we auto-inject missing start-node
# variables before we surface them as errors.
# variables. We also repair a mistaken output name when its source
# exposes exactly one valid output; ambiguous references still fail
# closed in the structural validator.
cls._normalize_sys_query_references(nodes=nodes, mode=mode)
cls._reconcile_variable_references(nodes=nodes, mode=mode)
# Schema backstop: a "file" / "file-list" start variable MUST carry a
@ -851,6 +897,104 @@ class WorkflowGenerator:
# Variable-reference reconciliation
# ------------------------------------------------------------------
@classmethod
def _normalize_sys_query_references(
cls,
*,
nodes: list[dict[str, Any]],
mode: WorkflowGenerationMode,
) -> None:
"""Normalize malformed ``sys.query`` references without changing their intent.
Chatflows expose ``sys.query`` directly. Plain workflows do not, so
their query references become a Start-node input and the existing
reconciliation pass declares that input immediately afterwards.
"""
if mode == "advanced-chat":
target_node_id = "sys"
else:
start_node = next(
(node for node in nodes if node.get("data", {}).get("type") == BuiltinNodeTypes.START),
None,
)
start_node_id = start_node.get("id") if start_node else None
if not isinstance(start_node_id, str) or not start_node_id:
return
target_node_id = start_node_id
for node in nodes:
data = node.get("data")
if isinstance(data, dict):
cls._normalize_sys_query_reference_in_data(data, target_node_id=target_node_id)
@classmethod
def _normalize_sys_query_reference_in_data(
cls,
value: Any,
*,
target_node_id: str,
allow_selector: bool = True,
) -> Any:
"""Rewrite query placeholders and selectors at any node-data depth.
Some node schemas store selectors inside another list, for example a
parameter extractor's ``query`` or a variable aggregator's
``variables``. Literal string-list fields opt out so an option list
such as ``["sys", "query"]`` is preserved.
"""
target_placeholder = f"{{{{#{target_node_id}.query#}}}}"
if isinstance(value, str):
return value.replace("{{#sys.query#}}", target_placeholder).replace("{{#sys,query#}}", target_placeholder)
if isinstance(value, dict):
for key, item in list(value.items()):
item_allows_selector = allow_selector and key not in cls._NON_SELECTOR_LIST_KEYS
if item_allows_selector and cls._is_sys_query_selector(item):
value[key] = [target_node_id, "query"]
continue
if (
item_allows_selector
and cls._is_selector_field(key)
and isinstance(item, str)
and cls._is_sys_query_token(item)
):
value[key] = [target_node_id, "query"]
continue
value[key] = cls._normalize_sys_query_reference_in_data(
item,
target_node_id=target_node_id,
allow_selector=item_allows_selector,
)
return value
if isinstance(value, list):
if allow_selector and cls._is_sys_query_selector(value):
return [target_node_id, "query"]
for index, item in enumerate(value):
value[index] = cls._normalize_sys_query_reference_in_data(
item,
target_node_id=target_node_id,
allow_selector=allow_selector,
)
return value
@staticmethod
def _is_sys_query_selector(value: Any) -> bool:
"""Recognize the valid selector and common one-item LLM variants."""
if value == ["sys", "query"]:
return True
if not isinstance(value, list) or len(value) != 1 or not isinstance(value[0], str):
return False
return WorkflowGenerator._is_sys_query_token(value[0])
@staticmethod
def _is_sys_query_token(value: str) -> bool:
"""Return whether a string is a compact malformed query selector."""
return value.replace(" ", "") in {"sys.query", "sys,query"}
@staticmethod
def _is_selector_field(key: str) -> bool:
"""Return whether a data field explicitly stores one selector."""
return key == "selector" or key.endswith("_selector")
# Detects ``{{#node_id.var#}}`` placeholders. We match the EXACT regex
# Dify's workflow runtime uses (see
# ``graphon.runtime.variable_pool.VARIABLE_PATTERN``):
@ -903,9 +1047,13 @@ class WorkflowGenerator:
@classmethod
def _reconcile_variable_references(cls, *, nodes: list[dict[str, Any]], mode: WorkflowGenerationMode) -> None:
"""
Walk every variable reference, ensure it resolves; auto-fix missing
start-node variables (the safe, dominant case) by adding a stub
``paragraph`` entry to ``start.data.variables``.
Apply deterministic repairs to unresolved variable references.
Missing start-node inputs are added as ``paragraph`` variables. For
non-start nodes, a mistaken output name is rewritten only when the
source exposes exactly one declared output. Sources with zero or
multiple outputs remain untouched so validation fails closed instead
of guessing which value the workflow should consume.
For Advanced-Chat mode, ``sys.query`` and ``sys.files`` are always
treated as resolved without any declaration. Tool nodes' parameter
@ -934,16 +1082,83 @@ class WorkflowGenerator:
continue
if cls._declares_variable(target, var):
continue
# Missing variable. Auto-fix start-node references; let everything
# else fall through and surface in the result's ``error`` field
# via the post-postprocess validator below.
if start_node is not None and target is start_node:
cls._inject_start_variable(start_node, var)
logger.info("Workflow generator: auto-injected missing start variable %r", var)
continue
replacement = cls._sole_declared_variable(target)
if replacement is None:
continue
for node in nodes:
data = node.get("data")
if isinstance(data, dict):
cls._rewrite_variable_reference_in_data(
data,
node_id=node_id,
old_variable=var,
new_variable=replacement,
)
logger.info(
"Workflow generator: rewrote unresolved reference %s.%s to sole output %s.%s",
node_id,
var,
node_id,
replacement,
)
@classmethod
def _collect_refs_in_data(cls, value: Any, out: set[tuple[str, str]]) -> None:
"""Recursively walk a node's ``data`` and harvest every reference."""
def _rewrite_variable_reference_in_data(
cls,
value: Any,
*,
node_id: str,
old_variable: str,
new_variable: str,
allow_selector: bool = True,
) -> Any:
"""Rewrite one exact placeholder or selector at any data depth."""
if isinstance(value, str):
return cls._VAR_REF_RE.sub(
lambda match: (
f"{{{{#{node_id}.{new_variable}#}}}}"
if match.group(1) == node_id and match.group(2) == old_variable
else match.group(0)
),
value,
)
if isinstance(value, dict):
for key, item in list(value.items()):
value[key] = cls._rewrite_variable_reference_in_data(
item,
node_id=node_id,
old_variable=old_variable,
new_variable=new_variable,
allow_selector=allow_selector and key not in cls._NON_SELECTOR_LIST_KEYS,
)
return value
if isinstance(value, list):
if allow_selector and value == [node_id, old_variable]:
return [node_id, new_variable]
for index, item in enumerate(value):
value[index] = cls._rewrite_variable_reference_in_data(
item,
node_id=node_id,
old_variable=old_variable,
new_variable=new_variable,
allow_selector=allow_selector,
)
return value
@classmethod
def _collect_refs_in_data(
cls,
value: Any,
out: set[tuple[str, str]],
*,
allow_selector: bool = True,
) -> None:
"""Recursively harvest placeholders and selectors at any data depth."""
if isinstance(value, str):
for match in cls._VAR_REF_RE.finditer(value):
node_id, var = match.group(1).strip(), match.group(2).strip()
@ -951,28 +1166,20 @@ class WorkflowGenerator:
out.add((node_id, var))
return
if isinstance(value, dict):
# Known selector shapes: 2-element [node_id, var] lists.
for k, v in value.items():
# ``value_selector`` / ``query_variable_selector`` / etc.: a
# flat 2-element list of strings. Skip keys whose value is a
# plain string list that merely HAPPENS to have two entries —
# a 2-option ``select`` or a file variable's two allowed upload
# methods are NOT ``[node_id, var]`` selectors and must not be
# mistaken for references.
if (
isinstance(v, list)
and len(v) == 2
and all(isinstance(x, str) for x in v)
and k not in cls._NON_SELECTOR_LIST_KEYS
):
node_id, var = v[0].strip(), v[1].strip()
if node_id and var:
out.add((node_id, var))
cls._collect_refs_in_data(v, out)
cls._collect_refs_in_data(
v,
out,
allow_selector=allow_selector and k not in cls._NON_SELECTOR_LIST_KEYS,
)
return
if isinstance(value, list):
if allow_selector and len(value) == 2 and all(isinstance(item, str) for item in value):
node_id, var = value[0].strip(), value[1].strip()
if node_id and var:
out.add((node_id, var))
for item in value:
cls._collect_refs_in_data(item, out)
cls._collect_refs_in_data(item, out, allow_selector=allow_selector)
@classmethod
def _declares_variable(cls, node: dict[str, Any], var: str) -> bool:
@ -1022,6 +1229,37 @@ class WorkflowGenerator:
# produce outputs of their own.
return False
@classmethod
def _sole_declared_variable(cls, node: dict[str, Any]) -> str | None:
"""Return the only output exposed by ``node``, or ``None`` when ambiguous."""
data = node.get("data") or {}
node_type = data.get("type")
if node_type == BuiltinNodeTypes.LLM:
schema = ((data.get("structured_output") or {}).get("schema") or {}).get("properties") or {}
return "text" if not schema else None
if node_type == BuiltinNodeTypes.CODE:
outputs = [key for key in (data.get("outputs") or {}) if isinstance(key, str)]
return outputs[0] if len(outputs) == 1 else None
if node_type == BuiltinNodeTypes.PARAMETER_EXTRACTOR:
parameters = [
parameter.get("name")
for parameter in (data.get("parameters") or [])
if isinstance(parameter, dict) and isinstance(parameter.get("name"), str)
]
return parameters[0] if len(parameters) == 1 else None
if not isinstance(node_type, str):
return None
single_output_by_type: dict[str, str] = {
BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL: "result",
BuiltinNodeTypes.TEMPLATE_TRANSFORM: "output",
BuiltinNodeTypes.ITERATION: "output",
BuiltinNodeTypes.LOOP: "output",
BuiltinNodeTypes.DOCUMENT_EXTRACTOR: "text",
BuiltinNodeTypes.VARIABLE_AGGREGATOR: "output",
BuiltinNodeTypes.LEGACY_VARIABLE_AGGREGATOR: "output",
}
return single_output_by_type.get(node_type)
@classmethod
def _sanitize_node_ids(cls, *, nodes: list[dict[str, Any]], edges: list[dict[str, Any]]) -> None:
"""
@ -1137,6 +1375,129 @@ class WorkflowGenerator:
new_id = id_map.get(node_id, node_id)
return f"{{{{#{new_id}.{rest}#}}}}"
@classmethod
def _insert_multi_retrieval_context_templates(
cls,
*,
nodes: list[dict[str, Any]],
edges: list[dict[str, Any]],
) -> None:
"""Fan multiple retrieval inputs into an LLM through one template.
The repair is intentionally narrow: it applies only when at least two
knowledge-retrieval nodes have direct edges into the same LLM and that
LLM currently uses one of those results as its enabled context. This
is enough to repair the builder's lossy single-context output without
guessing about unrelated retrievals or mutually exclusive branches.
"""
nodes_by_id: dict[str, dict[str, Any]] = {
node_id: node for node in nodes if isinstance(node_id := node.get("id"), str)
}
used_ids = set(nodes_by_id)
llm_nodes = [node for node in nodes if node.get("data", {}).get("type") == BuiltinNodeTypes.LLM]
for llm_node in llm_nodes:
llm_id = llm_node.get("id")
if not isinstance(llm_id, str):
continue
incoming_retrieval_edges: list[dict[str, Any]] = []
for edge in edges:
source_id = edge.get("source")
if edge.get("target") != llm_id or not isinstance(source_id, str):
continue
source_node = nodes_by_id.get(source_id)
if source_node and source_node.get("data", {}).get("type") == BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL:
incoming_retrieval_edges.append(edge)
retrieval_ids = list(
dict.fromkeys(
edge["source"] for edge in incoming_retrieval_edges if isinstance(edge.get("source"), str)
)
)
if len(retrieval_ids) < 2:
continue
llm_data = llm_node.get("data")
if not isinstance(llm_data, dict):
continue
context = llm_data.get("context")
if not isinstance(context, dict) or not context.get("enabled"):
continue
selector = context.get("variable_selector")
if selector not in [[retrieval_id, "result"] for retrieval_id in retrieval_ids]:
continue
template_id = cls._next_generated_node_id(prefix="retrieval_context", used_ids=used_ids)
variables = [
{"variable": f"knowledge_{index}", "value_selector": [retrieval_id, "result"]}
for index, retrieval_id in enumerate(retrieval_ids, start=1)
]
sections = [
(
f"## Knowledge source {index}\n"
f"{{% for item in knowledge_{index} %}}{{{{ item.content }}}}\n{{% endfor %}}"
)
for index in range(1, len(retrieval_ids) + 1)
]
nodes.append(
{
"id": template_id,
"type": "custom",
"position": {"x": 0, "y": 0},
"data": {
"type": BuiltinNodeTypes.TEMPLATE_TRANSFORM,
"title": "Combine Knowledge",
"variables": variables,
"template": "\n\n".join(sections),
},
}
)
incoming_edge_objects = {id(edge) for edge in incoming_retrieval_edges}
edges[:] = [edge for edge in edges if id(edge) not in incoming_edge_objects]
edges.extend(
{"source": retrieval_id, "target": template_id, "type": "custom"} for retrieval_id in retrieval_ids
)
edges.append({"source": template_id, "target": llm_id, "type": "custom"})
context["variable_selector"] = [template_id, "output"]
cls._ensure_llm_context_placeholder(llm_data)
logger.info(
"Workflow generator: inserted template %s to combine retrieval inputs for LLM %s",
template_id,
llm_id,
)
@staticmethod
def _next_generated_node_id(*, prefix: str, used_ids: set[str]) -> str:
"""Return a short runtime-safe node id and reserve it in ``used_ids``."""
suffix = 1
candidate = prefix
while candidate in used_ids:
suffix += 1
candidate = f"{prefix}_{suffix}"
used_ids.add(candidate)
return candidate
@staticmethod
def _ensure_llm_context_placeholder(llm_data: dict[str, Any]) -> None:
"""Ensure an enabled LLM context is actually present in its prompt."""
prompt_template = llm_data.get("prompt_template")
if isinstance(prompt_template, list):
messages = [message for message in prompt_template if isinstance(message, dict)]
if any("{{#context#}}" in str(message.get("text") or "") for message in messages):
return
target = next((message for message in reversed(messages) if message.get("role") == "user"), None)
if target is None:
prompt_template.append({"role": "user", "text": "{{#context#}}"})
return
target["text"] = f"{target.get('text') or ''}\n\n{{{{#context#}}}}"
return
if isinstance(prompt_template, dict):
text = str(prompt_template.get("text") or "")
if "{{#context#}}" not in text:
prompt_template["text"] = f"{text}\n\n{{{{#context#}}}}"
@classmethod
def _repair_branch_edge_handles(cls, *, nodes: list[dict[str, Any]], edges: list[dict[str, Any]]) -> None:
"""
@ -1690,8 +2051,9 @@ class WorkflowGenerator:
"""
Walk every variable reference and flag anything pointing at a node
that doesn't declare it. The postprocess step has already
auto-injected missing start-node variables, so by the time this
runs only NON-start references should ever fail.
auto-injected missing start-node variables and repaired references to
sole outputs, so by the time this runs only ambiguous or impossible
references should fail.
"""
out: list[WorkflowGenerateErrorDict] = []
by_id: dict[str, dict[str, Any]] = {n.get("id", ""): n for n in nodes if n.get("id")}

View File

@ -7,7 +7,8 @@ required to return after ``json_repair`` parsing. They mirror the runtime
can be written straight into a draft workflow without further translation.
"""
from typing import Final, Literal, NotRequired, TypedDict
from enum import StrEnum
from typing import Literal, NotRequired, TypedDict
WorkflowGenerationMode = Literal["workflow", "advanced-chat"]
@ -22,22 +23,22 @@ WorkflowGenerationModeRequest = Literal["workflow", "advanced-chat", "auto"]
# Machine-readable error codes returned in ``WorkflowGenerateResultDict.errors``.
# Frontend maps these to localised copy via ``workflow.generator.errors.<code>``
# i18n keys, so any change here MUST be mirrored in the FE i18n map.
class WorkflowGenerateErrorCode:
INVALID_JSON: Final = "INVALID_JSON"
INVALID_SCHEMA: Final = "INVALID_SCHEMA"
EMPTY_INSTRUCTION: Final = "EMPTY_INSTRUCTION"
INSTRUCTION_TOO_LONG: Final = "INSTRUCTION_TOO_LONG"
DUPLICATE_NODE_ID: Final = "DUPLICATE_NODE_ID"
GRAPH_CYCLE: Final = "GRAPH_CYCLE"
EMPTY_PLAN: Final = "EMPTY_PLAN"
UNKNOWN_NODE_REFERENCE: Final = "UNKNOWN_NODE_REFERENCE"
INVALID_CONTAINER: Final = "INVALID_CONTAINER"
UNRESOLVED_REFERENCE: Final = "UNRESOLVED_REFERENCE"
UNKNOWN_TOOL: Final = "UNKNOWN_TOOL"
MISSING_TERMINAL: Final = "MISSING_TERMINAL"
MISSING_START: Final = "MISSING_START"
DANGLING_EDGE: Final = "DANGLING_EDGE"
MODEL_ERROR: Final = "MODEL_ERROR"
class WorkflowGenerateErrorCode(StrEnum):
INVALID_JSON = "INVALID_JSON"
INVALID_SCHEMA = "INVALID_SCHEMA"
EMPTY_INSTRUCTION = "EMPTY_INSTRUCTION"
INSTRUCTION_TOO_LONG = "INSTRUCTION_TOO_LONG"
DUPLICATE_NODE_ID = "DUPLICATE_NODE_ID"
GRAPH_CYCLE = "GRAPH_CYCLE"
EMPTY_PLAN = "EMPTY_PLAN"
UNKNOWN_NODE_REFERENCE = "UNKNOWN_NODE_REFERENCE"
INVALID_CONTAINER = "INVALID_CONTAINER"
UNRESOLVED_REFERENCE = "UNRESOLVED_REFERENCE"
UNKNOWN_TOOL = "UNKNOWN_TOOL"
MISSING_TERMINAL = "MISSING_TERMINAL"
MISSING_START = "MISSING_START"
DANGLING_EDGE = "DANGLING_EDGE"
MODEL_ERROR = "MODEL_ERROR"
class WorkflowGenerateErrorDict(TypedDict):

View File

@ -9684,7 +9684,7 @@ Generate a Dify workflow graph from natural language
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workflow graph generated successfully | **application/json**: [GeneratorResponse](#generatorresponse)<br> |
| 200 | Workflow graph generated successfully | **application/json**: [WorkflowGenerateResponse](#workflowgenerateresponse)<br> |
| 400 | Invalid request parameters | |
| 402 | Provider quota exceeded | |
@ -9699,10 +9699,10 @@ Stream a Dify workflow graph (plan then result) via SSE
#### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Server-Sent Events stream of plan/result events |
| 400 | Invalid request parameters |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Server-Sent Events stream; each data frame matches this plan/result event schema | **application/json**: [WorkflowGenerateStreamEventResponse](#workflowgeneratestreameventresponse)<br> |
| 400 | Invalid request parameters | |
### [POST] /workflow-generate/suggestions
Suggest example workflow-generator instructions for the tenant
@ -9717,7 +9717,7 @@ Suggest example workflow-generator instructions for the tenant
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Suggestions generated successfully | **application/json**: [GeneratorResponse](#generatorresponse)<br> |
| 200 | Suggestions generated successfully | **application/json**: [WorkflowInstructionSuggestionsResponse](#workflowinstructionsuggestionsresponse)<br> |
| 400 | Invalid request parameters | |
### [GET] /workflow/{workflow_run_id}/events
@ -23172,6 +23172,20 @@ How a workflow node is bound to an Agent.
| number_limits | integer | | No |
| transfer_methods | [ string ] | | No |
#### WorkflowGenerateErrorCode
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| WorkflowGenerateErrorCode | string | | |
#### WorkflowGenerateErrorResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| code | [WorkflowGenerateErrorCode](#workflowgenerateerrorcode) | | Yes |
| detail | string | | Yes |
| node_id | string | | No |
#### WorkflowGeneratePayload
Payload for the cmd+k `/create` and `/refine` workflow generator endpoint.
@ -23182,12 +23196,107 @@ can reuse its existing handler.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| current_graph | object | Existing draft graph to refine (cmd+k `/refine`); omit for create-from-scratch | No |
| current_graph | [WorkflowGraph](#workflowgraph) | Existing draft graph to refine (cmd+k `/refine`); omit for create-from-scratch | No |
| ideal_output | string | Optional sample output for grounding | No |
| instruction | string | Natural-language workflow description | Yes |
| mode | string, <br>**Available values:** "advanced-chat", "auto", "workflow" | Target app mode for the generated graph; 'auto' lets the backend classify the instruction<br>*Enum:* `"advanced-chat"`, `"auto"`, `"workflow"` | Yes |
| model_config | [ModelConfig](#modelconfig) | Model configuration | Yes |
#### WorkflowGeneratePlanEventResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| app_name | string | | No |
| description | string | | No |
| event | string | | No |
| icon | string | | No |
| mode | string | *Enum:* `"advanced-chat"`, `"workflow"` | Yes |
| nodes | [ [WorkflowPlanNodeResponse](#workflowplannoderesponse) ] | | Yes |
| start_inputs | [ [WorkflowPlanStartInputResponse](#workflowplanstartinputresponse) ] | | No |
| title | string | | No |
#### WorkflowGenerateResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| app_name | string | | No |
| error | string | | No |
| errors | [ [WorkflowGenerateErrorResponse](#workflowgenerateerrorresponse) ] | | No |
| graph | [WorkflowGraph](#workflowgraph) | | Yes |
| icon | string | | No |
| message | string | | No |
| mode | string | | No |
#### WorkflowGenerateResultEventResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| app_name | string | | No |
| error | string | | No |
| errors | [ [WorkflowGenerateErrorResponse](#workflowgenerateerrorresponse) ] | | No |
| event | string | | No |
| graph | [WorkflowGraph](#workflowgraph) | | Yes |
| icon | string | | No |
| message | string | | No |
| mode | string | | No |
#### WorkflowGenerateStreamEventResponse
Schema for each JSON object carried by an SSE ``data:`` frame.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| WorkflowGenerateStreamEventResponse | [WorkflowGeneratePlanEventResponse](#workflowgenerateplaneventresponse)<br>[WorkflowGenerateResultEventResponse](#workflowgenerateresulteventresponse) | Schema for each JSON object carried by an SSE ``data:`` frame. | |
#### WorkflowGraph
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| edges | [ [WorkflowGraphEdge](#workflowgraphedge) ] | | Yes |
| nodes | [ [WorkflowGraphNode](#workflowgraphnode) ] | | Yes |
| viewport | [WorkflowGraphViewport](#workflowgraphviewport) | | Yes |
#### WorkflowGraphEdge
React Flow edge shape with extensible renderer metadata.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| id | string | | Yes |
| source | string | | Yes |
| target | string | | Yes |
| type | string | | Yes |
#### WorkflowGraphNode
React Flow node shape accepted and returned by the generator.
Node-specific configuration lives under ``data`` and wrapper metadata
differs for container children, so unknown wrapper fields must survive
request validation and response serialization.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| data | object | | Yes |
| id | string | | Yes |
| position | [WorkflowGraphPosition](#workflowgraphposition) | | Yes |
| type | string | | Yes |
#### WorkflowGraphPosition
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| x | number | | Yes |
| y | number | | Yes |
#### WorkflowGraphViewport
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| x | number | | Yes |
| y | number | | Yes |
| zoom | number | | Yes |
#### WorkflowInstructionSuggestionsPayload
Payload for the workflow-generator instruction-suggestions endpoint.
@ -23202,6 +23311,12 @@ tenant's default model. The underlying generator never raises — an empty
| language | string | Optional language to write the suggestions in | No |
| mode | string, <br>**Available values:** "advanced-chat", "workflow" | Target app mode for the suggestions<br>*Enum:* `"advanced-chat"`, `"workflow"` | Yes |
#### WorkflowInstructionSuggestionsResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| suggestions | [ string ] | | Yes |
#### WorkflowListQuery
| Name | Type | Description | Required |
@ -23289,6 +23404,22 @@ tenant's default model. The underlying generator never raises — an empty
| paused_at | string | | No |
| paused_nodes | [ [PausedNodeResponse](#pausednoderesponse) ] | | Yes |
#### WorkflowPlanNodeResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| label | string | | Yes |
| node_type | string | | Yes |
| purpose | string | | No |
#### WorkflowPlanStartInputResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| label | string | | No |
| type | string | | No |
| variable | string | | Yes |
#### WorkflowPreviousNodeOutputRef
| Name | Type | Description | Required |

View File

@ -268,6 +268,20 @@ def _workflow_generate_payload() -> dict:
}
def _workflow_graph(*, node_id: str | None = None) -> dict:
nodes = []
if node_id:
nodes.append(
{
"id": node_id,
"type": "custom",
"position": {"x": 0, "y": 0},
"data": {"type": "start", "title": "Start"},
}
)
return {"nodes": nodes, "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}}
def _stub_workflow_service(monkeypatch: pytest.MonkeyPatch, returns=None, raises: Exception | None = None):
def _call(**_kwargs):
if raises is not None:
@ -286,10 +300,15 @@ def test_workflow_generate_returns_service_result(app: Flask, monkeypatch: pytes
method = unwrap(api.post)
expected = {
"graph": {"nodes": [{"id": "node-1"}], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
"graph": _workflow_graph(node_id="node-1"),
"message": "Summarize",
"app_name": "",
"icon": "",
"error": "",
"errors": [],
"mode": None,
}
expected["graph"]["nodes"][0]["parentId"] = "container-1"
_stub_workflow_service(monkeypatch, returns=expected)
with app.test_request_context(
@ -302,6 +321,17 @@ def test_workflow_generate_returns_service_result(app: Flask, monkeypatch: pytes
assert response == expected
def test_workflow_generate_response_schema_is_concrete() -> None:
schema = generator_module.WorkflowGenerateResponse.model_json_schema()
assert schema["properties"]["graph"]["$ref"].endswith("/$defs/WorkflowGraph")
error_items = schema["properties"]["errors"]["items"]
assert error_items["$ref"].endswith("/$defs/WorkflowGenerateErrorResponse")
assert set(schema["$defs"]["WorkflowGenerateErrorCode"]["enum"]) == {
code.value for code in generator_module.WorkflowGenerateErrorCode
}
def test_workflow_generate_maps_provider_token_error(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
"""ProviderTokenNotInitError → ProviderNotInitializeError so the frontend
can render the same "provider missing" UX as /rule-generate."""
@ -421,7 +451,7 @@ def test_workflow_generate_forwards_current_graph_for_refine(app: Flask, monkeyp
monkeypatch.setattr(generator_module.WorkflowGeneratorService, "generate_workflow_graph", _capture)
graph = {"nodes": [{"id": "node1"}], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}}
graph = _workflow_graph(node_id="node1")
payload = _workflow_generate_payload()
payload["current_graph"] = graph
with app.test_request_context(
@ -613,7 +643,7 @@ def test_workflow_generate_stream_emits_plan_then_result(app: Flask, monkeypatch
def _stream(**_kwargs):
yield ("plan", {"title": "Summarizer", "mode": "workflow", "nodes": []})
yield ("result", {"graph": {"nodes": []}, "error": "", "mode": "workflow"})
yield ("result", {"graph": _workflow_graph(), "error": "", "mode": "workflow"})
monkeypatch.setattr(generator_module.WorkflowGeneratorService, "generate_workflow_graph_stream", _stream)

View File

@ -119,6 +119,18 @@ class TestGetBuilderSystemPrompt:
scoped = get_builder_system_prompt("workflow", {"start", "llm", "end"})
assert len(scoped) < len(BUILDER_SYSTEM_PROMPT_WORKFLOW)
def test_documents_multi_retrieval_fan_in(self):
prompt = get_builder_system_prompt(
"workflow",
{"start", "knowledge-retrieval", "llm", "end"},
)
assert "context.variable_selector accepts only one selector" in prompt
assert 'value_selector: ["node2", "result"]' in prompt
assert 'value_selector: ["node3", "result"]' in prompt
assert "edge from EACH retrieval node to the template" in prompt
assert 'template\'s ``["<template-node-id>", "output"]``' in prompt
class TestBuildNodeConfigCheatsheet:
def test_none_returns_full_cheatsheet(self):

View File

@ -8,10 +8,12 @@ readable error envelope.
"""
import json
from copy import deepcopy
from typing import Any, cast
from unittest.mock import MagicMock
import pytest
from jinja2 import Template
from core.workflow.generator.runner import WorkflowGenerator
from core.workflow.generator.types import GraphDict
@ -1382,6 +1384,253 @@ class TestWorkflowGeneratorVariableReferences:
assert start["data"]["variables"] == []
assert result["error"] == ""
@pytest.mark.parametrize(
("mode", "terminal_type", "expected_selector", "expected_start_variables"),
[
("advanced-chat", "answer", ["sys", "query"], []),
("workflow", "end", ["node1", "query"], ["query"]),
],
)
def test_normalizes_malformed_sys_query_selector(
self,
mode,
terminal_type,
expected_selector,
expected_start_variables,
):
terminal_data = {"type": terminal_type, "title": "Terminal"}
if terminal_type == "answer":
terminal_data["answer"] = "{{#node2.result#}}"
else:
terminal_data["outputs"] = [{"variable": "result", "value_selector": ["node2", "result"]}]
graph = cast(
GraphDict,
{
"nodes": [
{
"id": "node1",
"type": "custom",
"position": {"x": 0, "y": 0},
"data": {"type": "start", "title": "Start", "variables": []},
},
{
"id": "node2",
"type": "custom",
"position": {"x": 0, "y": 0},
"data": {
"type": "code",
"title": "Code",
"variables": [{"variable": "query", "value_selector": ["sys,query"]}],
"outputs": {"result": {"type": "string"}},
},
},
{
"id": "node3",
"type": "custom",
"position": {"x": 0, "y": 0},
"data": terminal_data,
},
],
"edges": [
{"id": "e1", "source": "node1", "target": "node2", "type": "custom"},
{"id": "e2", "source": "node2", "target": "node3", "type": "custom"},
],
"viewport": {"x": 0, "y": 0, "zoom": 0.7},
},
)
result = WorkflowGenerator._postprocess_graph(graph=graph, mode=mode)
start_node = next(node for node in result["nodes"] if node["id"] == "node1")
code_node = next(node for node in result["nodes"] if node["id"] == "node2")
assert code_node["data"]["variables"][0]["value_selector"] == expected_selector
assert [variable["variable"] for variable in start_node["data"]["variables"]] == expected_start_variables
assert WorkflowGenerator._validate_structure(graph=result, mode=mode) == []
@pytest.mark.parametrize(
"consumer_data",
[
{
"type": "llm",
"prompt_template": [{"role": "user", "text": "Question: {{#sys,query#}}"}],
},
{"type": "question-classifier", "query_variable_selector": ["sys.query"]},
{"type": "knowledge-retrieval", "query_variable_selector": "sys.query"},
{
"type": "if-else",
"cases": [{"conditions": [{"variable_selector": ["sys,query"]}]}],
},
{"type": "parameter-extractor", "query": [["sys", "query"]]},
{"type": "variable-aggregator", "variables": [["sys.query"]]},
{
"type": "tool",
"tool_parameters": {"query": {"type": "variable", "value": ["sys,query"]}},
},
],
)
@pytest.mark.parametrize(("mode", "expected_node_id"), [("advanced-chat", "sys"), ("workflow", "node1")])
def test_normalizes_sys_query_references_across_node_types(self, consumer_data, mode, expected_node_id):
graph = cast(
GraphDict,
{
"nodes": [
{
"id": "node1",
"type": "custom",
"position": {"x": 0, "y": 0},
"data": {
"type": "start",
"title": "Start",
"variables": [],
# These are literals, not selectors, even though
# their values happen to resemble one.
"options": ["sys", "query"],
},
},
{
"id": "node2",
"type": "custom",
"position": {"x": 0, "y": 0},
"data": {"title": "Consumer", **deepcopy(consumer_data)},
},
],
"edges": [],
"viewport": {"x": 0, "y": 0, "zoom": 0.7},
},
)
result = WorkflowGenerator._postprocess_graph(graph=graph, mode=mode)
refs: set[tuple[str, str]] = set()
consumer = next(node for node in result["nodes"] if node["id"] == "node2")
WorkflowGenerator._collect_refs_in_data(consumer["data"], refs)
assert refs == {(expected_node_id, "query")}
start = next(node for node in result["nodes"] if node["id"] == "node1")
assert start["data"]["options"] == ["sys", "query"]
expected_start_variables = [] if mode == "advanced-chat" else ["query"]
assert [variable["variable"] for variable in start["data"]["variables"]] == expected_start_variables
def test_repairs_llm_that_uses_only_one_of_two_incoming_retrieval_results(self):
graph = cast(
GraphDict,
{
"nodes": [
{
"id": "node1",
"type": "custom",
"position": {"x": 0, "y": 0},
"data": {
"type": "start",
"title": "Start",
"variables": [{"variable": "query", "type": "paragraph"}],
},
},
{
"id": "node2",
"type": "custom",
"position": {"x": 0, "y": 0},
"data": {
"type": "knowledge-retrieval",
"title": "Knowledge A",
"query_variable_selector": ["node1", "query"],
},
},
{
"id": "node3",
"type": "custom",
"position": {"x": 0, "y": 0},
"data": {
"type": "knowledge-retrieval",
"title": "Knowledge B",
"query_variable_selector": ["node1", "query"],
},
},
{
"id": "node4",
"type": "custom",
"position": {"x": 0, "y": 0},
"data": {
"type": "llm",
"title": "Synthesize",
"context": {"enabled": True, "variable_selector": ["node2", "result"]},
"prompt_template": [
{
"role": "user",
"text": "Answer using all retrieved knowledge.",
}
],
},
},
{
"id": "node5",
"type": "custom",
"position": {"x": 0, "y": 0},
"data": {
"type": "end",
"title": "End",
"outputs": [{"variable": "answer", "value_selector": ["node4", "text"]}],
},
},
],
"edges": [
{"id": "a", "source": "node1", "target": "node2", "type": "custom"},
{"id": "b", "source": "node1", "target": "node3", "type": "custom"},
{"id": "c", "source": "node2", "target": "node4", "type": "custom"},
{"id": "d", "source": "node3", "target": "node4", "type": "custom"},
{"id": "e", "source": "node4", "target": "node5", "type": "custom"},
],
"viewport": {"x": 0, "y": 0, "zoom": 0.7},
},
)
result = WorkflowGenerator._postprocess_graph(graph=graph, mode="workflow")
llm = next(node for node in result["nodes"] if node["id"] == "node4")
template = next(node for node in result["nodes"] if node["data"]["type"] == "template-transform")
template_refs: set[tuple[str, str]] = set()
WorkflowGenerator._collect_refs_in_data(template["data"], template_refs)
assert template_refs == {("node2", "result"), ("node3", "result")}
rendered_context = Template(template["data"]["template"]).render(
knowledge_1=[{"content": "Alpha result"}],
knowledge_2=[{"content": "Beta result"}],
)
assert "## Knowledge source 1\nAlpha result" in rendered_context
assert "## Knowledge source 2\nBeta result" in rendered_context
assert llm["data"]["context"] == {
"enabled": True,
"variable_selector": [template["id"], "output"],
}
assert llm["data"]["prompt_template"][0]["text"] == ("Answer using all retrieved knowledge.\n\n{{#context#}}")
assert {edge["source"] for edge in result["edges"] if edge["target"] == template["id"]} == {
"node2",
"node3",
}
assert {edge["source"] for edge in result["edges"] if edge["target"] == "node4"} == {template["id"]}
assert WorkflowGenerator._validate_structure(graph=result, mode="workflow") == []
def test_inserts_template_into_plan_with_two_retrievals_and_one_llm(self):
plan_nodes = [
{"label": "Start", "node_type": "start", "purpose": "Accept the query."},
{"label": "Knowledge A", "node_type": "knowledge-retrieval", "purpose": "Search source A."},
{"label": "Knowledge B", "node_type": "knowledge-retrieval", "purpose": "Search source B."},
{"label": "Synthesize", "node_type": "llm", "purpose": "Answer from both sources."},
{"label": "End", "node_type": "end", "purpose": "Return the answer."},
]
WorkflowGenerator._insert_multi_retrieval_template_plan(plan_nodes)
assert [node["node_type"] for node in plan_nodes] == [
"start",
"knowledge-retrieval",
"knowledge-retrieval",
"template-transform",
"llm",
"end",
]
assert plan_nodes[3]["label"] == "Combine Knowledge"
def test_start_inputs_flow_into_builder_user_prompt(self):
# The planner's ``start_inputs`` must be visible to the builder so
# it can populate ``start.data.variables`` proactively. We sniff the
@ -1944,11 +2193,10 @@ class TestWorkflowGeneratorStructuredErrors:
codes = [e["code"] for e in result["errors"]]
assert "MISSING_TERMINAL" in codes
def test_validator_emits_unresolved_reference_for_non_start_node(self):
# The LLM node tries to reference a key the CODE node never declares
# (its ``outputs`` dict only has ``summary``, not ``mystery``). The
# postprocess only auto-injects MISSING ``start`` vars, so the
# validator must surface non-start unresolved refs.
def test_repairs_unresolved_reference_when_source_has_one_output(self):
# The LLM node references a key the CODE node never declares, but the
# source exposes exactly one output. Postprocessing can therefore
# repair the selector without guessing or changing the graph shape.
planner = self._planner(
[
{"label": "Start", "node_type": "start", "purpose": "x"},
@ -1987,7 +2235,11 @@ class TestWorkflowGeneratorStructuredErrors:
"id": "node4",
"type": "custom",
"position": {"x": 0, "y": 0},
"data": {"type": "end", "title": "End"},
"data": {
"type": "end",
"title": "End",
"outputs": [{"variable": "out", "value_selector": ["node2", "mystery"]}],
},
},
],
edges=[
@ -2007,11 +2259,43 @@ class TestWorkflowGeneratorStructuredErrors:
mode="workflow",
instruction="x",
)
codes = [e["code"] for e in result["errors"]]
assert "UNRESOLVED_REFERENCE" in codes
# Detail should mention the offending key so the user can find it.
unresolved = next(e for e in result["errors"] if e["code"] == "UNRESOLVED_REFERENCE")
assert "mystery" in unresolved["detail"]
assert result["error"] == ""
llm_node = next(node for node in result["graph"]["nodes"] if node["id"] == "node3")
assert llm_node["data"]["prompt_template"][0]["text"] == "Look at {{#node2.summary#}}."
end_node = next(node for node in result["graph"]["nodes"] if node["id"] == "node4")
assert end_node["data"]["outputs"][0]["value_selector"] == ["node2", "summary"]
def test_keeps_unresolved_reference_when_source_outputs_are_ambiguous(self):
nodes = [
{
"id": "node2",
"data": {
"type": "code",
"outputs": {
"summary": {"type": "string"},
"details": {"type": "string"},
},
},
},
{
"id": "node3",
"data": {
"type": "llm",
"prompt_template": [{"role": "user", "text": "Look at {{#node2.mystery#}}."}],
},
},
]
WorkflowGenerator._reconcile_variable_references(nodes=nodes, mode="workflow")
errors = WorkflowGenerator._collect_unresolved_refs(nodes=nodes, mode="workflow")
assert errors == [
{
"code": "UNRESOLVED_REFERENCE",
"detail": "Reference {#node2.mystery#} not declared on node 'node2'",
"node_id": "node2",
}
]
def test_planner_json_failure_retries_once_then_recovers(self):
# First planner response is non-JSON (the LLM wrapped the response in

View File

@ -5,16 +5,26 @@ export type ClientOptions = {
}
export type WorkflowGeneratePayload = {
current_graph?: {
[key: string]: unknown
} | null
current_graph?: WorkflowGraph | null
ideal_output?: string
instruction: string
mode: 'advanced-chat' | 'auto' | 'workflow'
model_config: ModelConfig
}
export type GeneratorResponse = unknown
export type WorkflowGenerateResponse = {
app_name?: string
error?: string
errors?: Array<WorkflowGenerateErrorResponse>
graph: WorkflowGraph
icon?: string
message?: string
mode?: 'advanced-chat' | 'workflow' | null
}
export type WorkflowGenerateStreamEventResponse =
| WorkflowGeneratePlanEventResponse
| WorkflowGenerateResultEventResponse
export type WorkflowInstructionSuggestionsPayload = {
count?: number
@ -22,6 +32,16 @@ export type WorkflowInstructionSuggestionsPayload = {
mode: 'advanced-chat' | 'workflow'
}
export type WorkflowInstructionSuggestionsResponse = {
suggestions: Array<string>
}
export type WorkflowGraph = {
edges: Array<WorkflowGraphEdge>
nodes: Array<WorkflowGraphNode>
viewport: WorkflowGraphViewport
}
export type ModelConfig = {
completion_params?: {
[key: string]: unknown
@ -31,8 +51,94 @@ export type ModelConfig = {
provider: string
}
export type WorkflowGenerateErrorResponse = {
code: WorkflowGenerateErrorCode
detail: string
node_id?: string | null
}
export type WorkflowGeneratePlanEventResponse = {
app_name?: string
description?: string
event?: 'plan'
icon?: string
mode: 'advanced-chat' | 'workflow'
nodes: Array<WorkflowPlanNodeResponse>
start_inputs?: Array<WorkflowPlanStartInputResponse>
title?: string
}
export type WorkflowGenerateResultEventResponse = {
app_name?: string
error?: string
errors?: Array<WorkflowGenerateErrorResponse>
event?: 'result'
graph: WorkflowGraph
icon?: string
message?: string
mode?: 'advanced-chat' | 'workflow' | null
}
export type WorkflowGraphEdge = {
id: string
source: string
target: string
type: string
[key: string]: unknown
}
export type WorkflowGraphNode = {
data: {
[key: string]: unknown
}
id: string
position: WorkflowGraphPosition
type: string
[key: string]: unknown
}
export type WorkflowGraphViewport = {
x: number
y: number
zoom: number
}
export type LlmMode = 'chat' | 'completion'
export type WorkflowGenerateErrorCode =
| 'DANGLING_EDGE'
| 'DUPLICATE_NODE_ID'
| 'EMPTY_INSTRUCTION'
| 'EMPTY_PLAN'
| 'GRAPH_CYCLE'
| 'INSTRUCTION_TOO_LONG'
| 'INVALID_CONTAINER'
| 'INVALID_JSON'
| 'INVALID_SCHEMA'
| 'MISSING_START'
| 'MISSING_TERMINAL'
| 'MODEL_ERROR'
| 'UNKNOWN_NODE_REFERENCE'
| 'UNKNOWN_TOOL'
| 'UNRESOLVED_REFERENCE'
export type WorkflowPlanNodeResponse = {
label: string
node_type: string
purpose?: string
}
export type WorkflowPlanStartInputResponse = {
label?: string
type?: string
variable: string
}
export type WorkflowGraphPosition = {
x: number
y: number
}
export type PostWorkflowGenerateData = {
body: WorkflowGeneratePayload
path?: never
@ -46,7 +152,7 @@ export type PostWorkflowGenerateErrors = {
}
export type PostWorkflowGenerateResponses = {
200: GeneratorResponse
200: WorkflowGenerateResponse
}
export type PostWorkflowGenerateResponse =
@ -64,9 +170,7 @@ export type PostWorkflowGenerateStreamErrors = {
}
export type PostWorkflowGenerateStreamResponses = {
200: {
[key: string]: unknown
}
200: WorkflowGenerateStreamEventResponse
}
export type PostWorkflowGenerateStreamResponse =
@ -84,7 +188,7 @@ export type PostWorkflowGenerateSuggestionsErrors = {
}
export type PostWorkflowGenerateSuggestionsResponses = {
200: GeneratorResponse
200: WorkflowInstructionSuggestionsResponse
}
export type PostWorkflowGenerateSuggestionsResponse =

View File

@ -2,11 +2,6 @@
import * as z from 'zod'
/**
* GeneratorResponse
*/
export const zGeneratorResponse = z.unknown()
/**
* WorkflowInstructionSuggestionsPayload
*
@ -22,6 +17,34 @@ export const zWorkflowInstructionSuggestionsPayload = z.object({
mode: z.enum(['advanced-chat', 'workflow']),
})
/**
* WorkflowInstructionSuggestionsResponse
*/
export const zWorkflowInstructionSuggestionsResponse = z.object({
suggestions: z.array(z.string()),
})
/**
* WorkflowGraphEdge
*
* React Flow edge shape with extensible renderer metadata.
*/
export const zWorkflowGraphEdge = z.object({
id: z.string(),
source: z.string(),
target: z.string(),
type: z.string(),
})
/**
* WorkflowGraphViewport
*/
export const zWorkflowGraphViewport = z.object({
x: z.number(),
y: z.number(),
zoom: z.number(),
})
/**
* LLMMode
*
@ -39,6 +62,101 @@ export const zModelConfig = z.object({
provider: z.string(),
})
/**
* WorkflowGenerateErrorCode
*/
export const zWorkflowGenerateErrorCode = z.enum([
'DANGLING_EDGE',
'DUPLICATE_NODE_ID',
'EMPTY_INSTRUCTION',
'EMPTY_PLAN',
'GRAPH_CYCLE',
'INSTRUCTION_TOO_LONG',
'INVALID_CONTAINER',
'INVALID_JSON',
'INVALID_SCHEMA',
'MISSING_START',
'MISSING_TERMINAL',
'MODEL_ERROR',
'UNKNOWN_NODE_REFERENCE',
'UNKNOWN_TOOL',
'UNRESOLVED_REFERENCE',
])
/**
* WorkflowGenerateErrorResponse
*/
export const zWorkflowGenerateErrorResponse = z.object({
code: zWorkflowGenerateErrorCode,
detail: z.string(),
node_id: z.string().nullish(),
})
/**
* WorkflowPlanNodeResponse
*/
export const zWorkflowPlanNodeResponse = z.object({
label: z.string(),
node_type: z.string(),
purpose: z.string().optional().default(''),
})
/**
* WorkflowPlanStartInputResponse
*/
export const zWorkflowPlanStartInputResponse = z.object({
label: z.string().optional().default(''),
type: z.string().optional().default(''),
variable: z.string(),
})
/**
* WorkflowGeneratePlanEventResponse
*/
export const zWorkflowGeneratePlanEventResponse = z.object({
app_name: z.string().optional().default(''),
description: z.string().optional().default(''),
event: z.literal('plan').optional().default('plan'),
icon: z.string().optional().default(''),
mode: z.enum(['advanced-chat', 'workflow']),
nodes: z.array(zWorkflowPlanNodeResponse),
start_inputs: z.array(zWorkflowPlanStartInputResponse).optional(),
title: z.string().optional().default(''),
})
/**
* WorkflowGraphPosition
*/
export const zWorkflowGraphPosition = z.object({
x: z.number(),
y: z.number(),
})
/**
* WorkflowGraphNode
*
* React Flow node shape accepted and returned by the generator.
*
* Node-specific configuration lives under ``data`` and wrapper metadata
* differs for container children, so unknown wrapper fields must survive
* request validation and response serialization.
*/
export const zWorkflowGraphNode = z.object({
data: z.record(z.string(), z.unknown()),
id: z.string(),
position: zWorkflowGraphPosition,
type: z.string(),
})
/**
* WorkflowGraph
*/
export const zWorkflowGraph = z.object({
edges: z.array(zWorkflowGraphEdge),
nodes: z.array(zWorkflowGraphNode),
viewport: zWorkflowGraphViewport,
})
/**
* WorkflowGeneratePayload
*
@ -49,30 +167,67 @@ export const zModelConfig = z.object({
* can reuse its existing handler.
*/
export const zWorkflowGeneratePayload = z.object({
current_graph: z.record(z.string(), z.unknown()).nullish(),
current_graph: zWorkflowGraph.nullish(),
ideal_output: z.string().optional().default(''),
instruction: z.string(),
mode: z.enum(['advanced-chat', 'auto', 'workflow']),
model_config: zModelConfig,
})
/**
* WorkflowGenerateResponse
*/
export const zWorkflowGenerateResponse = z.object({
app_name: z.string().optional().default(''),
error: z.string().optional().default(''),
errors: z.array(zWorkflowGenerateErrorResponse).optional(),
graph: zWorkflowGraph,
icon: z.string().optional().default(''),
message: z.string().optional().default(''),
mode: z.enum(['advanced-chat', 'workflow']).nullish(),
})
/**
* WorkflowGenerateResultEventResponse
*/
export const zWorkflowGenerateResultEventResponse = z.object({
app_name: z.string().optional().default(''),
error: z.string().optional().default(''),
errors: z.array(zWorkflowGenerateErrorResponse).optional(),
event: z.literal('result').optional().default('result'),
graph: zWorkflowGraph,
icon: z.string().optional().default(''),
message: z.string().optional().default(''),
mode: z.enum(['advanced-chat', 'workflow']).nullish(),
})
/**
* WorkflowGenerateStreamEventResponse
*
* Schema for each JSON object carried by an SSE ``data:`` frame.
*/
export const zWorkflowGenerateStreamEventResponse = z.union([
zWorkflowGeneratePlanEventResponse,
zWorkflowGenerateResultEventResponse,
])
export const zPostWorkflowGenerateBody = zWorkflowGeneratePayload
/**
* Workflow graph generated successfully
*/
export const zPostWorkflowGenerateResponse = zGeneratorResponse
export const zPostWorkflowGenerateResponse = zWorkflowGenerateResponse
export const zPostWorkflowGenerateStreamBody = zWorkflowGeneratePayload
/**
* Server-Sent Events stream of plan/result events
* Server-Sent Events stream; each data frame matches this plan/result event schema
*/
export const zPostWorkflowGenerateStreamResponse = z.record(z.string(), z.unknown())
export const zPostWorkflowGenerateStreamResponse = zWorkflowGenerateStreamEventResponse
export const zPostWorkflowGenerateSuggestionsBody = zWorkflowInstructionSuggestionsPayload
/**
* Suggestions generated successfully
*/
export const zPostWorkflowGenerateSuggestionsResponse = zGeneratorResponse
export const zPostWorkflowGenerateSuggestionsResponse = zWorkflowInstructionSuggestionsResponse

View File

@ -73,7 +73,7 @@ export const createCommand: SlashCommandHandler = {
aliases: ['new', 'generate'],
// Fallback only — the palette localises the root row via the slashKeyMap in
// command-selector.tsx (gotoAnything.actions.createCategoryDesc).
description: 'Create an AI-generated workflow or chatflow',
description: getI18n().t(($) => $['gotoAnything.actions.createCategoryDesc'], { ns: 'app' }),
mode: 'submenu',
async search(args: string, locale?: string) {

View File

@ -51,7 +51,7 @@ export const refineCommand: SlashCommandHandler = {
aliases: ['improve'],
// Fallback only — the palette localises the root row via the slashKeyMap in
// command-selector.tsx (gotoAnything.actions.refineCategoryDesc).
description: 'Refine the current workflow or chatflow graph',
description: getI18n().t(($) => $['gotoAnything.actions.refineCategoryDesc'], { ns: 'app' }),
mode: 'direct',
// Only surface inside a Workflow / Advanced-Chat Studio — elsewhere there's

View File

@ -101,12 +101,12 @@ describe('applyToNewApp', () => {
// Instruction-only-of-punctuation must still produce a usable, non-empty
// app name so create-app doesn't fail validation.
it('should fall back to "Generated Workflow" when the instruction is empty', async () => {
await applyToNewApp({ mode: 'workflow', graph: makeGraph(), instruction: ' ' })
it('should reject an empty instruction before creating an app', async () => {
await expect(
applyToNewApp({ mode: 'workflow', graph: makeGraph(), instruction: ' ' }),
).rejects.toThrow('Cannot create a generated app without an instruction.')
expect(mockCreateApp).toHaveBeenCalledWith(
expect.objectContaining({ name: 'Generated Workflow' }),
)
expect(mockCreateApp).not.toHaveBeenCalled()
})
// When the planner picks a name + emoji, those win over the

View File

@ -1,9 +1,9 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { fetchWorkflowInstructionSuggestions } from '@/service/debug'
import { fetchWorkflowInstructionSuggestions } from '@/service/workflow-generator'
import ExamplePrompts from '../example-prompts'
vi.mock('@/service/debug', () => ({
vi.mock('@/service/workflow-generator', () => ({
fetchWorkflowInstructionSuggestions: vi.fn(),
}))
@ -172,7 +172,9 @@ describe('ExamplePrompts', () => {
expect(mockFetch).toHaveBeenCalledTimes(1)
mockFetch.mockResolvedValue({ suggestions: ['second set'] })
await user.click(screen.getByTestId('workflow-gen-suggestions-refresh'))
await user.click(
screen.getByRole('button', { name: /workflowGenerator\.examples\.refresh/i }),
)
expect(await screen.findByRole('button', { name: 'second set' })).toBeInTheDocument()
expect(mockFetch).toHaveBeenCalledTimes(2)

View File

@ -1,4 +1,4 @@
import type { WorkflowGenPlan } from '@/service/debug'
import type { WorkflowGenPlan } from '@/service/workflow-generator'
import { render, screen } from '@testing-library/react'
import GenerationPlan from '../generation-plan'
@ -7,7 +7,9 @@ describe('GenerationPlan', () => {
// the user sees real progress rather than a bare spinner.
it('shows the planning state while the plan is null', () => {
render(<GenerationPlan plan={null} />)
expect(screen.getByText(/workflowGenerator\.phases\.planning/i)).toBeInTheDocument()
const status = screen.getByRole('status')
expect(status).toHaveTextContent(/workflowGenerator\.phases\.planning/i)
expect(status.parentElement).toHaveAttribute('aria-busy', 'true')
})
// Once the plan streams in, the outline (node purposes + identity) renders and
@ -29,7 +31,7 @@ describe('GenerationPlan', () => {
expect(screen.getByText('URL Summarizer')).toBeInTheDocument()
expect(screen.getByText('capture the URL')).toBeInTheDocument()
expect(screen.getByText('summarize the page')).toBeInTheDocument()
expect(screen.getByText(/workflowGenerator\.phases\.building/i)).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent(/workflowGenerator\.phases\.building/i)
// The planning-only state must be gone once a plan is present.
expect(screen.queryByText(/workflowGenerator\.phases\.planning/i)).not.toBeInTheDocument()
})

View File

@ -0,0 +1,268 @@
import type { WorkflowGenerateErrorResponse } from '@dify/contracts/api/console/workflow-generate/types.gen'
import type { GenerateWorkflowStreamCallbacks } from '@/service/workflow-generator'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import WorkflowGeneratorModal from '../index'
import { useWorkflowGeneratorStore } from '../store'
const mockGenerateWorkflow = vi.fn()
const mockGenerateWorkflowStream = vi.fn()
const mockFetchSuggestions = vi.fn().mockResolvedValue({ suggestions: [] })
const mockFetchWorkflowDraft = vi.fn()
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useSuspenseQuery: () => ({ data: { rbac_enabled: false } }),
}
})
vi.mock('@/features/system-features/client', () => ({
systemFeaturesQueryOptions: vi.fn(() => ({})),
}))
vi.mock('@/next/navigation', () => ({
useRouter: () => ({ push: vi.fn() }),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelListAndDefaultModelAndCurrentProviderAndModel: () => ({
defaultModel: {
model: 'gpt-4o',
provider: { provider: 'openai' },
},
}),
}))
vi.mock(
'@/app/components/header/account-setting/model-provider-page/model-parameter-modal',
() => ({
default: () => <div>model selector</div>,
}),
)
vi.mock('@/app/components/workflow/workflow-preview', () => ({
default: () => <div>workflow preview</div>,
}))
vi.mock('@/service/workflow-generator', () => ({
fetchWorkflowInstructionSuggestions: (...args: unknown[]) => mockFetchSuggestions(...args),
generateWorkflow: (...args: unknown[]) => mockGenerateWorkflow(...args),
generateWorkflowStream: (...args: unknown[]) => mockGenerateWorkflowStream(...args),
}))
vi.mock('@/service/workflow', () => ({
fetchWorkflowDraft: (...args: unknown[]) => mockFetchWorkflowDraft(...args),
}))
describe('WorkflowGeneratorModal', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
sessionStorage.clear()
useWorkflowGeneratorStore.setState({
isOpen: true,
mode: 'workflow',
intent: 'create',
currentAppId: null,
currentAppMode: null,
initialInstruction: '',
autoMode: false,
})
})
describe('Accessibility', () => {
it('should expose the dialog title and instruction label', async () => {
render(<WorkflowGeneratorModal />)
expect(screen.getByRole('dialog', { name: /workflowGenerator\.title/i })).toBeInTheDocument()
expect(
screen.getByRole('textbox', { name: /workflowGenerator\.instruction/i }),
).toBeInTheDocument()
})
it('should keep the instruction field keyboard-operable', async () => {
const user = userEvent.setup()
render(<WorkflowGeneratorModal />)
const instruction = screen.getByRole('textbox', {
name: /workflowGenerator\.instruction/i,
})
await user.type(instruction, 'Summarize a URL')
expect(instruction).toHaveValue('Summarize a URL')
})
})
describe('Generation errors', () => {
it('should surface an invalid schema error when generation returns an empty graph', async () => {
const user = userEvent.setup()
mockGenerateWorkflowStream.mockImplementation(
(_body: unknown, callbacks: GenerateWorkflowStreamCallbacks) => {
callbacks.onResult?.({
graph: { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } },
})
},
)
render(<WorkflowGeneratorModal />)
const instruction = screen.getByRole('textbox', {
name: /workflowGenerator\.instruction/i,
})
await user.type(instruction, 'Build a researched answer')
const generateButton = screen.getByRole('button', {
name: /workflowGenerator\.generate/i,
})
await waitFor(() => expect(generateButton).toBeEnabled())
await user.click(generateButton)
expect(
await screen.findByText(/workflowGenerator\.errors\.INVALID_SCHEMA/i),
).toBeInTheDocument()
})
it('should localize every structured generation error', async () => {
const user = userEvent.setup()
const errorCodes: WorkflowGenerateErrorResponse['code'][] = [
'DANGLING_EDGE',
'DUPLICATE_NODE_ID',
'EMPTY_INSTRUCTION',
'EMPTY_PLAN',
'GRAPH_CYCLE',
'INSTRUCTION_TOO_LONG',
'INVALID_CONTAINER',
'INVALID_JSON',
'INVALID_SCHEMA',
'MISSING_START',
'MISSING_TERMINAL',
'MODEL_ERROR',
'UNKNOWN_NODE_REFERENCE',
'UNKNOWN_TOOL',
'UNRESOLVED_REFERENCE',
]
let nextErrorIndex = 0
mockGenerateWorkflowStream.mockImplementation(
(_body: unknown, callbacks: GenerateWorkflowStreamCallbacks) => {
const code = errorCodes[nextErrorIndex++]!
callbacks.onResult?.({
errors: [{ code, detail: `${code} detail` }],
graph: { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } },
})
},
)
render(<WorkflowGeneratorModal />)
const instruction = screen.getByRole('textbox', {
name: /workflowGenerator\.instruction/i,
})
await user.type(instruction, 'Build a researched answer')
const generateButton = screen.getByRole('button', {
name: /workflowGenerator\.generate/i,
})
await waitFor(() => expect(generateButton).toBeEnabled())
for (const [index, code] of errorCodes.entries()) {
const action =
index === 0
? generateButton
: await screen.findByRole('button', {
name: /workflowGenerator\.regenerate/i,
})
await user.click(action)
expect(
await screen.findByText(new RegExp(`workflowGenerator\\.errors\\.${code}`, 'i')),
).toBeInTheDocument()
}
})
})
describe('Generation result', () => {
it('should include the current draft graph when refining a workflow', async () => {
const user = userEvent.setup()
const currentGraph = {
nodes: [
{
id: 'start',
type: 'custom',
position: { x: 0, y: 0 },
data: { type: 'start', title: 'Start' },
},
],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
}
mockFetchWorkflowDraft.mockResolvedValue({ graph: currentGraph })
mockGenerateWorkflowStream.mockImplementation(
(_body: unknown, callbacks: GenerateWorkflowStreamCallbacks) => {
callbacks.onResult?.({
errors: [{ code: 'MODEL_ERROR', detail: 'Stop after capturing the request' }],
graph: { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } },
})
},
)
useWorkflowGeneratorStore.setState({
intent: 'refine',
currentAppId: 'app-1',
currentAppMode: 'workflow',
})
render(<WorkflowGeneratorModal />)
await user.type(
screen.getByRole('textbox', { name: /workflowGenerator\.instruction/i }),
'Improve this workflow',
)
const generateButton = screen.getByRole('button', {
name: /workflowGenerator\.generate/i,
})
await waitFor(() => expect(generateButton).toBeEnabled())
await user.click(generateButton)
await waitFor(() => {
expect(mockGenerateWorkflowStream).toHaveBeenCalledWith(
expect.objectContaining({ current_graph: currentGraph }),
expect.any(Object),
)
})
})
it('should show the generated app identity', async () => {
const user = userEvent.setup()
mockGenerateWorkflowStream.mockImplementation(
(_body: unknown, callbacks: GenerateWorkflowStreamCallbacks) => {
callbacks.onResult?.({
app_name: 'Research assistant',
icon: '🧭',
graph: {
nodes: [
{
id: 'start',
type: 'custom',
position: { x: 0, y: 0 },
data: { type: 'start', title: 'Start' },
},
],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
})
},
)
render(<WorkflowGeneratorModal />)
await user.type(
screen.getByRole('textbox', { name: /workflowGenerator\.instruction/i }),
'Build a researched answer',
)
const generateButton = screen.getByRole('button', {
name: /workflowGenerator\.generate/i,
})
await waitFor(() => expect(generateButton).toBeEnabled())
await user.click(generateButton)
expect(await screen.findByText('🧭')).toBeInTheDocument()
expect(screen.getByText('Research assistant')).toBeInTheDocument()
})
})
})

View File

@ -57,7 +57,9 @@ const isHashCollisionResponse = (e: unknown): boolean => {
// strip trailing punctuation.
const deriveAppName = (instruction: string): string => {
const trimmed = instruction.trim().slice(0, 40)
return trimmed.replace(/[.,!?;:。,!?;:]+$/, '').trim() || 'Generated Workflow'
const name = trimmed.replace(/[.,!?;:。,!?;:]+$/, '').trim()
if (!name) throw new Error('Cannot create a generated app without an instruction.')
return name
}
type ApplyToNewAppParams = {

View File

@ -1,11 +1,10 @@
'use client'
import type { WorkflowGeneratorMode } from './types'
import { cn } from '@langgenius/dify-ui/cn'
import { RiRefreshLine } from '@remixicon/react'
import { useSessionStorageState } from 'ahooks'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { fetchWorkflowInstructionSuggestions } from '@/service/debug'
import { fetchWorkflowInstructionSuggestions } from '@/service/workflow-generator'
type Props = Readonly<{
mode: WorkflowGeneratorMode
@ -15,7 +14,12 @@ type Props = Readonly<{
const SUGGESTION_COUNT = 4
// Placeholder pill widths (px) while suggestions stream in — varied so the
// skeleton reads like a row of chips rather than a progress bar.
const SKELETON_WIDTHS = [88, 132, 104, 120]
const SKELETONS = [
{ id: 'short', width: 88 },
{ id: 'long', width: 132 },
{ id: 'medium', width: 104 },
{ id: 'wide', width: 120 },
] as const
// AbortController throws a DOMException in modern browsers and a plain Error in
// older / non-DOM environments — accept both so a user-triggered abort (modal
@ -60,7 +64,7 @@ const ExamplePrompts = ({ mode, onSelect }: Props) => {
})
const [isLoading, setIsLoading] = useState(false)
const abortRef = useRef<AbortController | null>(null)
const didInit = useRef(false)
const didInitRef = useRef(false)
const fetchSuggestions = useCallback(async () => {
abortRef.current?.abort()
@ -90,8 +94,8 @@ const ExamplePrompts = ({ mode, onSelect }: Props) => {
// is fixed per open (the modal remounts each time), so a mount-only effect is
// correct here.
useEffect(() => {
if (didInit.current) return
didInit.current = true
if (didInitRef.current) return
didInitRef.current = true
if (!cached || cached.length === 0) void fetchSuggestions()
return () => {
abortRef.current?.abort()
@ -111,32 +115,34 @@ const ExamplePrompts = ({ mode, onSelect }: Props) => {
</span>
<button
type="button"
data-testid="workflow-gen-suggestions-refresh"
aria-label={t(($) => $['workflowGenerator.examples.refresh'])}
title={t(($) => $['workflowGenerator.examples.refresh'])}
className="flex size-4 cursor-pointer items-center justify-center rounded text-text-quaternary hover:text-text-tertiary disabled:cursor-not-allowed disabled:opacity-50"
className="flex size-4 cursor-pointer items-center justify-center rounded text-text-quaternary outline-hidden hover:text-text-tertiary focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => {
void fetchSuggestions()
}}
disabled={isLoading}
>
<RiRefreshLine className={cn('size-3.5', isLoading && 'animate-spin')} />
<span
aria-hidden="true"
className={cn('i-ri-refresh-line size-3.5', isLoading && 'animate-spin')}
/>
</button>
</div>
<div className="flex flex-wrap gap-1.5">
{isLoading
? SKELETON_WIDTHS.map((w, i) => (
? SKELETONS.map(({ id, width }) => (
<div
key={i}
key={id}
className="h-[26px] animate-pulse rounded-md bg-components-button-secondary-bg"
style={{ width: w }}
style={{ width }}
/>
))
: prompts.map((prompt) => (
<button
key={prompt}
type="button"
className="cursor-pointer rounded-md border-[0.5px] border-divider-regular bg-components-button-secondary-bg px-2 py-1 system-xs-regular text-text-secondary hover:bg-components-button-secondary-bg-hover"
className="cursor-pointer rounded-md border-[0.5px] border-divider-regular bg-components-button-secondary-bg px-2 py-1 system-xs-regular text-text-secondary outline-hidden hover:bg-components-button-secondary-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
onClick={() => onSelect(prompt)}
>
{prompt}

View File

@ -1,7 +1,6 @@
'use client'
import type { BlockEnum } from '@/app/components/workflow/types'
import type { WorkflowGenPlan } from '@/service/debug'
import { RiLoader4Line } from '@remixicon/react'
import type { WorkflowGenPlan } from '@/service/workflow-generator'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { SkeletonContainer, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
@ -26,7 +25,10 @@ const SKELETON_ROWS = ['s1', 's2', 's3', 's4'] as const
const PlanningSkeleton = memo(() => {
const { t } = useTranslation('workflow')
return (
<div className="flex h-full w-0 grow flex-col bg-background-default-subtle p-6">
<div
aria-busy="true"
className="flex min-h-0 w-full grow flex-col bg-background-default-subtle p-6 md:w-0"
>
<SkeletonRow className="mb-3">
<SkeletonRectangle className="size-5 rounded-md" />
<SkeletonRectangle className="h-3 w-40" />
@ -44,8 +46,12 @@ const PlanningSkeleton = memo(() => {
))}
</SkeletonContainer>
</div>
<div className="mt-3 flex items-center gap-1.5 text-[13px] text-text-tertiary">
<RiLoader4Line className="size-4 animate-spin" />
<div
aria-live="polite"
className="mt-3 flex items-center gap-1.5 text-[13px] text-text-tertiary"
role="status"
>
<span aria-hidden="true" className="i-ri-loader-4-line size-4 animate-spin" />
<span>{t(($) => $['workflowGenerator.phases.planning'])}</span>
</div>
</div>
@ -68,10 +74,17 @@ const GenerationPlan = ({ plan }: Props) => {
if (!plan) return <PlanningSkeleton />
return (
<div className="flex h-full w-0 grow flex-col bg-background-default-subtle p-6">
<div
aria-busy="true"
className="flex min-h-0 w-full grow flex-col bg-background-default-subtle p-6 md:w-0"
>
{(plan.icon || plan.app_name || plan.title) && (
<div className="mb-3 flex items-center gap-2">
{plan.icon && <span className="text-xl leading-none">{plan.icon}</span>}
{plan.icon && (
<span aria-hidden="true" className="text-xl leading-none">
{plan.icon}
</span>
)}
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-text-primary">
{plan.app_name || plan.title}
@ -87,8 +100,11 @@ const GenerationPlan = ({ plan }: Props) => {
<div className="grow overflow-y-auto rounded-2xl border border-divider-subtle bg-background-default p-4">
<ol className="space-y-2.5">
{plan.nodes.map((node, index) => (
<li key={`${node.label}-${index}`} className="flex items-start gap-2.5">
{plan.nodes.map((node) => (
<li
key={`${node.node_type}-${node.label}-${node.purpose ?? ''}`}
className="flex items-start gap-2.5"
>
<BlockIcon type={node.node_type as BlockEnum} size="md" className="mt-px shrink-0" />
<div className="min-w-0">
<div className="system-sm-medium text-text-secondary">
@ -106,8 +122,12 @@ const GenerationPlan = ({ plan }: Props) => {
</ol>
</div>
<div className="mt-3 flex items-center gap-1.5 text-[13px] text-text-tertiary">
<RiLoader4Line className="size-4 animate-spin" />
<div
aria-live="polite"
className="mt-3 flex items-center gap-1.5 text-[13px] text-text-tertiary"
role="status"
>
<span aria-hidden="true" className="i-ri-loader-4-line size-4 animate-spin" />
<span>{t(($) => $['workflowGenerator.phases.building'])}</span>
</div>
</div>

View File

@ -1,4 +1,5 @@
'use client'
import type { WorkflowGenerateErrorResponse } from '@dify/contracts/api/console/workflow-generate/types.gen'
import type { SelectorParam, TFunction } from 'i18next'
import type { GeneratedGraph } from './types'
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
@ -6,7 +7,7 @@ import type {
GenerateWorkflowBody,
GenerateWorkflowResponse as StreamResult,
WorkflowGenPlan,
} from '@/service/debug'
} from '@/service/workflow-generator'
import type { CompletionParams, ModelModeType } from '@/types/app'
import {
AlertDialog,
@ -18,13 +19,12 @@ import {
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
import { Field, FieldLabel } from '@langgenius/dify-ui/field'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { RiErrorWarningLine } from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import VersionSelector from '@/app/components/app/configuration/config/automatic/version-selector'
@ -34,8 +34,8 @@ import ModelParameterModal from '@/app/components/header/account-setting/model-p
import WorkflowPreview from '@/app/components/workflow/workflow-preview'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useRouter } from '@/next/navigation'
import { generateWorkflow, generateWorkflowStream } from '@/service/debug'
import { fetchWorkflowDraft } from '@/service/workflow'
import { generateWorkflow, generateWorkflowStream } from '@/service/workflow-generator'
import { getRedirectionPath } from '@/utils/app-redirection'
import {
applyToCurrentApp,
@ -67,24 +67,8 @@ const MAX_INSTRUCTION_LENGTH = 10_000
// A single structured generation error. Mirrors the backend ``errors[]`` entry
// (stable ``code`` + human ``detail`` + optional ``node_id``) so the error panel
// can localise the message and point at the offending node.
type GenError = { code: string; detail: string; node_id?: string }
type WorkflowGeneratorErrorCode =
| 'DANGLING_EDGE'
| 'DUPLICATE_NODE_ID'
| 'EMPTY_INSTRUCTION'
| 'EMPTY_PLAN'
| 'GRAPH_CYCLE'
| 'INSTRUCTION_TOO_LONG'
| 'INVALID_CONTAINER'
| 'INVALID_JSON'
| 'INVALID_SCHEMA'
| 'MISSING_START'
| 'MISSING_TERMINAL'
| 'MODEL_ERROR'
| 'UNKNOWN_NODE_REFERENCE'
| 'UNKNOWN_TOOL'
| 'UNRESOLVED_REFERENCE'
type GenError = WorkflowGenerateErrorResponse
type WorkflowGeneratorErrorCode = WorkflowGenerateErrorResponse['code']
const workflowGeneratorErrorSelectors: Record<
WorkflowGeneratorErrorCode,
@ -107,20 +91,16 @@ const workflowGeneratorErrorSelectors: Record<
UNRESOLVED_REFERENCE: ($) => $['workflowGenerator.errors.UNRESOLVED_REFERENCE'],
}
function isWorkflowGeneratorErrorCode(code: string): code is WorkflowGeneratorErrorCode {
return Object.hasOwn(workflowGeneratorErrorSelectors, code)
}
function getWorkflowGeneratorErrorMessage(error: GenError, t: TFunction<'workflow'>) {
if (isWorkflowGeneratorErrorCode(error.code))
return t(workflowGeneratorErrorSelectors[error.code])
return error.detail || t(($) => $['workflowGenerator.generateFailed'])
return t(workflowGeneratorErrorSelectors[error.code])
}
const renderPlaceholder = (label: string) => (
<div className="flex h-full w-0 grow flex-col items-center justify-center space-y-3 px-8">
<span className="i-custom-vender-other-generator size-8 text-text-quaternary" />
<div className="flex min-h-0 w-full grow flex-col items-center justify-center space-y-3 px-8 md:w-0">
<span
aria-hidden="true"
className="i-custom-vender-other-generator size-8 text-text-quaternary"
/>
<div className="text-center text-[13px] leading-5 font-normal text-text-tertiary">{label}</div>
</div>
)
@ -172,7 +152,7 @@ const RecoveryDialog = ({
</AlertDialog>
)
const WorkflowGeneratorModal: React.FC = () => {
function WorkflowGeneratorModal() {
const { t } = useTranslation('workflow')
const router = useRouter()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
@ -336,12 +316,15 @@ const WorkflowGeneratorModal: React.FC = () => {
const handleResult = useCallback(
(res: StreamResult) => {
if (res.errors?.length) {
setGenError(res.errors as GenError[])
setGenError(res.errors)
return
}
if (!res.graph?.nodes?.length) {
setGenError([
{ code: 'EMPTY', detail: res.error || t(($) => $['workflowGenerator.generateFailed']) },
{
code: 'INVALID_SCHEMA',
detail: res.error || t(($) => $['workflowGenerator.generateFailed']),
},
])
return
}
@ -393,8 +376,10 @@ const WorkflowGeneratorModal: React.FC = () => {
// resolved concrete mode comes back on the result and drives apply.
mode: autoMode ? 'auto' : mode,
instruction,
model_config: model,
...(currentGraph ? { current_graph: currentGraph } : {}),
model_config: { ...model, mode: model.mode || 'chat' },
...(currentGraph
? { current_graph: currentGraph as unknown as GenerateWorkflowBody['current_graph'] }
: {}),
}
const finish = () => {
@ -582,26 +567,26 @@ const WorkflowGeneratorModal: React.FC = () => {
}
}}
>
<DialogContent className="h-[min(680px,calc(100dvh-2rem))] max-h-none! w-[1140px] max-w-none! min-w-[1140px] overflow-hidden! border-none p-0! text-left align-middle">
<div className="flex h-full min-h-0 flex-wrap">
<DialogContent className="h-[min(680px,calc(100dvh-2rem))] max-h-none! w-[calc(100vw-2rem)] max-w-[1140px]! min-w-0 overflow-hidden! border-none p-0! text-left align-middle">
<div className="flex h-full min-h-0 flex-col md:flex-row">
{/* Left pane: instructions + ideal output + model selector */}
<div className="h-full w-[570px] shrink-0 overflow-y-auto border-r border-divider-regular p-6">
<div className="max-h-[55%] w-full shrink-0 overflow-y-auto border-b border-divider-regular p-6 md:h-full md:max-h-none md:w-1/2 md:border-r md:border-b-0 lg:w-[570px]">
<div className="mb-5">
<div className="text-lg leading-[28px] font-bold text-text-primary">
<DialogTitle className="text-lg leading-[28px] font-bold text-text-primary">
{isRefine
? t(($) => $['workflowGenerator.refineTitle'], { mode: modeLabel })
: t(($) => $['workflowGenerator.title'], { mode: modeLabel })}
</div>
<div className="mt-1 text-[13px] font-normal text-text-tertiary">
</DialogTitle>
<DialogDescription className="mt-1 text-[13px] font-normal text-text-tertiary">
{isRefine
? t(($) => $['workflowGenerator.refineDescription'])
: t(($) => $['workflowGenerator.description'])}
</div>
</DialogDescription>
</div>
<div>
<ModelParameterModal
popupClassName="w-[520px]!"
popupClassName="w-[min(520px,calc(100vw-2rem))]!"
isAdvancedMode={true}
provider={model.provider}
completionParams={model.completion_params}
@ -612,10 +597,10 @@ const WorkflowGeneratorModal: React.FC = () => {
/>
</div>
<div className="mt-4">
<div className="mb-1.5 system-sm-semibold-uppercase text-text-secondary">
<Field className="mt-4 gap-0" name="workflow-generator-instruction">
<FieldLabel className="mb-1.5 system-sm-semibold-uppercase text-text-secondary">
{t(($) => $['workflowGenerator.instruction'])}
</div>
</FieldLabel>
<Textarea
// Autofocus is appropriate here: the modal's sole purpose is to
// capture an instruction, so focusing it on open aids the flow.
@ -638,6 +623,7 @@ const WorkflowGeneratorModal: React.FC = () => {
}
}}
maxLength={MAX_INSTRUCTION_LENGTH}
autoComplete="off"
/>
{/* Example prompts are create-from-scratch starters ("Summarize a
@ -668,22 +654,25 @@ const WorkflowGeneratorModal: React.FC = () => {
onClick={onGenerate}
disabled={!model.name}
>
<span className="i-custom-vender-other-generator size-4" />
<span aria-hidden="true" className="i-custom-vender-other-generator size-4" />
<span className="text-xs font-semibold">
{t(($) => $['workflowGenerator.generate'])}
</span>
</Button>
)}
</div>
</div>
</Field>
</div>
{/* Right pane: planning → graph result / actionable error / empty placeholder */}
{isLoading ? (
<GenerationPlan plan={plan} />
) : genError?.length ? (
<div className="flex h-full w-0 grow flex-col items-center justify-center gap-4 px-8">
<RiErrorWarningLine className="size-8 text-text-quaternary" />
<div className="flex min-h-0 w-full grow flex-col items-center justify-center gap-4 px-8 md:w-0">
<span
aria-hidden="true"
className="i-ri-error-warning-line size-8 text-text-quaternary"
/>
<div className="text-center">
<div className="system-md-medium text-text-secondary">{genErrorMessage}</div>
{firstGenError?.node_id && (
@ -713,12 +702,16 @@ const WorkflowGeneratorModal: React.FC = () => {
</div>
</div>
) : current?.graph?.nodes?.length ? (
<div className="flex h-full w-0 grow flex-col bg-background-default-subtle p-6">
<div className="flex min-h-0 w-full grow flex-col bg-background-default-subtle p-6 md:w-0">
{/* Planner-picked identity surfaces the app_name + icon the
UI used to discard so the user sees what they'll create. */}
{(current.icon || current.app_name) && (
<div className="mb-2 flex items-center gap-2">
{current.icon && <span className="text-lg leading-none">{current.icon}</span>}
{current.icon && (
<span aria-hidden="true" className="text-lg leading-none">
{current.icon}
</span>
)}
{current.app_name && (
<span className="truncate text-sm font-semibold text-text-primary">
{current.app_name}
@ -761,8 +754,8 @@ const WorkflowGeneratorModal: React.FC = () => {
</div>
<div className="relative w-full grow overflow-hidden rounded-2xl border border-divider-subtle bg-background-default">
<WorkflowPreview
nodes={current.graph.nodes}
edges={current.graph.edges}
nodes={(current.graph as unknown as GeneratedGraph).nodes}
edges={(current.graph as unknown as GeneratedGraph).edges}
viewport={current.graph.viewport}
miniMapToRight
/>
@ -817,4 +810,4 @@ const WorkflowGeneratorModal: React.FC = () => {
)
}
export default React.memo(WorkflowGeneratorModal)
export default WorkflowGeneratorModal

View File

@ -1,7 +1,11 @@
import type {
WorkflowGeneratePayload,
WorkflowGenerateResponse,
} from '@dify/contracts/api/console/workflow-generate/types.gen'
import type { Viewport } from 'reactflow'
import type { Edge, Node } from '@/app/components/workflow/types'
export type WorkflowGeneratorMode = 'workflow' | 'advanced-chat'
export type WorkflowGeneratorMode = Exclude<WorkflowGeneratePayload['mode'], 'auto'>
/**
* `create` builds a brand-new app from scratch; `refine` feeds the current
@ -10,27 +14,10 @@ export type WorkflowGeneratorMode = 'workflow' | 'advanced-chat'
*/
export type WorkflowGeneratorIntent = 'create' | 'refine'
/** React Flow-compatible view of a validated generator graph at the canvas boundary. */
export type GeneratedGraph = {
nodes: Node[]
edges: Edge[]
viewport: Viewport
}
export type GenerateWorkflowResponse = {
graph: GeneratedGraph
message?: string
/**
* Planner-picked product-style name. Used by applyToNewApp; empty triggers
* a deriveAppName(instruction) fallback.
*/
app_name?: string
/** Planner-picked emoji icon for the new App. Empty triggers a 🤖 fallback. */
icon?: string
/**
* Resolved app mode for this generation. Echoes the requested mode, except
* when the request used `mode: 'auto'` then it's the concrete mode the
* planner picked, used to decide which app type "Create new app" builds.
*/
mode?: WorkflowGeneratorMode
error?: string
}
export type GenerateWorkflowResponse = WorkflowGenerateResponse

View File

@ -34,6 +34,14 @@ const loadConsoleQueryWithRequest = async (request: ReturnType<typeof vi.fn>) =>
return module.consoleQuery
}
const loadWorkflowGenerationStream = async (sseGeneratorPost: ReturnType<typeof vi.fn>) => {
vi.resetModules()
vi.doMock('@/utils/client', () => ({ isClient: true, isServer: false }))
vi.doMock('./base', () => ({ request: vi.fn(), sseGeneratorPost }))
const module = await import('./client')
return module.streamWorkflowGeneration
}
const createMutationContext = (queryClient: QueryClient): MutationFunctionContext => ({
client: queryClient,
meta: undefined,
@ -253,6 +261,21 @@ describe('getBaseURL', () => {
})
})
describe('streamWorkflowGeneration', () => {
it('should preserve the generator stream transport contract', async () => {
const expectedResult = Promise.resolve()
const sseGeneratorPost = vi.fn().mockReturnValue(expectedResult)
const streamWorkflowGeneration = await loadWorkflowGenerationStream(sseGeneratorPost)
const body = { instruction: 'Build a researched answer' }
const callbacks = { onCompleted: vi.fn() }
const result = streamWorkflowGeneration('/workflow-generate/stream', body, callbacks)
expect(result).toBe(expectedResult)
expect(sseGeneratorPost).toHaveBeenCalledWith('/workflow-generate/stream', body, callbacks)
})
})
// Scenario: oRPC operation context controls transport behavior without handwritten REST helpers.
describe('consoleQuery transport context', () => {
afterEach(() => {

View File

@ -19,10 +19,14 @@ import { createTanstackQueryUtils } from '@orpc/tanstack-query'
import { API_PREFIX, APP_VERSION, IS_MARKETPLACE, MARKETPLACE_API_PREFIX } from '@/config'
import { isClient } from '@/utils/client'
// oxlint-disable-next-line no-restricted-imports
import { request } from './base'
import { request, sseGeneratorPost } from './base'
import { createConsoleDynamicLink } from './console-link'
import { normalizeConsoleOpenAPIURL } from './console-openapi-url'
export function streamWorkflowGeneration(...args: Parameters<typeof sseGeneratorPost>) {
return sseGeneratorPost(...args)
}
function getMarketplaceHeaders() {
return new Headers({
'X-Dify-Version': !IS_MARKETPLACE ? APP_VERSION : '999.0.0',

View File

@ -3,20 +3,23 @@ import type { AppModeEnum } from '@/types/app'
// no-restricted-imports rule targets production imports, not test
// instrumentation — mirrors sibling service specs (annotation.spec.ts etc.).
// oxlint-disable-next-line no-restricted-imports
import { get, post, sseGeneratorPost, ssePost } from './base'
import { get, post, ssePost } from './base'
import { consoleClient, streamWorkflowGeneration } from './client'
import {
fetchConversationMessages,
fetchPromptTemplate,
fetchSuggestedQuestions,
fetchTextGenerationMessage,
fetchWorkflowInstructionSuggestions,
generateBasicAppFirstTimeRule,
generateRule,
generateWorkflow,
generateWorkflowStream,
sendCompletionMessage,
stopChatMessageResponding,
} from './debug'
import {
fetchWorkflowInstructionSuggestions,
generateWorkflow,
generateWorkflowStream,
} from './workflow-generator'
// Stub the shared `post` wrapper so tests verify only what `generateWorkflow`
// composes on top of it — URL, body, and the typed response surface.
@ -24,7 +27,18 @@ vi.mock('./base', () => ({
post: vi.fn(),
get: vi.fn(),
ssePost: vi.fn(),
sseGeneratorPost: vi.fn(),
}))
vi.mock('./client', () => ({
streamWorkflowGeneration: vi.fn(),
consoleClient: {
workflowGenerate: {
post: vi.fn(),
suggestions: {
post: vi.fn(),
},
},
},
}))
describe('debug service — generateWorkflow', () => {
@ -34,17 +48,25 @@ describe('debug service — generateWorkflow', () => {
// The new endpoint lives at /workflow-generate; the controller mirrors
// /rule-generate so the body must flow through unchanged.
it('should POST to /workflow-generate with the body verbatim', () => {
it('should call the generated workflow contract with the body verbatim', () => {
const body = {
mode: 'workflow' as const,
instruction: 'Summarize a URL',
ideal_output: 'A 3-sentence summary.',
model_config: { provider: 'openai', name: 'gpt-4o', mode: 'chat', completion_params: {} },
model_config: {
provider: 'openai',
name: 'gpt-4o',
mode: 'chat' as const,
completion_params: {},
},
}
generateWorkflow(body)
expect(post).toHaveBeenCalledWith('/workflow-generate', { body })
expect(consoleClient.workflowGenerate.post).toHaveBeenCalledWith(
{ body },
{ signal: expect.any(AbortSignal) },
)
})
// The optional fields must still POST cleanly — `ideal_output` defaulting
@ -53,46 +75,47 @@ describe('debug service — generateWorkflow', () => {
const body = {
mode: 'advanced-chat' as const,
instruction: 'Friendly support bot',
model_config: { provider: 'openai', name: 'gpt-4o', mode: 'chat' },
model_config: { provider: 'openai', name: 'gpt-4o', mode: 'chat' as const },
}
generateWorkflow(body)
expect(post).toHaveBeenCalledWith('/workflow-generate', { body })
expect(consoleClient.workflowGenerate.post).toHaveBeenCalledWith(
{ body },
{ signal: expect.any(AbortSignal) },
)
})
// When the caller threads a ``getAbortController`` callback (the modal's
// pattern for cancelling the in-flight request on close / double-click /
// 60 s timeout), it must reach ``post()`` as the third argument so the
// shared fetch wrapper wires it into the AbortController plumbing.
// Without this the modal cannot abort the request and a close-while-
// loading leaks the request beyond its UI surface.
it('should forward getAbortController to post when provided', () => {
it('should expose the controller whose signal is passed to the generated client', () => {
const body = {
mode: 'workflow' as const,
instruction: 'Long-running generation',
model_config: { provider: 'openai', name: 'gpt-4o', mode: 'chat' },
model_config: { provider: 'openai', name: 'gpt-4o', mode: 'chat' as const },
}
const getAbortController = vi.fn()
generateWorkflow(body, { getAbortController })
expect(post).toHaveBeenCalledWith('/workflow-generate', { body }, { getAbortController })
const controller = getAbortController.mock.calls[0]![0]
expect(consoleClient.workflowGenerate.post).toHaveBeenCalledWith(
{ body },
{ signal: controller.signal },
)
})
// No options → no third argument. Keeps the call site clean and lets the
// shared wrapper apply its own defaults without a phantom empty object.
it('should NOT pass a third argument when no options are provided', () => {
it('should create an internal abort signal when no callback is provided', () => {
const body = {
mode: 'workflow' as const,
instruction: 'Plain call',
model_config: { provider: 'openai', name: 'gpt-4o', mode: 'chat' },
model_config: { provider: 'openai', name: 'gpt-4o', mode: 'chat' as const },
}
generateWorkflow(body)
expect(post).toHaveBeenCalledWith('/workflow-generate', { body })
expect(vi.mocked(post).mock.calls[0]).toHaveLength(2)
expect(consoleClient.workflowGenerate.post).toHaveBeenCalledWith(
{ body },
{ signal: expect.any(AbortSignal) },
)
})
describe('other endpoints', () => {
@ -152,7 +175,7 @@ describe('debug service — generateWorkflow', () => {
const body = {
mode: 'workflow' as const,
instruction: 'test',
model_config: { provider: 'test', name: 'test', mode: 'chat' },
model_config: { provider: 'test', name: 'test', mode: 'chat' as const },
}
const callbacks = {
onPlan: vi.fn(),
@ -162,7 +185,7 @@ describe('debug service — generateWorkflow', () => {
getAbortController: vi.fn(),
}
vi.mocked(sseGeneratorPost).mockImplementation((_url, _body, options) => {
vi.mocked(streamWorkflowGeneration).mockImplementation((_url, _body, options) => {
options?.onPlan?.({ title: 'plan' })
options?.onResult?.({ graph: { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } } })
return Promise.resolve()
@ -170,25 +193,26 @@ describe('debug service — generateWorkflow', () => {
await generateWorkflowStream(body, callbacks)
expect(sseGeneratorPost).toHaveBeenCalled()
expect(streamWorkflowGeneration).toHaveBeenCalled()
expect(callbacks.onPlan).toHaveBeenCalled()
expect(callbacks.onResult).toHaveBeenCalled()
})
it('fetchWorkflowInstructionSuggestions without getAbortController', async () => {
await fetchWorkflowInstructionSuggestions({ mode: 'workflow' })
expect(post).toHaveBeenCalledWith('/workflow-generate/suggestions', {
body: { mode: 'workflow' },
})
expect(consoleClient.workflowGenerate.suggestions.post).toHaveBeenCalledWith(
{ body: { mode: 'workflow' } },
{ signal: expect.any(AbortSignal) },
)
})
it('fetchWorkflowInstructionSuggestions with getAbortController', async () => {
const getAbortController = vi.fn()
await fetchWorkflowInstructionSuggestions({ mode: 'workflow' }, { getAbortController })
expect(post).toHaveBeenCalledWith(
'/workflow-generate/suggestions',
const controller = getAbortController.mock.calls[0]![0]
expect(consoleClient.workflowGenerate.suggestions.post).toHaveBeenCalledWith(
{ body: { mode: 'workflow' } },
{ getAbortController },
{ signal: controller.signal },
)
})

View File

@ -1,9 +1,7 @@
import type { Viewport } from 'reactflow'
import type { IOnCompleted, IOnData, IOnError, IOnMessageReplace } from './base'
import type { Edge, Node } from '@/app/components/workflow/types'
import type { ChatPromptConfig, CompletionPromptConfig } from '@/models/debug'
import type { AppModeEnum, ModelModeType } from '@/types/app'
import { get, post, sseGeneratorPost, ssePost } from './base'
import { get, post, ssePost } from './base'
type BasicAppFirstRes = {
prompt: string
@ -95,210 +93,6 @@ export const generateRule = (body: Record<string, any>) => {
})
}
/**
* One structured error from the workflow generator backend. ``code`` is a
* stable machine-readable identifier the frontend maps to localised copy
* via the ``workflowGenerator.errors.<code>`` i18n keys; ``detail`` is the
* raw English diagnostic; ``node_id`` is set when the error is tied to a
* specific node (the preview canvas can highlight it).
*
* Stable codes adding a new one without updating the i18n map will fall
* back to ``detail`` and that's fine, but every value listed here MUST
* exist in both en-US and zh-Hans.
*/
// Not exported: knip flags unused exports and the modal looks codes up by
// string interpolation (``workflowGenerator.errors.${code}``) rather than
// importing the union. Kept here so the ``GenerateWorkflowResponse``
// definition below documents the contract in one place.
type GenerateWorkflowErrorCode =
| 'INVALID_JSON'
| 'INVALID_SCHEMA'
| 'EMPTY_INSTRUCTION'
| 'EMPTY_PLAN'
| 'UNKNOWN_NODE_REFERENCE'
| 'INVALID_CONTAINER'
| 'UNRESOLVED_REFERENCE'
| 'UNKNOWN_TOOL'
| 'MISSING_TERMINAL'
| 'MISSING_START'
| 'DANGLING_EDGE'
| 'MODEL_ERROR'
type GenerateWorkflowError = {
code: GenerateWorkflowErrorCode | string
detail: string
node_id?: string
}
export type GenerateWorkflowResponse = {
graph: {
nodes: Node[]
edges: Edge[]
viewport: Viewport
}
message?: string
/**
* Planner-picked product-style name (e.g. "URL Summarizer"). Empty when
* the planner omits it; the caller (applyToNewApp) supplies a fallback.
*/
app_name?: string
/**
* Planner-picked emoji that captures the workflow's purpose. Empty when
* the planner omits it; the caller supplies a 🤖 fallback.
*/
icon?: string
/**
* Resolved app mode. Echoes the request mode, except when the request used
* ``mode: 'auto'`` then this is the concrete mode the planner picked, which
* the caller uses to decide which app type to create.
*/
mode?: 'workflow' | 'advanced-chat'
/** Human-readable concatenation of ``errors[].detail``. "" on success. */
error?: string
/** Structured errors with stable codes for FE-localised mapping. [] on success. */
errors?: GenerateWorkflowError[]
}
export type GenerateWorkflowBody = {
/** ``'auto'`` lets the planner pick Workflow vs Chatflow; the resolved mode comes back on the response. */
mode: 'workflow' | 'advanced-chat' | 'auto'
instruction: string
ideal_output?: string
model_config: {
provider: string
name: string
mode: string
completion_params?: Record<string, unknown>
}
/**
* Existing draft graph for the cmd+k `/refine` flow. When present the
* backend refines this graph instead of generating from scratch. Omitted
* for `/create`.
*/
current_graph?: {
nodes: Node[]
edges: Edge[]
viewport?: Viewport
}
}
export type GenerateWorkflowOptions = {
/**
* Callback receiving the ``AbortController`` for the in-flight request.
* The caller stores it and aborts on modal close / second submit / hard
* timeout. Pattern mirrors ``fetchSuggestedQuestions`` / ``fetchConversationMessages``
* which already thread this through ``base.ts``.
*/
getAbortController?: (controller: AbortController) => void
}
export const generateWorkflow = (body: GenerateWorkflowBody, options?: GenerateWorkflowOptions) => {
// Only pass the third argument when the caller actually supplied one —
// otherwise the shared ``post()`` wrapper sees ``undefined`` and that
// breaks tests asserting the 2-arg call shape, with no behaviour upside.
if (options?.getAbortController) {
return post<GenerateWorkflowResponse>(
'/workflow-generate',
{ body },
{
getAbortController: options.getAbortController,
},
)
}
return post<GenerateWorkflowResponse>('/workflow-generate', { body })
}
// ─── Plan-first streaming (cmd+k generator) ──────────────────────────────────
/** One node in the planner's high-level plan, shown before the graph builds. */
type WorkflowGenPlanNode = {
label: string
node_type: string
purpose?: string
}
/** A start-node input the generated app will ask the end-user for. */
type WorkflowGenPlanInput = {
variable: string
label?: string
type?: string
}
/** The planner result, streamed ahead of the built graph as the ``plan`` event. */
export type WorkflowGenPlan = {
title?: string
description?: string
app_name?: string
icon?: string
/** Resolved mode — concrete even when the request used ``mode: 'auto'``. */
mode?: 'workflow' | 'advanced-chat'
nodes: WorkflowGenPlanNode[]
start_inputs?: WorkflowGenPlanInput[]
}
export type GenerateWorkflowStreamCallbacks = {
onPlan?: (plan: WorkflowGenPlan) => void
onResult?: (result: GenerateWorkflowResponse) => void
onError?: (message: string) => void
onCompleted?: () => void
getAbortController?: (controller: AbortController) => void
}
/**
* Plan-first streaming variant of ``generateWorkflow``. The backend emits the
* planner result (``onPlan``) as soon as it's ready typically a few seconds
* in then the built graph (``onResult``) once the builder + validation
* finish. Lets the modal show real progress (an outline of the plan) instead
* of a guessed phase timer, and surfaces the graph the moment it lands.
*/
export const generateWorkflowStream = (
body: GenerateWorkflowBody,
callbacks: GenerateWorkflowStreamCallbacks,
) => {
return sseGeneratorPost('/workflow-generate/stream', body, {
onPlan: (data) => callbacks.onPlan?.(data as unknown as WorkflowGenPlan),
onResult: (data) => callbacks.onResult?.(data as unknown as GenerateWorkflowResponse),
onError: callbacks.onError,
onCompleted: callbacks.onCompleted,
getAbortController: callbacks.getAbortController,
})
}
// ─── AI-generated instruction suggestions (generator "ideas" chips) ───────────
export type WorkflowInstructionSuggestionsBody = {
mode: 'workflow' | 'advanced-chat'
/** UI language so suggestions come back localized, e.g. 'zh-Hans'. */
language?: string
count?: number
}
export type WorkflowInstructionSuggestionsResponse = {
suggestions: string[]
}
/**
* Fetch a handful of short, workspace-grounded example instructions for the
* generator's "ideas" chips. Backed by the tenant default model; the backend
* soft-fails to ``{ suggestions: [] }`` (no toast), so the caller falls back to
* its static curated list on an empty result.
*/
export const fetchWorkflowInstructionSuggestions = (
body: WorkflowInstructionSuggestionsBody,
options?: GenerateWorkflowOptions,
) => {
if (options?.getAbortController) {
return post<WorkflowInstructionSuggestionsResponse>(
'/workflow-generate/suggestions',
{ body },
{
getAbortController: options.getAbortController,
},
)
}
return post<WorkflowInstructionSuggestionsResponse>('/workflow-generate/suggestions', { body })
}
export const fetchPromptTemplate = ({
appMode,
mode,

View File

@ -0,0 +1,54 @@
import type {
WorkflowGeneratePayload,
WorkflowGeneratePlanEventResponse,
WorkflowGenerateResponse,
WorkflowInstructionSuggestionsPayload,
} from '@dify/contracts/api/console/workflow-generate/types.gen'
// The generated client handles JSON endpoints. Streaming remains on the
// generator-specific SSE adapter until oRPC supports this event framing.
import { consoleClient, streamWorkflowGeneration } from './client'
export type GenerateWorkflowBody = WorkflowGeneratePayload
export type GenerateWorkflowResponse = WorkflowGenerateResponse
export type WorkflowGenPlan = WorkflowGeneratePlanEventResponse
export type WorkflowInstructionSuggestionsBody = WorkflowInstructionSuggestionsPayload
export type GenerateWorkflowOptions = {
getAbortController?: (controller: AbortController) => void
}
export function generateWorkflow(body: GenerateWorkflowBody, options?: GenerateWorkflowOptions) {
const controller = new AbortController()
options?.getAbortController?.(controller)
return consoleClient.workflowGenerate.post({ body }, { signal: controller.signal })
}
export type GenerateWorkflowStreamCallbacks = {
onPlan?: (plan: WorkflowGenPlan) => void
onResult?: (result: GenerateWorkflowResponse) => void
onError?: (message: string) => void
onCompleted?: () => void
getAbortController?: (controller: AbortController) => void
}
export function generateWorkflowStream(
body: GenerateWorkflowBody,
callbacks: GenerateWorkflowStreamCallbacks,
) {
return streamWorkflowGeneration('/workflow-generate/stream', body, {
onPlan: (data) => callbacks.onPlan?.(data as WorkflowGenPlan),
onResult: (data) => callbacks.onResult?.(data as GenerateWorkflowResponse),
onError: callbacks.onError,
onCompleted: callbacks.onCompleted,
getAbortController: callbacks.getAbortController,
})
}
export function fetchWorkflowInstructionSuggestions(
body: WorkflowInstructionSuggestionsBody,
options?: GenerateWorkflowOptions,
) {
const controller = new AbortController()
options?.getAbortController?.(controller)
return consoleClient.workflowGenerate.suggestions.post({ body }, { signal: controller.signal })
}