mirror of
https://github.com/langgenius/dify.git
synced 2026-07-21 02:28:30 +08:00
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:
parent
8e0d10bf5e
commit
7865ffd429
@ -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())
|
||||
|
||||
@ -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.
|
||||
|
||||
"""
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")}
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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 = {
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 },
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
54
web/service/workflow-generator.ts
Normal file
54
web/service/workflow-generator.ts
Normal 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 })
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user