feat: enhance go to anything (#32130)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Crazywoola 2026-06-04 19:06:17 +08:00 committed by GitHub
parent c8abb11bf0
commit 0bfbd2061e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
48 changed files with 8391 additions and 5 deletions

View File

@ -1,4 +1,5 @@
from collections.abc import Sequence
from typing import Literal
from flask_restx import Resource
from pydantic import BaseModel, Field
@ -25,6 +26,7 @@ from graphon.model_runtime.entities.llm_entities import LLMMode
from graphon.model_runtime.errors.invoke import InvokeError
from libs.login import login_required
from models import App
from services.workflow_generator_service import WorkflowGeneratorService
from services.workflow_service import WorkflowService
@ -42,6 +44,24 @@ class InstructionTemplatePayload(BaseModel):
type: str = Field(..., description="Instruction template type")
class WorkflowGeneratePayload(BaseModel):
"""Payload for the cmd+k `/create` and `/refine` workflow generator endpoint.
See ``services/workflow_generator_service.py`` for behaviour. Errors are
surfaced through the same envelope as ``/rule-generate`` so the frontend
can reuse its existing handler.
"""
mode: Literal["workflow", "advanced-chat"] = Field(..., description="Target app mode for the generated graph")
instruction: str = Field(..., description="Natural-language workflow description")
ideal_output: str = Field(default="", description="Optional sample output for grounding")
model_config_data: ModelConfig = Field(..., alias="model_config", description="Model configuration")
current_graph: dict | None = Field(
default=None,
description="Existing draft graph to refine (cmd+k `/refine`); omit for create-from-scratch",
)
register_enum_models(console_ns, LLMMode)
register_schema_models(
console_ns,
@ -50,6 +70,7 @@ register_schema_models(
RuleStructuredOutputPayload,
InstructionGeneratePayload,
InstructionTemplatePayload,
WorkflowGeneratePayload,
ModelConfig,
)
@ -265,3 +286,56 @@ class InstructionGenerationTemplateApi(Resource):
return {"data": INSTRUCTION_GENERATE_TEMPLATE_CODE}
case _:
raise ValueError(f"Invalid type: {args.type}")
@console_ns.route("/workflow-generate")
class WorkflowGenerateApi(Resource):
"""Generate a Workflow / Chatflow draft graph from a natural-language description.
Triggered by the cmd+k `/create` slash command. Returns a graph payload
shaped exactly like ``WorkflowService.sync_draft_workflow``'s input, so the
frontend can hand it straight to ``/apps/{id}/workflows/draft``.
"""
@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.response(400, "Invalid request parameters")
@console_ns.response(402, "Provider quota exceeded")
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def post(self, current_tenant_id: str):
args = WorkflowGeneratePayload.model_validate(console_ns.payload)
# Reject obviously-empty instructions at the boundary — Pydantic only
# validates ``instruction`` is a str, but a whitespace-only string
# would still hit the LLM and waste a planner+builder roundtrip on a
# response that the postprocess validator would reject anyway.
if not args.instruction.strip():
return {
"error": "Instruction is required",
"errors": [{"code": "EMPTY_INSTRUCTION", "detail": "Instruction is required"}],
}, 400
try:
result = WorkflowGeneratorService.generate_workflow_graph(
tenant_id=current_tenant_id,
mode=args.mode,
instruction=args.instruction,
model_config=args.model_config_data,
ideal_output=args.ideal_output,
current_graph=args.current_graph,
)
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
raise ProviderQuotaExceededError()
except ModelCurrentlyNotSupportError:
raise ProviderModelCurrentlyNotSupportError()
except InvokeError as e:
raise CompletionRequestError(e.description)
return result

View File

@ -0,0 +1,20 @@
"""
Workflow generator package.
Generates a Dify workflow graph (nodes, edges, viewport) from a natural-language
instruction. Intended for the cmd+k `/create` slash command's preview/apply flow.
Pipeline (slim, single-shot variant):
runner.WorkflowGenerator.generate_workflow_graph(...)
planner_prompts: short LLM call high-level node plan
builder_prompts: structured-output LLM call full graph JSON
postprocess: fill defaults, auto-layout viewport, sanity-check edges
The runner is pure domain logic; ``WorkflowGeneratorService`` (in ``services/``)
owns the model-manager dependency and is what controllers call.
"""
from .runner import WorkflowGenerator
__all__ = ["WorkflowGenerator"]

View File

@ -0,0 +1 @@
"""Prompt templates for the workflow generator (planner + builder)."""

View File

@ -0,0 +1,553 @@
"""
Builder prompts.
The builder is the second step of the slim plannerbuilder pipeline. It takes
the planner's high-level node list and emits the *full* graph JSON consumed by
``WorkflowService.sync_draft_workflow``.
The builder owns: node configuration (prompts, code, headers, etc.), edge wiring,
handle ids ("source"/"target"), positions, and the viewport. It is the only
prompt that needs to know the concrete shape of each node type keep its
examples accurate or the LLM will invent fields.
"""
import json
from typing import Any
# Per-node-type configuration cheatsheet.
#
# Each entry mirrors the production ``defaultValue`` from
# ``web/app/components/workflow/nodes/<type>/default.ts`` so the generated
# graph loads in Studio identically to a manually-created node and survives
# both ``WorkflowService.sync_draft_workflow``'s structural checks and the
# runtime entity validation each node performs when the workflow runs.
#
# The postprocessor in ``runner.py`` fills missing wrapper fields (``type``,
# ``positionAbsolute``, ``width``, ``height``, ``sourcePosition`` /
# ``targetPosition``, edge ``data.sourceType`` / ``data.targetType``), so the
# LLM only needs to emit semantically meaningful fields.
NODE_CONFIG_CHEATSHEET = """\
## Node wrapper (every node, top-level)
{"id": "node1" (digits + letters only see "Node IDs" below),
"type": "custom", # ReactFlow renderer key. Iteration/loop
# *start* children use special types
# (see Containers below).
"position": {"x": <number>, "y": <number>},
"data": { ... per-type fields ... }}
Children of iteration / loop containers additionally need
``parentId``, ``zIndex: 1002`` and ``extent: "parent"`` see Containers.
## Shared "data" fields (every node)
{"type": "<node-type>", # e.g. "llm", "start", "if-else"
"title": "<short label>",
"desc": "<one-liner>",
"selected": false}
## Per type — additional "data" fields
- start:
{"variables": [
{"variable": "url", "label": "URL", "type": "text-input",
"required": true, "max_length": 256, "options": []},
{"variable": "topic", "label": "Topic", "type": "paragraph",
"required": false, "max_length": 4096, "options": []}
]}
EVERY user-supplied value referenced by a downstream node
(``{{#node-id.var#}}`` in a prompt / answer / template, or
``["node-id", "var"]`` in a value_selector / iterator_selector /
tool_parameters) MUST be declared here as an entry of ``variables``.
If the planner's ``start_inputs`` list is non-empty, use it verbatim
(the user prompt section "Start inputs" surfaces it). Types:
text-input | paragraph | select | number | file | file-list.
In Advanced-Chat mode ``sys.query`` and ``sys.files`` are automatic
system variables downstream nodes may reference them; do NOT add
them to ``variables``.
- end (Workflow mode only):
{"outputs": [
{"variable": "result", "value_selector": ["<src-node-id>", "<out-var>"]}
]}
- answer (Advanced Chat mode only):
{"variables": [],
"answer": "<text with {{#<src>.<var>#}} placeholders>"}
- llm:
{"model": {"provider": "<provider>", "name": "<model>", "mode": "chat",
"completion_params": {"temperature": 0.7}},
"prompt_template": [
{"role": "system", "text": "<system prompt>"},
{"role": "user", "text": "<user prompt with {{#<src>.<var>#}}>"}
],
"context": {"enabled": false, "variable_selector": []},
"vision": {"enabled": false}}
Prompt-writing rules for the user-message text:
* ``{{#node.var#}}`` placeholders are interpolated by Dify BEFORE the
LLM sees them at run time the model only sees the resolved value.
So an instruction like "Translate this: {{#node1.text#}}" is read
by the LLM as "Translate this: <the actual text>".
* NEVER include placeholder syntax inside an "example output" block
in your prompt the LLM will treat the example as the literal
answer template and echo placeholders back as output. Wrong:
Output JSON: {"en": "{{#node1.text#}}", "es": "{{#node1.text#}}"}
Right:
Translate the input into English, Spanish, French, German.
Output a JSON object with keys "en", "es", "fr", "de" whose
values are the translations.
Input: {{#node1.text#}}
* Each placeholder only resolves the variable from its source node
it cannot be a Jinja template or call a function.
- knowledge-retrieval:
{"query_variable_selector": ["<src>", "<var>"],
"query_attachment_selector": [],
"dataset_ids": [],
"retrieval_mode": "multiple",
"multiple_retrieval_config": {"top_k": 4, "score_threshold": null,
"reranking_enable": false}}
- code (escape hatch only if no installed tool fits):
{"code_language": "python3",
"code": "def main(arg1: str) -> dict:\\n return {'result': arg1}",
"variables": [{"variable": "arg1", "value_selector": ["<src>", "<var>"]}],
"outputs": {"result": {"type": "string", "children": null}}}
- template-transform:
{"template": "Hello {{ name }}",
"variables": [{"variable": "name", "value_selector": ["<src>", "<var>"]}]}
- http-request (escape hatch only if no installed tool fits):
{"variables": [], "method": "get", "url": "https://example.com",
"authorization": {"type": "no-auth", "config": null},
"headers": "", "params": "",
"body": {"type": "none", "data": []},
"ssl_verify": true,
"timeout": {"max_connect_timeout": 0, "max_read_timeout": 0,
"max_write_timeout": 0},
"retry_config": {"retry_enabled": true, "max_retries": 3,
"retry_interval": 100}}
- tool (PREFERRED for external actions when listed in Available tools):
{"provider_id": "<provider>", # provider portion of provider/tool
"provider_type": "builtin", # exact value from catalogue
"provider_name": "<provider>", # usually same as provider_id
"tool_name": "<tool>", # tool portion of provider/tool
"tool_label": "<Tool>",
"tool_node_version": "2",
"tool_configurations": {},
"tool_parameters": {"<param>": {"type": "mixed",
"value": "{{#<src>.<var>#}}"}}}
Parameter ``type`` is one of:
"mixed" string template referencing variables ({{#...#}})
"variable" direct reference, value is ["<src>", "<var>"]
"constant" literal value
- if-else:
{"_targetBranches": [{"id": "true", "name": "IF"},
{"id": "false", "name": "ELSE"}],
"logical_operator": "and",
"cases": [
{"case_id": "true",
"logical_operator": "and",
"conditions": [{"id": "c1",
"variable_selector": ["<src>", "<var>"],
"comparison_operator": "is",
"value": "<value>"}]}
]}
Source handle for downstream edges = the case_id ("true" / "false").
- question-classifier:
{"query_variable_selector": ["<src>", "<var>"],
"model": {"provider": "<p>", "name": "<m>", "mode": "chat",
"completion_params": {"temperature": 0.7}},
"classes": [{"id": "1", "name": "Topic A", "label": "CLASS 1"},
{"id": "2", "name": "Topic B", "label": "CLASS 2"}],
"_targetBranches": [{"id": "1", "name": ""}, {"id": "2", "name": ""}],
"vision": {"enabled": false},
"instruction": ""}
Source handle for downstream edges = the class_id ("1" / "2" / ...).
- parameter-extractor:
{"query": [["<src>", "<var>"]], # array of value_selector arrays
"model": {"provider": "<p>", "name": "<m>", "mode": "chat",
"completion_params": {"temperature": 0.7}},
"parameters": [{"name": "topic", "type": "string",
"description": "<purpose>", "required": true}],
"reasoning_mode": "prompt",
"vision": {"enabled": false},
"instruction": ""}
- document-extractor:
{"variable_selector": ["<src>", "<file-var>"], # a file / file-list input
"is_array_file": false} # true when the input is a
# file-list (array of files)
Single output variable ``text``: a string when ``is_array_file`` is false,
an array of strings (one per file) when it is true. ``variable_selector``
MUST point at a ``start`` variable declared with type "file" / "file-list"
(or ``sys.files`` in Advanced-Chat mode).
- variable-aggregator (merge mutually-exclusive branches into one output):
{"output_type": "string", # VarType of the merged value — one of
# string | number | object | array[string] |
# array[number] | array[object] | file |
# array[file] | any. Match the branch vars.
"variables": [["<branchA-node>", "<var>"],
["<branchB-node>", "<var>"]]}
Output variable: ``output`` (the first branch that actually ran). Place it
after an ``if-else`` / ``question-classifier`` to rejoin paths before the
``end`` / ``answer`` node. Each entry of ``variables`` is a value_selector
array, NOT a placeholder string.
- list-operator (filter / sort / slice an array variable):
{"variable": ["<src>", "<array-var>"],
"filter_by": {"enabled": false, "conditions": []},
"extract_by": {"enabled": false, "serial": "1"},
"order_by": {"enabled": false, "key": "", "value": "asc"},
"limit": {"enabled": false, "size": 10}}
Enable only the sub-features you need; ``conditions`` reuse the if-else
condition shape (key / comparison_operator / value). Outputs: ``result``
(the processed array), ``first_record``, ``last_record``.
## Containers — iteration / loop
These are SUBGRAPH nodes. To use one you MUST emit, in order:
1. The container node itself, e.g. for iteration:
id: "nodeK"
type: "custom"
data: {"type": "iteration",
"title": "<label>",
"desc": "",
"selected": false,
"start_node_id": "nodeKstart",
"iterator_selector": ["<src>", "<list-var>"],
"output_selector": ["<inner-last-node>", "<out-var>"],
"is_parallel": false,
"parallel_nums": 10,
"error_handle_mode": "terminated",
"flatten_output": true}
width: 808
height: 204
zIndex: 1
For loop, swap "iteration" "loop" and use:
data: {"type": "loop", "title": "...", "desc": "",
"selected": false, "start_node_id": "nodeKstart",
"break_conditions": [], "loop_count": 10,
"logical_operator": "and"}
2. The auto-start child (one per container):
id: "nodeKstart"
type: "custom-iteration-start" # loop → "custom-loop-start"
parentId: "nodeK"
extent: "parent"
draggable: false
selectable: false
zIndex: 1002
position: {"x": 60, "y": 78} # relative to parent
data: {"type": "iteration-start", # loop → "loop-start"
"title": "", "desc": "",
"isInIteration": true, # loop → "isInLoop": true
"selected": false}
3. Each inner-pipeline node (any node type, follows normal data rules) MUST add:
parentId: "nodeK"
extent: "parent"
zIndex: 1002
position: {x, y} # relative to parent
data: {..., "isInIteration": true, # loop → "isInLoop": true
"iteration_id": "nodeK"} # loop → "loop_id"
4. Edges INSIDE a container must add to ``data``:
"isInIteration": true # loop → "isInLoop": true
"iteration_id": "nodeK" # loop → "loop_id"
and use ``zIndex: 1002``. Edges OUTSIDE containers use the default
``isInIteration: false`` / ``isInLoop: false``.
5. The container's incoming/outgoing edges connect to the container's id
(``nodeK``), NOT to inner nodes. The first inner edge connects from
``nodeKstart``.
## Edge handles
- Most nodes: sourceHandle "source", targetHandle "target".
- if-else cases: sourceHandle is the case_id ("true" / "false" / ...).
- question-classifier: sourceHandle is the class_id ("1" / "2" / ...).
- iteration-start / sourceHandle "source"; the edge from the *start node
loop-start: is what kicks off the first inner step.
"""
_BASE_SYSTEM_PROMPT_HEAD = """You are a Dify workflow builder.
You are given:
1. A user instruction (what the workflow should do).
2. A node plan from the planner (which nodes to use, in execution order).
Your job: emit a complete Dify workflow graph as JSON. The graph will be written
directly into a Studio draft, so it must be syntactically valid and structurally
correct.
# Hard rules
1. The output is a single JSON object no prose, no Markdown, no code fences.
2. NODE IDs MUST USE ONLY ALPHANUMERICS + UNDERSCORES never hyphens.
Dify's run-time placeholder regex (see ``variable_pool.VARIABLE_PATTERN``)
is ``\\{\\{#([a-zA-Z0-9_]{1,50}(?:\\.[a-zA-Z_][a-zA-Z0-9_]{0,29}){1,10})#\\}\\}``,
so any placeholder pointing at a hyphenated id (e.g. ``{{#node-1.text#}}``)
silently fails to match at run time and the literal string survives into
the prompt the user then sees ``{{#node-1.text#}}`` in their output.
Use the EXACT ids from the plan, formatted as ``node1``, ``node2``, ... in
plan order. Edge ``source`` / ``target`` must reference these ids.
3. Every node has top-level fields: id, type, position, data.
- "type" is always "custom" (ReactFlow node renderer).
- "data.type" is the actual node type ("llm", "start", etc.).
4. Every edge has top-level fields: id, source, target, type, sourceHandle, targetHandle.
- "type" is always "custom".
- "sourceHandle"/"targetHandle" follow the cheatsheet (default: "source"/"target").
- Edge id format: "<source>-<sourceHandle>-<target>-<targetHandle>".
5. Use the model from the planner context for ALL "llm" / "question-classifier" /
"parameter-extractor" nodes (provider, name, mode, completion_params).
6. Reference upstream outputs with the literal placeholder syntax
``{{#<node-id>.<output-var>#}}`` — that's DOUBLE curly braces with ``#``
markers inside (matching Dify's runtime placeholder regex
``\\{\\{#[^#]+#\\}\\}``). NEVER emit single-brace ``{#…#}`` — Dify will
not interpolate it, so the LLM at run time would see the literal
placeholder string in its prompt and echo it back as output. Use
``["<node-id>", "<output-var>"]`` for ``value_selector`` /
``query_variable_selector`` / etc.
7. The "start" node owns input variables; downstream nodes reference them as
``["<start-node-id>", "<var-name>"]`` for selectors or
``{{#<start-node-id>.<var-name>#}}`` inside prompt strings.
8. NEVER emit "code" or "http-request" nodes if a tool from the "Available tools"
section below covers the same task replace them with a "tool" node referencing
the exact provider/tool identifier from the catalogue. "code" / "http-request"
are last-resort escape hatches for arbitrary transformations and APIs that no
installed tool can express.
9. EVERY variable reference MUST resolve to a real, declared variable on the
source node never invent a variable name. Specifically:
- ``{{#<node-id>.<var>#}}`` inside a prompt / ``answer`` / ``template-transform``
template (DOUBLE braces single ``{#…#}`` is NOT a Dify placeholder
and will NOT be substituted), AND ``["<node-id>", "<var>"]`` inside a
``value_selector`` /
``query_variable_selector`` / ``iterator_selector`` / ``output_selector`` /
``tool_parameters[*].value`` (when ``type: "variable"``), MUST point at a
value that the source node actually exposes:
* ``start`` one of the ``data.variables[*].variable`` entries you
declared on the start node. Add an entry if you need a new input.
* ``llm`` ``text`` (the default LLM output) or, when structured
output is enabled, a key from its schema.
* ``code`` a key in ``data.outputs``.
* ``knowledge-retrieval`` ``result`` (the standard array output).
* ``parameter-extractor`` one of the ``data.parameters[*].name``.
* ``document-extractor`` ``text`` (extracted file text; an array of
strings when ``is_array_file`` is true).
* ``variable-aggregator`` ``output``.
* ``list-operator`` ``result`` (array), ``first_record``,
``last_record``.
* ``tool`` any parameter declared by the tool the run time
validates these, so you can name them freely, but pick from the
documented provider/tool.
If the planner's "Start inputs" list (see user prompt) is non-empty,
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.
"""
_BASE_SYSTEM_PROMPT_TAIL = """\
# Layout
- Place nodes left-to-right with x=80 + 320 * index, y=280.
- Viewport: {"x": 0, "y": 0, "zoom": 0.7}.
"""
_BASE_SYSTEM_PROMPT_FOOTER = """
# Output schema
{
"nodes": [...],
"edges": [...],
"viewport": {"x": 0, "y": 0, "zoom": 0.7}
}
"""
_WORKFLOW_MODE_RULES = """# Mode-specific rules — Workflow
- The graph MUST start with exactly one "start" node and end with exactly one "end" node.
- Do NOT use "answer" nodes (those are for Advanced Chat only).
- The "end" node's outputs[].value_selector must point at a real upstream output.
"""
_ADVANCED_CHAT_MODE_RULES = """# Mode-specific rules — Advanced Chat (Chatflow)
- The graph MUST start with exactly one "start" node and end with exactly one "answer" node.
- Do NOT use "end" nodes (those are for plain Workflow apps).
- The "start" node should expose "sys.query" / "sys.files" automatically; user-defined
variables go in start.data.variables.
- The "answer" node's "answer" field references upstream outputs as
{{#<node-id>.<var>#}} and is what the user sees in chat.
"""
BUILDER_SYSTEM_PROMPT_WORKFLOW = (
_BASE_SYSTEM_PROMPT_HEAD
+ _WORKFLOW_MODE_RULES
+ _BASE_SYSTEM_PROMPT_TAIL
+ NODE_CONFIG_CHEATSHEET
+ _BASE_SYSTEM_PROMPT_FOOTER
)
BUILDER_SYSTEM_PROMPT_ADVANCED_CHAT = (
_BASE_SYSTEM_PROMPT_HEAD
+ _ADVANCED_CHAT_MODE_RULES
+ _BASE_SYSTEM_PROMPT_TAIL
+ NODE_CONFIG_CHEATSHEET
+ _BASE_SYSTEM_PROMPT_FOOTER
)
BUILDER_USER_PROMPT = """# User instruction
{instruction}
{ideal_output_section}\
{existing_graph_section}\
# Selected model (use for all LLM-based nodes)
provider={provider}, name={name}, mode={mode_label}
{tool_catalogue_section}\
{start_inputs_section}\
# Node plan (from planner — use these labels and node_types in this order)
{plan_block}
Now emit the complete workflow graph JSON.
"""
def format_builder_existing_graph_section(current_graph: dict | None) -> str:
"""
Refine mode: give the builder the FULL existing graph JSON so it can keep
every node and edge the user's change does not touch byte-for-byte — same
ids, same config, same prompt templates. Without the full config the
builder would regenerate untouched nodes from scratch and silently drop
the user's hand-tuned settings.
Returns an empty string in create mode (no ``current_graph``); the builder
then behaves exactly as before, constructing the graph purely from the
planner's node plan.
"""
if not current_graph:
return ""
graph_json = json.dumps(current_graph, ensure_ascii=False, separators=(",", ":"))
return (
"# Existing graph to refine (JSON)\n\n"
"You are REFINING this existing graph, NOT building from scratch. Apply "
"ONLY the change the user instruction describes. Every node and edge the "
"change does not affect MUST be preserved verbatim — keep the same node "
"ids, the same `data` config, and the same prompt templates. The node "
"plan below is the target node set after your change; use the existing "
"graph as the source of truth for the config of nodes that carry over.\n\n"
f"```json\n{graph_json}\n```\n\n"
)
def format_start_inputs_section(start_inputs: list[dict[str, Any]]) -> str:
"""
Surface the planner's ``start_inputs`` list to the builder so it can
populate ``start.data.variables`` with the exact set of inputs every
downstream variable reference will need. Empty list empty section,
because the builder may then declare no input variables (e.g. an
Advanced-Chat workflow that only consumes ``sys.query``).
"""
if not start_inputs:
return ""
lines = ["# Start inputs (copy each entry verbatim into start.data.variables)"]
lines.append("")
for inp in start_inputs:
variable = str(inp.get("variable") or "").strip()
label = str(inp.get("label") or "").strip()
type_ = str(inp.get("type") or "paragraph").strip()
if not variable:
continue
lines.append(f"- variable={variable!r} label={label!r} type={type_!r}")
lines.append("")
return "\n".join(lines) + "\n"
def format_builder_tool_catalogue_section(catalogue_text: str) -> str:
"""
Builder-facing catalogue block. The builder needs the same identifiers
the planner saw, plus a stern reminder that ``tool`` nodes MUST set
``provider_id`` / ``provider_name`` / ``tool_name`` to entries that
actually exist in this list hallucinated tools fail at draft sync.
"""
if not catalogue_text.strip():
return ""
return (
"# Available tools (use these exact provider/tool identifiers — "
"for each 'tool' node, set provider_id and provider_name to the "
"provider portion and tool_name to the tool portion)\n\n"
f"{catalogue_text}\n\n"
)
def format_plan_block(plan_nodes: list[dict[str, Any]]) -> str:
"""
Render the planner output as a numbered list the builder can quote.
Node IDs use no separator (``node1``, ``node2``, ...) because Dify's
run-time placeholder regex requires ``[a-zA-Z0-9_]`` in the node-id
slot a hyphenated id like ``node-1`` would silently fail to match
at run time and the literal ``{{#node-1.var#}}`` survives into the
LLM prompt.
For container children (planner emitted a ``"parent": "<label>"`` key),
we resolve the parent label to its ``nodeN`` id and surface it on the
same line so the builder knows to set ``parentId`` and the
``isInIteration`` / ``isInLoop`` markers on inner nodes.
"""
# First pass: label → node-id so we can resolve "parent" hints.
label_to_id: dict[str, str] = {}
for idx, node in enumerate(plan_nodes, start=1):
label = str(node.get("label") or "")
if label and label not in label_to_id:
label_to_id[label] = f"node{idx}"
lines = []
for idx, node in enumerate(plan_nodes, start=1):
node_id = f"node{idx}"
label = node.get("label", "")
node_type = node.get("node_type", "")
purpose = node.get("purpose", "")
parent_label = str(node.get("parent") or "")
parent_clause = ""
if parent_label:
parent_id = label_to_id.get(parent_label, "")
if parent_id:
parent_clause = f" parent={parent_id}"
else:
parent_clause = f" parent={parent_label!r}"
lines.append(f"{idx}. id={node_id} type={node_type} label={label!r}{parent_clause}\n purpose: {purpose}")
return "\n".join(lines)
def get_builder_system_prompt(mode: str) -> str:
"""Pick the system prompt branch for Workflow vs Advanced Chat."""
if mode == "advanced-chat":
return BUILDER_SYSTEM_PROMPT_ADVANCED_CHAT
return BUILDER_SYSTEM_PROMPT_WORKFLOW

View File

@ -0,0 +1,189 @@
"""
Planner prompts.
The planner is the lightweight first step in the slim plannerbuilder pipeline.
It receives the user's natural-language instruction and emits a high-level
node plan in JSON. The builder later turns that plan into the final graph.
We keep the planner deliberately short the heavy lifting (config schemas,
edge wiring, default values) belongs in the builder. The planner only commits
to the *which-node-types* decision so the builder gets a tight scaffold.
"""
PLANNER_SYSTEM_PROMPT = """You are a Dify workflow planner.
Given a user's natural-language description of an automation, you choose the
minimum set of Dify workflow nodes needed to fulfil it, in execution order.
# Available node types
- "start" workflow entry point. Always present. Holds input form variables.
- "end" workflow exit point (Workflow mode only). Returns variables.
- "answer" chat reply (Advanced Chat mode only). Streams a message.
- "llm" call an LLM with a prompt.
- "knowledge-retrieval" query Dify knowledge bases.
- "code" run a Python/JavaScript snippet.
- "template-transform" Jinja2 string templating.
- "http-request" call an external HTTP API.
- "tool" call a Dify built-in / plugin tool (e.g. web search, time, audio).
- "if-else" conditional branch on a value.
- "iteration" run a sub-pipeline over each item of a list (parallel-friendly map).
- "loop" repeat a sub-pipeline until an exit condition is met.
- "question-classifier" route to a labelled branch based on free-text intent.
- "parameter-extractor" extract structured params from free text using LLM.
- "document-extractor" extract plain text from uploaded files (PDF, Word, PPT,
Markdown, etc.). Feed its "text" output into an "llm" /
"code" node. Requires a "file" or "file-list" input.
- "variable-aggregator" merge several branch outputs into one "output" variable;
use after "if-else" / "question-classifier" to rejoin
mutually-exclusive paths before "end" / "answer".
- "list-operator" filter / sort / slice an array variable (e.g. the items
fed into or produced by an "iteration").
# Rules
1. Always start with exactly one "start" node.
2. End with exactly one "end" (Workflow mode) or "answer" (Advanced Chat mode).
3. Keep it minimal prefer 36 nodes for simple flows. Do NOT add nodes "just in case".
4. For COMPLEX scenes, reach for control-flow nodes instead of stuffing logic into
prompts:
- branching / mutually-exclusive paths "if-else" (deterministic value check) or
"question-classifier" (semantic / intent routing)
- "for each item in a list" "iteration"
- "keep going until condition" "loop"
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
arbitrary external APIs not provided by any installed tool, and to "code" for
genuine data transformations no tool can express.
6. Each node "label" must be a short, human-readable, Title-Case name ( 25 chars).
7. Each node "purpose" is one sentence explaining what it does in this workflow.
For "tool" nodes, name the chosen tool inside the purpose, e.g.
"Search the web using google/search.".
8. For "iteration" and "loop" nodes (containers), list the container node first
and then EACH inner-pipeline step as its own entry tagged with
``"parent": "<container-label>"``. Container children execute in declaration
order from the container's auto-generated start node. Example:
{"label": "Per Item", "node_type": "iteration", "purpose": "..."},
{"label": "Summarize Item", "node_type": "llm", "purpose": "...",
"parent": "Per Item"},
{"label": "Store Item", "node_type": "code", "purpose": "...",
"parent": "Per Item"}
Nodes without a ``"parent"`` are top-level.
9. Pick a short, human-readable ``app_name`` ( 30 chars, Title Case) and
exactly ONE ``icon`` emoji that captures the workflow's purpose at a
glance these are used as the App's display name and icon when the user
applies the generation to a brand-new app. Prefer concise nouns
("URL Summarizer", "Translator", "Issue Triage") and a topical emoji
(📰 for news/summary, 🌐 for translation, 🐛 for issues, 🎓 for
tutoring, 🔎 for search, 🗂 for routing/classification).
10. Declare the workflow's user-supplied inputs in ``start_inputs``. Every
user value a downstream node will reference (URLs, queries, topics,
file uploads, etc.) MUST appear here so the start node can expose it
at run time otherwise the LLM / code / answer node's ``{#start.<var>#}``
reference will fail at run time with "variable not found". Each entry
is ``{"variable": "<snake_case>", "label": "<UI label>",
"type": "text-input" | "paragraph" | "number" | "select" | "file" |
"file-list"}``. Use:
- "text-input" for short single-line values (URLs, names),
- "paragraph" for free-form multi-line text (descriptions, queries),
- "number" / "select" / "file" / "file-list" for the obvious cases.
In Advanced-Chat mode the ``sys.query`` / ``sys.files`` system
variables are automatic downstream nodes may reference them without
a ``start_inputs`` entry. In Workflow mode there is NO automatic
variable; everything the user supplies must be in ``start_inputs``.
11. Output strictly the JSON object no prose, no Markdown, no code fences.
# Output schema
{
"title": "<≤ 40-char title of the workflow>",
"description": "<one-sentence summary>",
"app_name": "<≤ 30-char product-style name, e.g. 'URL Summarizer'>",
"icon": "<single emoji that captures the workflow's purpose, e.g. '📰'>",
"start_inputs": [
{"variable": "url", "label": "URL", "type": "text-input"}
],
"nodes": [
{"label": "Start", "node_type": "start", "purpose": "..."},
{"label": "Summarize", "node_type": "llm", "purpose": "..."},
{"label": "End", "node_type": "end", "purpose": "..."}
]
}
"""
PLANNER_USER_PROMPT = """# Mode
{mode}
# User instruction
{instruction}
{existing_graph_section}{ideal_output_section}{tool_catalogue_section}\
Return the JSON plan now.
"""
def format_existing_graph_section(current_graph: dict | None) -> str:
"""
Refine mode: surface a compact summary of the graph the user is editing so
the planner amends the existing node set rather than inventing one from
scratch. Returns an empty string in create mode (no ``current_graph``), in
which case the planner behaves exactly as before.
We pass only ids / node-types / titles + edge endpoints here the planner
decides *which nodes* exist, so it needs the shape, not the per-node config.
The builder gets the full graph JSON to preserve untouched node config.
"""
if not current_graph:
return ""
nodes = current_graph.get("nodes") or []
edges = current_graph.get("edges") or []
node_lines = []
for node in nodes:
if not isinstance(node, dict):
continue
data = node.get("data") or {}
node_lines.append(f"- id={node.get('id', '')!r} type={data.get('type', '')!r} title={data.get('title', '')!r}")
edge_lines = []
for edge in edges:
if not isinstance(edge, dict):
continue
edge_lines.append(f"- {edge.get('source', '')} -> {edge.get('target', '')}")
nodes_block = "\n".join(node_lines) or "(none)"
edges_block = "\n".join(edge_lines) or "(none)"
return (
"# Existing graph to refine\n\n"
"You are REFINING an existing workflow, NOT building one from scratch. "
"The user instruction above describes the change they want. Re-plan the "
"node list to reflect that change while keeping everything the "
"instruction does not mention — preserve existing nodes, their order, "
"and their labels wherever the change leaves them untouched. Only add, "
"remove, or rename nodes the requested change actually requires.\n\n"
f"Current nodes:\n{nodes_block}\n\n"
f"Current edges:\n{edges_block}\n\n"
)
def format_ideal_output_section(ideal_output: str) -> str:
"""Return an empty string when the user did not provide ideal output."""
if not ideal_output.strip():
return ""
return f"# Ideal output\n\n{ideal_output}\n\n"
def format_tool_catalogue_section(catalogue_text: str) -> str:
"""
Embed the installed-tool catalogue so the planner can pick concrete
``tool`` nodes by exact ``provider/tool`` identifier instead of inventing
names. Returns an empty string when no tools are installed.
"""
if not catalogue_text.strip():
return ""
return (
"# Available tools (planner: when picking 'tool' nodes, choose "
"from this list and reference them by exact provider/tool name)\n\n"
f"{catalogue_text}\n\n"
)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,153 @@
"""
Tool catalogue for the workflow generator.
Returns a compact, LLM-readable inventory of the tools currently installed for
a tenant (both hardcoded built-in providers and plugin providers). The planner
uses this to recommend ``tool`` nodes by exact ``provider/tool`` identifier;
the builder consumes the same list so it can emit a syntactically correct
``tool`` node ``data`` block (provider_id, provider_type, tool_name,
tool_label).
Format: one tool per line, ``- <provider>/<tool> <one-line description>``.
The list is intentionally capped if a tenant has hundreds of plugin tools,
sending the full catalogue blows past LLM context windows. We sort by
provider name and truncate to ``_MAX_TOOLS`` lines so the prompt stays
bounded. Tools beyond the cap are dropped silently; if quality suffers, the
fix is a planner-time relevance filter, not a bigger dump.
"""
import logging
from operator import itemgetter
from typing import TypedDict
from core.tools.builtin_tool.provider import BuiltinToolProviderController
from core.tools.plugin_tool.provider import PluginToolProviderController
from core.tools.tool_manager import ToolManager
logger = logging.getLogger(__name__)
_MAX_TOOLS = 80
class ToolCatalogueEntry(TypedDict):
provider_name: str
provider_type: str # "builtin" | "plugin" — what the workflow tool node uses
plugin_id: str # empty string for hardcoded built-ins
tool_name: str
tool_label: str
description: str # one-line LLM-friendly description
def build_tool_catalogue(tenant_id: str) -> list[ToolCatalogueEntry]:
"""
Enumerate installed tools for the given tenant.
Failures inside a single provider (mis-declared tool, plugin runtime
error) are logged and skipped one bad provider must not break the
whole generator. Returns at most ``_MAX_TOOLS`` entries.
"""
entries: list[ToolCatalogueEntry] = []
for provider in ToolManager.list_builtin_providers(tenant_id):
provider_name = provider.entity.identity.name
plugin_id = ""
# Hardcoded built-ins return "builtin"; plugin providers return "plugin".
# Use the provider's own declared value so the catalogue matches what
# ``tool`` workflow nodes need in their ``data.provider_type`` field.
provider_type = provider.provider_type.value
if isinstance(provider, PluginToolProviderController):
plugin_id = provider.plugin_id or ""
elif not isinstance(provider, BuiltinToolProviderController):
# Unknown provider class — skip rather than guess.
continue
try:
tools = list(provider.get_tools())
except Exception:
logger.exception(
"Workflow generator: failed to list tools for provider %s",
provider_name,
)
continue
for tool in tools:
try:
tool_name = tool.entity.identity.name
tool_label = _i18n_text(tool.entity.identity.label)
description = _tool_description(tool.entity.description)
entries.append(
ToolCatalogueEntry(
provider_name=provider_name,
provider_type=provider_type,
plugin_id=plugin_id,
tool_name=tool_name,
tool_label=tool_label,
description=description,
)
)
except Exception:
logger.exception(
"Workflow generator: failed to describe tool %s in provider %s",
getattr(getattr(tool, "entity", None), "identity", None),
provider_name,
)
continue
entries.sort(key=itemgetter("provider_name", "tool_name"))
return entries[:_MAX_TOOLS]
def installed_tool_keys(entries: list[ToolCatalogueEntry]) -> set[tuple[str, str]]:
"""
Return the set of ``(provider_name, tool_name)`` pairs available for the
tenant. The validator in ``runner.py`` consults this set so a planner /
builder that hallucinates a tool name fails loudly at generation time
instead of producing a runtime-broken graph.
The set is keyed on ``provider_name`` (not ``provider_id``) because the
builder prompt is instructed to put the provider's catalogue name into
BOTH ``data.provider_id`` and ``data.provider_name`` on tool nodes
they are the same value for both built-in and plugin providers.
"""
return {(e["provider_name"], e["tool_name"]) for e in entries}
def format_tool_catalogue(entries: list[ToolCatalogueEntry]) -> str:
"""
Render the catalogue as a compact multi-line block for prompt injection.
Returns an empty string when no tools are installed callers should skip
the section entirely in that case.
"""
if not entries:
return ""
lines = []
for e in entries:
desc = e["description"].replace("\n", " ").strip()
if len(desc) > 120:
desc = desc[:117] + "..."
line = f"- {e['provider_name']}/{e['tool_name']}"
if e["tool_label"] and e["tool_label"] != e["tool_name"]:
line += f" ({e['tool_label']})"
if desc:
line += f"{desc}"
lines.append(line)
return "\n".join(lines)
def _i18n_text(label) -> str:
"""Pull the English label out of an I18nObject (falls back to .name)."""
if label is None:
return ""
en = getattr(label, "en_US", None)
if en:
return en
return getattr(label, "zh_Hans", "") or ""
def _tool_description(description) -> str:
"""Pull the LLM-facing description (``.llm``) from a ToolDescription."""
if description is None:
return ""
return getattr(description, "llm", "") or ""

View File

@ -0,0 +1,147 @@
"""
Typed payloads for workflow generation.
These TypedDicts describe the shape that the planner and builder LLM calls are
required to return after ``json_repair`` parsing. They mirror the runtime
``graph`` shape consumed by ``WorkflowService.sync_draft_workflow`` so the output
can be written straight into a draft workflow without further translation.
"""
from typing import Final, Literal, NotRequired, TypedDict
WorkflowGenerationMode = Literal["workflow", "advanced-chat"]
# 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"
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 WorkflowGenerateErrorDict(TypedDict):
"""One structured error from the workflow generator.
``code`` is the stable machine-readable identifier listed in
``WorkflowGenerateErrorCode``. ``detail`` is the raw human-readable
diagnostic (English). ``node_id`` is set when the error is tied to a
specific node so the frontend can highlight it on the preview canvas.
"""
code: str
detail: str
node_id: NotRequired[str]
class PlannerNodeDict(TypedDict):
"""One node from the planner's high-level plan."""
label: str
node_type: str
purpose: str
class PlannerStartInputDict(TypedDict):
"""One user-supplied input the start node will declare.
The planner emits this list so the builder can populate
``start.data.variables`` and downstream ``{#start.<var>#}`` references
resolve at run time. Optional older prompts may omit it; the runner's
postprocess walker still auto-fixes missing references.
"""
variable: str
label: str
type: str # "text-input" | "paragraph" | "number" | "select" | "file" | "file-list"
class PlannerResultDict(TypedDict):
"""Top-level planner response."""
title: str
description: str
app_name: NotRequired[str]
icon: NotRequired[str]
start_inputs: NotRequired[list[PlannerStartInputDict]]
nodes: list[PlannerNodeDict]
class GraphNodePositionDict(TypedDict):
x: float
y: float
class GraphNodeDict(TypedDict):
"""A workflow graph node as serialised in the draft graph JSON."""
id: str
type: str # ReactFlow custom-node key, e.g. "custom"
position: GraphNodePositionDict
data: dict
width: NotRequired[int]
height: NotRequired[int]
positionAbsolute: NotRequired[GraphNodePositionDict]
sourcePosition: NotRequired[str]
targetPosition: NotRequired[str]
selected: NotRequired[bool]
dragging: NotRequired[bool]
class GraphEdgeDict(TypedDict):
"""A workflow graph edge as serialised in the draft graph JSON."""
id: str
source: str
target: str
type: str # always "custom" for Dify's custom-edge renderer
sourceHandle: NotRequired[str]
targetHandle: NotRequired[str]
data: NotRequired[dict]
class GraphViewportDict(TypedDict):
x: float
y: float
zoom: float
class GraphDict(TypedDict):
"""Full graph payload — matches ``WorkflowService.sync_draft_workflow``."""
nodes: list[GraphNodeDict]
edges: list[GraphEdgeDict]
viewport: GraphViewportDict
class WorkflowGenerateResultDict(TypedDict):
"""What the runner returns. ``error`` is "" on success.
``app_name`` and ``icon`` are populated from the planner output when the
LLM emits them (newer prompts) and default to empty strings when it
doesn't. The frontend's ``applyToNewApp`` consumes them with its own
fallback so old prompts and missing fields stay safe.
``errors`` is the structured-error sibling of ``error``. ``error`` is a
human-readable concatenation kept for backwards compat with the original
envelope; ``errors`` carries the machine-readable codes so the frontend
can localise the message and tie failures to specific nodes. On success
both ``error == ""`` and ``errors == []``.
"""
graph: GraphDict
message: str
app_name: str
icon: str
error: str
errors: list[WorkflowGenerateErrorDict]

View File

@ -8493,6 +8493,27 @@ Get website crawl status
| 400 | Invalid provider |
| 404 | Crawl job not found |
### /workflow-generate
#### POST
##### Description
Generate a Dify workflow graph from natural language
##### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| payload | body | | Yes | [WorkflowGeneratePayload](#workflowgeneratepayload) |
##### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Workflow graph generated successfully |
| 400 | Invalid request parameters |
| 402 | Provider quota exceeded |
### /workflow/{workflow_run_id}/events
#### GET
@ -16658,6 +16679,22 @@ How a workflow node is bound to an Agent.
| ---- | ---- | ----------- | -------- |
| features | object | Workflow feature configuration | Yes |
#### WorkflowGeneratePayload
Payload for the cmd+k `/create` and `/refine` workflow generator endpoint.
See ``services/workflow_generator_service.py`` for behaviour. Errors are
surfaced through the same envelope as ``/rule-generate`` so the frontend
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 |
| ideal_output | string | Optional sample output for grounding | No |
| instruction | string | Natural-language workflow description | Yes |
| mode | string | Target app mode for the generated graph<br>*Enum:* `"advanced-chat"`, `"workflow"` | Yes |
| model_config | [ModelConfig](#modelconfig) | Model configuration | Yes |
#### WorkflowListQuery
| Name | Type | Description | Required |

View File

@ -0,0 +1,96 @@
"""
Workflow generator service.
Thin facade over ``core.workflow.generator.WorkflowGenerator`` that owns the
model-manager / model-instance plumbing. Controllers call this; the pure
domain class never touches the model registry directly.
Pattern mirrors ``LLMGenerator.generate_rule_config`` see
``core/llm_generator/llm_generator.py`` but lives in ``services/`` because
the generator output is consumed at the application layer (sync_draft_workflow,
createApp) rather than from inside another workflow.
"""
import logging
from typing import Any
from core.app.app_config.entities import ModelConfig
from core.model_manager import ModelManager
from core.workflow.generator import WorkflowGenerator
from core.workflow.generator.tool_catalogue import build_tool_catalogue, format_tool_catalogue, installed_tool_keys
from core.workflow.generator.types import WorkflowGenerateResultDict, WorkflowGenerationMode
from graphon.model_runtime.entities.model_entities import ModelType
logger = logging.getLogger(__name__)
class WorkflowGeneratorService:
"""
Coordinates model resolution with the workflow generator domain logic.
Single public method (``generate_workflow_graph``) keeps the surface area
minimal the cmd+k `/create` flow is the only caller today.
"""
@classmethod
def generate_workflow_graph(
cls,
*,
tenant_id: str,
mode: WorkflowGenerationMode,
instruction: str,
model_config: ModelConfig,
ideal_output: str = "",
current_graph: dict[str, Any] | None = None,
) -> WorkflowGenerateResultDict:
"""
Resolve a model instance for the tenant and run the generator.
``current_graph`` is the existing draft graph for the cmd+k `/refine`
flow when present the generator refines it instead of creating a new
graph from scratch. ``None`` is the `/create` path.
Errors from the LLM call (auth, quota, invoke) propagate so the
controller can map them to existing HTTP error envelopes (same
envelope as ``/rule-generate``).
"""
model_manager = ModelManager.for_tenant(tenant_id=tenant_id)
model_instance = model_manager.get_model_instance(
tenant_id=tenant_id,
model_type=ModelType.LLM,
provider=model_config.provider,
model=model_config.name,
)
model_parameters: dict[str, Any] = dict(model_config.completion_params or {})
# Build the installed-tool catalogue for this tenant so the planner/
# builder can pick concrete tools instead of inventing names, AND so
# the runner's validator can reject hallucinated tool names BEFORE
# the user clicks Apply. A failure here (plugin daemon unreachable,
# etc.) must not block generation — log and fall back to the no-tool
# path, which also disables tool validation in the runner (None
# sentinel rather than empty set, so we don't reject every tool
# node just because we couldn't enumerate the catalogue).
tool_catalogue_text = ""
installed_tools: set[tuple[str, str]] | None = None
try:
entries = build_tool_catalogue(tenant_id)
tool_catalogue_text = format_tool_catalogue(entries)
installed_tools = installed_tool_keys(entries)
except Exception:
logger.exception("Workflow generator: failed to build tool catalogue for tenant %s", tenant_id)
return WorkflowGenerator.generate_workflow_graph(
model_instance=model_instance,
model_parameters=model_parameters,
provider=model_config.provider,
model_name=model_config.name,
model_mode=model_config.mode.value,
mode=mode,
instruction=instruction,
ideal_output=ideal_output,
tool_catalogue_text=tool_catalogue_text,
installed_tools=installed_tools,
current_graph=current_graph,
)

View File

@ -254,3 +254,208 @@ def test_instruction_template_invalid_type(app) -> None:
):
with pytest.raises(ValueError):
method()
# ─ /workflow-generate ─────────────────────────────────────────────────────────
def _workflow_generate_payload() -> dict:
return {
"mode": "workflow",
"instruction": "Summarize a URL",
"ideal_output": "A 3-sentence summary.",
"model_config": _model_config_payload(),
}
def _stub_workflow_service(monkeypatch: pytest.MonkeyPatch, returns=None, raises: Exception | None = None):
def _call(**_kwargs):
if raises is not None:
raise raises
return returns or {
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
"message": "",
"error": "",
}
monkeypatch.setattr(generator_module.WorkflowGeneratorService, "generate_workflow_graph", _call)
def test_workflow_generate_returns_service_result(app, monkeypatch: pytest.MonkeyPatch) -> None:
api = generator_module.WorkflowGenerateApi()
method = _unwrap(api.post)
expected = {
"graph": {"nodes": [{"id": "node-1"}], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
"message": "Summarize",
"error": "",
}
_stub_workflow_service(monkeypatch, returns=expected)
with app.test_request_context(
"/console/api/workflow-generate",
method="POST",
json=_workflow_generate_payload(),
):
response = method("t1")
assert response == expected
def test_workflow_generate_maps_provider_token_error(app, monkeypatch: pytest.MonkeyPatch) -> None:
"""ProviderTokenNotInitError → ProviderNotInitializeError so the frontend
can render the same "provider missing" UX as /rule-generate."""
api = generator_module.WorkflowGenerateApi()
method = _unwrap(api.post)
_stub_workflow_service(monkeypatch, raises=ProviderTokenNotInitError("missing token"))
with app.test_request_context(
"/console/api/workflow-generate",
method="POST",
json=_workflow_generate_payload(),
):
with pytest.raises(ProviderNotInitializeError):
method("t1")
def test_workflow_generate_maps_quota_error(app, monkeypatch: pytest.MonkeyPatch) -> None:
from controllers.console.app.error import ProviderQuotaExceededError
from core.errors.error import QuotaExceededError
api = generator_module.WorkflowGenerateApi()
method = _unwrap(api.post)
_stub_workflow_service(monkeypatch, raises=QuotaExceededError())
with app.test_request_context(
"/console/api/workflow-generate",
method="POST",
json=_workflow_generate_payload(),
):
with pytest.raises(ProviderQuotaExceededError):
method("t1")
def test_workflow_generate_maps_model_not_support_error(app, monkeypatch: pytest.MonkeyPatch) -> None:
from controllers.console.app.error import ProviderModelCurrentlyNotSupportError
from core.errors.error import ModelCurrentlyNotSupportError
api = generator_module.WorkflowGenerateApi()
method = _unwrap(api.post)
_stub_workflow_service(monkeypatch, raises=ModelCurrentlyNotSupportError("not supported"))
with app.test_request_context(
"/console/api/workflow-generate",
method="POST",
json=_workflow_generate_payload(),
):
with pytest.raises(ProviderModelCurrentlyNotSupportError):
method("t1")
def test_workflow_generate_maps_invoke_error(app, monkeypatch: pytest.MonkeyPatch) -> None:
from controllers.console.app.error import CompletionRequestError
from graphon.model_runtime.errors.invoke import InvokeError
api = generator_module.WorkflowGenerateApi()
method = _unwrap(api.post)
_stub_workflow_service(monkeypatch, raises=InvokeError("LLM unreachable"))
with app.test_request_context(
"/console/api/workflow-generate",
method="POST",
json=_workflow_generate_payload(),
):
with pytest.raises(CompletionRequestError):
method("t1")
def test_workflow_generate_accepts_advanced_chat_mode(app, monkeypatch: pytest.MonkeyPatch) -> None:
"""The payload Literal must accept advanced-chat as well as workflow."""
api = generator_module.WorkflowGenerateApi()
method = _unwrap(api.post)
captured: dict = {}
def _capture(**kwargs):
captured.update(kwargs)
return {
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
"message": "",
"error": "",
}
monkeypatch.setattr(generator_module.WorkflowGeneratorService, "generate_workflow_graph", _capture)
payload = _workflow_generate_payload()
payload["mode"] = "advanced-chat"
with app.test_request_context(
"/console/api/workflow-generate",
method="POST",
json=payload,
):
method("t1")
assert captured["mode"] == "advanced-chat"
assert captured["instruction"] == "Summarize a URL"
assert captured["ideal_output"] == "A 3-sentence summary."
def test_workflow_generate_forwards_current_graph_for_refine(app, monkeypatch: pytest.MonkeyPatch) -> None:
"""cmd+k `/refine`: the optional current_graph field reaches the service."""
api = generator_module.WorkflowGenerateApi()
method = _unwrap(api.post)
captured: dict = {}
def _capture(**kwargs):
captured.update(kwargs)
return {
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
"message": "",
"error": "",
}
monkeypatch.setattr(generator_module.WorkflowGeneratorService, "generate_workflow_graph", _capture)
graph = {"nodes": [{"id": "node1"}], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}}
payload = _workflow_generate_payload()
payload["current_graph"] = graph
with app.test_request_context(
"/console/api/workflow-generate",
method="POST",
json=payload,
):
method("t1")
assert captured["current_graph"] == graph
def test_workflow_generate_current_graph_defaults_to_none(app, monkeypatch: pytest.MonkeyPatch) -> None:
"""Omitting current_graph (the `/create` path) forwards None to the service."""
api = generator_module.WorkflowGenerateApi()
method = _unwrap(api.post)
captured: dict = {}
def _capture(**kwargs):
captured.update(kwargs)
return {
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
"message": "",
"error": "",
}
monkeypatch.setattr(generator_module.WorkflowGeneratorService, "generate_workflow_graph", _capture)
with app.test_request_context(
"/console/api/workflow-generate",
method="POST",
json=_workflow_generate_payload(),
):
method("t1")
assert captured["current_graph"] is None

View File

@ -0,0 +1,129 @@
"""
Unit tests for the planner / builder prompt format helpers.
These helpers are pure string-shaping functions that wrap conditional sections
into the LLM prompts. We assert they (1) emit empty strings when the source
data is empty so the prompt stays tight, (2) include the relevant header text
when data is present, and (3) round-trip the raw catalogue text unchanged.
"""
from core.workflow.generator.prompts.builder_prompts import (
BUILDER_SYSTEM_PROMPT_ADVANCED_CHAT,
BUILDER_SYSTEM_PROMPT_WORKFLOW,
format_builder_tool_catalogue_section,
format_plan_block,
get_builder_system_prompt,
)
from core.workflow.generator.prompts.planner_prompts import (
format_ideal_output_section,
format_tool_catalogue_section,
)
class TestFormatIdealOutputSection:
def test_returns_empty_string_for_blank_input(self):
assert format_ideal_output_section("") == ""
assert format_ideal_output_section(" \n\t ") == ""
def test_wraps_content_in_a_labelled_section(self):
out = format_ideal_output_section("A short summary.")
assert out.startswith("# Ideal output")
assert "A short summary." in out
assert out.endswith("\n\n")
class TestPlannerCatalogueSection:
def test_returns_empty_when_catalogue_is_blank(self):
# No installed tools — the planner shouldn't see an "Available tools"
# heading at all; an empty string keeps the prompt tight.
assert format_tool_catalogue_section("") == ""
assert format_tool_catalogue_section(" ") == ""
def test_emits_a_planner_facing_header_with_the_catalogue(self):
out = format_tool_catalogue_section("- google/search — Search.")
assert "# Available tools" in out
assert "planner" in out.lower()
assert "- google/search — Search." in out
class TestBuilderCatalogueSection:
def test_returns_empty_when_catalogue_is_blank(self):
assert format_builder_tool_catalogue_section("") == ""
def test_includes_strict_provider_tool_guidance(self):
out = format_builder_tool_catalogue_section("- google/search — Search.")
# The builder must be told to use the *exact* identifiers — hallucinated
# tools fail at sync time.
assert "exact" in out.lower()
assert "provider_id" in out
assert "tool_name" in out
assert "- google/search — Search." in out
class TestFormatPlanBlock:
def test_renders_one_line_per_node(self):
out = format_plan_block(
[
{"label": "Start", "node_type": "start", "purpose": "Take input"},
{"label": "Summarize", "node_type": "llm", "purpose": "Summarize"},
]
)
lines = out.split("\n")
# Two nodes → 4 lines (each entry takes id-line + purpose-line).
assert any(line.startswith("1.") and "node1" in line for line in lines)
assert any(line.startswith("2.") and "node2" in line for line in lines)
assert "purpose: Take input" in out
assert "purpose: Summarize" in out
def test_handles_missing_fields_gracefully(self):
out = format_plan_block([{"node_type": "llm"}])
# Missing label/purpose must not raise — they degrade to empty strings.
assert "node1" in out
assert "type=llm" in out
class TestGetBuilderSystemPrompt:
def test_returns_workflow_prompt_for_workflow_mode(self):
# The two prompts are structurally similar but differ in their
# mode-specific rules block.
prompt = get_builder_system_prompt("workflow")
assert prompt is BUILDER_SYSTEM_PROMPT_WORKFLOW
assert 'exactly one "end" node' in prompt
def test_returns_advanced_chat_prompt_for_advanced_chat_mode(self):
prompt = get_builder_system_prompt("advanced-chat")
assert prompt is BUILDER_SYSTEM_PROMPT_ADVANCED_CHAT
assert 'exactly one "answer" node' in prompt
class TestFormatPlanBlockParentHints:
def test_resolves_parent_label_to_node_id(self):
# The planner emits parent="Per Item" as a hint; the builder needs the
# resolved id ("node-N") to set parentId on the inner node.
from core.workflow.generator.prompts.builder_prompts import format_plan_block
out = format_plan_block(
[
{"label": "Start", "node_type": "start", "purpose": "x"},
{"label": "Per Item", "node_type": "iteration", "purpose": "iterate"},
{"label": "Sum Item", "node_type": "llm", "purpose": "summarize one", "parent": "Per Item"},
]
)
# The inner line should mention parent=node2 (the iteration node).
assert "parent=node2" in out
# Top-level nodes must not have a parent clause.
first_line = out.splitlines()[0]
assert "parent=" not in first_line
def test_omits_parent_clause_when_label_is_unknown(self):
# A typo / unknown parent label should degrade to quoting the raw
# label string rather than fabricating a node id.
from core.workflow.generator.prompts.builder_prompts import format_plan_block
out = format_plan_block(
[
{"label": "Start", "node_type": "start", "purpose": "x"},
{"label": "Step", "node_type": "code", "purpose": "x", "parent": "Ghost Container"},
]
)
assert "parent='Ghost Container'" in out

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,349 @@
"""Unit tests for the tool catalogue helpers."""
from types import SimpleNamespace
from unittest.mock import patch
from core.workflow.generator.tool_catalogue import (
ToolCatalogueEntry,
_i18n_text,
_tool_description,
build_tool_catalogue,
format_tool_catalogue,
installed_tool_keys,
)
def _entry(provider: str, tool: str, *, label: str = "", description: str = "") -> ToolCatalogueEntry:
return ToolCatalogueEntry(
provider_name=provider,
provider_type="builtin",
plugin_id="",
tool_name=tool,
tool_label=label,
description=description,
)
class TestInstalledToolKeys:
"""The validator in ``runner.py`` looks up tool nodes against this set.
Keys MUST be ``(provider_name, tool_name)`` tuples the builder prompt
is instructed to put ``provider_name`` into both ``data.provider_id``
and ``data.provider_name`` on tool nodes, so the runner's check accepts
either field. The set therefore keys on ``provider_name``, not
``plugin_id`` or any other identifier.
"""
def test_empty_input_returns_empty_set(self):
assert installed_tool_keys([]) == set()
def test_returns_provider_tool_tuples(self):
keys = installed_tool_keys(
[
_entry("google", "search"),
_entry("github", "list_issues"),
]
)
assert keys == {("google", "search"), ("github", "list_issues")}
def test_dedupes_duplicate_entries(self):
# Defensive — the catalogue builder dedupes on read, but a duplicate
# entry slipping through should collapse rather than break the set
# type contract.
keys = installed_tool_keys([_entry("x", "y"), _entry("x", "y")])
assert keys == {("x", "y")}
class TestFormatToolCatalogue:
def test_empty_input_returns_empty_string(self):
assert format_tool_catalogue([]) == ""
def test_renders_provider_slash_tool_per_line(self):
out = format_tool_catalogue(
[
_entry("google", "search", description="Search the web with Google."),
_entry("time", "current_time", description="Return the current time."),
]
)
lines = out.split("\n")
assert lines == [
"- google/search — Search the web with Google.",
"- time/current_time — Return the current time.",
]
def test_includes_label_when_different_from_tool_name(self):
out = format_tool_catalogue(
[
_entry("google", "search", label="Google Search", description="Search."),
]
)
assert out == "- google/search (Google Search) — Search."
def test_omits_label_when_identical_to_tool_name(self):
out = format_tool_catalogue(
[
_entry("time", "current_time", label="current_time", description="Now."),
]
)
assert out == "- time/current_time — Now."
def test_truncates_long_descriptions(self):
long_desc = "x" * 200
out = format_tool_catalogue([_entry("p", "t", description=long_desc)])
# Truncated to 117 chars + "..."
assert out.endswith("...")
assert len(out.split("", 1)[1]) == 120
def test_strips_newlines_from_descriptions(self):
out = format_tool_catalogue([_entry("p", "t", description="line1\nline2\nline3")])
assert "\n" not in out.split("", 1)[1]
assert "line1 line2 line3" in out
# ── Helpers ──────────────────────────────────────────────────────────────────
class _FakeI18n(SimpleNamespace):
"""Minimal stand-in for ``I18nObject`` — only the attrs we read."""
class _FakeToolEntity(SimpleNamespace):
"""Tool entity exposing ``identity`` + ``description`` like the real thing."""
class _FakeToolIdentity(SimpleNamespace):
"""Identity holding ``name`` + ``label`` like ``ToolIdentity``."""
class _FakeToolDescription(SimpleNamespace):
"""Description with the ``llm`` attribute we read for prompts."""
class _FakeTool:
"""Tool stand-in: ``.entity`` is the only attribute the catalogue reads."""
def __init__(self, entity):
self.entity = entity
def _make_tool(name: str, label_en: str = "", description_llm: str = "") -> _FakeTool:
return _FakeTool(
entity=_FakeToolEntity(
identity=_FakeToolIdentity(
name=name,
label=_FakeI18n(en_US=label_en, zh_Hans=""),
),
description=_FakeToolDescription(llm=description_llm),
)
)
class _FakeProviderType(SimpleNamespace):
"""Stand-in for ``ToolProviderType`` — only ``.value`` is read."""
def _make_builtin_provider(name: str, tools: list, raises_on_get_tools: bool = False):
"""
Build something ``isinstance(..., BuiltinToolProviderController)`` will
answer True to without actually constructing one (those require real
on-disk plugin metadata). We patch the isinstance call sites instead.
"""
provider = SimpleNamespace(
entity=SimpleNamespace(identity=SimpleNamespace(name=name)),
provider_type=_FakeProviderType(value="builtin"),
get_tools=((lambda: (_ for _ in ()).throw(RuntimeError("boom"))) if raises_on_get_tools else (lambda: tools)),
)
provider._is_builtin = True
return provider
def _make_plugin_provider(name: str, plugin_id: str, tools: list):
provider = SimpleNamespace(
entity=SimpleNamespace(identity=SimpleNamespace(name=name)),
provider_type=_FakeProviderType(value="plugin"),
plugin_id=plugin_id,
get_tools=lambda: tools,
)
provider._is_plugin = True
return provider
def _make_unknown_provider(name: str):
"""A provider matching neither class — must be skipped."""
return SimpleNamespace(
entity=SimpleNamespace(identity=SimpleNamespace(name=name)),
provider_type=_FakeProviderType(value="weird"),
get_tools=lambda: [_make_tool("ghost")],
)
def _patched_isinstance(obj, cls):
"""
Reroute isinstance checks the catalogue uses to the fake providers built
above. Anything else falls through to the real isinstance.
"""
from core.tools.builtin_tool.provider import BuiltinToolProviderController
from core.tools.plugin_tool.provider import PluginToolProviderController
if cls is BuiltinToolProviderController:
return bool(getattr(obj, "_is_builtin", False))
if cls is PluginToolProviderController:
return bool(getattr(obj, "_is_plugin", False))
import builtins as _b
return _b.isinstance(obj, cls)
# ── _i18n_text / _tool_description ───────────────────────────────────────────
class TestI18nText:
def test_returns_empty_string_when_label_is_none(self):
assert _i18n_text(None) == ""
def test_returns_en_us_when_present(self):
assert _i18n_text(_FakeI18n(en_US="Search", zh_Hans="搜索")) == "Search"
def test_falls_back_to_zh_hans_when_en_us_blank(self):
# Some plugins ship only Chinese metadata; falling back keeps the
# planner aware of those tools instead of dropping them silently.
assert _i18n_text(_FakeI18n(en_US="", zh_Hans="搜索")) == "搜索"
def test_returns_empty_when_both_locales_missing(self):
assert _i18n_text(_FakeI18n()) == ""
class TestToolDescription:
def test_returns_empty_string_for_none_description(self):
# ToolEntity.description is Optional — must not raise on absent.
assert _tool_description(None) == ""
def test_returns_llm_attribute(self):
assert _tool_description(_FakeToolDescription(llm="Web search")) == "Web search"
def test_returns_empty_when_llm_missing(self):
assert _tool_description(SimpleNamespace()) == ""
# ── build_tool_catalogue ─────────────────────────────────────────────────────
class TestBuildToolCatalogue:
"""
The builder iterates the ``ToolManager.list_builtin_providers`` generator
(which already covers both hardcoded and plugin providers in production).
We patch the generator + isinstance so the tests can exercise every branch
without standing up real plugin daemon state.
"""
@patch("core.workflow.generator.tool_catalogue.isinstance", side_effect=_patched_isinstance)
@patch("core.workflow.generator.tool_catalogue.ToolManager.list_builtin_providers")
def test_returns_empty_list_for_tenant_with_no_tools(self, mock_list, mock_isinstance):
mock_list.return_value = iter([])
assert build_tool_catalogue("tenant-1") == []
@patch("core.workflow.generator.tool_catalogue.isinstance", side_effect=_patched_isinstance)
@patch("core.workflow.generator.tool_catalogue.ToolManager.list_builtin_providers")
def test_collects_hardcoded_and_plugin_tools(self, mock_list, mock_isinstance):
# Mixed-tenant scenario: hardcoded provider plus a plugin provider,
# each carrying one tool. The catalogue must include all four fields
# the workflow tool node will need (provider_name / provider_type /
# plugin_id / tool_name).
hardcoded = _make_builtin_provider(
"time",
[_make_tool("current_time", label_en="Current Time", description_llm="Return now.")],
)
plugin = _make_plugin_provider(
"google",
plugin_id="langgenius/google",
tools=[_make_tool("search", label_en="Google Search", description_llm="Search the web.")],
)
mock_list.return_value = iter([hardcoded, plugin])
entries = build_tool_catalogue("tenant-1")
# Sorted alphabetically by provider_name.
assert [(e["provider_name"], e["tool_name"]) for e in entries] == [
("google", "search"),
("time", "current_time"),
]
google = entries[0]
assert google["provider_type"] == "plugin"
assert google["plugin_id"] == "langgenius/google"
assert google["tool_label"] == "Google Search"
assert google["description"] == "Search the web."
time_entry = entries[1]
assert time_entry["provider_type"] == "builtin"
assert time_entry["plugin_id"] == ""
@patch("core.workflow.generator.tool_catalogue.isinstance", side_effect=_patched_isinstance)
@patch("core.workflow.generator.tool_catalogue.ToolManager.list_builtin_providers")
def test_skips_unknown_provider_classes(self, mock_list, mock_isinstance):
# If ToolManager ever yields a provider the catalogue doesn't know how
# to label, we must continue (not raise) and leave it out of the
# output rather than guessing at provider_type.
unknown = _make_unknown_provider("mystery")
hardcoded = _make_builtin_provider("time", [_make_tool("now")])
mock_list.return_value = iter([unknown, hardcoded])
entries = build_tool_catalogue("tenant-1")
assert [e["provider_name"] for e in entries] == ["time"]
@patch("core.workflow.generator.tool_catalogue.isinstance", side_effect=_patched_isinstance)
@patch("core.workflow.generator.tool_catalogue.ToolManager.list_builtin_providers")
def test_continues_when_a_provider_get_tools_raises(self, mock_list, mock_isinstance):
# A buggy plugin must not break the whole catalogue. Resilient
# per-provider try/except is what keeps generation usable in tenants
# with broken installs.
bad = _make_builtin_provider("broken", [], raises_on_get_tools=True)
good = _make_builtin_provider("time", [_make_tool("now")])
mock_list.return_value = iter([bad, good])
entries = build_tool_catalogue("tenant-1")
assert [e["provider_name"] for e in entries] == ["time"]
@patch("core.workflow.generator.tool_catalogue.isinstance", side_effect=_patched_isinstance)
@patch("core.workflow.generator.tool_catalogue.ToolManager.list_builtin_providers")
def test_skips_individual_tools_when_their_metadata_is_broken(self, mock_list, mock_isinstance):
# Per-tool try/except — a single mis-declared tool inside an otherwise
# healthy provider gets dropped, the rest still surface.
good_tool = _make_tool("ok", label_en="Ok", description_llm="Healthy tool.")
# Bad tool: accessing .entity.identity raises because entity is None.
bad_tool = SimpleNamespace(entity=None)
hardcoded = _make_builtin_provider("p", [bad_tool, good_tool])
mock_list.return_value = iter([hardcoded])
entries = build_tool_catalogue("tenant-1")
assert [e["tool_name"] for e in entries] == ["ok"]
@patch("core.workflow.generator.tool_catalogue.isinstance", side_effect=_patched_isinstance)
@patch("core.workflow.generator.tool_catalogue.ToolManager.list_builtin_providers")
def test_truncates_to_max_tools_to_keep_prompt_bounded(self, mock_list, mock_isinstance):
# A tenant with hundreds of plugin tools would blow the LLM context
# window. The catalogue caps the output at ``_MAX_TOOLS``.
big_provider = _make_builtin_provider(
"p",
[_make_tool(f"t{i:03d}") for i in range(200)],
)
mock_list.return_value = iter([big_provider])
entries = build_tool_catalogue("tenant-1")
assert len(entries) == 80
@patch("core.workflow.generator.tool_catalogue.isinstance", side_effect=_patched_isinstance)
@patch("core.workflow.generator.tool_catalogue.ToolManager.list_builtin_providers")
def test_defaults_plugin_id_to_empty_string_when_missing(self, mock_list, mock_isinstance):
# Plugin provider whose plugin_id is None should serialise to "" so
# the consumer can safely index ``e["plugin_id"]`` without a None
# check at every callsite.
plugin = _make_plugin_provider("p", plugin_id=None, tools=[_make_tool("t")])
mock_list.return_value = iter([plugin])
entries = build_tool_catalogue("tenant-1")
assert entries[0]["plugin_id"] == ""

View File

@ -0,0 +1,201 @@
"""
Unit tests for ``WorkflowGeneratorService``.
The service is a thin facade its job is (1) hand the tenant model_config to
``ModelManager`` to get a model_instance, (2) build the tool catalogue, and
(3) delegate to ``WorkflowGenerator``. We mock both dependencies so the tests
stay fast and focus on the wiring itself.
"""
from unittest.mock import MagicMock, patch
from core.app.app_config.entities import ModelConfig
from graphon.model_runtime.entities.llm_entities import LLMMode
from services.workflow_generator_service import WorkflowGeneratorService
def _model_config() -> ModelConfig:
return ModelConfig(
provider="openai",
name="gpt-4o",
mode=LLMMode.CHAT,
completion_params={"temperature": 0.4},
)
class TestWorkflowGeneratorService:
@patch("services.workflow_generator_service.WorkflowGenerator")
@patch("services.workflow_generator_service.ModelManager")
@patch("services.workflow_generator_service.build_tool_catalogue")
@patch("services.workflow_generator_service.format_tool_catalogue")
def test_forwards_model_instance_and_catalogue_text_to_generator(
self,
mock_format_catalogue,
mock_build_catalogue,
mock_model_manager,
mock_workflow_generator,
):
"""Happy path: model_instance + catalogue text + payload flow through."""
# Arrange
instance = MagicMock(name="model_instance")
mock_model_manager.for_tenant.return_value.get_model_instance.return_value = instance
mock_build_catalogue.return_value = [{"provider_name": "google"}]
mock_format_catalogue.return_value = "- google/search — Search."
mock_workflow_generator.generate_workflow_graph.return_value = {
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
"message": "ok",
"error": "",
}
# Act
result = WorkflowGeneratorService.generate_workflow_graph(
tenant_id="t-1",
mode="workflow",
instruction="Summarize a URL",
model_config=_model_config(),
ideal_output="A 3-sentence summary",
)
# Assert
mock_model_manager.for_tenant.assert_called_once_with(tenant_id="t-1")
mock_workflow_generator.generate_workflow_graph.assert_called_once()
call_kwargs = mock_workflow_generator.generate_workflow_graph.call_args.kwargs
assert call_kwargs["model_instance"] is instance
assert call_kwargs["provider"] == "openai"
assert call_kwargs["model_name"] == "gpt-4o"
assert call_kwargs["mode"] == "workflow"
assert call_kwargs["instruction"] == "Summarize a URL"
assert call_kwargs["ideal_output"] == "A 3-sentence summary"
assert call_kwargs["tool_catalogue_text"] == "- google/search — Search."
assert call_kwargs["model_parameters"] == {"temperature": 0.4}
assert result["error"] == ""
@patch("services.workflow_generator_service.WorkflowGenerator")
@patch("services.workflow_generator_service.ModelManager")
@patch("services.workflow_generator_service.build_tool_catalogue")
def test_catalogue_build_failure_falls_back_to_empty_text(
self,
mock_build_catalogue,
mock_model_manager,
mock_workflow_generator,
):
"""
A plugin-daemon outage must not block generation the catalogue helper
is wrapped in try/except so a failure downgrades to an empty catalogue.
"""
# Arrange
mock_model_manager.for_tenant.return_value.get_model_instance.return_value = MagicMock()
mock_build_catalogue.side_effect = RuntimeError("plugin daemon unreachable")
mock_workflow_generator.generate_workflow_graph.return_value = {
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
"message": "",
"error": "",
}
# Act
WorkflowGeneratorService.generate_workflow_graph(
tenant_id="t-1",
mode="workflow",
instruction="Summarize a URL",
model_config=_model_config(),
)
# Assert: generation still ran, catalogue text was empty.
call_kwargs = mock_workflow_generator.generate_workflow_graph.call_args.kwargs
assert call_kwargs["tool_catalogue_text"] == ""
@patch("services.workflow_generator_service.WorkflowGenerator")
@patch("services.workflow_generator_service.ModelManager")
@patch("services.workflow_generator_service.build_tool_catalogue")
@patch("services.workflow_generator_service.format_tool_catalogue")
def test_defaults_ideal_output_to_empty_string(
self,
mock_format_catalogue,
mock_build_catalogue,
mock_model_manager,
mock_workflow_generator,
):
"""Callers can omit ideal_output; the runner should still receive ""."""
mock_model_manager.for_tenant.return_value.get_model_instance.return_value = MagicMock()
mock_build_catalogue.return_value = []
mock_format_catalogue.return_value = ""
mock_workflow_generator.generate_workflow_graph.return_value = {
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
"message": "",
"error": "",
}
WorkflowGeneratorService.generate_workflow_graph(
tenant_id="t-1",
mode="advanced-chat",
instruction="A chat bot",
model_config=_model_config(),
)
call_kwargs = mock_workflow_generator.generate_workflow_graph.call_args.kwargs
assert call_kwargs["ideal_output"] == ""
assert call_kwargs["mode"] == "advanced-chat"
@patch("services.workflow_generator_service.WorkflowGenerator")
@patch("services.workflow_generator_service.ModelManager")
@patch("services.workflow_generator_service.build_tool_catalogue")
@patch("services.workflow_generator_service.format_tool_catalogue")
def test_forwards_current_graph_for_refine(
self,
mock_format_catalogue,
mock_build_catalogue,
mock_model_manager,
mock_workflow_generator,
):
"""The cmd+k `/refine` path passes the existing draft graph through to the runner."""
mock_model_manager.for_tenant.return_value.get_model_instance.return_value = MagicMock()
mock_build_catalogue.return_value = []
mock_format_catalogue.return_value = ""
mock_workflow_generator.generate_workflow_graph.return_value = {
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
"message": "",
"error": "",
}
current_graph = {"nodes": [{"id": "node1"}], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}}
WorkflowGeneratorService.generate_workflow_graph(
tenant_id="t-1",
mode="workflow",
instruction="Add a translation step",
model_config=_model_config(),
current_graph=current_graph,
)
call_kwargs = mock_workflow_generator.generate_workflow_graph.call_args.kwargs
assert call_kwargs["current_graph"] is current_graph
@patch("services.workflow_generator_service.WorkflowGenerator")
@patch("services.workflow_generator_service.ModelManager")
@patch("services.workflow_generator_service.build_tool_catalogue")
@patch("services.workflow_generator_service.format_tool_catalogue")
def test_defaults_current_graph_to_none_for_create(
self,
mock_format_catalogue,
mock_build_catalogue,
mock_model_manager,
mock_workflow_generator,
):
"""Omitting current_graph (the `/create` path) forwards None to the runner."""
mock_model_manager.for_tenant.return_value.get_model_instance.return_value = MagicMock()
mock_build_catalogue.return_value = []
mock_format_catalogue.return_value = ""
mock_workflow_generator.generate_workflow_graph.return_value = {
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
"message": "",
"error": "",
}
WorkflowGeneratorService.generate_workflow_graph(
tenant_id="t-1",
mode="workflow",
instruction="Summarize a URL",
model_config=_model_config(),
)
call_kwargs = mock_workflow_generator.generate_workflow_graph.call_args.kwargs
assert call_kwargs["current_graph"] is None

View File

@ -46,6 +46,7 @@ import { test } from './test/orpc.gen'
import { trialApps } from './trial-apps/orpc.gen'
import { trialModels } from './trial-models/orpc.gen'
import { website } from './website/orpc.gen'
import { workflowGenerate } from './workflow-generate/orpc.gen'
import { workflow } from './workflow/orpc.gen'
import { workspaces } from './workspaces/orpc.gen'
@ -97,5 +98,6 @@ export const contract = {
trialModels,
website,
workflow,
workflowGenerate,
workspaces,
}

View File

@ -0,0 +1,35 @@
// This file is auto-generated by @hey-api/openapi-ts
import { oc } from '@orpc/contract'
import * as z from 'zod'
import { zPostWorkflowGenerateBody, zPostWorkflowGenerateResponse } from './zod.gen'
/**
* Generate a Dify workflow graph from natural language
*
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
*
* @deprecated
*/
export const post = oc
.route({
deprecated: true,
description:
'Generate a Dify workflow graph from natural language\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
inputStructure: 'detailed',
method: 'POST',
operationId: 'postWorkflowGenerate',
path: '/workflow-generate',
tags: ['console'],
})
.input(z.object({ body: zPostWorkflowGenerateBody }))
.output(zPostWorkflowGenerateResponse)
export const workflowGenerate = {
post,
}
export const contract = {
workflowGenerate,
}

View File

@ -0,0 +1,53 @@
// This file is auto-generated by @hey-api/openapi-ts
export type ClientOptions = {
baseUrl: `${string}://${string}/console/api` | (string & {})
}
export type WorkflowGeneratePayload = {
current_graph?: {
[key: string]: unknown
} | null
ideal_output?: string
instruction: string
mode: 'advanced-chat' | 'workflow'
model_config: ModelConfig
}
export type ModelConfig = {
completion_params?: {
[key: string]: unknown
}
mode: LlmMode
name: string
provider: string
}
export type LlmMode = 'chat' | 'completion'
export type PostWorkflowGenerateData = {
body: WorkflowGeneratePayload
path?: never
query?: never
url: '/workflow-generate'
}
export type PostWorkflowGenerateErrors = {
400: {
[key: string]: unknown
}
402: {
[key: string]: unknown
}
}
export type PostWorkflowGenerateError = PostWorkflowGenerateErrors[keyof PostWorkflowGenerateErrors]
export type PostWorkflowGenerateResponses = {
200: {
[key: string]: unknown
}
}
export type PostWorkflowGenerateResponse
= PostWorkflowGenerateResponses[keyof PostWorkflowGenerateResponses]

View File

@ -0,0 +1,44 @@
// This file is auto-generated by @hey-api/openapi-ts
import * as z from 'zod'
/**
* LLMMode
*
* Enum class for large language model mode.
*/
export const zLlmMode = z.enum(['chat', 'completion'])
/**
* ModelConfig
*/
export const zModelConfig = z.object({
completion_params: z.record(z.string(), z.unknown()).optional(),
mode: zLlmMode,
name: z.string(),
provider: z.string(),
})
/**
* WorkflowGeneratePayload
*
* Payload for the cmd+k `/create` and `/refine` workflow generator endpoint.
*
* See ``services/workflow_generator_service.py`` for behaviour. Errors are
* surfaced through the same envelope as ``/rule-generate`` so the frontend
* can reuse its existing handler.
*/
export const zWorkflowGeneratePayload = z.object({
current_graph: z.record(z.string(), z.unknown()).nullish(),
ideal_output: z.string().optional().default(''),
instruction: z.string(),
mode: z.enum(['advanced-chat', 'workflow']),
model_config: zModelConfig,
})
export const zPostWorkflowGenerateBody = zWorkflowGeneratePayload
/**
* Workflow graph generated successfully
*/
export const zPostWorkflowGenerateResponse = z.record(z.string(), z.unknown())

View File

@ -0,0 +1,65 @@
import { act, renderHook } from '@testing-library/react'
import useGenGraph from '../../../app/components/workflow/workflow-generator/use-gen-graph'
describe('useGenGraph', () => {
beforeEach(() => {
sessionStorage.clear()
})
const makeResponse = (label: string) => ({
graph: {
nodes: [{ id: label, type: 'custom', position: { x: 0, y: 0 }, data: { type: 'start', title: label } }],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
message: label,
})
it('starts with an empty version list and undefined current', () => {
const { result } = renderHook(() => useGenGraph({ storageKey: 'k1' }))
expect(result.current.versions).toEqual([])
expect(result.current.current).toBeUndefined()
})
it('appends versions and tracks the latest one as current', () => {
const { result } = renderHook(() => useGenGraph({ storageKey: 'k2' }))
act(() => {
result.current.addVersion(makeResponse('v1') as never)
})
expect(result.current.versions).toHaveLength(1)
expect(result.current.current?.message).toBe('v1')
act(() => {
result.current.addVersion(makeResponse('v2') as never)
})
expect(result.current.versions).toHaveLength(2)
expect(result.current.current?.message).toBe('v2')
expect(result.current.currentVersionIndex).toBe(1)
})
it('allows switching back to an older version', () => {
const { result } = renderHook(() => useGenGraph({ storageKey: 'k3' }))
act(() => {
result.current.addVersion(makeResponse('a') as never)
result.current.addVersion(makeResponse('b') as never)
})
act(() => {
result.current.setCurrentVersionIndex(0)
})
expect(result.current.current?.message).toBe('a')
})
it('isolates state by storageKey', () => {
const { result: r1 } = renderHook(() => useGenGraph({ storageKey: 'mode-a' }))
const { result: r2 } = renderHook(() => useGenGraph({ storageKey: 'mode-b' }))
act(() => {
r1.current.addVersion(makeResponse('only-a') as never)
})
expect(r1.current.versions).toHaveLength(1)
expect(r2.current.versions).toHaveLength(0)
})
})

View File

@ -10,6 +10,7 @@ import Header from '@/app/components/header'
import HeaderWrapper from '@/app/components/header/header-wrapper'
import { OAuthRegistrationAnalytics } from '@/app/components/oauth-registration-analytics'
import ReadmePanel from '@/app/components/plugins/readme-panel'
import WorkflowGeneratorMount from '@/app/components/workflow/workflow-generator/mount'
import { AppContextProvider } from '@/context/app-context-provider'
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
import { ModalContextProvider } from '@/context/modal-context-provider'
@ -40,6 +41,7 @@ const Layout = async ({ children }: { children: ReactNode }) => {
<PartnerStack />
<ReadmePanel />
<GotoAnything />
<WorkflowGeneratorMount />
</ModalContextProvider>
</ProviderContextProvider>
</EventEmitterContextProvider>

View File

@ -0,0 +1,178 @@
import { executeCommand } from '../command-bus'
import { createCommand } from '../create'
// Stub the icon imports — these are React components we don't render here.
vi.mock('@remixicon/react', () => ({
RiChat3Line: () => null,
RiNodeTree: () => null,
}))
// search() localises its labels via getI18n(); echo the key back so the
// filtering/payload assertions stay deterministic without a real i18n init.
vi.mock('react-i18next', () => ({
getI18n: () => ({ t: (key: string) => key }),
}))
// We spy on the store at module scope so the `create.open` handler that
// register() pushes into the command bus can be observed by the tests.
const mockOpenGenerator = vi.fn()
vi.mock('@/app/components/workflow/workflow-generator/store', () => ({
useWorkflowGeneratorStore: {
getState: () => ({ openGenerator: mockOpenGenerator }),
},
}))
// Controllable app-store state — the handler reads `appDetail` to decide
// whether to thread the current Studio app through to the generator. Mutated
// per-test; getState() reads it lazily so updates land after the mock factory.
const mockAppStore: { appDetail: { id: string, mode: string } | undefined } = {
appDetail: undefined,
}
vi.mock('@/app/components/app/store', () => ({
useStore: {
getState: () => ({ appDetail: mockAppStore.appDetail }),
},
}))
describe('/create slash command', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('handler metadata', () => {
// The slash registry relies on this metadata to route /create through the
// submenu UX rather than executing immediately.
it('should expose submenu mode with the expected name and aliases', () => {
expect(createCommand.mode).toBe('submenu')
expect(createCommand.name).toBe('create')
expect(createCommand.aliases).toEqual(['new', 'generate'])
})
})
describe('search()', () => {
// An empty arg list should surface every option; the submenu uses this to
// render its initial list when the user types just `/create`.
it('should surface both workflow and chatflow when args is empty', async () => {
const results = await createCommand.search('')
expect(results.map(r => r.id)).toEqual(['create-workflow', 'create-chatflow'])
})
// Typing a partial keyword should narrow the list and each result should
// carry the right command-bus payload so the navigation hook can fire it.
it('should filter by query and attach the right command payload', async () => {
const results = await createCommand.search('chat')
expect(results.map(r => r.id)).toEqual(['create-chatflow'])
expect(results[0]!.data.command).toBe('create.open')
expect(results[0]!.data.args).toEqual({ mode: 'advanced-chat' })
})
// A non-matching query returns an empty list rather than throwing, so the
// goto-anything dialog can render an empty-state without special-casing.
it('should return an empty list when the query matches nothing', async () => {
const results = await createCommand.search('zzz-no-match')
expect(results).toEqual([])
})
// Labels/descriptions must be localised through i18n (ns: 'app') rather
// than hardcoded English, so the palette renders in the user's language.
it('should source titles and descriptions from i18n keys', async () => {
const results = await createCommand.search('')
expect(results[0]!.title).toBe('gotoAnything.actions.createWorkflow')
expect(results[0]!.description).toBe('gotoAnything.actions.createWorkflowDesc')
expect(results[1]!.title).toBe('gotoAnything.actions.createChatflow')
expect(results[1]!.description).toBe('gotoAnything.actions.createChatflowDesc')
})
// The localised label is also searchable, not just the id — a token that
// appears only in the (mocked) title key still narrows the list, proving
// the filter consults the translated label.
it('should filter by the localised label, not just the id', async () => {
const results = await createCommand.search('createChatflow')
expect(results.map(r => r.id)).toEqual(['create-chatflow'])
})
})
describe('register() — `create.open` command-bus handler', () => {
// Register populates the global command bus; tests below rely on it so we
// run it once per case and clean up via the symmetric unregister(). Reset
// the app-store state so each case controls its own Studio context.
beforeEach(() => {
mockAppStore.appDetail = undefined
createCommand.register?.({} as never)
})
afterEach(() => {
createCommand.unregister?.()
})
// No Studio app open (e.g. /create from the apps list) — the modal opens
// for new-app creation only, with just the requested mode.
it('should open the generator with only the requested mode when no Studio app is open', async () => {
await executeCommand('create.open', { mode: 'workflow' })
expect(mockOpenGenerator).toHaveBeenCalledWith({ mode: 'workflow' })
})
// In-Studio create-and-apply: when a graph-based app is open and its mode
// matches the picked mode, the handler threads id + mode through so the
// modal can offer "Apply to current draft".
it('should thread the current app context when a matching Studio app is open', async () => {
mockAppStore.appDetail = { id: 'abc-123', mode: 'workflow' }
await executeCommand('create.open', { mode: 'workflow' })
expect(mockOpenGenerator).toHaveBeenCalledWith({
mode: 'workflow',
currentAppId: 'abc-123',
currentAppMode: 'workflow',
})
})
// Mode mismatch (Workflow Studio open, but the user picked Chatflow) must
// NOT capture currentAppId — applying a chatflow graph onto a workflow
// draft is the dead-end we explicitly avoid, so it stays new-app only.
it('should fall back to new-app only when the picked mode differs from the open app', async () => {
mockAppStore.appDetail = { id: 'abc-123', mode: 'workflow' }
await executeCommand('create.open', { mode: 'advanced-chat' })
expect(mockOpenGenerator).toHaveBeenCalledWith({ mode: 'advanced-chat' })
})
// Non-graph Studio apps (Chat / Agent / Completion) have no canvas to
// apply onto, so the handler ignores them and opens new-app only.
it('should ignore non-graph app modes and open new-app only', async () => {
mockAppStore.appDetail = { id: 'abc-123', mode: 'chat' }
await executeCommand('create.open', { mode: 'workflow' })
expect(mockOpenGenerator).toHaveBeenCalledWith({ mode: 'workflow' })
})
// Defensive fallback: if a caller forgets to pass a mode (or passes none),
// the handler must still open the generator with a safe default rather
// than crashing the goto-anything dialog.
it('should default to workflow mode when no args are passed', async () => {
await executeCommand('create.open')
expect(mockOpenGenerator).toHaveBeenCalledWith({ mode: 'workflow' })
})
})
describe('unregister()', () => {
// After unregister, the bus must drop the handler so a later execute call
// becomes a silent no-op (prevents stale references between mounts).
it('should remove the command-bus handler so it stops firing', async () => {
createCommand.register?.({} as never)
createCommand.unregister?.()
Object.defineProperty(window, 'location', {
writable: true,
value: { pathname: '/apps' },
})
await executeCommand('create.open', { mode: 'workflow' })
expect(mockOpenGenerator).not.toHaveBeenCalled()
})
})
})

View File

@ -0,0 +1,151 @@
import { executeCommand } from '../command-bus'
import { refineCommand } from '../refine'
// Stub the icon import — it's a React component we don't render here.
vi.mock('@remixicon/react', () => ({
RiSparkling2Line: () => null,
}))
// search() localises its title/description via getI18n(); echo the key back
// so assertions stay deterministic without a real i18n init.
vi.mock('react-i18next', () => ({
getI18n: () => ({ t: (key: string) => key }),
}))
// Spy on the generator store so we can observe what /refine opens it with.
const mockOpenGenerator = vi.fn()
vi.mock('@/app/components/workflow/workflow-generator/store', () => ({
useWorkflowGeneratorStore: {
getState: () => ({ openGenerator: mockOpenGenerator }),
},
}))
// Controllable app-store state — /refine reads appDetail to gate availability
// and to pick the mode + id it refines. Mutated per-test; read lazily.
const mockAppStore: { appDetail: { id: string, mode: string } | undefined } = {
appDetail: undefined,
}
vi.mock('@/app/components/app/store', () => ({
useStore: {
getState: () => ({ appDetail: mockAppStore.appDetail }),
},
}))
describe('/refine slash command', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAppStore.appDetail = undefined
})
describe('handler metadata', () => {
it('should be a direct command named refine with the expected alias', () => {
expect(refineCommand.mode).toBe('direct')
expect(refineCommand.name).toBe('refine')
expect(refineCommand.aliases).toEqual(['improve'])
})
})
describe('isAvailable()', () => {
// /refine only makes sense inside a graph-based Studio — elsewhere there's
// no draft graph to refine, so the command must hide itself.
it('should be unavailable when no app is open', () => {
expect(refineCommand.isAvailable?.()).toBe(false)
})
it('should be available in a Workflow Studio', () => {
mockAppStore.appDetail = { id: 'app-1', mode: 'workflow' }
expect(refineCommand.isAvailable?.()).toBe(true)
})
it('should be available in an Advanced-Chat Studio', () => {
mockAppStore.appDetail = { id: 'app-1', mode: 'advanced-chat' }
expect(refineCommand.isAvailable?.()).toBe(true)
})
it('should be unavailable for non-graph apps (chat / agent / completion)', () => {
mockAppStore.appDetail = { id: 'app-1', mode: 'chat' }
expect(refineCommand.isAvailable?.()).toBe(false)
})
})
describe('execute()', () => {
// The core behaviour: open the generator in refine intent, threading the
// current app's id + mode so the modal fetches its draft as context.
it('should open the generator in refine intent for a Workflow Studio', () => {
mockAppStore.appDetail = { id: 'app-1', mode: 'workflow' }
refineCommand.execute?.()
expect(mockOpenGenerator).toHaveBeenCalledWith({
intent: 'refine',
mode: 'workflow',
currentAppId: 'app-1',
currentAppMode: 'workflow',
})
})
it('should map advanced-chat apps to the advanced-chat generator mode', () => {
mockAppStore.appDetail = { id: 'app-2', mode: 'advanced-chat' }
refineCommand.execute?.()
expect(mockOpenGenerator).toHaveBeenCalledWith({
intent: 'refine',
mode: 'advanced-chat',
currentAppId: 'app-2',
currentAppMode: 'advanced-chat',
})
})
it('should be a no-op when no graph-based app is open', () => {
mockAppStore.appDetail = { id: 'app-3', mode: 'chat' }
refineCommand.execute?.()
expect(mockOpenGenerator).not.toHaveBeenCalled()
})
})
describe('search()', () => {
// The submenu/result list renders one localised entry carrying the
// refine.open command-bus payload.
it('should return a single localised refine result', async () => {
const results = await refineCommand.search('')
expect(results).toHaveLength(1)
expect(results[0]!.id).toBe('refine-current')
expect(results[0]!.title).toBe('gotoAnything.actions.refineTitle')
expect(results[0]!.data.command).toBe('refine.open')
})
})
describe('register() — `refine.open` command-bus handler', () => {
beforeEach(() => {
refineCommand.register?.({} as never)
})
afterEach(() => {
refineCommand.unregister?.()
})
it('should open the generator via the command bus too', async () => {
mockAppStore.appDetail = { id: 'app-1', mode: 'workflow' }
await executeCommand('refine.open', {})
expect(mockOpenGenerator).toHaveBeenCalledWith({
intent: 'refine',
mode: 'workflow',
currentAppId: 'app-1',
currentAppMode: 'workflow',
})
})
it('should stop firing after unregister', async () => {
mockAppStore.appDetail = { id: 'app-1', mode: 'workflow' }
refineCommand.unregister?.()
await executeCommand('refine.open', {})
expect(mockOpenGenerator).not.toHaveBeenCalled()
})
})
})

View File

@ -106,6 +106,8 @@ describe('SlashCommandProvider', () => {
'account',
'zen',
'go',
'create',
'refine',
])
expect(mockRegister).toHaveBeenCalledWith(expect.objectContaining({ name: 'theme' }), { setTheme: mockSetTheme })
expect(mockRegister).toHaveBeenCalledWith(expect.objectContaining({ name: 'language' }), { setLocale: mockSetLocale })
@ -121,6 +123,8 @@ describe('SlashCommandProvider', () => {
'account',
'zen',
'go',
'create',
'refine',
])
})
})

View File

@ -0,0 +1,119 @@
import type { SlashCommandHandler } from './types'
import type { WorkflowGeneratorMode } from '@/app/components/workflow/workflow-generator/types'
import { RiChat3Line, RiNodeTree } from '@remixicon/react'
import * as React from 'react'
import { getI18n } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useWorkflowGeneratorStore } from '@/app/components/workflow/workflow-generator/store'
import { AppModeEnum } from '@/types/app'
import { registerCommands, unregisterCommands } from './command-bus'
type CreateOption = {
id: string
/** i18n key (ns: 'app') for the option's display label. */
titleKey: string
/** i18n key (ns: 'app') for the option's one-line description. */
descKey: string
mode: WorkflowGeneratorMode
icon: React.ComponentType<{ className?: string }>
}
// `as const` keeps titleKey/descKey as literal types so the typed `i18n.t`
// accepts them as known keys; `satisfies` still validates the shape.
const OPTIONS = [
{
id: 'workflow',
titleKey: 'gotoAnything.actions.createWorkflow',
descKey: 'gotoAnything.actions.createWorkflowDesc',
mode: 'workflow',
icon: RiNodeTree,
},
{
id: 'chatflow',
titleKey: 'gotoAnything.actions.createChatflow',
descKey: 'gotoAnything.actions.createChatflowDesc',
mode: 'advanced-chat',
icon: RiChat3Line,
},
] as const satisfies readonly CreateOption[]
/**
* `/create` command generate a Workflow or Chatflow app from a
* natural-language description.
*
* The user-picked mode is passed through to the generator modal explicitly
* rather than sniffed from the URL, which avoids the mode-mismatch dead-end
* the URL-sniffing approach used to produce.
*
* When triggered from inside a graph-based Studio (Workflow / Advanced-Chat)
* whose app mode matches the picked mode, it threads the current app (id +
* mode) through so the modal offers "Apply to current draft" this is the
* in-Studio create-and-apply journey that replaced the old toolbar button.
* With no Studio app open, or when the picked mode differs from the open
* app's mode, it falls back to new-app creation only.
*/
export const createCommand: SlashCommandHandler = {
name: 'create',
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',
mode: 'submenu',
async search(args: string, locale?: string) {
const i18n = getI18n()
const tr = (key: (typeof OPTIONS)[number]['titleKey' | 'descKey']) =>
i18n.t(key, { ns: 'app', lng: locale })
const query = args.trim().toLowerCase()
const filtered = OPTIONS.filter(
opt => !query || opt.id.includes(query) || tr(opt.titleKey).toLowerCase().includes(query),
)
return filtered.map(opt => ({
id: `create-${opt.id}`,
title: tr(opt.titleKey),
description: tr(opt.descKey),
type: 'command' as const,
icon: (
<div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg">
<opt.icon className="size-4 text-text-tertiary" />
</div>
),
data: { command: 'create.open', args: { mode: opt.mode } },
}))
},
register() {
registerCommands({
'create.open': async (args) => {
const mode: WorkflowGeneratorMode = (args?.mode ?? 'workflow') as WorkflowGeneratorMode
// If a graph-based Studio app is open and its mode matches the picked
// mode, thread it through so the modal can offer "Apply to current
// draft". A mode mismatch (or no app open) falls back to new-app only,
// mirroring the precondition the modal uses for canApplyToCurrent.
const appDetail = useAppStore.getState().appDetail
const currentAppMode: WorkflowGeneratorMode | null
= appDetail?.mode === AppModeEnum.WORKFLOW
? 'workflow'
: appDetail?.mode === AppModeEnum.ADVANCED_CHAT
? 'advanced-chat'
: null
if (appDetail && currentAppMode === mode) {
useWorkflowGeneratorStore.getState().openGenerator({
mode,
currentAppId: appDetail.id,
currentAppMode,
})
return
}
useWorkflowGeneratorStore.getState().openGenerator({ mode })
},
})
},
unregister() {
unregisterCommands(['create.open'])
},
}

View File

@ -0,0 +1,91 @@
import type { SlashCommandHandler } from './types'
import type { WorkflowGeneratorMode } from '@/app/components/workflow/workflow-generator/types'
import { RiSparkling2Line } from '@remixicon/react'
import * as React from 'react'
import { getI18n } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useWorkflowGeneratorStore } from '@/app/components/workflow/workflow-generator/store'
import { AppModeEnum } from '@/types/app'
import { registerCommands, unregisterCommands } from './command-bus'
/**
* Map the open app's mode to a generator mode, or null when the current app
* isn't a graph-based Studio (Chat / Agent / Completion have no canvas to
* refine). This is the single source of truth for both the availability gate
* and the mode we open the generator with.
*/
const currentStudioMode = (): WorkflowGeneratorMode | null => {
const appMode = useAppStore.getState().appDetail?.mode
if (appMode === AppModeEnum.WORKFLOW)
return 'workflow'
if (appMode === AppModeEnum.ADVANCED_CHAT)
return 'advanced-chat'
return null
}
/**
* Open the generator in `refine` intent for the current Studio. The modal
* fetches the current draft graph and sends it as context so the LLM amends
* what's on the canvas instead of starting from scratch. No-op when there's
* no graph-based app open (the command is gated by `isAvailable`, but the
* guard keeps a stray command-bus call safe).
*/
const openRefineGenerator = () => {
const appDetail = useAppStore.getState().appDetail
const mode = currentStudioMode()
if (!appDetail || !mode)
return
useWorkflowGeneratorStore.getState().openGenerator({
intent: 'refine',
mode,
currentAppId: appDetail.id,
currentAppMode: mode,
})
}
/**
* `/refine` command refine the CURRENT Workflow / Chatflow draft graph from
* a natural-language change description. Only available inside a graph-based
* Studio; the mode is taken from the open app (no submenu to pick), and the
* result always applies back to the current draft.
*/
export const refineCommand: SlashCommandHandler = {
name: 'refine',
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',
mode: 'direct',
// Only surface inside a Workflow / Advanced-Chat Studio — elsewhere there's
// no draft graph to refine.
isAvailable: () => currentStudioMode() !== null,
execute: openRefineGenerator,
async search(_args: string, locale?: string) {
const i18n = getI18n()
return [{
id: 'refine-current',
title: i18n.t('gotoAnything.actions.refineTitle', { ns: 'app', lng: locale }),
description: i18n.t('gotoAnything.actions.refineDesc', { ns: 'app', lng: locale }),
type: 'command' as const,
icon: (
<div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg">
<RiSparkling2Line className="size-4 text-text-tertiary" />
</div>
),
data: { command: 'refine.open', args: {} },
}]
},
register() {
registerCommands({
'refine.open': async () => openRefineGenerator(),
})
},
unregister() {
unregisterCommands(['refine.open'])
},
}

View File

@ -7,10 +7,12 @@ import { setLocaleOnClient } from '@/i18n-config'
import { accountCommand } from './account'
import { executeCommand } from './command-bus'
import { communityCommand } from './community'
import { createCommand } from './create'
import { docsCommand } from './docs'
import { forumCommand } from './forum'
import { goCommand } from './go'
import { languageCommand } from './language'
import { refineCommand } from './refine'
import { slashCommandRegistry } from './registry'
import { themeCommand } from './theme'
import { zenCommand } from './zen'
@ -50,6 +52,8 @@ const registerSlashCommands = (deps: Record<string, any>) => {
slashCommandRegistry.register(accountCommand, {})
slashCommandRegistry.register(zenCommand, {})
slashCommandRegistry.register(goCommand, {})
slashCommandRegistry.register(createCommand, {})
slashCommandRegistry.register(refineCommand, {})
}
const unregisterSlashCommands = () => {
@ -62,6 +66,8 @@ const unregisterSlashCommands = () => {
slashCommandRegistry.unregister('account')
slashCommandRegistry.unregister('zen')
slashCommandRegistry.unregister('go')
slashCommandRegistry.unregister('create')
slashCommandRegistry.unregister('refine')
}
export const SlashCommandProvider = () => {

View File

@ -109,6 +109,8 @@ const CommandSelector: FC<Props> = ({ actions, onCommandSelect, searchFilter, co
? (
(() => {
const slashKeyMap = {
'/create': 'gotoAnything.actions.createCategoryDesc',
'/refine': 'gotoAnything.actions.refineCategoryDesc',
'/theme': 'gotoAnything.actions.themeCategoryDesc',
'/language': 'gotoAnything.actions.languageChangeDesc',
'/account': 'gotoAnything.actions.accountDesc',

View File

@ -33,10 +33,10 @@ const FileUploadSetting: FC<Props> = ({
const { t } = useTranslation()
const {
allowed_file_upload_methods,
allowed_file_upload_methods = [],
max_length,
allowed_file_types,
allowed_file_extensions,
allowed_file_types = [],
allowed_file_extensions = [],
} = payload
const { data: fileUploadConfigResponse } = useFileUploadConfig()
const {

View File

@ -0,0 +1,265 @@
import type { GeneratedGraph } from '../types'
import { AppModeEnum } from '@/types/app'
import { applyToCurrentApp, applyToNewApp, WorkflowApplyHashCollisionError, WorkflowApplyOrphanError } from '../apply'
// Stub the service calls so each test can assert what was POSTed without
// touching real fetch / next router state.
const mockCreateApp = vi.fn()
const mockSyncWorkflowDraft = vi.fn()
const mockFetchWorkflowDraft = vi.fn()
const mockDeleteApp = vi.fn()
vi.mock('@/service/apps', () => ({
createApp: (params: unknown) => mockCreateApp(params),
deleteApp: (appId: string) => mockDeleteApp(appId),
}))
vi.mock('@/service/workflow', () => ({
fetchWorkflowDraft: (url: string) => mockFetchWorkflowDraft(url),
syncWorkflowDraft: (params: unknown) => mockSyncWorkflowDraft(params),
}))
const makeGraph = (): GeneratedGraph => ({
nodes: [
{ id: 'node-1', type: 'custom', position: { x: 0, y: 0 }, data: { type: 'start', title: 'Start' } } as never,
],
edges: [],
viewport: { x: 0, y: 0, zoom: 0.7 },
})
describe('applyToNewApp', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCreateApp.mockResolvedValue({ id: 'new-app-1', mode: AppModeEnum.WORKFLOW })
mockSyncWorkflowDraft.mockResolvedValue({})
})
// The new-app path must create the app, then sync the generated graph to
// its draft and return the routing context the caller uses to navigate.
it('should create the app, sync the draft and return the new app id and mode', async () => {
const graph = makeGraph()
const result = await applyToNewApp({ mode: 'workflow', graph, instruction: 'Summarize a URL' })
expect(mockCreateApp).toHaveBeenCalledWith(expect.objectContaining({
mode: AppModeEnum.WORKFLOW,
icon_type: 'emoji',
}))
expect(mockSyncWorkflowDraft).toHaveBeenCalledWith({
url: 'apps/new-app-1/workflows/draft',
params: {
graph,
features: {},
environment_variables: [],
conversation_variables: [],
},
})
expect(result).toEqual({ appId: 'new-app-1', appMode: AppModeEnum.WORKFLOW })
})
// Mode → AppModeEnum must round-trip for chatflow; the type-level guarantee
// is verified at runtime so a regression here is caught before users hit it.
it('should map advanced-chat mode to AppModeEnum.ADVANCED_CHAT', async () => {
mockCreateApp.mockResolvedValueOnce({ id: 'cf-1', mode: AppModeEnum.ADVANCED_CHAT })
const result = await applyToNewApp({
mode: 'advanced-chat',
graph: makeGraph(),
instruction: 'A chat bot that answers questions',
})
expect(mockCreateApp).toHaveBeenCalledWith(expect.objectContaining({ mode: AppModeEnum.ADVANCED_CHAT }))
expect(result.appMode).toBe(AppModeEnum.ADVANCED_CHAT)
})
// The derived name keeps the user instruction recognisable in the apps list
// — strip trailing punctuation and never produce an empty string.
it('should derive a sensible app name from the instruction', async () => {
await applyToNewApp({ mode: 'workflow', graph: makeGraph(), instruction: ' Build a translator. ' })
expect(mockCreateApp).toHaveBeenCalledWith(expect.objectContaining({ name: 'Build a translator' }))
})
// 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: ' ' })
expect(mockCreateApp).toHaveBeenCalledWith(expect.objectContaining({ name: 'Generated Workflow' }))
})
// When the planner picks a name + emoji, those win over the
// instruction-derived fallback so users see a real product name in the
// apps list (e.g. "URL Summarizer" + 📰 instead of "Summarize a URL" + 🤖).
it('should prefer planner-supplied app_name and icon over the fallbacks', async () => {
await applyToNewApp({
mode: 'workflow',
graph: makeGraph(),
instruction: 'Summarize a URL',
appName: 'URL Summarizer',
icon: '📰',
})
expect(mockCreateApp).toHaveBeenCalledWith(expect.objectContaining({
name: 'URL Summarizer',
icon: '📰',
}))
})
// When the planner returns whitespace-only values (older prompts / model
// drift), the fallbacks must kick in so we never POST an empty string to
// createApp.
it('should fall back when planner-supplied app_name / icon are blank', async () => {
await applyToNewApp({
mode: 'workflow',
graph: makeGraph(),
instruction: 'Summarize a URL',
appName: ' ',
icon: '',
})
expect(mockCreateApp).toHaveBeenCalledWith(expect.objectContaining({
name: 'Summarize a URL',
icon: '🤖',
}))
})
// Sync failure must roll back the createApp so the user isn't left with an
// empty app in their /apps list. deleteApp is called with the new app id,
// and the original sync error is re-thrown so the caller can toast it.
it('should delete the new app when syncWorkflowDraft fails', async () => {
mockCreateApp.mockResolvedValueOnce({ id: 'doomed', mode: AppModeEnum.WORKFLOW })
const syncErr = new Error('sync exploded')
mockSyncWorkflowDraft.mockRejectedValueOnce(syncErr)
mockDeleteApp.mockResolvedValueOnce(undefined)
await expect(applyToNewApp({
mode: 'workflow',
graph: makeGraph(),
instruction: 'x',
})).rejects.toBe(syncErr)
expect(mockDeleteApp).toHaveBeenCalledWith('doomed')
})
// The truly stuck path: sync fails AND the rollback delete also fails. We
// throw WorkflowApplyOrphanError so the caller can route to /apps where
// the orphan is at least discoverable for manual cleanup. The error
// carries the orphan app id so the toast can name it.
it('should throw WorkflowApplyOrphanError when both sync and rollback fail', async () => {
mockCreateApp.mockResolvedValueOnce({ id: 'orphan-7', mode: AppModeEnum.WORKFLOW })
mockSyncWorkflowDraft.mockRejectedValueOnce(new Error('sync exploded'))
mockDeleteApp.mockRejectedValueOnce(new Error('delete also exploded'))
let caught: unknown
try {
await applyToNewApp({ mode: 'workflow', graph: makeGraph(), instruction: 'x' })
}
catch (e) {
caught = e
}
expect(caught).toBeInstanceOf(WorkflowApplyOrphanError)
expect((caught as WorkflowApplyOrphanError).orphanAppId).toBe('orphan-7')
})
})
describe('applyToCurrentApp', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSyncWorkflowDraft.mockResolvedValue({})
})
// Happy path: the fetch yields an existing draft so the sync MUST include
// its hash. Without this, the backend rejects the write with
// WorkflowHashNotEqualError (the original bug behind the manual fix).
it('should fetch the current draft and forward its hash on sync', async () => {
mockFetchWorkflowDraft.mockResolvedValue({
hash: 'h-existing',
features: { file_upload: { enabled: true } },
environment_variables: [{ id: 'e1', name: 'API_KEY', value_type: 'secret', value: 'x' }],
conversation_variables: [{ id: 'c1', name: 'memory', value_type: 'string', value: '' }],
})
const graph = makeGraph()
await applyToCurrentApp({ appId: 'app-42', graph })
expect(mockFetchWorkflowDraft).toHaveBeenCalledWith('apps/app-42/workflows/draft')
expect(mockSyncWorkflowDraft).toHaveBeenCalledWith({
url: 'apps/app-42/workflows/draft',
params: expect.objectContaining({
graph,
features: { file_upload: { enabled: true } },
hash: 'h-existing',
}),
})
// Existing env vars and conversation vars must be preserved verbatim.
const params = mockSyncWorkflowDraft.mock.calls[0]![0].params
expect(params.environment_variables).toHaveLength(1)
expect(params.conversation_variables).toHaveLength(1)
})
// First-apply path: a freshly created Workflow app has no draft yet, so the
// fetch resolves to undefined and we must sync without a hash field so the
// backend lazy-creates the draft instead of raising.
it('should sync without a hash when no draft yet exists', async () => {
mockFetchWorkflowDraft.mockResolvedValue(undefined)
await applyToCurrentApp({ appId: 'fresh-app', graph: makeGraph() })
expect(mockSyncWorkflowDraft).toHaveBeenCalledTimes(1)
const params = mockSyncWorkflowDraft.mock.calls[0]![0].params
expect(params).not.toHaveProperty('hash')
expect(params.features).toEqual({})
expect(params.environment_variables).toEqual([])
expect(params.conversation_variables).toEqual([])
})
// Resilience: a fetch failure (network blip, transient 5xx) must not block
// the apply — fall back to a hashless sync so the new draft can still land.
it('should fall back to a hashless sync when fetchWorkflowDraft throws', async () => {
mockFetchWorkflowDraft.mockRejectedValue(new Error('network down'))
await applyToCurrentApp({ appId: 'app-7', graph: makeGraph() })
expect(mockSyncWorkflowDraft).toHaveBeenCalledTimes(1)
const params = mockSyncWorkflowDraft.mock.calls[0]![0].params
expect(params).not.toHaveProperty('hash')
})
// A 409 from syncWorkflowDraft is the backend's signal that another tab
// edited the draft between our fetch and sync. The apply layer translates
// that Response into a typed error so the caller can show a Reload
// affordance instead of a generic "apply failed" toast.
it('should translate a 409 sync rejection into WorkflowApplyHashCollisionError', async () => {
mockFetchWorkflowDraft.mockResolvedValue({
hash: 'h1',
features: {},
environment_variables: [],
conversation_variables: [],
})
// ``base.ts`` rejects with the raw Response on non-401 — fake just the
// ``status`` field, which is what ``isHashCollisionResponse`` consults.
mockSyncWorkflowDraft.mockRejectedValueOnce({ status: 409, code: 'draft_workflow_not_sync' })
await expect(applyToCurrentApp({ appId: 'app-9', graph: makeGraph() }))
.rejects
.toBeInstanceOf(WorkflowApplyHashCollisionError)
})
// Non-409 errors (5xx, network) MUST NOT be misclassified as hash
// collisions — those still surface as the original rejection so the
// generic "apply failed" toast fires.
it('should NOT translate non-409 sync rejections', async () => {
mockFetchWorkflowDraft.mockResolvedValue({
hash: 'h1',
features: {},
environment_variables: [],
conversation_variables: [],
})
const original = { status: 500, code: 'internal_server_error' }
mockSyncWorkflowDraft.mockRejectedValueOnce(original)
await expect(applyToCurrentApp({ appId: 'app-9', graph: makeGraph() }))
.rejects
.toBe(original)
})
})

View File

@ -0,0 +1,57 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ExamplePrompts from '../example-prompts'
describe('ExamplePrompts', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('rendering', () => {
// Workflow mode surfaces a curated 4-prompt set; the count matters
// because the chip row's wrap behaviour was tuned for ≤ 4 entries.
it('should render the 4 workflow-mode prompts', () => {
render(<ExamplePrompts mode="workflow" onSelect={vi.fn()} />)
expect(screen.getAllByRole('button')).toHaveLength(4)
expect(screen.getByRole('button', { name: /workflowGenerator\.examples\.workflow\.summarize/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /workflowGenerator\.examples\.workflow\.translate/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /workflowGenerator\.examples\.workflow\.rag/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /workflowGenerator\.examples\.workflow\.classify/i })).toBeInTheDocument()
})
// Advanced-chat mode surfaces a different (3-prompt) set tailored to
// chatflow patterns. None of the workflow prompts should leak through.
it('should render the 3 chatflow-mode prompts when mode is advanced-chat', () => {
render(<ExamplePrompts mode="advanced-chat" onSelect={vi.fn()} />)
expect(screen.getAllByRole('button')).toHaveLength(3)
expect(screen.getByRole('button', { name: /workflowGenerator\.examples\.chatflow\.support/i })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /workflowGenerator\.examples\.workflow\.summarize/i })).not.toBeInTheDocument()
})
// The "Try one of these" label anchors the row visually; missing it
// would degrade the section to anonymous chips.
it('should render a section label above the chips', () => {
render(<ExamplePrompts mode="workflow" onSelect={vi.fn()} />)
expect(screen.getByText(/workflowGenerator\.examples\.label/i)).toBeInTheDocument()
})
})
describe('selection', () => {
// Clicking a chip is the whole point of the component — it must hand
// the chip text back to the parent verbatim so the parent can populate
// the instruction textarea.
it('should forward the clicked chip\'s text to onSelect', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
render(<ExamplePrompts mode="workflow" onSelect={onSelect} />)
const chip = screen.getByRole('button', { name: /workflowGenerator\.examples\.workflow\.summarize/i })
await user.click(chip)
expect(onSelect).toHaveBeenCalledTimes(1)
expect(onSelect.mock.calls[0]![0]).toMatch(/workflowGenerator\.examples\.workflow\.summarize/i)
})
})
})

View File

@ -0,0 +1,92 @@
import { act, render, screen } from '@testing-library/react'
import GenerationPhases from '../generation-phases'
describe('GenerationPhases', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
// The first frame the user sees during generation must be the "planning"
// phase — never an empty container or a different phase — so the perceived
// latency starts dropping immediately.
it('should start on the planning phase', () => {
render(<GenerationPhases startedAt={1} />)
expect(screen.getByText(/phases\.planning/i)).toBeInTheDocument()
})
// After the planner timer elapses we move to "building". The component
// doesn't reset to "planning" if the parent stays mounted — the timer
// chain only steps forward.
it('should advance to the building phase after the planning timer', () => {
render(<GenerationPhases startedAt={1} />)
act(() => {
vi.advanceTimersByTime(3500)
})
expect(screen.getByText(/phases\.building/i)).toBeInTheDocument()
expect(screen.queryByText(/phases\.planning/i)).not.toBeInTheDocument()
})
// The validating phase is the last in the schedule; once we get there we
// stay there indefinitely so a slow LLM doesn't make the indicator loop
// backwards and confuse the user.
it('should land on validating and not loop back to planning even after long delays', () => {
render(<GenerationPhases startedAt={1} />)
// Advance through phases in two steps — React schedules the next
// ``setTimeout`` only after the prior effect re-runs with the new
// ``phaseIndex``, so a single combined advance leaves us mid-phase.
act(() => {
vi.advanceTimersByTime(3500)
})
act(() => {
vi.advanceTimersByTime(12000)
})
expect(screen.getByText(/phases\.validating/i)).toBeInTheDocument()
act(() => {
vi.advanceTimersByTime(60000)
})
// Still validating — no reset, no loop.
expect(screen.getByText(/phases\.validating/i)).toBeInTheDocument()
expect(screen.queryByText(/phases\.planning/i)).not.toBeInTheDocument()
})
// Unmount cleanup matters because the modal is destroyed when the user
// closes it mid-generation; lingering timers would keep firing setState on
// an unmounted tree.
it('should not leak a timer when unmounted before the next phase fires', () => {
const { unmount } = render(<GenerationPhases startedAt={1} />)
// Sanity: pending timer should exist.
expect(vi.getTimerCount()).toBeGreaterThan(0)
unmount()
expect(vi.getTimerCount()).toBe(0)
})
// A second Generate click bumps ``startedAt``. The component must reset to
// "Planning" so the indicator doesn't appear wedged on "Validating" from
// the previous attempt. Without this the user thinks the system is stuck.
it('should reset to the planning phase when startedAt changes', () => {
const { rerender } = render(<GenerationPhases startedAt={1} />)
// Drive the first attempt all the way to validating.
act(() => {
vi.advanceTimersByTime(3500)
})
act(() => {
vi.advanceTimersByTime(12000)
})
expect(screen.getByText(/phases\.validating/i)).toBeInTheDocument()
// New attempt starts → bump startedAt. The component should snap back
// to planning rather than staying on validating.
rerender(<GenerationPhases startedAt={2} />)
expect(screen.getByText(/phases\.planning/i)).toBeInTheDocument()
expect(screen.queryByText(/phases\.validating/i)).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,155 @@
import { act, renderHook } from '@testing-library/react'
import { useWorkflowGeneratorStore } from '../store'
// Reset zustand state between tests so they don't share opener context.
const resetStore = () => {
useWorkflowGeneratorStore.setState({
isOpen: false,
mode: 'workflow',
currentAppId: null,
currentAppMode: null,
})
}
describe('useWorkflowGeneratorStore', () => {
beforeEach(() => {
vi.clearAllMocks()
resetStore()
})
describe('initial state', () => {
// Default snapshot: the generator modal is closed, mode is "workflow", and
// there is no current-app context attached.
it('should start closed in workflow mode with no current app', () => {
const { result } = renderHook(() => useWorkflowGeneratorStore())
expect(result.current.isOpen).toBe(false)
expect(result.current.mode).toBe('workflow')
expect(result.current.currentAppId).toBeNull()
expect(result.current.currentAppMode).toBeNull()
})
})
describe('openGenerator', () => {
// Opening from a non-Studio surface (e.g. /apps page): only the requested
// mode is set; currentAppId stays null so the modal hides "Apply to current".
it('should open with the requested mode and no current app by default', () => {
const { result } = renderHook(() => useWorkflowGeneratorStore())
act(() => {
result.current.openGenerator({ mode: 'advanced-chat' })
})
expect(result.current.isOpen).toBe(true)
expect(result.current.mode).toBe('advanced-chat')
expect(result.current.currentAppId).toBeNull()
expect(result.current.currentAppMode).toBeNull()
})
// Opening from inside Studio: caller passes currentAppId + currentAppMode
// so the modal can show "Apply to current draft".
it('should accept a current app id and mode when opened from Studio', () => {
const { result } = renderHook(() => useWorkflowGeneratorStore())
act(() => {
result.current.openGenerator({
mode: 'workflow',
currentAppId: 'app-123',
currentAppMode: 'workflow',
})
})
expect(result.current.isOpen).toBe(true)
expect(result.current.mode).toBe('workflow')
expect(result.current.currentAppId).toBe('app-123')
expect(result.current.currentAppMode).toBe('workflow')
})
// Reopening with new parameters must overwrite the previous mode/context;
// stale state would let the modal apply to the wrong app.
it('should overwrite previous state on a subsequent open', () => {
const { result } = renderHook(() => useWorkflowGeneratorStore())
act(() => {
result.current.openGenerator({ mode: 'workflow', currentAppId: 'app-1', currentAppMode: 'workflow' })
})
act(() => {
result.current.openGenerator({ mode: 'advanced-chat' })
})
expect(result.current.mode).toBe('advanced-chat')
expect(result.current.currentAppId).toBeNull()
expect(result.current.currentAppMode).toBeNull()
})
})
describe('closeGenerator', () => {
// Closing flips isOpen back to false but preserves mode / currentAppId so
// a subsequent reopen can decide whether to keep or replace them.
it('should close the modal without clearing the captured context', () => {
const { result } = renderHook(() => useWorkflowGeneratorStore())
act(() => {
result.current.openGenerator({ mode: 'workflow', currentAppId: 'app-9', currentAppMode: 'workflow' })
})
act(() => {
result.current.closeGenerator()
})
expect(result.current.isOpen).toBe(false)
expect(result.current.mode).toBe('workflow')
expect(result.current.currentAppId).toBe('app-9')
expect(result.current.currentAppMode).toBe('workflow')
})
})
describe('history reset on /create open', () => {
// /create must always start on the empty placeholder — the previous
// session's versions belong to a different intent and confuse the user.
// Without this, opening /create twice in a row would re-show the prior
// generated graph. Studio-refine sessions (with currentAppId) keep
// their history so close+reopen of the toolbar Generate doesn't lose
// the versions the user was comparing.
it('should clear new-app sessionStorage keys when opened without a currentAppId', () => {
sessionStorage.setItem('workflow-gen-workflow-new-versions', JSON.stringify([{ ghost: true }]))
sessionStorage.setItem('workflow-gen-workflow-new-version-index', '3')
const { result } = renderHook(() => useWorkflowGeneratorStore())
act(() => {
result.current.openGenerator({ mode: 'workflow' })
})
expect(sessionStorage.getItem('workflow-gen-workflow-new-versions')).toBeNull()
expect(sessionStorage.getItem('workflow-gen-workflow-new-version-index')).toBeNull()
})
// Only the mode being opened gets cleared — opening /create for
// workflow must not wipe a parallel advanced-chat /create session in
// another tab's sessionStorage path (we share the same sessionStorage
// namespace per tab, but only the corresponding mode key is wiped).
it('should leave the other mode\'s new-app history alone', () => {
sessionStorage.setItem('workflow-gen-advanced-chat-new-versions', JSON.stringify([{ keep: true }]))
const { result } = renderHook(() => useWorkflowGeneratorStore())
act(() => {
result.current.openGenerator({ mode: 'workflow' })
})
expect(sessionStorage.getItem('workflow-gen-advanced-chat-new-versions')).not.toBeNull()
})
// Studio refine sessions (currentAppId present) must NOT clear their
// history — the user expects to find their previous versions when they
// reopen the toolbar Generate button.
it('should NOT clear history when opened with a currentAppId', () => {
sessionStorage.setItem('workflow-gen-workflow-app-42-versions', JSON.stringify([{ keep: true }]))
const { result } = renderHook(() => useWorkflowGeneratorStore())
act(() => {
result.current.openGenerator({ mode: 'workflow', currentAppId: 'app-42', currentAppMode: 'workflow' })
})
expect(sessionStorage.getItem('workflow-gen-workflow-app-42-versions')).not.toBeNull()
})
})
})

View File

@ -0,0 +1,195 @@
import type { GeneratedGraph, WorkflowGeneratorMode } from './types'
import { createApp, deleteApp } from '@/service/apps'
import { fetchWorkflowDraft, syncWorkflowDraft } from '@/service/workflow'
import { AppModeEnum } from '@/types/app'
const MODE_TO_APP_MODE: Record<WorkflowGeneratorMode, AppModeEnum> = {
'workflow': AppModeEnum.WORKFLOW,
'advanced-chat': AppModeEnum.ADVANCED_CHAT,
}
/**
* Thrown by ``applyToCurrentApp`` when the backend rejects the sync because
* the draft's ``unique_hash`` doesn't match typically another tab edited
* the draft after we fetched it. The caller maps this to a dedicated
* "Workspace was edited elsewhere" toast with a Reload affordance instead
* of a generic "Apply failed".
*
* Backend surfaces this as HTTP 409 with ``error_code:
* "draft_workflow_not_sync"`` (see
* ``api/controllers/console/app/error.py::DraftWorkflowNotSync``).
*/
export class WorkflowApplyHashCollisionError extends Error {
constructor() {
super('Workflow draft was modified in another tab; reload required.')
this.name = 'WorkflowApplyHashCollisionError'
}
}
/**
* Thrown by ``applyToNewApp`` when the freshly-created app's draft sync
* failed AND we couldn't roll back by deleting the new app. The caller
* routes the user to ``/apps`` so the orphan is at least discoverable and
* surfaces a localised toast without this an unrecoverable orphan would
* silently sit in the app list with no graph.
*/
export class WorkflowApplyOrphanError extends Error {
readonly orphanAppId: string
constructor(orphanAppId: string, cause?: unknown) {
// ES2022 Error supports ``cause`` natively via the options bag — far
// cleaner than reassigning a typed-cast property after construction.
super(`Failed to apply graph; new app ${orphanAppId} may be orphaned.`, { cause })
this.name = 'WorkflowApplyOrphanError'
this.orphanAppId = orphanAppId
}
}
const isHashCollisionResponse = (e: unknown): boolean => {
// The shared ``post()`` wrapper rejects with the raw ``Response`` for non-401
// failures (see ``service/base.ts::request`` catch branch). At this layer the
// only reliable signal is the HTTP status.
if (!e || typeof e !== 'object')
return false
return (e as { status?: number }).status === 409
}
// Derive a sane App name from the user's instruction: trim, cap at 40 chars,
// strip trailing punctuation.
const deriveAppName = (instruction: string): string => {
const trimmed = instruction.trim().slice(0, 40)
return trimmed.replace(/[.,!?;:。,!?;:]+$/, '').trim() || 'Generated Workflow'
}
type ApplyToNewAppParams = {
mode: WorkflowGeneratorMode
graph: GeneratedGraph
instruction: string
/**
* Planner-picked product-style name (e.g. "URL Summarizer"). When empty,
* we fall back to ``deriveAppName(instruction)`` so the apps list never
* shows an empty title.
*/
appName?: string
/**
* Planner-picked emoji (e.g. "📰"). When empty, we fall back to 🤖
* which is the historical default.
*/
icon?: string
}
/**
* Apply path A create a brand-new Workflow / Chatflow app and write the
* generated graph into its draft. Returns the created app id so the caller
* can route to ``/app/{id}/workflow``.
*/
export const applyToNewApp = async ({
mode,
graph,
instruction,
appName,
icon,
}: ApplyToNewAppParams): Promise<{ appId: string, appMode: AppModeEnum }> => {
const appMode = MODE_TO_APP_MODE[mode]
const name = (appName ?? '').trim() || deriveAppName(instruction)
const appIcon = (icon ?? '').trim() || '🤖'
const app = await createApp({
name,
mode: appMode,
icon_type: 'emoji',
icon: appIcon,
icon_background: '#FFEAD5',
description: instruction.trim().slice(0, 200),
})
// Sync the generated graph into the brand-new app's draft. ``createApp``
// already succeeded so the app exists; if the sync fails (network blip,
// backend rejection of the graph) we MUST roll the createApp back so the
// user isn't left with a discoverable-but-empty app sitting at the top
// of their /apps list. ``deleteApp`` is best-effort — if that also fails
// (it usually won't, it's a simple DELETE) we surface ``WorkflowApplyOrphanError``
// so the caller can route to /apps where the orphan is still recoverable
// by hand.
try {
await syncWorkflowDraft({
url: `apps/${app.id}/workflows/draft`,
params: {
graph,
features: {},
environment_variables: [],
conversation_variables: [],
},
})
}
catch (syncErr) {
try {
await deleteApp(app.id)
}
catch (deleteErr) {
throw new WorkflowApplyOrphanError(app.id, deleteErr)
}
throw syncErr
}
return { appId: app.id, appMode }
}
type ApplyToCurrentAppParams = {
appId: string
graph: GeneratedGraph
}
/**
* Apply path B overwrite the current Workflow Studio's draft graph.
*
* The backend's ``sync_draft_workflow`` rejects writes whose ``hash`` doesn't
* match the existing draft's ``unique_hash`` (WorkflowHashNotEqualError), so we
* must read the current draft first to grab its hash. We also preserve the
* existing ``features``, ``environment_variables`` and ``conversation_variables``
* only nodes / edges / viewport (the ``graph`` field) get replaced by the
* generated graph.
*
* Caller is responsible for showing the overwrite confirmation dialog before
* invoking this.
*/
export const applyToCurrentApp = async ({
appId,
graph,
}: ApplyToCurrentAppParams): Promise<void> => {
const url = `apps/${appId}/workflows/draft`
// First sync may have no existing draft (workflow apps can exist before Studio
// has created/saved a draft). ``fetchWorkflowDraft`` rejects on non-2xx (e.g.
// 404), so we treat any fetch failure as "no existing draft" and sync without
// a hash.
let existing: Awaited<ReturnType<typeof fetchWorkflowDraft>> | null = null
try {
existing = await fetchWorkflowDraft(url)
}
catch {
existing = null
}
try {
await syncWorkflowDraft({
url,
params: {
graph,
features: existing?.features ?? {},
environment_variables: existing?.environment_variables ?? [],
conversation_variables: existing?.conversation_variables ?? [],
// Field is accepted by the backend but not typed in the Pick<> shape of
// ``syncWorkflowDraft``'s params — spread it in so it reaches the wire.
...(existing?.hash ? { hash: existing.hash } : {}),
} as Parameters<typeof syncWorkflowDraft>[0]['params'],
})
}
catch (e) {
// 409 → draft was edited in another tab between our fetch and sync.
// Translate the raw Response rejection into a typed error so the caller
// can show a Reload affordance instead of a generic "apply failed" toast.
if (isHashCollisionResponse(e))
throw new WorkflowApplyHashCollisionError()
throw e
}
}

View File

@ -0,0 +1,67 @@
'use client'
import type { WorkflowGeneratorMode } from './types'
import { memo, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
type Props = {
mode: WorkflowGeneratorMode
onSelect: (prompt: string) => void
}
/**
* "Try one of these" chips that sit below the instruction textarea.
*
* For brand-new users the blank instruction box is intimidating they don't
* know what kinds of prompts the planner handles well. The chips give them
* a one-click way to populate a real prompt so they can see the modal end-
* to-end on their first attempt.
*
* The four prompts per mode are intentionally chosen to cover a spread of
* shapes:
* - workflow: summarization, translation, RAG, classification.
* - advanced-chat: support agent, tutor, triage.
*
* The strings live in i18n so they translate alongside the rest of the
* generator UI.
*/
const ExamplePrompts: React.FC<Props> = ({ mode, onSelect }) => {
const { t } = useTranslation('workflow')
const prompts = useMemo(() => {
if (mode === 'workflow') {
return [
t('workflowGenerator.examples.workflow.summarize'),
t('workflowGenerator.examples.workflow.translate'),
t('workflowGenerator.examples.workflow.rag'),
t('workflowGenerator.examples.workflow.classify'),
]
}
return [
t('workflowGenerator.examples.chatflow.support'),
t('workflowGenerator.examples.chatflow.tutor'),
t('workflowGenerator.examples.chatflow.triage'),
]
}, [mode, t])
return (
<div className="mt-3">
<div className="mb-1.5 system-xs-medium-uppercase text-text-tertiary">
{t('workflowGenerator.examples.label')}
</div>
<div className="flex flex-wrap gap-1.5">
{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"
onClick={() => onSelect(prompt)}
>
{prompt}
</button>
))}
</div>
</div>
)
}
export default memo(ExamplePrompts)

View File

@ -0,0 +1,72 @@
'use client'
import { memo, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
/**
* Approximate stage durations (ms) for the slim plannerbuilder pipeline.
*
* The endpoint is single-shot we don't get real per-phase events from the
* backend but the user perception of "the system is doing things" is much
* better than a static spinner. The schedule below targets the typical
* 1518 s response time. If the real response lands earlier the modal
* unmounts this component; if it lands later we hold on the last phase
* indefinitely (rather than cycling back) so the user doesn't think we
* restarted.
*/
const PLANNING_MS = 3500
const BUILDING_MS = 12000
type Props = {
/**
* Per-attempt nonce typically ``Date.now()`` of when Generate was
* clicked. The component resets ``phaseIndex`` whenever this changes so a
* second Generate click starts the indicator from "Planning…" instead of
* resuming wherever the previous attempt left off.
*/
startedAt: number
}
const GenerationPhases = ({ startedAt }: Props) => {
const { t } = useTranslation('workflow')
const [phaseIndex, setPhaseIndex] = useState(0)
// Reset the indicator whenever a new attempt starts. Without this, a
// failed first attempt followed by a quick retry would resume mid-phase
// (or stuck on "Validating…") which looks like the system is wedged.
// ``set-state-in-effect`` flags this pattern, but the reset is the
// intent — driven by an external prop change, not by render-time state.
useEffect(() => {
// eslint-disable-next-line react/set-state-in-effect
setPhaseIndex(0)
}, [startedAt])
useEffect(() => {
if (phaseIndex === 0) {
const timer = setTimeout(() => setPhaseIndex(1), PLANNING_MS)
return () => clearTimeout(timer)
}
if (phaseIndex === 1) {
const timer = setTimeout(() => setPhaseIndex(2), BUILDING_MS)
return () => clearTimeout(timer)
}
// phaseIndex === 2 — terminal phase, no further timer.
}, [phaseIndex])
const label = (() => {
if (phaseIndex === 0)
return t('workflowGenerator.phases.planning')
if (phaseIndex === 1)
return t('workflowGenerator.phases.building')
return t('workflowGenerator.phases.validating')
})()
return (
<div className="flex h-full w-0 grow flex-col items-center justify-center space-y-3">
<Loading />
<div className="text-[13px] text-text-tertiary">{label}</div>
</div>
)
}
export default memo(GenerationPhases)

View File

@ -0,0 +1,592 @@
'use client'
import type { GeneratedGraph } from './types'
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { CompletionParams, Model, ModelModeType } from '@/types/app'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import IdeaOutput from '@/app/components/app/configuration/config/automatic/idea-output'
import VersionSelector from '@/app/components/app/configuration/config/automatic/version-selector'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
import WorkflowPreview from '@/app/components/workflow/workflow-preview'
import { useAppContext } from '@/context/app-context'
import { useLocalStorage } from '@/hooks/use-local-storage'
import { useRouter } from '@/next/navigation'
import { generateWorkflow } from '@/service/debug'
import { fetchWorkflowDraft } from '@/service/workflow'
import { getRedirectionPath } from '@/utils/app-redirection'
import { applyToCurrentApp, applyToNewApp, WorkflowApplyHashCollisionError, WorkflowApplyOrphanError } from './apply'
import ExamplePrompts from './example-prompts'
import GenerationPhases from './generation-phases'
import { useWorkflowGeneratorStore } from './store'
import useGenGraph from './use-gen-graph'
const STORAGE_MODEL_KEY = 'workflow-gen-model'
const FE_TIMEOUT_MS = 60_000
// Stable default used both as the SSR/empty-storage seed for the persisted
// model and as the merge base when patching a partial update. Module-level so
// the reference stays identical across renders (useLocalStorage uses it as the
// server value, which must not change identity each render).
const EMPTY_MODEL: Model = {
name: '',
provider: '',
mode: 'chat' as unknown as ModelModeType.chat,
completion_params: {} as CompletionParams,
}
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="text-center text-[13px] leading-5 font-normal text-text-tertiary">
{label}
</div>
</div>
)
// AbortController throws a DOMException in modern browsers and a plain
// Error in older / non-DOM environments — accept both so we don't toast
// for an abort the user intentionally triggered.
const isAbortError = (e: unknown): boolean =>
(e instanceof DOMException || e instanceof Error) && e.name === 'AbortError'
type RecoveryDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description: string
cancelLabel: string
confirmLabel: string
onConfirm: () => void
}
// Shared shell for "we hit a snag — here's a Reload / Confirm button"
// dialogs. The overwrite-confirm and hash-collision dialogs differ only in
// copy and confirm handler; this collapses 30 lines of duplicate JSX to
// one props bag and keeps the visual styling in lockstep across both.
const RecoveryDialog = ({ open, onOpenChange, title, description, cancelLabel, confirmLabel, onConfirm }: RecoveryDialogProps) => (
<AlertDialog open={open} onOpenChange={o => !o && onOpenChange(false)}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{title}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{description}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{cancelLabel}</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={onConfirm}>{confirmLabel}</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
)
const WorkflowGeneratorModal: React.FC = () => {
const { t } = useTranslation('workflow')
const router = useRouter()
const { isCurrentWorkspaceEditor } = useAppContext()
const isOpen = useWorkflowGeneratorStore(s => s.isOpen)
const mode = useWorkflowGeneratorStore(s => s.mode)
const intent = useWorkflowGeneratorStore(s => s.intent)
const currentAppId = useWorkflowGeneratorStore(s => s.currentAppId)
const currentAppMode = useWorkflowGeneratorStore(s => s.currentAppMode)
const closeGenerator = useWorkflowGeneratorStore(s => s.closeGenerator)
const isRefine = intent === 'refine' && !!currentAppId
// Persisted model selection. ``useLocalStorage`` is the storage boundary
// mandated for client-only preferences — the empty model is the SSR/seed
// value so ``model`` is always a concrete ``Model`` (never null) here.
const [model, setModel] = useLocalStorage<Model>(STORAGE_MODEL_KEY, EMPTY_MODEL)
const { defaultModel } = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textGeneration)
// Hydrate model from defaultModel once it loads (async). We deliberately set state
// from an effect here because defaultModel only resolves after the workspace's model
// catalogue fetch completes.
useEffect(() => {
if (defaultModel && !model.name) {
setModel(prev => ({
...(prev ?? EMPTY_MODEL),
name: defaultModel.model,
provider: defaultModel.provider.provider,
}))
}
}, [defaultModel, model.name, setModel])
const handleModelChange = useCallback((newValue: { modelId: string, provider: string, mode?: string, features?: string[] }) => {
setModel(prev => ({
...(prev ?? EMPTY_MODEL),
provider: newValue.provider,
name: newValue.modelId,
mode: newValue.mode as ModelModeType,
}))
}, [setModel])
const handleCompletionParamsChange = useCallback((newParams: FormValue) => {
setModel(prev => ({
...(prev ?? EMPTY_MODEL),
completion_params: newParams as CompletionParams,
}))
}, [setModel])
const [instruction, setInstruction] = useState('')
const [ideaOutput, setIdeaOutput] = useState('')
const storageKey = `${mode}-${currentAppId ?? 'new'}`
const { addVersion, current, currentVersionIndex, setCurrentVersionIndex, versions } = useGenGraph({
storageKey,
})
const [isLoading, { setTrue: setLoadingTrue, setFalse: setLoadingFalse }] = useBoolean(false)
const [isApplying, { setTrue: setApplyingTrue, setFalse: setApplyingFalse }] = useBoolean(false)
// Per-attempt nonce — bumped on each Generate click so ``GenerationPhases``
// can reset its internal phase timer instead of resuming wherever the
// previous attempt left off (which makes the UI look wedged).
const [startedAt, setStartedAt] = useState(0)
// Confirmation dialog for "Apply to current draft"
const [isShowConfirmOverwrite, { setTrue: showConfirmOverwrite, setFalse: hideConfirmOverwrite }] = useBoolean(false)
// Surfaced when the backend rejects the draft sync because another tab
// edited the workspace after we fetched it. Dedicated dialog instead of a
// toast because the user needs an explicit Reload action — without that,
// a generic "apply failed" toast leaves them stuck and confused.
const [isShowHashCollision, { setTrue: showHashCollision, setFalse: hideHashCollision }] = useBoolean(false)
// Holds the AbortController of the in-flight ``/workflow-generate`` request
// so we can cancel it on (a) modal close, (b) a second Generate click
// while loading, (c) the hard 60 s frontend timeout, or (d) the user
// pressing Cancel. Without this an in-flight request outlives the modal
// and can race a future Generate call.
const abortRef = useRef<AbortController | null>(null)
// Companion timer so the timeout doesn't keep running after the response
// lands. Cleared inside the same ``finally`` block that flips loading off.
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Mode the user generated against. If they switch app context mid-flight
// (e.g. open the same modal from a different Studio in another tab) we
// hide the "Apply to current" button so the wrong-mode graph never lands
// in the wrong Studio. Captured at Generate time, not Apply time.
const generatedModeRef = useRef<typeof mode | null>(null)
const clearTimers = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
}, [])
const abortInFlight = useCallback(() => {
if (abortRef.current) {
abortRef.current.abort()
abortRef.current = null
}
clearTimers()
}, [clearTimers])
// Cleanup on unmount — a modal unmount mid-generation must NOT leave the
// request running in the background (it would still resolve, mutate the
// store, and toast "applied" against a stale modal).
useEffect(() => {
return () => {
abortInFlight()
}
// The cleanup function reads refs only, so it's stable; we intentionally
// exclude ``abortInFlight`` from deps to avoid re-running this effect on
// every render.
// eslint-disable-next-line react/exhaustive-deps
}, [])
// Note: the modal is mounted lazily by ``mount.tsx`` which unmounts it when
// ``isOpen`` flips to false, so transient state (instruction / ideaOutput)
// resets implicitly on the next open. No reset effect needed.
const isValid = () => {
const trimmed = instruction.trim()
if (!trimmed) {
toast.error(t('workflowGenerator.instructionRequired'))
return false
}
if (!model.name) {
// No usable model resolved (provider catalogue empty or still
// loading). Without this guard the request would fly with an empty
// ``model_config.name`` and surface as a backend 400 — not actionable
// for the user. Tell them to pick a model.
toast.error(t('workflowGenerator.modelRequired'))
return false
}
return true
}
const onGenerate = async () => {
if (!isValid())
return
// Cancel any previous in-flight request (double-click guard). The
// previous promise will reject with AbortError which our catch swallows.
abortInFlight()
setStartedAt(Date.now())
generatedModeRef.current = mode
setLoadingTrue()
// Hard frontend timeout — aborts the request and surfaces a localised
// toast so the user sees something actionable instead of a perpetual
// spinner if the backend hangs.
timeoutRef.current = setTimeout(() => {
abortRef.current?.abort()
abortRef.current = null
toast.error(t('workflowGenerator.errors.timeout'))
}, FE_TIMEOUT_MS)
try {
// Refine mode: pull the current draft graph so the backend amends it
// instead of starting from scratch. The modal mounts outside the Studio's
// ReactFlow provider, so we read the persisted draft rather than the live
// canvas. A fetch failure (no draft saved yet) degrades gracefully to a
// from-scratch generation — better than blocking the user entirely.
let currentGraph: Awaited<ReturnType<typeof fetchWorkflowDraft>>['graph'] | undefined
if (isRefine && currentAppId) {
try {
const draft = await fetchWorkflowDraft(`apps/${currentAppId}/workflows/draft`)
if (draft?.graph?.nodes?.length)
currentGraph = draft.graph
}
catch {
currentGraph = undefined
}
}
const res = await generateWorkflow({
mode,
instruction,
ideal_output: ideaOutput,
model_config: model,
...(currentGraph ? { current_graph: currentGraph } : {}),
}, {
getAbortController: (c) => { abortRef.current = c },
})
const first = res.errors?.[0]
if (first) {
// Prefer the localised copy for the structured code; fall back to
// the backend's human-readable ``detail`` for codes we don't have
// a translation for yet.
const i18nKey = `workflowGenerator.errors.${first.code}`
const localised = t(i18nKey, { defaultValue: '' })
toast.error(localised || first.detail || res.error || t('workflowGenerator.generateFailed'))
return
}
if (res.error) {
toast.error(res.error)
return
}
addVersion(res)
}
catch (e: unknown) {
// Aborts are intentional (modal close, second click, timeout) — never
// toast for them. The timeout path already showed its own toast.
if (isAbortError(e))
return
const message = e instanceof Error ? e.message : ''
toast.error(message || t('workflowGenerator.generateFailed'))
}
finally {
setLoadingFalse()
clearTimers()
abortRef.current = null
}
}
const onCancelGeneration = useCallback(() => {
abortInFlight()
setLoadingFalse()
}, [abortInFlight, setLoadingFalse])
// "Apply to current" is valid only when the visible graph was generated
// for the app we'd be writing to. We require: a current app exists, its
// mode matches the current modal mode, AND the last Generate (if any)
// ran in this same mode — otherwise the user switched tabs mid-flight
// and we'd be writing a workflow graph into a chatflow draft (or vice
// versa). Falls back to "Create new app" only.
const generatedMode = generatedModeRef.current
const generatedModeMatches = generatedMode === null || generatedMode === mode
const canApplyToCurrent = !!currentAppId && currentAppMode === mode && generatedModeMatches
const handleApplyToNew = useCallback(async () => {
if (!current?.graph || isApplying)
return
setApplyingTrue()
try {
const { appId, appMode } = await applyToNewApp({
mode,
graph: current.graph as GeneratedGraph,
instruction,
appName: current.app_name,
icon: current.icon,
})
toast.success(t('workflowGenerator.applied'))
closeGenerator()
router.push(getRedirectionPath(isCurrentWorkspaceEditor, { id: appId, mode: appMode }))
}
catch (e: unknown) {
if (e instanceof WorkflowApplyOrphanError) {
// Sync failed AND we couldn't roll back. Route the user to /apps so
// the orphan is still discoverable — they can delete it by hand.
toast.error(t('workflowGenerator.errors.apply_failed_orphan'))
closeGenerator()
router.push('/apps')
return
}
const message = e instanceof Error ? e.message : ''
toast.error(message || t('workflowGenerator.applyFailed'))
}
finally {
setApplyingFalse()
}
}, [current, instruction, mode, router, isCurrentWorkspaceEditor, closeGenerator, t, isApplying, setApplyingTrue, setApplyingFalse])
const handleApplyToCurrentConfirmed = useCallback(async () => {
if (!current?.graph || !currentAppId || isApplying)
return
hideConfirmOverwrite()
setApplyingTrue()
try {
await applyToCurrentApp({ appId: currentAppId, graph: current.graph as GeneratedGraph })
toast.success(t('workflowGenerator.applied'))
closeGenerator()
// Hard reload the workflow page so the canvas picks up the new draft —
// ``router.refresh()`` only revalidates server-rendered route data, and
// the Studio canvas is hydrated client-side via react-query / zustand.
if (typeof window !== 'undefined')
window.location.reload()
}
catch (e: unknown) {
if (e instanceof WorkflowApplyHashCollisionError) {
// Another tab edited the draft after we fetched it. Show a
// dedicated dialog with a Reload affordance instead of a generic
// "apply failed" toast — the user needs to know what actually
// happened so they can pick up the other tab's edits before
// retrying.
showHashCollision()
return
}
const message = e instanceof Error ? e.message : ''
toast.error(message || t('workflowGenerator.applyFailed'))
}
finally {
setApplyingFalse()
}
}, [current, currentAppId, hideConfirmOverwrite, closeGenerator, t, isApplying, setApplyingTrue, setApplyingFalse, showHashCollision])
const modeLabel = mode === 'workflow' ? t('workflowGenerator.modes.workflow') : t('workflowGenerator.modes.chatflow')
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) {
// Cancel any in-flight request BEFORE closing the store — a
// request that resolves after the modal closes would still toast
// against the now-unmounted modal and pollute version history.
abortInFlight()
closeGenerator()
}
}}
>
<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">
{/* 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="mb-5">
<div 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">
{isRefine ? t('workflowGenerator.refineDescription') : t('workflowGenerator.description')}
</div>
</div>
<div>
<ModelParameterModal
popupClassName="w-[520px]!"
isAdvancedMode={true}
provider={model.provider}
completionParams={model.completion_params}
modelId={model.name}
setModel={handleModelChange}
onCompletionParamsChange={handleCompletionParamsChange}
hideDebugWithMultipleModel
/>
</div>
<div className="mt-4">
<div className="mb-1.5 system-sm-semibold-uppercase text-text-secondary">
{t('workflowGenerator.instruction')}
</div>
<Textarea
className="h-[160px]"
placeholder={isRefine
? t('workflowGenerator.refineInstructionPlaceholder')
: t('workflowGenerator.instructionPlaceholder')}
value={instruction}
onValueChange={setInstruction}
/>
{/* Example prompts are create-from-scratch starters ("Summarize a
URL"); they don't fit a refine-the-current-graph task, so hide
them in refine mode. */}
{!isRefine && <ExamplePrompts mode={mode} onSelect={setInstruction} />}
<IdeaOutput
value={ideaOutput}
onChange={setIdeaOutput}
/>
<div className="mt-7 flex justify-end space-x-2">
<Button onClick={closeGenerator}>
{t('workflowGenerator.dismiss')}
</Button>
{isLoading
? (
// Cancel surfaces the abort affordance during the 60 s
// window where the user might want to bail (slow
// model, wrong instruction, etc.). Hidden when idle so
// the row stays focused on the primary action.
<Button
className="flex space-x-1"
variant="secondary"
onClick={onCancelGeneration}
>
<span className="text-xs font-semibold">{t('workflowGenerator.cancel')}</span>
</Button>
)
: (
<Button
className="flex space-x-1"
variant="primary"
onClick={onGenerate}
disabled={!model.name}
>
<span className="i-custom-vender-other-generator size-4" />
<span className="text-xs font-semibold">{t('workflowGenerator.generate')}</span>
</Button>
)}
</div>
</div>
</div>
{/* Right pane: preview + version selector + apply */}
{(!isLoading && current?.graph?.nodes?.length)
? (
<div className="flex h-full w-0 grow flex-col bg-background-default-subtle p-6">
<div className="mb-3 flex items-center justify-between">
<VersionSelector
versionLen={versions?.length || 0}
value={currentVersionIndex || 0}
onChange={setCurrentVersionIndex}
/>
<div className="flex items-center space-x-2">
{canApplyToCurrent
? (
// Studio button entry — overwrite the current draft
// is the only meaningful Apply action, so collapse
// the two buttons into one primary "Apply".
<Button
size="small"
variant="primary"
onClick={showConfirmOverwrite}
disabled={isApplying}
>
{t('workflowGenerator.studioApply')}
</Button>
)
: (
// cmd+k /create entry — no current-app context, so
// the only path is "Create new app".
<Button
size="small"
variant="primary"
onClick={handleApplyToNew}
disabled={isApplying}
>
{t('workflowGenerator.applyToNew')}
</Button>
)}
</div>
</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}
viewport={current.graph.viewport}
miniMapToRight
/>
</div>
{current.message && (
<div className="mt-2 system-xs-regular text-text-tertiary">
{current.message}
</div>
)}
</div>
)
: null}
{isLoading && <GenerationPhases startedAt={startedAt} />}
{!isLoading && !current?.graph?.nodes?.length && renderPlaceholder(t('workflowGenerator.placeholder'))}
</div>
<RecoveryDialog
open={isShowConfirmOverwrite}
onOpenChange={() => hideConfirmOverwrite()}
title={t('workflowGenerator.overwriteTitle')}
description={t('workflowGenerator.overwriteMessage')}
cancelLabel={t('operation.cancel', { ns: 'common' })}
confirmLabel={t('operation.confirm', { ns: 'common' })}
onConfirm={handleApplyToCurrentConfirmed}
/>
{/* Hash-collision recovery surfaces when another tab edited the
draft between our fetch and sync. Reload picks up those edits;
Dismiss returns to the modal so the user can copy the generated
graph manually before re-fetching. */}
<RecoveryDialog
open={isShowHashCollision}
onOpenChange={() => hideHashCollision()}
title={t('workflowGenerator.errors.hash_collision_title')}
description={t('workflowGenerator.errors.hash_collision')}
cancelLabel={t('operation.cancel', { ns: 'common' })}
confirmLabel={t('workflowGenerator.reload')}
onConfirm={() => {
hideHashCollision()
if (typeof window !== 'undefined')
window.location.reload()
}}
/>
</DialogContent>
</Dialog>
)
}
export default React.memo(WorkflowGeneratorModal)

View File

@ -0,0 +1,22 @@
'use client'
import * as React from 'react'
import dynamic from '@/next/dynamic'
import { useWorkflowGeneratorStore } from './store'
// Lazy-load the modal so the bundle of the common layout stays light;
// the modal is only mounted on demand when cmd+k `/create` fires.
const WorkflowGeneratorModal = dynamic(() => import('./index'), { ssr: false })
/**
* Global mount point for the workflow generator modal. Place once in the
* common layout next to ``<GotoAnything />`` the modal opens whenever the
* zustand store flips ``isOpen`` to true.
*/
const WorkflowGeneratorMount: React.FC = () => {
const isOpen = useWorkflowGeneratorStore(s => s.isOpen)
if (!isOpen)
return null
return <WorkflowGeneratorModal />
}
export default WorkflowGeneratorMount

View File

@ -0,0 +1,58 @@
'use client'
import type { WorkflowGeneratorIntent, WorkflowGeneratorMode } from './types'
import { create } from 'zustand'
type WorkflowGeneratorStore = {
isOpen: boolean
mode: WorkflowGeneratorMode
/** `create` = build a new app; `refine` = amend the current Studio draft. */
intent: WorkflowGeneratorIntent
currentAppId: string | null
currentAppMode: WorkflowGeneratorMode | null
openGenerator: (params: {
mode: WorkflowGeneratorMode
intent?: WorkflowGeneratorIntent
currentAppId?: string | null
currentAppMode?: WorkflowGeneratorMode | null
}) => void
closeGenerator: () => void
}
/**
* Wipe the session-storage entries ``useGenGraph`` keeps for the new-app
* (no ``currentAppId``) bucket. We do this every time ``/create`` opens so
* the panel starts on the empty placeholder instead of showing whatever
* graph the previous /create session produced those generations belong
* to a different intent and confusingly leak across opens.
*
* Studio-refine sessions (``currentAppId`` set) keep their history so the
* user can close and reopen the generator from the same Studio without losing
* the versions they were comparing.
*/
const resetNewAppHistory = (mode: WorkflowGeneratorMode) => {
if (typeof window === 'undefined')
return
const storageKey = `${mode}-new`
try {
sessionStorage.removeItem(`workflow-gen-${storageKey}-versions`)
sessionStorage.removeItem(`workflow-gen-${storageKey}-version-index`)
}
catch {
// sessionStorage can throw in privacy-restricted contexts; the stale
// state will still flush on tab close, so swallowing here is fine.
}
}
export const useWorkflowGeneratorStore = create<WorkflowGeneratorStore>(set => ({
isOpen: false,
mode: 'workflow',
intent: 'create',
currentAppId: null,
currentAppMode: null,
openGenerator: ({ mode, intent = 'create', currentAppId = null, currentAppMode = null }) => {
if (!currentAppId)
resetNewAppHistory(mode)
set({ isOpen: true, mode, intent, currentAppId, currentAppMode })
},
closeGenerator: () => set({ isOpen: false }),
}))

View File

@ -0,0 +1,30 @@
import type { Viewport } from 'reactflow'
import type { Edge, Node } from '@/app/components/workflow/types'
export type WorkflowGeneratorMode = 'workflow' | 'advanced-chat'
/**
* `create` builds a brand-new app from scratch; `refine` feeds the current
* Studio draft graph to the generator as context so it amends what's already
* on the canvas. Only `refine` requires an open Studio (a `currentAppId`).
*/
export type WorkflowGeneratorIntent = 'create' | 'refine'
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
error?: string
}

View File

@ -0,0 +1,45 @@
import type { GenerateWorkflowResponse } from './types'
import { useSessionStorageState } from 'ahooks'
import { useCallback } from 'react'
const KEY_PREFIX = 'workflow-gen-'
type Params = {
storageKey: string
}
/**
* Session-storage-backed version history for generated workflows.
*
* Mirrors ``app/configuration/config/automatic/use-gen-data.ts`` so the
* cmd+k workflow generator's UX (left pane edit Generate right pane
* version selector) matches the existing Prompt Generator.
*/
const useGenGraph = ({ storageKey }: Params) => {
const [versions, setVersions] = useSessionStorageState<GenerateWorkflowResponse[]>(
`${KEY_PREFIX}${storageKey}-versions`,
{ defaultValue: [] },
)
const [currentVersionIndex, setCurrentVersionIndex] = useSessionStorageState<number>(
`${KEY_PREFIX}${storageKey}-version-index`,
{ defaultValue: 0 },
)
const current = versions?.[currentVersionIndex ?? 0]
const addVersion = useCallback((version: GenerateWorkflowResponse) => {
setCurrentVersionIndex(() => versions?.length || 0)
setVersions(prev => [...(prev ?? []), version])
}, [setVersions, setCurrentVersionIndex, versions?.length])
return {
versions,
addVersion,
currentVersionIndex,
setCurrentVersionIndex,
current,
}
}
export default useGenGraph

View File

@ -51,11 +51,20 @@
"exportFailed": "Export DSL failed.",
"gotoAnything.actions.accountDesc": "Navigate to account page",
"gotoAnything.actions.communityDesc": "Open Discord community",
"gotoAnything.actions.createCategoryDesc": "Create an AI-generated workflow or chatflow",
"gotoAnything.actions.createCategoryTitle": "Create",
"gotoAnything.actions.createChatflow": "Chatflow",
"gotoAnything.actions.createChatflowDesc": "Generate a chatflow (advanced chat) app from a description",
"gotoAnything.actions.createWorkflow": "Workflow",
"gotoAnything.actions.createWorkflowDesc": "Generate a workflow app from a description",
"gotoAnything.actions.docDesc": "Open help documentation",
"gotoAnything.actions.feedbackDesc": "Open community feedback discussions",
"gotoAnything.actions.languageCategoryDesc": "Switch interface language",
"gotoAnything.actions.languageCategoryTitle": "Language",
"gotoAnything.actions.languageChangeDesc": "Change UI language",
"gotoAnything.actions.refineCategoryDesc": "Refine the current workflow or chatflow graph",
"gotoAnything.actions.refineDesc": "Describe a change to apply to the current draft",
"gotoAnything.actions.refineTitle": "Refine current graph",
"gotoAnything.actions.runDesc": "Run quick commands (theme, language, ...)",
"gotoAnything.actions.runTitle": "Commands",
"gotoAnything.actions.searchApplications": "Search Applications",

View File

@ -1226,5 +1226,58 @@
"versionHistory.nameThisVersion": "Name this version",
"versionHistory.releaseNotesPlaceholder": "Describe what changed",
"versionHistory.restorationTip": "After version restoration, the current draft will be overwritten.",
"versionHistory.title": "Versions"
"versionHistory.title": "Versions",
"workflowGenerator.applied": "Applied",
"workflowGenerator.applyFailed": "Failed to apply workflow",
"workflowGenerator.applyToCurrent": "Apply to current draft",
"workflowGenerator.applyToNew": "Create new app",
"workflowGenerator.cancel": "Cancel",
"workflowGenerator.description": "Describe what you want the workflow to do. Pick a model, write an instruction, and preview the generated graph before applying it to Studio.",
"workflowGenerator.dismiss": "Dismiss",
"workflowGenerator.errors.DANGLING_EDGE": "The generated workflow has an edge pointing at a node that doesn't exist. Try again or refine your instruction.",
"workflowGenerator.errors.EMPTY_INSTRUCTION": "Please write an instruction first.",
"workflowGenerator.errors.EMPTY_PLAN": "The model returned an empty plan. Try a more specific instruction.",
"workflowGenerator.errors.INVALID_CONTAINER": "The generated workflow has a malformed loop or iteration container. Try a simpler instruction or pick a more capable model.",
"workflowGenerator.errors.INVALID_JSON": "The model returned a response we couldn't parse. Try again or pick a more capable model.",
"workflowGenerator.errors.INVALID_SCHEMA": "The model returned a graph in an unexpected shape. Try again or pick a more capable model.",
"workflowGenerator.errors.MISSING_START": "The generated workflow is missing its start node. Try regenerating.",
"workflowGenerator.errors.MISSING_TERMINAL": "The generated workflow is missing an end or answer node. Try regenerating.",
"workflowGenerator.errors.MODEL_ERROR": "The model call failed. Check your provider quota and try again.",
"workflowGenerator.errors.UNKNOWN_NODE_REFERENCE": "A node in the generated workflow points at a node that doesn't exist. Try regenerating.",
"workflowGenerator.errors.UNKNOWN_TOOL": "The workflow references a tool that isn't installed for this workspace. Install it from the Tools page or refine your instruction.",
"workflowGenerator.errors.UNRESOLVED_REFERENCE": "A node in the generated workflow references a variable that isn't declared upstream. Try regenerating.",
"workflowGenerator.errors.apply_failed_orphan": "We couldn't finish creating the app. An empty draft may have been left in your apps list — please remove it manually.",
"workflowGenerator.errors.hash_collision": "The workflow draft was edited in another tab. Reload to pick up those changes, then try applying again.",
"workflowGenerator.errors.hash_collision_title": "Workspace was edited elsewhere",
"workflowGenerator.errors.timeout": "Generation took too long. The model might be slow or unavailable — try again.",
"workflowGenerator.examples.chatflow.support": "Customer-support bot backed by a knowledge base",
"workflowGenerator.examples.chatflow.triage": "Triage incoming questions and route to a specialist prompt",
"workflowGenerator.examples.chatflow.tutor": "Multi-language tutor that explains step by step",
"workflowGenerator.examples.label": "Try one of these",
"workflowGenerator.examples.workflow.classify": "Fetch GitHub issues and classify them",
"workflowGenerator.examples.workflow.rag": "Knowledge-base query, then format the answer as Markdown",
"workflowGenerator.examples.workflow.summarize": "Summarize a URL",
"workflowGenerator.examples.workflow.translate": "Translate text to multiple languages",
"workflowGenerator.generate": "Generate",
"workflowGenerator.generateFailed": "Failed to generate workflow",
"workflowGenerator.instruction": "Instructions",
"workflowGenerator.instructionPlaceholder": "Describe the workflow you want — what input, what processing, what output.",
"workflowGenerator.instructionRequired": "Please write an instruction first",
"workflowGenerator.loading": "Generating workflow…",
"workflowGenerator.modelRequired": "Please pick a model before generating.",
"workflowGenerator.modes.chatflow": "Chatflow",
"workflowGenerator.modes.workflow": "Workflow",
"workflowGenerator.overwriteMessage": "Applying this workflow will replace the current draft graph. This cannot be undone.",
"workflowGenerator.overwriteTitle": "Overwrite the current draft?",
"workflowGenerator.phases.building": "Building nodes…",
"workflowGenerator.phases.planning": "Planning the workflow…",
"workflowGenerator.phases.validating": "Validating the graph…",
"workflowGenerator.placeholder": "Write an instruction on the left, then click Generate to preview the workflow graph.",
"workflowGenerator.refineDescription": "Describe the change you want. The current draft is used as context; the generated graph replaces it when you apply.",
"workflowGenerator.refineInstructionPlaceholder": "Describe the change — e.g. add a translation step, switch to a tool, add error handling.",
"workflowGenerator.refineTitle": "Refine {{mode}}",
"workflowGenerator.reload": "Reload",
"workflowGenerator.studioApply": "Apply",
"workflowGenerator.studioButton": "Generate",
"workflowGenerator.title": "Generate {{mode}}"
}

View File

@ -51,11 +51,20 @@
"exportFailed": "导出 DSL 失败",
"gotoAnything.actions.accountDesc": "导航到账户页面",
"gotoAnything.actions.communityDesc": "打开 Discord 社区",
"gotoAnything.actions.createCategoryDesc": "创建由 AI 生成的工作流或 Chatflow",
"gotoAnything.actions.createCategoryTitle": "创建",
"gotoAnything.actions.createChatflow": "Chatflow",
"gotoAnything.actions.createChatflowDesc": "根据描述生成一个 Chatflow高级聊天应用",
"gotoAnything.actions.createWorkflow": "Workflow",
"gotoAnything.actions.createWorkflowDesc": "根据描述生成一个工作流应用",
"gotoAnything.actions.docDesc": "打开帮助文档",
"gotoAnything.actions.feedbackDesc": "打开社区反馈讨论",
"gotoAnything.actions.languageCategoryDesc": "切换界面语言",
"gotoAnything.actions.languageCategoryTitle": "语言",
"gotoAnything.actions.languageChangeDesc": "更改界面语言",
"gotoAnything.actions.refineCategoryDesc": "优化当前的工作流或 Chatflow 图",
"gotoAnything.actions.refineDesc": "描述要应用到当前草稿的修改",
"gotoAnything.actions.refineTitle": "优化当前图",
"gotoAnything.actions.runDesc": "快速执行命令(主题、语言等)",
"gotoAnything.actions.runTitle": "命令",
"gotoAnything.actions.searchApplications": "搜索应用程序",

View File

@ -1226,5 +1226,58 @@
"versionHistory.nameThisVersion": "命名",
"versionHistory.releaseNotesPlaceholder": "请描述变更",
"versionHistory.restorationTip": "版本回滚后,当前草稿将被覆盖。",
"versionHistory.title": "版本"
"versionHistory.title": "版本",
"workflowGenerator.applied": "已应用",
"workflowGenerator.applyFailed": "应用工作流失败",
"workflowGenerator.applyToCurrent": "应用到当前草稿",
"workflowGenerator.applyToNew": "创建新应用",
"workflowGenerator.cancel": "取消",
"workflowGenerator.description": "描述你希望工作流完成的任务。选择模型、撰写指令,预览生成的图后再应用到 Studio。",
"workflowGenerator.dismiss": "关闭",
"workflowGenerator.errors.DANGLING_EDGE": "生成的工作流中有连线指向了不存在的节点,请重试或细化指令。",
"workflowGenerator.errors.EMPTY_INSTRUCTION": "请先填写指令。",
"workflowGenerator.errors.EMPTY_PLAN": "模型没有返回任何规划,尝试写一个更具体的指令。",
"workflowGenerator.errors.INVALID_CONTAINER": "生成的工作流中循环或迭代容器结构异常。尝试简化指令或更换更强的模型。",
"workflowGenerator.errors.INVALID_JSON": "模型返回的内容无法解析,请重试或更换更强的模型。",
"workflowGenerator.errors.INVALID_SCHEMA": "模型返回的图结构不符合预期,请重试或更换更强的模型。",
"workflowGenerator.errors.MISSING_START": "生成的工作流缺少起始节点,请重新生成。",
"workflowGenerator.errors.MISSING_TERMINAL": "生成的工作流缺少结束或回答节点,请重新生成。",
"workflowGenerator.errors.MODEL_ERROR": "模型调用失败。请检查供应商配额后重试。",
"workflowGenerator.errors.UNKNOWN_NODE_REFERENCE": "工作流中有节点引用了不存在的节点,请重新生成。",
"workflowGenerator.errors.UNKNOWN_TOOL": "工作流引用了当前工作空间未安装的工具。请先在「工具」页面安装该工具,或细化指令。",
"workflowGenerator.errors.UNRESOLVED_REFERENCE": "工作流中有节点引用了上游未声明的变量,请重新生成。",
"workflowGenerator.errors.apply_failed_orphan": "未能完成应用,应用列表中可能残留一个空草稿——请手动删除。",
"workflowGenerator.errors.hash_collision": "工作流草稿被另一个标签页修改过,请重新加载以同步那些改动后再尝试应用。",
"workflowGenerator.errors.hash_collision_title": "工作空间在别处被修改",
"workflowGenerator.errors.timeout": "生成耗时过长。模型可能较慢或不可用——请重试。",
"workflowGenerator.examples.chatflow.support": "基于知识库的客服机器人",
"workflowGenerator.examples.chatflow.triage": "分诊问题并路由到对应的专业 Prompt",
"workflowGenerator.examples.chatflow.tutor": "多语言导师,分步骤讲解",
"workflowGenerator.examples.label": "试试这些",
"workflowGenerator.examples.workflow.classify": "拉取 GitHub Issue 并分类",
"workflowGenerator.examples.workflow.rag": "查询知识库,然后以 Markdown 格式输出答案",
"workflowGenerator.examples.workflow.summarize": "总结一个网址",
"workflowGenerator.examples.workflow.translate": "把文本翻译成多种语言",
"workflowGenerator.generate": "生成",
"workflowGenerator.generateFailed": "生成工作流失败",
"workflowGenerator.instruction": "指令",
"workflowGenerator.instructionPlaceholder": "描述你想要的工作流——输入是什么、处理流程、输出形式。",
"workflowGenerator.instructionRequired": "请先填写指令",
"workflowGenerator.loading": "正在生成工作流……",
"workflowGenerator.modelRequired": "请先选择一个模型再生成。",
"workflowGenerator.modes.chatflow": "Chatflow",
"workflowGenerator.modes.workflow": "Workflow",
"workflowGenerator.overwriteMessage": "应用此工作流将覆盖当前草稿,操作不可撤销。",
"workflowGenerator.overwriteTitle": "覆盖当前草稿?",
"workflowGenerator.phases.building": "正在构建节点……",
"workflowGenerator.phases.planning": "正在规划工作流……",
"workflowGenerator.phases.validating": "正在校验图……",
"workflowGenerator.placeholder": "在左侧填写指令,点击「生成」预览工作流图。",
"workflowGenerator.refineDescription": "描述你想要的修改。当前草稿会作为上下文;应用时生成的图将替换它。",
"workflowGenerator.refineInstructionPlaceholder": "描述修改内容——例如添加翻译步骤、改用某个工具、增加错误处理。",
"workflowGenerator.refineTitle": "优化 {{mode}}",
"workflowGenerator.reload": "重新加载",
"workflowGenerator.studioApply": "应用",
"workflowGenerator.studioButton": "生成",
"workflowGenerator.title": "生成 {{mode}}"
}

83
web/service/debug.spec.ts Normal file
View File

@ -0,0 +1,83 @@
// service/base is the dependency we're mocking in this test; the
// no-restricted-imports rule targets production imports, not test
// instrumentation — mirrors sibling service specs (annotation.spec.ts etc.).
// eslint-disable-next-line no-restricted-imports
import { post } from './base'
import { generateWorkflow } from './debug'
// Stub the shared `post` wrapper so tests verify only what `generateWorkflow`
// composes on top of it — URL, body, and the typed response surface.
vi.mock('./base', () => ({
post: vi.fn(),
get: vi.fn(),
ssePost: vi.fn(),
}))
describe('debug service — generateWorkflow', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// 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', () => {
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: {} },
}
generateWorkflow(body)
expect(post).toHaveBeenCalledWith('/workflow-generate', { body })
})
// The optional fields must still POST cleanly — `ideal_output` defaulting
// server-side requires the helper to forward the body as-is, not augment it.
it('should pass the body through even when ideal_output is omitted', () => {
const body = {
mode: 'advanced-chat' as const,
instruction: 'Friendly support bot',
model_config: { provider: 'openai', name: 'gpt-4o', mode: 'chat' },
}
generateWorkflow(body)
expect(post).toHaveBeenCalledWith('/workflow-generate', { body })
})
// 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', () => {
const body = {
mode: 'workflow' as const,
instruction: 'Long-running generation',
model_config: { provider: 'openai', name: 'gpt-4o', mode: 'chat' },
}
const getAbortController = vi.fn()
generateWorkflow(body, { getAbortController })
expect(post).toHaveBeenCalledWith('/workflow-generate', { body }, { getAbortController })
})
// 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', () => {
const body = {
mode: 'workflow' as const,
instruction: 'Plain call',
model_config: { provider: 'openai', name: 'gpt-4o', mode: 'chat' },
}
generateWorkflow(body)
expect(post).toHaveBeenCalledWith('/workflow-generate', { body })
expect(vi.mocked(post).mock.calls[0]).toHaveLength(2)
})
})

View File

@ -1,4 +1,6 @@
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, ssePost } from './base'
@ -68,6 +70,103 @@ 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
/** 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 = {
mode: 'workflow' | 'advanced-chat'
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 })
}
export const fetchPromptTemplate = ({
appMode,
mode,