From 7865ffd42996900592ee0cf16e92b2f1625239f4 Mon Sep 17 00:00:00 2001 From: Crazywoola <100913391+crazywoola@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:15:54 +0800 Subject: [PATCH] chore: Improve workflow generator accessibility and API contracts (#38838) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/controllers/console/app/generator.py | 153 ++++++- .../generator/prompts/builder_prompts.py | 21 +- .../generator/prompts/planner_prompts.py | 5 + api/core/workflow/generator/runner.py | 424 ++++++++++++++++-- api/core/workflow/generator/types.py | 35 +- api/openapi/markdown/console-openapi.md | 145 +++++- .../console/app/test_generator_api.py | 36 +- .../core/workflow/generator/test_prompts.py | 12 + .../core/workflow/generator/test_runner.py | 306 ++++++++++++- .../console/workflow-generate/types.gen.ts | 122 ++++- .../api/console/workflow-generate/zod.gen.ts | 175 +++++++- .../goto-anything/actions/commands/create.tsx | 2 +- .../goto-anything/actions/commands/refine.tsx | 2 +- .../__tests__/apply.spec.ts | 10 +- .../__tests__/example-prompts.spec.tsx | 8 +- .../__tests__/generation-plan.spec.tsx | 8 +- .../__tests__/index.spec.tsx | 268 +++++++++++ .../workflow/workflow-generator/apply.ts | 4 +- .../workflow-generator/example-prompts.tsx | 32 +- .../workflow-generator/generation-plan.tsx | 42 +- .../workflow/workflow-generator/index.tsx | 109 +++-- .../workflow/workflow-generator/types.ts | 27 +- web/service/client.spec.ts | 23 + web/service/client.ts | 6 +- web/service/debug.spec.ts | 92 ++-- web/service/debug.ts | 208 +-------- web/service/workflow-generator.ts | 54 +++ 27 files changed, 1869 insertions(+), 460 deletions(-) create mode 100644 web/app/components/workflow/workflow-generator/__tests__/index.spec.tsx create mode 100644 web/service/workflow-generator.ts diff --git a/api/controllers/console/app/generator.py b/api/controllers/console/app/generator.py index 0066509a33b..495d1403d7e 100644 --- a/api/controllers/console/app/generator.py +++ b/api/controllers/console/app/generator.py @@ -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()) diff --git a/api/core/workflow/generator/prompts/builder_prompts.py b/api/core/workflow/generator/prompts/builder_prompts.py index 9aaf75bddbd..85079739c15 100644 --- a/api/core/workflow/generator/prompts/builder_prompts.py +++ b/api/core/workflow/generator/prompts/builder_prompts.py @@ -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 ``["", "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. """ diff --git a/api/core/workflow/generator/prompts/planner_prompts.py b/api/core/workflow/generator/prompts/planner_prompts.py index 78f1313b41e..7ca6dfc9743 100644 --- a/api/core/workflow/generator/prompts/planner_prompts.py +++ b/api/core/workflow/generator/prompts/planner_prompts.py @@ -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 diff --git a/api/core/workflow/generator/runner.py b/api/core/workflow/generator/runner.py index 7474880d8f6..fdb70bf7ea4 100644 --- a/api/core/workflow/generator/runner.py +++ b/api/core/workflow/generator/runner.py @@ -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.#}`` # 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")} diff --git a/api/core/workflow/generator/types.py b/api/core/workflow/generator/types.py index df9e0fb5e85..0c42ac880c1 100644 --- a/api/core/workflow/generator/types.py +++ b/api/core/workflow/generator/types.py @@ -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.`` # 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): diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 00c2aafe263..41d0f4abebd 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -9684,7 +9684,7 @@ Generate a Dify workflow graph from natural language | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Workflow graph generated successfully | **application/json**: [GeneratorResponse](#generatorresponse)
| +| 200 | Workflow graph generated successfully | **application/json**: [WorkflowGenerateResponse](#workflowgenerateresponse)
| | 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)
| +| 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)
| +| 200 | Suggestions generated successfully | **application/json**: [WorkflowInstructionSuggestionsResponse](#workflowinstructionsuggestionsresponse)
| | 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,
**Available values:** "advanced-chat", "auto", "workflow" | Target app mode for the generated graph; 'auto' lets the backend classify the instruction
*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)
[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,
**Available values:** "advanced-chat", "workflow" | Target app mode for the suggestions
*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 | diff --git a/api/tests/unit_tests/controllers/console/app/test_generator_api.py b/api/tests/unit_tests/controllers/console/app/test_generator_api.py index 0516d3450a7..46ac13a9124 100644 --- a/api/tests/unit_tests/controllers/console/app/test_generator_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_generator_api.py @@ -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) diff --git a/api/tests/unit_tests/core/workflow/generator/test_prompts.py b/api/tests/unit_tests/core/workflow/generator/test_prompts.py index 4b43f648e4a..f2f7fc7b8d0 100644 --- a/api/tests/unit_tests/core/workflow/generator/test_prompts.py +++ b/api/tests/unit_tests/core/workflow/generator/test_prompts.py @@ -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 ``["", "output"]``' in prompt + class TestBuildNodeConfigCheatsheet: def test_none_returns_full_cheatsheet(self): diff --git a/api/tests/unit_tests/core/workflow/generator/test_runner.py b/api/tests/unit_tests/core/workflow/generator/test_runner.py index 5d75e6d0f7e..3c08cd63756 100644 --- a/api/tests/unit_tests/core/workflow/generator/test_runner.py +++ b/api/tests/unit_tests/core/workflow/generator/test_runner.py @@ -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 diff --git a/packages/contracts/generated/api/console/workflow-generate/types.gen.ts b/packages/contracts/generated/api/console/workflow-generate/types.gen.ts index 9aa10fd3eee..81a33655dbe 100644 --- a/packages/contracts/generated/api/console/workflow-generate/types.gen.ts +++ b/packages/contracts/generated/api/console/workflow-generate/types.gen.ts @@ -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 + 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 +} + +export type WorkflowGraph = { + edges: Array + nodes: Array + 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 + start_inputs?: Array + title?: string +} + +export type WorkflowGenerateResultEventResponse = { + app_name?: string + error?: string + errors?: Array + 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 = diff --git a/packages/contracts/generated/api/console/workflow-generate/zod.gen.ts b/packages/contracts/generated/api/console/workflow-generate/zod.gen.ts index 6c3796a90a9..58d2b611592 100644 --- a/packages/contracts/generated/api/console/workflow-generate/zod.gen.ts +++ b/packages/contracts/generated/api/console/workflow-generate/zod.gen.ts @@ -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 diff --git a/web/app/components/goto-anything/actions/commands/create.tsx b/web/app/components/goto-anything/actions/commands/create.tsx index bd29d533d41..1b4ee601ca6 100644 --- a/web/app/components/goto-anything/actions/commands/create.tsx +++ b/web/app/components/goto-anything/actions/commands/create.tsx @@ -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) { diff --git a/web/app/components/goto-anything/actions/commands/refine.tsx b/web/app/components/goto-anything/actions/commands/refine.tsx index 2eb73f680dc..9be329353ef 100644 --- a/web/app/components/goto-anything/actions/commands/refine.tsx +++ b/web/app/components/goto-anything/actions/commands/refine.tsx @@ -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 diff --git a/web/app/components/workflow/workflow-generator/__tests__/apply.spec.ts b/web/app/components/workflow/workflow-generator/__tests__/apply.spec.ts index 4b37570fe3a..c07f6fbcf17 100644 --- a/web/app/components/workflow/workflow-generator/__tests__/apply.spec.ts +++ b/web/app/components/workflow/workflow-generator/__tests__/apply.spec.ts @@ -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 diff --git a/web/app/components/workflow/workflow-generator/__tests__/example-prompts.spec.tsx b/web/app/components/workflow/workflow-generator/__tests__/example-prompts.spec.tsx index 0619c3ebb47..df8a53f5df7 100644 --- a/web/app/components/workflow/workflow-generator/__tests__/example-prompts.spec.tsx +++ b/web/app/components/workflow/workflow-generator/__tests__/example-prompts.spec.tsx @@ -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) diff --git a/web/app/components/workflow/workflow-generator/__tests__/generation-plan.spec.tsx b/web/app/components/workflow/workflow-generator/__tests__/generation-plan.spec.tsx index bce5d6269b6..56a39bb4c52 100644 --- a/web/app/components/workflow/workflow-generator/__tests__/generation-plan.spec.tsx +++ b/web/app/components/workflow/workflow-generator/__tests__/generation-plan.spec.tsx @@ -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() - 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() }) diff --git a/web/app/components/workflow/workflow-generator/__tests__/index.spec.tsx b/web/app/components/workflow/workflow-generator/__tests__/index.spec.tsx new file mode 100644 index 00000000000..5232ebc5418 --- /dev/null +++ b/web/app/components/workflow/workflow-generator/__tests__/index.spec.tsx @@ -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() + 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: () =>
model selector
, + }), +) + +vi.mock('@/app/components/workflow/workflow-preview', () => ({ + default: () =>
workflow preview
, +})) + +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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + }) + }) +}) diff --git a/web/app/components/workflow/workflow-generator/apply.ts b/web/app/components/workflow/workflow-generator/apply.ts index e51c0d991d8..beeb4982459 100644 --- a/web/app/components/workflow/workflow-generator/apply.ts +++ b/web/app/components/workflow/workflow-generator/apply.ts @@ -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 = { diff --git a/web/app/components/workflow/workflow-generator/example-prompts.tsx b/web/app/components/workflow/workflow-generator/example-prompts.tsx index 735ae1dbf3c..3567793ec7c 100644 --- a/web/app/components/workflow/workflow-generator/example-prompts.tsx +++ b/web/app/components/workflow/workflow-generator/example-prompts.tsx @@ -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(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) => {
{isLoading - ? SKELETON_WIDTHS.map((w, i) => ( + ? SKELETONS.map(({ id, width }) => (
)) : prompts.map((prompt) => (