Compare commits

..

29 Commits

Author SHA1 Message Date
5707bf994a [autofix.ci] apply automated fixes 2026-05-29 15:18:15 +00:00
2962b6fbff fix(workflow-generator): node ids must not contain hyphens
ROOT CAUSE — the actual reason every generated workflow has been
emitting literal ``{{#node-1.text#}}`` strings at run time.

Dify's run-time placeholder regex (``graphon.runtime.variable_pool.
VARIABLE_PATTERN``) is:

    \\{\\{#([a-zA-Z0-9_]{1,50}(?:\\.[a-zA-Z_][a-zA-Z0-9_]{0,29}){1,10})#\\}\\}

The node-id slot is ``[a-zA-Z0-9_]`` — letters, digits, underscores
only. NO HYPHENS. Production saved-draft node ids are numeric
timestamps (``1759032354471``) which match. My generator was emitting
``node-1`` / ``node-2`` style ids with hyphens — which means every
``{{#node-1.var#}}`` placeholder silently failed to match at run time,
the literal string survived into the LLM prompt, and the LLM at run
time echoed it back as the user's output.

All the prior fixes (variable auto-injection, double-brace syntax
rule, structural validation) were masked by this — they all assumed
``node-1`` was a legal placeholder target. It never was.

Fix:

- ``builder_prompts.py``: new hard rule 2 — node ids MUST be
  ``node1`` / ``node2`` / ... (no hyphens). The rule cites
  ``variable_pool.VARIABLE_PATTERN`` so a future reader doesn't drift
  back to hyphenated ids. Every example in the cheatsheet (``node-1``,
  ``node-K``, ``node-Kstart``) is rewritten without hyphens.
- ``format_plan_block`` emits ``node1``, ``node2`` so the builder
  user-prompt's plan block doesn't suggest hyphens either.
- ``runner.py`` gains ``_strip_hyphens_from_node_ids``: defensive
  postprocess pass that walks the graph BEFORE the rest of postprocess
  touches it, builds an ``old → new`` id map by stripping hyphens, and
  rewrites every node id + edge endpoint + ``parentId`` /
  ``start_node_id`` / ``iteration_id`` / ``loop_id`` data field +
  ``{{#…#}}`` placeholder + ``["node-id", "var"]`` selector. Uses a
  lenient ``_LENIENT_VAR_REF_RE`` (allows hyphens in id slot) for the
  rewrite — the strict ``_VAR_REF_RE`` then sees only Dify-valid
  placeholders, so the variable walker's auto-inject can actually run.
- ``_VAR_REF_RE`` is tightened to mirror Dify's run-time regex
  exactly, so the walker no longer false-positives on references the
  runtime would never resolve anyway.
- All existing test fixtures updated from ``node-1`` to ``node1``
  (the pattern the LLM is told to emit).
- 5 new tests in ``TestWorkflowGeneratorNodeIdHyphens``: strips
  hyphens from every node id, rewrites ``{{#…#}}`` placeholders when
  ids are remapped, rewrites ``value_selector`` lists, no-op when ids
  are already clean, walker doesn't match hyphenated placeholders.

All 63 generator tests + 19 service / controller tests pass. Ruff +
pyrefly + mypy clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 23:13:27 +08:00
28d4bfa081 fix(workflow-generator): use {{#…#}} (double braces), Dify's actual placeholder
User report: generated translation workflow produced

    {"en": "{{#node-1.text#}}", "es": "{{#node-1.text#}}", ...}

instead of real translations. Two compounding bugs:

1. The cheatsheet was self-contradictory about placeholder syntax.
   Rule 6 (and several follow-on rules) used single-brace ``{#…#}``,
   while the per-node-type examples used double-brace ``{{#…#}}``. The
   LLM trusted rule 6 and emitted single braces in some places.

2. Dify's runtime placeholder regex is
   ``\\{\\{#[^#]+#\\}\\}`` (double braces) — see
   ``graphon.runtime.variable_pool.VARIABLE_PATTERN``. Single-brace
   strings are NOT placeholders; they reach the LLM at run time as
   literal text. The LLM, seeing "Output JSON: {\"en\": \"{#…#}\"}",
   helpfully echoes the placeholder back as the answer — and now the
   user's translation output is just the literal placeholder string.

3. My postprocess walker also used the wrong regex
   ``\\{#([^.#]+)\\.([^#]+)#\\}``, so even the double-brace references
   the LLM did correctly emit were never validated — missing
   start-node variables were never auto-injected.

Fix:

- ``builder_prompts.py`` rule 6, 7, 9 + the cheatsheet's start +
  cheatsheet's advanced-chat answer description all consistently say
  ``{{#node-id.var#}}``. Rule 6 explicitly calls out that single-brace
  syntax is NOT a Dify placeholder and would survive into the LLM
  prompt literally.
- New "Prompt-writing rules for the user-message text" block on the
  ``llm`` cheatsheet entry warns the LLM never to put placeholder
  syntax INSIDE an example-output JSON inside the prompt (that's the
  exact failure mode the user hit — the LLM was told "Output:
  {\"en\": \"{{#node-1.text#}}\"}" and dutifully echoed it back). Shows
  a right way and a wrong way side by side.
- ``runner.py`` walker regex ``\\{\\{#([^.#]+)\\.([^#]+)#\\}\\}`` now
  matches Dify's actual placeholder. Module-doc comment cross-links to
  ``variable_pool.VARIABLE_PATTERN`` so a future reader doesn't drift
  again.
- Existing test fixtures using single-brace placeholders are updated
  to use double braces — the walker now correctly ignores single
  braces, so the auto-inject tests were silently broken too.

New test ``test_walker_matches_double_brace_placeholders_only``
verifies single-brace strings are skipped and double-brace strings are
captured, locking the contract in.

All checks green: ruff, pyrefly + mypy, 58 generator tests, 3
service tests, 16 controller tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 22:52:38 +08:00
84dd31a1e3 [autofix.ci] apply automated fixes 2026-05-29 14:42:52 +00:00
8c837dbe56 fix(workflow-generator): make variable references actually resolve at run time
User report: "the variable in the prompt area can not be accessed in the
node itself." When the LLM prompt referenced ``{#node-1.url#}`` but the
start node was emitted with ``"variables": []``, the draft saved fine
(structurally valid) and then blew up at run time when the variable
resolver could not find ``url`` anywhere.

Fix has three pieces:

1) Planner now declares user-supplied inputs up front
   ``planner_prompts.py`` gains rule 10: every user-supplied value a
   downstream node will reference must appear in a new ``start_inputs``
   array (``{variable, label, type}``). The schema example shows it.
   In Advanced-Chat mode ``sys.query`` / ``sys.files`` stay automatic and
   should not be declared.

2) Builder is on the hook for the contract
   ``builder_prompts.py`` adds a hard rule 9 enumerating the resolvable-
   variable contract per source-node type (start → ``data.variables``,
   llm → ``text`` or structured-output keys, code → ``data.outputs``,
   etc.). The cheatsheet's start example is now POPULATED with two
   realistic entries so the LLM has a concrete shape to copy. The new
   ``format_start_inputs_section`` surfaces the planner's
   ``start_inputs`` list directly to the builder's user prompt.

3) Postprocess safety net
   ``runner.py`` gains ``_reconcile_variable_references``: walks every
   ``{#node-id.var#}`` placeholder and every ``["node-id", "var"]``
   selector in each node's ``data``; for every dangling reference whose
   source is the start node, auto-injects a ``paragraph`` variable with
   a Title-Cased label derived from the variable name. References to
   ``sys.query`` / ``sys.files`` are short-circuited as resolved in
   Advanced-Chat mode. Tool nodes are treated as resolved (their schemas
   are dynamic). Refs to other node types that don't declare the
   variable are left alone (cheap fix → noisy fix vs. quiet bug → the
   structural validator catches topology issues separately).

5 new tests:
- auto-injects a missing start variable when the prompt references it,
- never duplicates / re-types a start variable the builder already
  declared (preserves builder-chosen ``text-input``),
- walks ``value_selector`` references the same way,
- leaves ``sys.query`` alone in Advanced-Chat mode (no spurious
  ``sys.query`` entry on the start node),
- planner's ``start_inputs`` surfaces in the builder's user prompt.

No new endpoint, no schema migration. Ruff + pyrefly + mypy clean, all
57 generator tests pass plus 19 service / controller tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 22:38:10 +08:00
f3337fe088 Merge branch 'main' into feat/go-to-anything-v2 2026-05-29 22:30:03 +08:00
0870b8e1d4 [autofix.ci] apply automated fixes 2026-05-29 14:22:14 +00:00
278197f94e feat(workflow-generator): add 'Try one of these' example-prompt chips
First-time users had no idea what kinds of prompts the planner handles
well — the instruction textarea is intimidating when it's blank. New
chips row below the textarea gives them a one-click way to populate a
realistic prompt so they can see the modal end-to-end on first attempt.

Mode-aware prompts:

- workflow:      Summarize a URL · Translate text to multiple languages ·
                 Knowledge-base query then format as Markdown · Fetch
                 GitHub issues and classify them
- advanced-chat: Customer-support bot backed by a knowledge base ·
                 Multi-language tutor that explains step by step · Triage
                 incoming questions and route to a specialist prompt

The four-shape spread for workflow mode (summarization, translation, RAG,
classification) is intentional — it gives users a feel for the planner's
range without a tutorial. Three-shape spread for chatflow likewise.

Implementation:
- New ``example-prompts.tsx`` reads the mode-specific prompts from i18n
  via ``useMemo``, renders each as a small native button so the row is
  keyboard-navigable. Clicking forwards the chip text verbatim to the
  parent via ``onSelect`` — the parent (modal) calls ``setInstruction``.
- 9 new i18n keys in en-US + zh-Hans (label + 7 prompts).
- Wired into the modal between the instruction textarea and IdeaOutput.

4 unit tests: workflow renders 4 prompts, advanced-chat renders 3 (and
no workflow prompts leak through), the "Try one of these" label is
present, and clicking forwards the chip's text to ``onSelect``.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 22:17:11 +08:00
77cb1ca430 feat(workflow-generator): cycle the loading state through real phase labels
The generation step used to show a static spinner with "Generating
workflow…" for the entire 15–18 s response time, so the modal felt like
a black box. Swap that for a three-phase indicator that walks through:

  1. Planning the workflow…   (≈ 3.5 s)
  2. Building nodes…           (≈ 12 s)
  3. Validating the graph…     (held until the response lands)

The endpoint is still single-shot — we don't get real progress events
from the backend in this PR — but the perceived latency is much better
than a black-box spinner.

Implementation notes:
- New ``generation-phases.tsx`` schedules sequential ``setTimeout`` per
  phase, terminating on the validating phase so a slow LLM never makes
  the indicator loop backwards (which would feel like a restart).
- The component cleans up its pending timer on unmount, so closing the
  modal mid-generation doesn't leak a timer.
- New i18n keys ``workflowGenerator.phases.{planning,building,
  validating}`` in en-US + zh-Hans.
- ``index.tsx``'s ``renderLoading`` is replaced by the new component;
  the unused ``Loading`` import is moved into ``generation-phases.tsx``
  where it actually lives.

4 unit tests cover: starts on planning, advances to building after the
first timer, terminates on validating and never loops, no timer leak on
unmount.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 22:15:18 +08:00
d921f134c6 feat(workflow-generator): apply uses planner-picked name + emoji
Consume the new ``app_name`` / ``icon`` fields the backend started
emitting in the previous commit. When the planner picks them, the new
app shows up in /apps with a real product-style name + topical emoji
(e.g. "URL Summarizer" + 📰) instead of "<first 30 chars of instruction>"
+ 🤖. When the planner omits them — old prompts, model drift, etc. —
the existing ``deriveAppName(instruction)`` + 🤖 fallback still runs,
so nothing breaks in the failure path.

- service/debug.ts: ``GenerateWorkflowResponse`` gains optional
  ``app_name`` / ``icon``.
- workflow-generator/types.ts: same fields mirrored on the modal's
  in-memory type so the sessionStorage version history serialises them.
- apply.ts: ``applyToNewApp`` accepts optional ``appName`` / ``icon``,
  prefers them when non-blank, falls back to ``deriveAppName`` + 🤖
  otherwise. Whitespace-only values count as blank so we never POST
  empty strings to createApp.
- workflow-generator/index.tsx: ``handleApplyToNew`` forwards
  ``current.app_name`` / ``current.icon`` from the active version.

2 new apply.spec tests: planner-supplied values win over fallbacks;
whitespace-only planner values still fall back cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 22:11:05 +08:00
4e1c129ceb feat(workflow-generator): planner picks the new App's name and emoji
Apply-to-new-app used to call every generated app ``<first 30 chars of
instruction>`` + 🤖 — so users got "Translate text from English to" + 🤖
instead of "Translator" + 🌐. Easy fix: ask the planner to pick them.

- planner_prompts.py: rule 9 instructs the LLM to emit
  ``app_name`` (≤ 30-char Title Case) + a single ``icon`` emoji. The
  output schema example shows a meaningful pair so the model has a
  concrete reference.
- types.py: ``PlannerResultDict`` gains ``app_name`` / ``icon`` as
  ``NotRequired`` (old prompts still parse cleanly).
- types.py: ``WorkflowGenerateResultDict`` adds required ``app_name`` /
  ``icon`` so the frontend can always read them.
- runner.py: surface both fields with whitespace stripped; default to
  "" when the planner omits them. The empty-result skeleton seeds the
  same defaults so failure paths still type-check.

3 unit tests cover: planner emits them → surfaced verbatim; planner
omits them → both default to ""; surrounding whitespace is stripped.

Frontend changes (apply.ts consuming the values, with deriveAppName as
the fallback) land in the next commit. Backend stays safe in isolation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 22:08:38 +08:00
f53340ad17 Merge branch 'main' into feat/go-to-anything-v2 2026-05-29 21:59:51 +08:00
63bdbdbd67 feat(workflow-generator): collapse modal Apply row to a single button
Two entry points → two distinct Apply UIs:

- Studio toolbar button entry (canApplyToCurrent === true): the only
  meaningful action is overwriting the current draft, so collapse the
  former two-button row ("Create new app" + "Apply to current draft")
  into a single primary "Apply" button. One fewer click for the
  dominant Studio refinement journey.
- cmd+k /create entry (canApplyToCurrent === false): no current-app
  context, so the only path is "Create new app" — same as today.

Pure presentational tweak — no new state, no new store fields, no
behaviour change in the apply.ts wiring. Uses the new
``workflowGenerator.studioApply`` i18n key added in the previous commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 21:50:39 +08:00
441ee884bb feat(workflow-generator): add Generate button to the Studio toolbar
New entry point for refining the current Workflow / Chatflow draft —
sits in the toolbar's components.middle slot, right next to Features.

- ``GenerateTrigger`` reads ``appDetail`` from useAppStore (same hook
  FeaturesTrigger uses), maps the mode to the generator's
  WorkflowGeneratorMode, and clicks ``openGenerator`` with the locked
  mode + currentAppId. The button renders nothing unless the app is
  Workflow or Advanced-Chat — those are the only modes with a graph
  draft to overwrite. Other modes (chat / completion / agent-chat)
  and the pre-load state both return null.
- ``disabled={nodesReadOnly}`` mirrors EnvButton / GlobalVariableButton
  so the button is greyed during runs or when viewing published
  versions.
- Layout: components.middle now wraps Generate + Features in a small
  flex group (Generate first, Features second — reads left-to-right as
  creation → configuration).
- i18n: new ``workflowGenerator.studioButton`` ("Generate" / "生成")
  plus a separate ``workflowGenerator.studioApply`` for the modal's
  single-button Apply row (next commit).

8 unit tests cover: renders for Workflow + Advanced-Chat, hides while
appDetail is loading, hides for Chat/Completion/Agent-Chat modes,
disabled when nodesReadOnly is true, clicking calls openGenerator with
the mode locked + currentAppId from appDetail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 21:50:39 +08:00
f671a741da refactor(workflow-generator): scope /create to new-app creation only
The /create command was sniffing window.location.pathname for /app/<id>/
workflow and injecting that into the generator modal as currentAppId, so
"Apply to current draft" would appear when the user fired cmd+k from
inside Studio. The problem: the URL doesn't actually tell us the app's
mode (Workflow vs Advanced-Chat both live under /workflow), so we were
passing the user-picked mode as the "current" mode — which produced a
dead-end if the user picked the wrong one from the submenu.

Refining the current draft is now handled by a dedicated Studio toolbar
button (next commit). /create stays simple: pick workflow / chatflow,
modal opens with no current-app context, the only Apply action is
"Create new app". One responsibility per entry point, zero mode-mismatch
state possible.

Tests updated: the four register-handler cases now assert
openGenerator is called with just { mode } regardless of pathname; the
"nested workflow sub-paths" test is removed (that behaviour is removed
by design).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 21:50:39 +08:00
d2a429acc1 [autofix.ci] apply automated fixes 2026-05-29 13:33:59 +00:00
9136f27c84 feat(workflow-generator): align cheatsheet + postprocess to FE+BE node spec
The builder's NODE_CONFIG_CHEATSHEET drifted from what
``web/app/components/workflow/nodes/<type>/default.ts`` actually emits,
which meant generated drafts shipped subtly wrong default values:
``llm`` had a phantom ``structured_output_enabled`` field, ``http``
was missing ``ssl_verify`` / ``retry_config`` / ``variables``,
``answer`` was missing ``variables``, ``if-else`` and
``question-classifier`` were missing the ``_targetBranches`` /
``vision`` blocks Studio needs to render branch UI, and ``tool`` was
missing ``tool_node_version: "2"``. Iteration / loop weren't documented
at all even though the planner was being told to use them.

Cheatsheet now mirrors each frontend ``defaultValue`` verbatim, plus a
full Containers section covering the iteration / loop subgraph pattern
shipped in production drafts (container node + auto-generated
``<id>start`` child of type ``custom-iteration-start`` /
``custom-loop-start`` with ``parentId`` / ``extent`` / ``zIndex 1002`` /
relative position; inner nodes carrying ``parentId`` +
``isInIteration`` / ``isInLoop`` + ``iteration_id`` / ``loop_id`` on
data; inner edges flagged the same way with ``zIndex 1002``).

Planner gains rule 8: when an iteration/loop is in the plan, list its
inner steps with ``"parent": "<container-label>"`` so the builder can
wire ``parentId`` correctly. ``format_plan_block`` resolves the parent
label to its ``node-N`` id and surfaces it on the inner node's line.

Runner postprocess:
- Skip auto-layout for nodes carrying ``parentId`` (container children
  have relative positions inside the parent — overriding would break
  the subgraph layout); top-level nodes still get the left-to-right
  re-flow.
- Compute parent-child membership and stamp inner edges with
  ``data.isInIteration`` / ``data.isInLoop`` + ``data.iteration_id`` /
  ``data.loop_id`` + ``zIndex: 1002`` so they render inside the
  subgraph rather than at the top level. Edges crossing the container
  boundary keep the defaults (False).
- Inner nodes additionally get ``extent: "parent"`` and
  ``zIndex: 1002`` so ReactFlow constrains them.

5 new backend tests: format_plan_block parent-label resolution (with
unknown-label fallback), inner-node position preservation, top-level
auto-layout still works, sibling-edge ``isInIteration`` flagging works,
boundary-crossing edges are not flagged.

All checks green: ruff, pyrefly + mypy, 68 backend tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 21:29:07 +08:00
96164bef83 feat(workflow-generator): UX + prompt polish from PR review
Four follow-ups from the PR review:

1. Move the preview pane's minimap to bottom-right. WorkflowPreview
   already exposes a ``miniMapToRight`` prop — pass it through so the
   minimap sits next to the apply buttons rather than overlapping the
   left edge.

2. Drop the Copy button from the preview pane. Users can already inspect
   the generated graph visually, and the "Apply to ..." actions cover
   what they actually want to do with it. Removed the unused
   ``copy-to-clipboard`` / ``RiClipboardLine`` imports, the
   ``handleCopy`` callback and the ``workflowGenerator.copied`` i18n
   keys (en-US + zh-Hans).

3. Teach the planner to use if-else / iteration / loop nodes for
   complex flows. New rule 4 calls them out explicitly:
   - branching / mutually-exclusive paths → "if-else" or
     "question-classifier" (semantic intent routing)
   - "for each item in a list" → "iteration"
   - "keep going until condition" → "loop"

4. Make tools beat code / http-request whenever possible. Planner gains
   a new rule 5 ("PREFER 'tool' over 'http-request' or 'code' whenever
   an installed tool from the 'Available tools' section covers the
   task"); builder gains a matching rule 8 ("NEVER emit 'code' or
   'http-request' nodes if a tool from the 'Available tools' section
   covers the same task — replace them with a 'tool' node referencing
   the exact provider/tool identifier"). Also added "iteration" and
   "loop" to the planner's node-type list which omitted them before.

All checks green: tsgo, pnpm eslint (only pre-existing Tailwind icon
warnings remain), ruff, 44 backend tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 19:55:35 +08:00
8688c0adee [autofix.ci] apply automated fixes 2026-05-29 11:30:31 +00:00
02bcec2dca test: cover workflow-generate controller + build_tool_catalogue
Codecov's follow-up report (#issuecomment, head 5de8812) still flagged
two big gaps after the previous test backfill:

- api/core/workflow/generator/tool_catalogue.py at 47.76% — only the
  format helper was covered; ``build_tool_catalogue`` (the ToolManager
  iteration, plus the per-provider/per-tool resilience paths) was not.
- api/controllers/console/app/generator.py at 62.50% — the new
  WorkflowGenerateApi.post body and its 4-way error envelope mapping
  were untested.

New tests (+13 backend, all units):

test_tool_catalogue.py:
- _i18n_text: None, en_US present, zh_Hans fallback, both missing.
- _tool_description: None, .llm present, .llm missing.
- build_tool_catalogue: empty tenant; mixed hardcoded + plugin output
  with provider_type/plugin_id/label/description; unknown provider
  class skipped; get_tools() exception keeps the catalogue going; bad
  tool entity dropped while siblings survive; _MAX_TOOLS=80 cap
  enforced; None plugin_id normalises to "".

test_generator_api.py:
- workflow_generate_returns_service_result: happy path.
- workflow_generate_maps_provider_token_error: ProviderTokenNotInitError
  → ProviderNotInitializeError.
- workflow_generate_maps_quota_error: QuotaExceededError
  → ProviderQuotaExceededError.
- workflow_generate_maps_model_not_support_error:
  ModelCurrentlyNotSupportError → ProviderModelCurrentlyNotSupportError.
- workflow_generate_maps_invoke_error: InvokeError → CompletionRequestError.
- workflow_generate_accepts_advanced_chat_mode: Literal payload check.

The catalogue tests patch ``isinstance`` at the module to route around
the real BuiltinToolProviderController/PluginToolProviderController
constructors (which require on-disk plugin state). All checks green:
pyrefly + mypy clean, ruff clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 19:25:41 +08:00
5de8812f6f [autofix.ci] apply automated fixes 2026-05-29 09:24:52 +00:00
7457508197 test: backfill workflow-generator coverage flagged by Codecov
Codecov pinned 55 lines of patch coverage gaps across the new files in
PR #32130. The bot's report (#issuecomment-4572059636) called out:

- create.tsx 33% — register()/create.open handler untested
- generator.py 62% — controller error mapping untested
- store.ts 40% — zustand actions untested
- service 73% — catalogue-failure fallback untested
- runner.py 87% — clamp + dedupe + layout edge cases untested
- planner_prompts.py 66% — empty-catalogue branch untested
- debug.ts 50% — generateWorkflow untested

New / expanded tests (all units, no infra):

Frontend (108 total, +43 new):
- store.spec.ts: initial state, openGenerator (with + without Studio
  context, overwrite-on-reopen), closeGenerator preserves context.
- create.spec.tsx: rehome from web/__tests__/ to sibling __tests__/ per
  the placement rule in docs/test.md, then cover register() — Studio
  URL detection, default-mode fallback, nested workflow sub-paths, and
  unregister() really removes the handler.
- apply.spec.ts: applyToNewApp (mode mapping, derived name, empty
  instruction fallback) and applyToCurrentApp (hash round-trip,
  no-draft fallback, fetch-failure resilience — covers the recent bug).
- debug.spec.ts: generateWorkflow POSTs to /workflow-generate with
  body verbatim (with + without ideal_output).

Backend (33 total, +19 new):
- test_prompts.py: format_ideal_output_section, planner +
  builder catalogue sections (empty + populated), format_plan_block,
  get_builder_system_prompt branch selection.
- test_workflow_generator_service.py: happy path forwards
  model_instance + catalogue text; build_tool_catalogue failure falls
  back to ""; default ideal_output is "".
- test_runner.py edge cases: _clamp_for_planner (high temp clamped,
  low temp preserved, missing temp defaulted), planner returning no
  nodes never invokes the builder, tool_catalogue_text reaches both
  prompts, postprocess overrides bogus positions and dedupes edges.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 17:19:11 +08:00
cad2af2de5 feat(workflow-generator): inject installed tool catalogue into planner+builder
The planner had no idea which tools the tenant actually has installed, so
``tool`` nodes were generated with hallucinated provider/tool names that
failed at draft sync. Add a tenant-scoped catalogue helper and pipe its
output into both planner and builder prompts.

- core/workflow/generator/tool_catalogue.py: enumerate hardcoded built-in
  providers plus plugin providers (via ToolManager.list_builtin_providers
  which already covers both), pull provider_name + tool_name + label +
  llm-facing description, cap to 80 entries to keep the prompt bounded,
  and render as a compact one-tool-per-line block. Per-provider failures
  are logged and skipped so one bad plugin can't kill generation.
- prompts/planner_prompts.py + prompts/builder_prompts.py: new optional
  catalogue section. Planner is told to pick concrete provider/tool
  identifiers from the list; builder is told to set provider_id /
  provider_name / tool_name to entries that actually exist.
- runner.py: thread the catalogue text through generate_workflow_graph
  → _run_planner / _run_builder. New param defaults to "" so unit tests
  and tool-less tenants still work unchanged.
- services/workflow_generator_service.py: build + format the catalogue
  for the calling tenant. Catalogue build failures (plugin daemon down,
  etc.) are logged and downgraded to "no catalogue" — never block
  generation outright.
- 5 new unit tests cover the catalogue formatter (empty/short/long
  descriptions, label dedupe, newline stripping); the existing 7 runner
  tests keep passing thanks to the default-empty param.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 16:47:24 +08:00
332bb27e51 Merge branch 'main' into feat/go-to-anything-v2 2026-05-29 16:46:12 +08:00
976d1d900b Merge branch 'main' into feat/go-to-anything-v2 2026-05-29 16:18:21 +08:00
1bd52368d9 fix(workflow-generator): make Apply-to-current actually overwrite the draft
The Studio canvas's sync_draft_workflow endpoint rejects writes whose
``hash`` doesn't match the existing draft's ``unique_hash`` — this is
how concurrent-edit detection works. ``applyToCurrentApp`` was sending
no hash, so every "Apply to current draft" click failed with
WorkflowHashNotEqualError as soon as the target app had any draft.

Now we:
1. fetchWorkflowDraft first to read the current hash + preserve the
   existing features / environment_variables / conversation_variables
   (only nodes / edges / viewport get replaced by the generated graph).
2. Spread ``hash`` into the sync params — the field is accepted by the
   backend but not in the Pick<> shape of syncWorkflowDraft's typed
   params, mirroring how use-nodes-sync-draft.ts:91 does it.
3. Fall back to a hashless sync when no existing draft is found (silent
   404 from fetchWorkflowDraft) so the very-first apply on a freshly
   created Workflow app still works.

Also swap router.refresh() for window.location.reload() on success —
the Studio canvas is hydrated client-side via react-query/zustand, so
router.refresh() only revalidates server data and the new draft never
showed up until the user manually reloaded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 16:03:42 +08:00
d6bec58bfa fix(workflow-generator): satisfy knip + update slash command registry test
- Drop the export on apply.ts:deriveAppName — it's only used internally
  by applyToNewApp, and knip was flagging the unused public export.
- Extend the slash.spec registration assertion to include the new
  'create' command so SlashCommandProvider's mount/unmount checks pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 15:50:42 +08:00
2a67c7e92d [autofix.ci] apply automated fixes 2026-05-29 07:31:34 +00:00
a1cc7c555e feat: cmd+k /create workflow generator (slim planner-builder)
Restart the natural-language Workflow generator from main with a Prompt-
Generator-shaped UX: cmd+k → /create → pick workflow/chatflow → describe
the task → preview the generated graph → Apply to a new app or overwrite
the current Studio draft.

Backend (slim planner→builder pipeline, ~600 LOC vs. the previous 13.5k):

- core/workflow/generator/: WorkflowGenerator runs PLANNER (high-level
  node plan) then BUILDER (full graph JSON via structured output), with a
  postprocess pass that fills safe defaults, lays nodes out left-to-right
  and runs a final structural sanity check (start/end shape, edge node
  references). Mermaid rendering, graph_builder, validation engine and
  node/edge repair are deferred — re-add when quality demands it.
- services/workflow_generator_service.py: facade owning the ModelManager
  dependency so core.workflow.generator stays pure domain code.
- controllers/console/app/generator.py: new POST /workflow-generate
  endpoint, error envelope identical to /rule-generate so the frontend
  reuses its existing handler.

Frontend (reuses WorkflowPreview verbatim and the Prompt-Generator pattern):

- components/workflow/workflow-generator/: two-pane modal (1140×680),
  zustand store for global open/close, lazy mount via @/next/dynamic,
  sessionStorage version history mirroring use-gen-data, apply.ts wiring
  both "Create new app" (createApp + syncWorkflowDraft + redirect) and
  "Apply to current draft" (syncWorkflowDraft with overwrite confirmation).
- components/goto-anything/actions/commands/create.tsx: submenu slash
  command surfacing Workflow + Chatflow, dispatching create.open through
  the existing command bus.
- (commonLayout)/layout.tsx: mount WorkflowGeneratorMount alongside
  GotoAnything so cmd+k works from anywhere in the app.
- service/debug.ts: typed generateWorkflow() helper.
- i18n/{en-US,zh-Hans}/workflow.json: 19 new workflowGenerator.* keys.

Tests:

- 7 backend unit tests cover happy path for both modes plus planner JSON
  failure, builder exception, structural validation, edge integrity.
- 9 frontend tests cover /create command surface + filtering, and the
  sessionStorage version history hook.

All checks green: pyrefly + mypy, ruff, pnpm tsgo, vitest (86 passing
including the existing 82 goto-anything regression tests).

Closes #29774

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 15:26:25 +08:00
51 changed files with 5907 additions and 138 deletions

0
.codex Normal file
View File

View File

@ -1,8 +0,0 @@
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
version = 1
name = "dify"
[setup]
script = '''
pnpm install --frozen-lockfile --prefer-offline
'''

View File

@ -467,8 +467,7 @@ class AppListApi(Resource):
@login_required
@account_initialization_required
@enterprise_license_required
@with_session(write=False)
def get(self, session: Session):
def get(self):
"""Get app list"""
current_user, current_tenant_id = current_account_with_tenant()
@ -505,7 +504,7 @@ class AppListApi(Resource):
draft_trigger_app_ids: set[str] = set()
if workflow_capable_app_ids:
draft_workflows = (
session.execute(
db.session.execute(
select(Workflow).where(
Workflow.version == Workflow.VERSION_DRAFT,
Workflow.app_id.in_(workflow_capable_app_ids),

View File

@ -1,8 +1,8 @@
from collections.abc import Sequence
from typing import Literal
from flask_restx import Resource
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from controllers.common.schema import register_enum_models, register_schema_models
from controllers.console import console_ns
@ -12,7 +12,6 @@ from controllers.console.app.error import (
ProviderNotInitializeError,
ProviderQuotaExceededError,
)
from controllers.console.app.wraps import with_session
from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id
from core.app.app_config.entities import ModelConfig
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
@ -21,10 +20,12 @@ from core.helper.code_executor.javascript.javascript_code_provider import Javasc
from core.helper.code_executor.python3.python3_code_provider import Python3CodeProvider
from core.llm_generator.entities import RuleCodeGeneratePayload, RuleGeneratePayload, RuleStructuredOutputPayload
from core.llm_generator.llm_generator import LLMGenerator
from extensions.ext_database import db
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 +43,20 @@ class InstructionTemplatePayload(BaseModel):
type: str = Field(..., description="Instruction template type")
class WorkflowGeneratePayload(BaseModel):
"""Payload for the cmd+k `/create` 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")
register_enum_models(console_ns, LLMMode)
register_schema_models(
console_ns,
@ -50,6 +65,7 @@ register_schema_models(
RuleStructuredOutputPayload,
InstructionGeneratePayload,
InstructionTemplatePayload,
WorkflowGeneratePayload,
ModelConfig,
)
@ -159,8 +175,7 @@ class InstructionGenerateApi(Resource):
@login_required
@account_initialization_required
@with_current_tenant_id
@with_session(write=False)
def post(self, session: Session, current_tenant_id: str):
def post(self, current_tenant_id: str):
args = InstructionGeneratePayload.model_validate(console_ns.payload)
providers: list[type[CodeNodeProvider]] = [Python3CodeProvider, JavascriptCodeProvider]
code_provider: type[CodeNodeProvider] | None = next(
@ -170,10 +185,10 @@ class InstructionGenerateApi(Resource):
try:
# Generate from nothing for a workflow node
if (args.current in (code_template, "")) and args.node_id != "":
app = session.get(App, args.flow_id)
app = db.session.get(App, args.flow_id)
if not app:
return {"error": f"app {args.flow_id} not found"}, 400
workflow = WorkflowService().get_draft_workflow(app_model=app, session=session)
workflow = WorkflowService().get_draft_workflow(app_model=app)
if not workflow:
return {"error": f"workflow {args.flow_id} not found"}, 400
nodes: Sequence = workflow.graph_dict["nodes"]
@ -265,3 +280,45 @@ 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)
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,
)
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,488 @@
"""
Builder prompts.
The builder is the second step of the slim planner→builder pipeline. It takes
the planner's high-level node list and emits the *full* graph JSON consumed by
``WorkflowService.sync_draft_workflow``.
The builder owns: node configuration (prompts, code, headers, etc.), edge wiring,
handle ids ("source"/"target"), positions, and the viewport. It is the only
prompt that needs to know the concrete shape of each node type — keep its
examples accurate or the LLM will invent fields.
"""
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": ""}
## 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``.
* ``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}\
# 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_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,140 @@
"""
Planner prompts.
The planner is the lightweight first step in the slim planner→builder 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.
# 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}
{ideal_output_section}{tool_catalogue_section}\
Return the JSON plan now.
"""
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"
)

View File

@ -0,0 +1,746 @@
"""
Workflow generator runner.
Slim planner→builder pipeline. Pure domain logic; the model instance is
injected by ``WorkflowGeneratorService`` so this module stays cleanly
separated from the infrastructure layer.
Pipeline:
1. PLANNER — short LLM call producing a high-level node list.
2. BUILDER — structured-output LLM call producing the full graph JSON.
3. POSTPROC — fill safe defaults, lay nodes out left-to-right, dedupe
edge ids, and run a final structural sanity check.
Intentionally NOT here (deferred to a future iteration):
- Mermaid rendering
- Heuristic node/edge auto-repair beyond default fill
- Multi-step validation engine with classification of fixable vs. user-required errors
- Tool / model catalogue filtering
If quality regresses below product threshold we add those back; for now the
single planner+builder pair shipped behind cmd+k `/create` is enough.
"""
import json
import logging
import re
from typing import Any, ClassVar, cast
import json_repair
from core.workflow.generator.prompts.builder_prompts import (
BUILDER_USER_PROMPT,
format_builder_tool_catalogue_section,
format_plan_block,
format_start_inputs_section,
get_builder_system_prompt,
)
from core.workflow.generator.prompts.planner_prompts import (
PLANNER_SYSTEM_PROMPT,
PLANNER_USER_PROMPT,
format_ideal_output_section,
format_tool_catalogue_section,
)
from core.workflow.generator.types import (
GraphDict,
GraphViewportDict,
PlannerResultDict,
WorkflowGenerateResultDict,
WorkflowGenerationMode,
)
from graphon.enums import BuiltinNodeTypes
from graphon.model_runtime.entities.llm_entities import LLMResult
from graphon.model_runtime.entities.message_entities import SystemPromptMessage, UserPromptMessage
logger = logging.getLogger(__name__)
_NODE_X_OFFSET = 80
_NODE_X_STEP = 320
_NODE_Y = 280
_DEFAULT_VIEWPORT: GraphViewportDict = {"x": 0.0, "y": 0.0, "zoom": 0.7}
_DEFAULT_NODE_WIDTH = 244
_DEFAULT_NODE_HEIGHT = 100
class WorkflowGenerator:
"""
Generates a Dify workflow graph from a natural-language instruction.
Domain layer — receives an already-constructed model instance. Use
``services.workflow_generator_service.WorkflowGeneratorService`` to
call this from controllers.
"""
@classmethod
def generate_workflow_graph(
cls,
*,
model_instance,
model_parameters: dict[str, Any],
provider: str,
model_name: str,
model_mode: str,
mode: WorkflowGenerationMode,
instruction: str,
ideal_output: str = "",
tool_catalogue_text: str = "",
) -> WorkflowGenerateResultDict:
"""
Run planner → builder → postprocess and return a graph payload.
``tool_catalogue_text`` is the formatted list of installed tools for
the calling tenant (see ``tool_catalogue.build_tool_catalogue`` /
``format_tool_catalogue``). It's injected into both the planner and
builder prompts so the LLM can pick concrete ``provider/tool``
identifiers instead of inventing names; an empty string skips the
section entirely (useful for unit tests).
Returns a dict with ``graph``, ``message`` and ``error``. On any
failure ``graph`` is an empty skeleton (single start node) and
``error`` carries a human-readable message; callers should toast
``error`` and keep the previous version visible.
"""
empty_result: WorkflowGenerateResultDict = {
"graph": {"nodes": [], "edges": [], "viewport": _DEFAULT_VIEWPORT},
"message": "",
"app_name": "",
"icon": "",
"error": "",
}
# ── 1. PLANNER ────────────────────────────────────────────────────
try:
plan = cls._run_planner(
model_instance=model_instance,
model_parameters=model_parameters,
mode=mode,
instruction=instruction,
ideal_output=ideal_output,
tool_catalogue_text=tool_catalogue_text,
)
except Exception as e:
logger.exception("Workflow generator: planner step failed")
empty_result["error"] = f"Failed to plan workflow: {e}"
return empty_result
plan_nodes: list[dict[str, Any]] = cast(list[dict[str, Any]], plan.get("nodes", []))
if not plan_nodes:
empty_result["error"] = "Planner returned no nodes"
return empty_result
# Planner-supplied user-input declarations. The builder uses these to
# populate ``start.data.variables`` so downstream ``{#start.<var>#}``
# references resolve at run time. Optional field — older prompts may
# omit it, in which case the postprocess walker auto-fixes references.
start_inputs_raw = plan.get("start_inputs") or []
start_inputs: list[dict[str, Any]] = [
cast(dict[str, Any], item)
for item in start_inputs_raw
if isinstance(item, dict) and (item.get("variable") or "").strip()
]
# ── 2. BUILDER ────────────────────────────────────────────────────
try:
graph = cls._run_builder(
model_instance=model_instance,
model_parameters=model_parameters,
provider=provider,
model_name=model_name,
model_mode=model_mode,
mode=mode,
instruction=instruction,
ideal_output=ideal_output,
plan_nodes=plan_nodes,
tool_catalogue_text=tool_catalogue_text,
start_inputs=start_inputs,
)
except Exception as e:
logger.exception("Workflow generator: builder step failed")
empty_result["error"] = f"Failed to build workflow graph: {e}"
return empty_result
# ── 3. POSTPROC ───────────────────────────────────────────────────
graph = cls._postprocess_graph(graph=graph, mode=mode)
# Surface the planner-supplied display metadata to the frontend so
# ``applyToNewApp`` can name the new app and pick a meaningful icon
# instead of the canned ``deriveAppName`` + 🤖 fallback. Both fields
# default to "" when the LLM omits them — the FE owns the fallback.
app_name = str(plan.get("app_name") or "").strip()
icon = str(plan.get("icon") or "").strip()
# Final structural sanity check — fail closed if start/end shape is wrong.
structural_error = cls._validate_structure(graph=graph, mode=mode)
if structural_error:
logger.warning("Workflow generator: structural validation failed: %s", structural_error)
return {
"graph": graph, # still return the partial graph so caller can debug
"message": plan.get("description", ""),
"app_name": app_name,
"icon": icon,
"error": structural_error,
}
return {
"graph": graph,
"message": plan.get("description", ""),
"app_name": app_name,
"icon": icon,
"error": "",
}
# ------------------------------------------------------------------
# Planner
# ------------------------------------------------------------------
@classmethod
def _run_planner(
cls,
*,
model_instance,
model_parameters: dict[str, Any],
mode: WorkflowGenerationMode,
instruction: str,
ideal_output: str,
tool_catalogue_text: str,
) -> PlannerResultDict:
user_prompt = PLANNER_USER_PROMPT.format(
mode=mode,
instruction=instruction.strip(),
ideal_output_section=format_ideal_output_section(ideal_output),
tool_catalogue_section=format_tool_catalogue_section(tool_catalogue_text),
)
messages = [
SystemPromptMessage(content=PLANNER_SYSTEM_PROMPT),
UserPromptMessage(content=user_prompt),
]
response: LLMResult = model_instance.invoke_llm(
prompt_messages=messages,
model_parameters=_clamp_for_planner(model_parameters),
stream=False,
)
text = response.message.get_text_content() or ""
parsed = json_repair.loads(text)
if not isinstance(parsed, dict):
raise ValueError(f"Planner returned non-object JSON: {type(parsed).__name__}")
nodes = parsed.get("nodes")
if not isinstance(nodes, list):
raise ValueError("Planner returned no 'nodes' array")
for node in nodes:
if not isinstance(node, dict) or "node_type" not in node:
raise ValueError(f"Planner node entry malformed: {node!r}")
return cast(PlannerResultDict, parsed)
# ------------------------------------------------------------------
# Builder
# ------------------------------------------------------------------
@classmethod
def _run_builder(
cls,
*,
model_instance,
model_parameters: dict[str, Any],
provider: str,
model_name: str,
model_mode: str,
mode: WorkflowGenerationMode,
instruction: str,
ideal_output: str,
plan_nodes: list[dict[str, Any]],
tool_catalogue_text: str,
start_inputs: list[dict[str, Any]] | None = None,
) -> GraphDict:
user_prompt = BUILDER_USER_PROMPT.format(
instruction=instruction.strip(),
ideal_output_section=format_ideal_output_section(ideal_output),
provider=provider,
name=model_name,
mode_label=model_mode,
plan_block=format_plan_block(plan_nodes),
tool_catalogue_section=format_builder_tool_catalogue_section(tool_catalogue_text),
start_inputs_section=format_start_inputs_section(start_inputs or []),
)
messages = [
SystemPromptMessage(content=get_builder_system_prompt(mode)),
UserPromptMessage(content=user_prompt),
]
response: LLMResult = model_instance.invoke_llm(
prompt_messages=messages,
model_parameters=model_parameters,
stream=False,
)
text = response.message.get_text_content() or ""
parsed = json_repair.loads(text)
if not isinstance(parsed, dict):
raise ValueError(f"Builder returned non-object JSON: {type(parsed).__name__}")
nodes = parsed.get("nodes")
edges = parsed.get("edges")
if not isinstance(nodes, list) or not isinstance(edges, list):
raise ValueError("Builder graph missing 'nodes' or 'edges' arrays")
viewport = parsed.get("viewport") or _DEFAULT_VIEWPORT
return cast(
GraphDict,
{
"nodes": nodes,
"edges": edges,
"viewport": viewport,
},
)
# ------------------------------------------------------------------
# Postprocessing
# ------------------------------------------------------------------
@classmethod
def _postprocess_graph(cls, *, graph: GraphDict, mode: WorkflowGenerationMode) -> GraphDict:
"""Fill safe defaults, normalise positions and dedupe edges."""
# Internally treat nodes/edges as untyped dicts — TypedDicts forbid the
# arbitrary-key setdefault writes we need here, but the caller only sees
# the final structurally-valid ``GraphDict`` shape.
nodes: list[dict[str, Any]] = list(cast(list[dict[str, Any]], graph.get("nodes", [])))
edges: list[dict[str, Any]] = list(cast(list[dict[str, Any]], graph.get("edges", [])))
# Defensive ID remap: Dify's run-time placeholder regex only accepts
# ``[a-zA-Z0-9_]`` in the node-id slot, so anything the LLM emits with
# hyphens (``node-1``, ``node-Kstart``, etc.) would break every
# placeholder pointing at it. Strip hyphens out of every id + every
# cross-reference (edges' ``source`` / ``target``, ``parentId``,
# ``start_node_id`` / ``iteration_id`` / ``loop_id`` on data, and the
# ``{{#…#}}`` and ``["node-id", "var"]`` references) BEFORE the rest
# of the postprocess pass touches them.
cls._strip_hyphens_from_node_ids(nodes=nodes, edges=edges)
# Container-child nodes carry their own relative positions inside the
# parent and have a special ``type`` (custom-iteration-start /
# custom-loop-start). We must not override their positions or wrapper
# ``type``; only top-level (parentId-less) nodes get the left-to-right
# auto layout.
top_level_index = 0
for node in nodes:
cls._fill_node_defaults(node)
if node.get("parentId"):
# Inner node — keep whatever the LLM emitted; only fill the
# absolutely-required defaults so the canvas can render it.
node.setdefault("position", {"x": 0.0, "y": 0.0})
node.setdefault("zIndex", 1002)
node.setdefault("extent", "parent")
else:
node["position"] = {
"x": float(_NODE_X_OFFSET + _NODE_X_STEP * top_level_index),
"y": float(_NODE_Y),
}
top_level_index += 1
node.setdefault("positionAbsolute", dict(node["position"]))
node.setdefault("width", _DEFAULT_NODE_WIDTH)
node.setdefault("height", _DEFAULT_NODE_HEIGHT)
node.setdefault("sourcePosition", "right")
node.setdefault("targetPosition", "left")
# ``parentId`` → set of inner-node ids, so edges between siblings can be
# marked ``isInIteration`` / ``isInLoop`` with the right container id.
inner_node_to_parent: dict[str, str] = {
n["id"]: n["parentId"] for n in nodes if n.get("parentId") and n.get("id")
}
# Map parent id → its container node-type so we can pick the right flag.
parent_type: dict[str, str] = {}
for n in nodes:
if n.get("id") in inner_node_to_parent.values():
parent_type[n["id"]] = n.get("data", {}).get("type", "")
# Dedupe edges (LLMs sometimes emit the same edge twice).
seen: set[tuple[str, str, str, str]] = set()
deduped_edges = []
for edge in edges:
cls._fill_edge_defaults(edge)
key = (
edge.get("source", ""),
edge.get("sourceHandle", "source"),
edge.get("target", ""),
edge.get("targetHandle", "target"),
)
if key in seen:
continue
seen.add(key)
edge["id"] = f"{key[0]}-{key[1]}-{key[2]}-{key[3]}"
deduped_edges.append(edge)
# Build source/target → node_type lookup so we can fill edge.data.{sourceType,targetType}
# which Dify's edge renderer needs.
type_by_id = {node.get("id", ""): node.get("data", {}).get("type", "") for node in nodes}
for edge in deduped_edges:
edge.setdefault("data", {})
edge["data"].setdefault("sourceType", type_by_id.get(edge.get("source", ""), ""))
edge["data"].setdefault("targetType", type_by_id.get(edge.get("target", ""), ""))
# An edge is "inside" a container iff both endpoints share the same
# parent. Set isInIteration / isInLoop + iteration_id / loop_id +
# zIndex so the canvas renders it inside the subgraph rather than
# at the top level. Edges connecting a container to the outside
# world keep the defaults (isInIteration=False, isInLoop=False).
src_parent = inner_node_to_parent.get(edge.get("source", ""))
tgt_parent = inner_node_to_parent.get(edge.get("target", ""))
in_iter = bool(src_parent and src_parent == tgt_parent and parent_type.get(src_parent) == "iteration")
in_loop = bool(src_parent and src_parent == tgt_parent and parent_type.get(src_parent) == "loop")
edge["data"].setdefault("isInIteration", in_iter)
edge["data"].setdefault("isInLoop", in_loop)
if in_iter:
edge["data"].setdefault("iteration_id", src_parent)
edge.setdefault("zIndex", 1002)
if in_loop:
edge["data"].setdefault("loop_id", src_parent)
edge.setdefault("zIndex", 1002)
viewport = graph.get("viewport") or _DEFAULT_VIEWPORT
# Coerce to floats in case the LLM emitted strings.
viewport = {
"x": float(viewport.get("x", 0.0)),
"y": float(viewport.get("y", 0.0)),
"zoom": float(viewport.get("zoom", 0.7)),
}
# Variable-reference walker: every ``{#node-id.var#}`` and every
# ``["node-id", "var"]`` selector must point at a variable the source
# node actually exposes — otherwise the workflow's variable resolver
# fails at run time with "variable not found". The dominant failure
# mode is a prompt that references ``{#start.url#}`` when the start
# node has ``variables: []``, so we auto-inject missing start-node
# variables before we surface them as errors.
cls._reconcile_variable_references(nodes=nodes, mode=mode)
return cast(GraphDict, {"nodes": nodes, "edges": deduped_edges, "viewport": viewport})
# ------------------------------------------------------------------
# Variable-reference reconciliation
# ------------------------------------------------------------------
# Detects ``{{#node_id.var#}}`` placeholders. We match the EXACT regex
# Dify's workflow runtime uses (see
# ``graphon.runtime.variable_pool.VARIABLE_PATTERN``):
#
# \{\{#([a-zA-Z0-9_]{1,50}(?:\.[a-zA-Z_][a-zA-Z0-9_]{0,29}){1,10})#\}\}
#
# Two consequences for the generator:
# 1. Node ids MUST be ``[a-zA-Z0-9_]`` — letters, digits, underscores.
# A hyphenated id like ``node-1`` does NOT match at run time, so the
# whole ``{{#node-1.var#}}`` survives into the LLM prompt literally
# and the LLM at run time echoes it back as the answer. The
# postprocess remap below defensively rewrites any hyphen the
# builder LLM still produces.
# 2. The walker must match the same regex so we don't auto-fix
# references the runtime would never resolve anyway.
_VAR_REF_RE: ClassVar = re.compile(
r"\{\{#([a-zA-Z0-9_]{1,50})\.([a-zA-Z_][a-zA-Z0-9_]{0,29}(?:\.[a-zA-Z_][a-zA-Z0-9_]{0,29}){0,9})#\}\}"
)
# Lenient sibling used only by the defensive hyphen-strip pass — it
# allows hyphens in the node-id slot so we can rewrite the LLM's
# ``{{#node-1.var#}}`` outputs BEFORE the strict walker sees them.
# Never use this for validation, only for rewriting.
_LENIENT_VAR_REF_RE: ClassVar = re.compile(r"\{\{#([A-Za-z0-9_-]+)\.([^#]+)#\}\}")
# Strings inside ``data`` that look like node-id slugs and need
# remapping when we defensively strip hyphens out of LLM-emitted ids.
_ID_FIELDS: ClassVar = frozenset({"start_node_id", "iteration_id", "loop_id", "parentId"})
@classmethod
def _reconcile_variable_references(cls, *, nodes: list[dict[str, Any]], mode: WorkflowGenerationMode) -> None:
"""
Walk every variable reference, ensure it resolves; auto-fix missing
start-node variables (the safe, dominant case) by adding a stub
``paragraph`` entry to ``start.data.variables``.
For Advanced-Chat mode, ``sys.query`` and ``sys.files`` are always
treated as resolved without any declaration. Tool nodes' parameter
references aren't validated here because we don't know each tool's
schema — the run time validates those.
"""
nodes_by_id: dict[str, dict[str, Any]] = {n.get("id", ""): n for n in nodes if n.get("id")}
start_node = next(
(n for n in nodes if n.get("data", {}).get("type") == BuiltinNodeTypes.START),
None,
)
# Collect every (node_id, var) reference the builder emitted.
refs: set[tuple[str, str]] = set()
for node in nodes:
cls._collect_refs_in_data(node.get("data") or {}, refs)
for node_id, var in refs:
# Advanced-Chat system variables are always resolved.
if mode == "advanced-chat" and node_id == "sys":
continue
target = nodes_by_id.get(node_id)
if target is None:
# An edge / data dangling reference — we can't fix it; the
# structural validator picks this up if it's a topology issue.
continue
if cls._declares_variable(target, var):
continue
# Missing variable. Auto-fix start-node references; let everything
# else fall through and surface in the result's ``error`` field
# via the post-postprocess validator below.
if start_node is not None and target is start_node:
cls._inject_start_variable(start_node, var)
logger.info("Workflow generator: auto-injected missing start variable %r", var)
@classmethod
def _collect_refs_in_data(cls, value: Any, out: set[tuple[str, str]]) -> None:
"""Recursively walk a node's ``data`` and harvest every reference."""
if isinstance(value, str):
for match in cls._VAR_REF_RE.finditer(value):
node_id, var = match.group(1).strip(), match.group(2).strip()
if node_id and var:
out.add((node_id, var))
return
if isinstance(value, dict):
# Known selector shapes: 2-element [node_id, var] lists.
for k, v in value.items():
# ``value_selector`` / ``query_variable_selector`` / etc.: a
# flat 2-element list of strings.
if (
isinstance(v, list)
and len(v) == 2
and all(isinstance(x, str) for x in v)
and k != "default" # default values for input variables are not selectors
):
node_id, var = v[0].strip(), v[1].strip()
if node_id and var:
out.add((node_id, var))
cls._collect_refs_in_data(v, out)
return
if isinstance(value, list):
for item in value:
cls._collect_refs_in_data(item, out)
@classmethod
def _declares_variable(cls, node: dict[str, Any], var: str) -> bool:
"""
Does ``node`` expose a variable named ``var``? Each node type
publishes outputs differently — start exposes ``data.variables``,
llm exposes ``text``, code exposes ``data.outputs`` keys, etc.
Tool parameters are validated at run time, not here.
"""
data = node.get("data") or {}
node_type = data.get("type")
if node_type == BuiltinNodeTypes.START:
return any(isinstance(v, dict) and v.get("variable") == var for v in (data.get("variables") or []))
if node_type == BuiltinNodeTypes.LLM:
# Default LLM output is ``text``. Structured-output keys land
# under ``structured_output.schema.properties`` when enabled.
if var == "text":
return True
schema = ((data.get("structured_output") or {}).get("schema") or {}).get("properties") or {}
return var in schema
if node_type == BuiltinNodeTypes.CODE:
return var in (data.get("outputs") or {})
if node_type == BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL:
return var == "result"
if node_type == BuiltinNodeTypes.PARAMETER_EXTRACTOR:
return any(isinstance(p, dict) and p.get("name") == var for p in (data.get("parameters") or []))
if node_type == BuiltinNodeTypes.HTTP_REQUEST:
return var in {"body", "status_code", "headers", "files"}
if node_type == BuiltinNodeTypes.TEMPLATE_TRANSFORM:
return var == "output"
if node_type == BuiltinNodeTypes.TOOL:
# Tool outputs are dynamic — validated at run time, not here.
return True
if node_type in (BuiltinNodeTypes.ITERATION, BuiltinNodeTypes.LOOP):
return var == "output"
if node_type == BuiltinNodeTypes.QUESTION_CLASSIFIER:
return var in {"class_id", "class_name"}
# Other node types (if-else, iteration-start, loop-start, ...) don't
# produce outputs of their own.
return False
@classmethod
def _strip_hyphens_from_node_ids(cls, *, nodes: list[dict[str, Any]], edges: list[dict[str, Any]]) -> None:
"""
Strip ``-`` out of every node id and rewrite every cross-reference.
Dify's run-time ``VARIABLE_PATTERN`` accepts only ``[a-zA-Z0-9_]`` in
the node-id slot of ``{{#…#}}`` placeholders. The builder LLM often
emits ``node-1`` style ids; left unfixed those make every placeholder
silently fail at run time, the literal ``{{#node-1.var#}}`` survives
into the prompt, and the LLM at run time echoes it back as the user's
output — the bug we are here to kill.
Approach: build a one-to-one ``old → new`` map by removing hyphens,
then rewrite (a) every node ``id``, (b) every edge ``source`` /
``target``, (c) every ``parentId`` / ``start_node_id`` /
``iteration_id`` / ``loop_id`` inside ``data``, (d) every
``{{#…#}}`` reference in any string, (e) every ``["node-id", "var"]``
value-selector list. We do NOT rename variable names — only ids.
"""
# Build id rewrite map. Collision-safe because we just strip a single
# character class — two different hyphenated ids ``node-1`` and
# ``node1`` would collide, but the builder LLM has been instructed
# to pick one style so in practice it's one or the other.
id_map: dict[str, str] = {}
for node in nodes:
old = node.get("id")
if not isinstance(old, str) or "-" not in old:
continue
new = old.replace("-", "")
id_map[old] = new
node["id"] = new
if not id_map:
return
# Rewrite edges' source / target.
for edge in edges:
for key in ("source", "target"):
v = edge.get(key)
if isinstance(v, str) and v in id_map:
edge[key] = id_map[v]
# Also rewrite the edge id if the builder emitted one referencing
# the old ids; the dedupe pass later recomputes it anyway, but
# rewriting here keeps logs sane.
eid = edge.get("id")
if isinstance(eid, str):
for old, new in id_map.items():
eid = eid.replace(old, new)
edge["id"] = eid
# Rewrite every reference inside any node's data (recursively).
for node in nodes:
data = node.get("data")
if isinstance(data, dict):
cls._rewrite_refs_in_data(data, id_map)
@classmethod
def _rewrite_refs_in_data(cls, value: Any, id_map: dict[str, str]) -> None:
"""Recursive sibling of ``_collect_refs_in_data`` that does rewrites."""
if isinstance(value, dict):
for k, v in list(value.items()):
if k in cls._ID_FIELDS and isinstance(v, str):
# Direct id field — apply the longest matching prefix
# (handles ``"nodeKstart"`` where ``nodeK`` is the
# container's old id).
for old, new in sorted(id_map.items(), key=lambda kv: -len(kv[0])):
if old in v:
value[k] = v.replace(old, new)
v = value[k]
if isinstance(v, str):
rewritten = cls._LENIENT_VAR_REF_RE.sub(lambda m: cls._rewrite_var_ref(m, id_map), v)
if rewritten != v:
value[k] = rewritten
elif isinstance(v, list) and len(v) == 2 and all(isinstance(x, str) for x in v) and v[0] in id_map:
# 2-element ``["node-id", "var"]`` selector list.
value[k] = [id_map[v[0]], v[1]]
else:
cls._rewrite_refs_in_data(v, id_map)
elif isinstance(value, list):
for item in value:
cls._rewrite_refs_in_data(item, id_map)
@classmethod
def _rewrite_var_ref(cls, m: "re.Match[str]", id_map: dict[str, str]) -> str:
node_id = m.group(1)
rest = m.group(2)
new_id = id_map.get(node_id, node_id)
return f"{{{{#{new_id}.{rest}#}}}}"
@classmethod
def _inject_start_variable(cls, start_node: dict[str, Any], var: str) -> None:
"""Add a default ``paragraph`` input so ``{{#start.<var>#}}`` resolves."""
data = start_node.setdefault("data", {})
existing = data.setdefault("variables", [])
if any(isinstance(v, dict) and v.get("variable") == var for v in existing):
return
existing.append(
{
"variable": var,
"label": _label_from_variable(var),
"type": "paragraph",
"required": True,
"max_length": 4096,
"options": [],
}
)
@classmethod
def _fill_node_defaults(cls, node: dict[str, Any]) -> None:
"""Ensure every node has the wrapper-level fields the Studio canvas needs."""
node.setdefault("type", "custom")
data = node.setdefault("data", {})
data.setdefault("title", node.get("id", "Node"))
data.setdefault("desc", "")
data.setdefault("selected", False)
# `data.type` is the actual node-type string — we never override it.
@classmethod
def _fill_edge_defaults(cls, edge: dict[str, Any]) -> None:
edge.setdefault("type", "custom")
edge.setdefault("sourceHandle", "source")
edge.setdefault("targetHandle", "target")
# ------------------------------------------------------------------
# Validation
# ------------------------------------------------------------------
@classmethod
def _validate_structure(cls, *, graph: GraphDict, mode: WorkflowGenerationMode) -> str:
"""
Return an error string if the graph violates the start/end-shape contract.
Only catches structural violations the user must know about. Per-node
config validation is deferred to ``WorkflowService.sync_draft_workflow``.
"""
nodes = graph.get("nodes", [])
if not nodes:
return "Generated graph has no nodes"
types = [node.get("data", {}).get("type", "") for node in nodes]
starts = [t for t in types if t == BuiltinNodeTypes.START]
if len(starts) != 1:
return f"Workflow must have exactly one 'start' node (found {len(starts)})"
if mode == "advanced-chat":
terminals = [t for t in types if t == BuiltinNodeTypes.ANSWER]
terminal_name = "answer"
else:
terminals = [t for t in types if t == BuiltinNodeTypes.END]
terminal_name = "end"
if len(terminals) < 1:
return f"Workflow must end with at least one '{terminal_name}' node"
# Edges must reference real node ids.
known_ids = {node.get("id", "") for node in nodes}
for edge in graph.get("edges", []):
if edge.get("source") not in known_ids:
return f"Edge references unknown source node: {edge.get('source')!r}"
if edge.get("target") not in known_ids:
return f"Edge references unknown target node: {edge.get('target')!r}"
return ""
def _clamp_for_planner(params: dict[str, Any]) -> dict[str, Any]:
"""
The planner needs only a tight, deterministic plan — clamp temperature
and max_tokens so we don't burn budget. Returns a copy.
"""
out = dict(params)
out.setdefault("temperature", 0.2)
if "temperature" in out and isinstance(out["temperature"], (int, float)) and out["temperature"] > 0.5:
out["temperature"] = 0.2
return out
def _label_from_variable(var: str) -> str:
"""Turn ``snake_case`` / ``camelCase`` into a Title-Cased UI label."""
if not var:
return ""
snake = re.sub(r"(?<!^)(?=[A-Z])", "_", var).lower()
return " ".join(part.capitalize() for part in snake.split("_") if part)
# Re-export json for callers / tests; keeps ruff happy when only the module is imported.
_ = json

View File

@ -0,0 +1,138 @@
"""
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 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,108 @@
"""
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 Literal, NotRequired, TypedDict
WorkflowGenerationMode = Literal["workflow", "advanced-chat"]
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.
"""
graph: GraphDict
message: str
app_name: str
icon: str
error: str

View File

@ -8271,6 +8271,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
@ -15986,6 +16007,21 @@ How a workflow node is bound to an Agent.
| ---- | ---- | ----------- | -------- |
| features | object | Workflow feature configuration | Yes |
#### WorkflowGeneratePayload
Payload for the cmd+k `/create` 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 |
| ---- | ---- | ----------- | -------- |
| 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,82 @@
"""
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
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 = "",
) -> WorkflowGenerateResultDict:
"""
Resolve a model instance for the tenant and run the generator.
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. A failure
# here (plugin daemon unreachable, etc.) must not block generation —
# log and fall back to the no-tool catalogue path.
try:
tool_catalogue_text = format_tool_catalogue(build_tool_catalogue(tenant_id))
except Exception:
logger.exception("Workflow generator: failed to build tool catalogue for tenant %s", tenant_id)
tool_catalogue_text = ""
return WorkflowGenerator.generate_workflow_graph(
model_instance=model_instance,
model_parameters=model_parameters,
provider=model_config.provider,
model_name=model_config.name,
model_mode=str(model_config.mode),
mode=mode,
instruction=instruction,
ideal_output=ideal_output,
tool_catalogue_text=tool_catalogue_text,
)

View File

@ -140,21 +140,14 @@ class WorkflowService:
)
return db.session.execute(stmt).scalar_one()
def get_draft_workflow(
self, app_model: App, workflow_id: str | None = None, session: Session | None = None
) -> Workflow | None:
def get_draft_workflow(self, app_model: App, workflow_id: str | None = None) -> Workflow | None:
"""
Get draft workflow
When ``session`` is provided, reuse it so callers that already hold a
Session avoid checking out an extra request-scoped ``db.session``
connection. Falls back to ``db.session`` for backward compatibility.
"""
if workflow_id:
return self.get_published_workflow_by_id(app_model, workflow_id, session=session)
return self.get_published_workflow_by_id(app_model, workflow_id)
# fetch draft workflow by app_model
bind = session if session is not None else db.session
workflow = bind.scalar(
workflow = db.session.scalar(
select(Workflow)
.where(
Workflow.tenant_id == app_model.tenant_id,

View File

@ -7,7 +7,6 @@ from importlib import util
from pathlib import Path
from types import ModuleType, SimpleNamespace
from typing import Any
from unittest.mock import MagicMock
import pytest
from flask.views import MethodView
@ -19,15 +18,6 @@ if not hasattr(builtins, "MethodView"):
builtins.MethodView = MethodView # type: ignore[attr-defined]
def _unwrap(func):
bound_self = getattr(func, "__self__", None)
while hasattr(func, "__wrapped__"):
func = func.__wrapped__
if bound_self is not None:
return func.__get__(bound_self, bound_self.__class__)
return func
@pytest.fixture(scope="module")
def app_module():
module_name = "controllers.console.app.app"
@ -405,46 +395,3 @@ def test_app_pagination_aliases_per_page_and_has_next(app_models):
assert len(serialized["data"]) == 2
assert serialized["data"][0]["icon_url"] == "signed:first-icon"
assert serialized["data"][1]["icon_url"] is None
def test_app_list_uses_injected_session_for_draft_workflows(app, app_module, monkeypatch):
api = app_module.AppListApi()
method = _unwrap(api.get)
current_user = SimpleNamespace(id="user-1")
app_item = SimpleNamespace(
id="app-1",
name="Workflow App",
desc_or_prompt="Summary",
mode="workflow",
mode_compatible_with_agent="workflow",
)
app_pagination = SimpleNamespace(page=1, per_page=20, total=1, has_next=False, items=[app_item])
workflow = SimpleNamespace(
id="workflow-1",
app_id="app-1",
walk_nodes=lambda: iter([("trigger-1", {"type": "trigger-webhook"})]),
)
session = MagicMock()
session.execute.return_value.scalars.return_value.all.return_value = [workflow]
scoped_session = SimpleNamespace(execute=MagicMock(side_effect=AssertionError("db.session should not be used")))
monkeypatch.setattr(app_module, "current_account_with_tenant", lambda: (current_user, "tenant-1"))
monkeypatch.setattr(
app_module,
"AppService",
lambda: SimpleNamespace(get_paginate_apps=lambda *_args, **_kwargs: app_pagination),
)
monkeypatch.setattr(
app_module,
"FeatureService",
SimpleNamespace(get_system_features=lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))),
)
monkeypatch.setattr(app_module, "db", SimpleNamespace(session=scoped_session))
with app.test_request_context("/console/api/apps?page=1&limit=20", method="GET"):
response, status = method(session)
assert status == 200
assert response["data"][0]["has_draft_trigger"] is True
session.execute.assert_called_once()
scoped_session.execute.assert_not_called()

View File

@ -1,7 +1,6 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
@ -25,17 +24,10 @@ def _model_config_payload():
def _install_workflow_service(monkeypatch: pytest.MonkeyPatch, workflow):
class _Service:
app_model = None
session = None
def get_draft_workflow(self, app_model, session=None):
self.app_model = app_model
self.session = session
def get_draft_workflow(self, app_model):
return workflow
service = _Service()
monkeypatch.setattr(generator_module, "WorkflowService", lambda: service)
return service
monkeypatch.setattr(generator_module, "WorkflowService", lambda: _Service())
def test_rule_generate_success(app, monkeypatch: pytest.MonkeyPatch) -> None:
@ -76,8 +68,7 @@ def test_instruction_generate_app_not_found(app, monkeypatch: pytest.MonkeyPatch
api = generator_module.InstructionGenerateApi()
method = _unwrap(api.post)
session = MagicMock()
session.get.return_value = None
monkeypatch.setattr(generator_module.db, "session", SimpleNamespace(get=lambda *_args, **_kwargs: None))
with app.test_request_context(
"/console/api/instruction-generate",
@ -89,11 +80,10 @@ def test_instruction_generate_app_not_found(app, monkeypatch: pytest.MonkeyPatch
"model_config": _model_config_payload(),
},
):
response, status = method(session, "t1")
response, status = method("t1")
assert status == 400
assert response["error"] == "app app-1 not found"
session.get.assert_called_once_with(generator_module.App, "app-1")
def test_instruction_generate_workflow_not_found(app, monkeypatch: pytest.MonkeyPatch) -> None:
@ -101,7 +91,7 @@ def test_instruction_generate_workflow_not_found(app, monkeypatch: pytest.Monkey
method = _unwrap(api.post)
app_model = SimpleNamespace(id="app-1")
session = SimpleNamespace(get=lambda *_args, **_kwargs: app_model)
monkeypatch.setattr(generator_module.db, "session", SimpleNamespace(get=lambda *_args, **_kwargs: app_model))
_install_workflow_service(monkeypatch, workflow=None)
with app.test_request_context(
@ -114,7 +104,7 @@ def test_instruction_generate_workflow_not_found(app, monkeypatch: pytest.Monkey
"model_config": _model_config_payload(),
},
):
response, status = method(session, "t1")
response, status = method("t1")
assert status == 400
assert response["error"] == "workflow app-1 not found"
@ -125,7 +115,7 @@ def test_instruction_generate_node_missing(app, monkeypatch: pytest.MonkeyPatch)
method = _unwrap(api.post)
app_model = SimpleNamespace(id="app-1")
session = SimpleNamespace(get=lambda *_args, **_kwargs: app_model)
monkeypatch.setattr(generator_module.db, "session", SimpleNamespace(get=lambda *_args, **_kwargs: app_model))
workflow = SimpleNamespace(graph_dict={"nodes": []})
_install_workflow_service(monkeypatch, workflow=workflow)
@ -140,7 +130,7 @@ def test_instruction_generate_node_missing(app, monkeypatch: pytest.MonkeyPatch)
"model_config": _model_config_payload(),
},
):
response, status = method(session, "t1")
response, status = method("t1")
assert status == 400
assert response["error"] == "node node-1 not found"
@ -151,7 +141,7 @@ def test_instruction_generate_code_node(app, monkeypatch: pytest.MonkeyPatch) ->
method = _unwrap(api.post)
app_model = SimpleNamespace(id="app-1")
session = SimpleNamespace(get=lambda *_args, **_kwargs: app_model)
monkeypatch.setattr(generator_module.db, "session", SimpleNamespace(get=lambda *_args, **_kwargs: app_model))
workflow = SimpleNamespace(
graph_dict={
@ -160,7 +150,7 @@ def test_instruction_generate_code_node(app, monkeypatch: pytest.MonkeyPatch) ->
]
}
)
workflow_service = _install_workflow_service(monkeypatch, workflow=workflow)
_install_workflow_service(monkeypatch, workflow=workflow)
monkeypatch.setattr(generator_module.LLMGenerator, "generate_code", lambda **_kwargs: {"code": "x"})
with app.test_request_context(
@ -173,17 +163,14 @@ def test_instruction_generate_code_node(app, monkeypatch: pytest.MonkeyPatch) ->
"model_config": _model_config_payload(),
},
):
response = method(session, "t1")
response = method("t1")
assert response == {"code": "x"}
assert workflow_service.app_model is app_model
assert workflow_service.session is session
def test_instruction_generate_legacy_modify(app, monkeypatch: pytest.MonkeyPatch) -> None:
api = generator_module.InstructionGenerateApi()
method = _unwrap(api.post)
session = SimpleNamespace()
monkeypatch.setattr(
generator_module.LLMGenerator,
@ -202,7 +189,7 @@ def test_instruction_generate_legacy_modify(app, monkeypatch: pytest.MonkeyPatch
"model_config": _model_config_payload(),
},
):
response = method(session, "t1")
response = method("t1")
assert response == {"instruction": "ok"}
@ -210,7 +197,6 @@ def test_instruction_generate_legacy_modify(app, monkeypatch: pytest.MonkeyPatch
def test_instruction_generate_incompatible_params(app, monkeypatch: pytest.MonkeyPatch) -> None:
api = generator_module.InstructionGenerateApi()
method = _unwrap(api.post)
session = SimpleNamespace()
with app.test_request_context(
"/console/api/instruction-generate",
@ -223,7 +209,7 @@ def test_instruction_generate_incompatible_params(app, monkeypatch: pytest.Monke
"model_config": _model_config_payload(),
},
):
response, status = method(session, "t1")
response, status = method("t1")
assert status == 400
assert response["error"] == "incompatible parameters"
@ -254,3 +240,151 @@ 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."

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,318 @@
"""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,
)
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 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,137 @@
"""
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"

View File

@ -346,19 +346,6 @@ class TestWorkflowService:
assert result == mock_workflow
def test_get_draft_workflow_uses_provided_session(self, workflow_service, mock_db_session):
"""Test get_draft_workflow can reuse an injected SQLAlchemy session."""
app = TestWorkflowAssociatedDataFactory.create_app_mock()
mock_workflow = TestWorkflowAssociatedDataFactory.create_workflow_mock()
session = MagicMock()
session.scalar.return_value = mock_workflow
result = workflow_service.get_draft_workflow(app, session=session)
assert result == mock_workflow
session.scalar.assert_called_once()
mock_db_session.session.scalar.assert_not_called()
def test_get_draft_workflow_returns_none(self, workflow_service, mock_db_session):
"""Test get_draft_workflow returns None when no draft exists."""
app = TestWorkflowAssociatedDataFactory.create_app_mock()
@ -383,21 +370,6 @@ class TestWorkflowService:
assert result == mock_workflow
def test_get_draft_workflow_with_workflow_id_reuses_provided_session(self, workflow_service):
"""Test get_draft_workflow passes an injected session to published workflow lookup."""
app = TestWorkflowAssociatedDataFactory.create_app_mock()
workflow_id = "workflow-123"
session = MagicMock()
mock_workflow = TestWorkflowAssociatedDataFactory.create_workflow_mock(version="v1")
with patch.object(
workflow_service, "get_published_workflow_by_id", return_value=mock_workflow
) as mock_get_published:
result = workflow_service.get_draft_workflow(app, workflow_id=workflow_id, session=session)
assert result == mock_workflow
mock_get_published.assert_called_once_with(app, workflow_id, session=session)
# ==================== Get Published Workflow Tests ====================
# These tests verify retrieval of published workflows (versioned snapshots)

View File

@ -44,6 +44,7 @@ import { tags } from './tags/orpc.gen'
import { test } from './test/orpc.gen'
import { trialApps } from './trial-apps/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'
@ -93,5 +94,6 @@ export const contract = {
trialApps,
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,50 @@
// This file is auto-generated by @hey-api/openapi-ts
export type ClientOptions = {
baseUrl: `${string}://${string}/console/api` | (string & {})
}
export type WorkflowGeneratePayload = {
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,43 @@
// 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` 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({
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,120 @@
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,
}))
// 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 }),
},
}))
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([])
})
})
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().
beforeEach(() => {
createCommand.register?.({} as never)
})
afterEach(() => {
createCommand.unregister?.()
})
// /create is scoped to new-app creation — it MUST always open the modal
// with just the requested mode, never with currentAppId. Refining the
// current Studio draft is handled by the Studio toolbar button instead.
it('should open the generator with only the requested mode (no current-app context)', async () => {
await executeCommand('create.open', { mode: 'workflow' })
expect(mockOpenGenerator).toHaveBeenCalledWith({ mode: 'workflow' })
})
// Critical guarantee: even when invoked from a workflow Studio URL, the
// handler must NOT sniff the URL to inject currentAppId — that branch
// produced a mode-mismatch dead-end when the user picked the "wrong"
// mode from the submenu while inside Studio.
it('should NOT capture currentAppId even when invoked from a Studio URL', async () => {
Object.defineProperty(window, 'location', {
writable: true,
value: { pathname: '/app/abc-123/workflow' },
})
await executeCommand('create.open', { mode: 'advanced-chat' })
expect(mockOpenGenerator).toHaveBeenCalledWith({ mode: 'advanced-chat' })
})
// 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

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

View File

@ -0,0 +1,83 @@
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 { useWorkflowGeneratorStore } from '@/app/components/workflow/workflow-generator/store'
import { registerCommands, unregisterCommands } from './command-bus'
type CreateOption = {
id: string
label: string
description: string
mode: WorkflowGeneratorMode
icon: React.ComponentType<{ className?: string }>
}
const OPTIONS: CreateOption[] = [
{
id: 'workflow',
label: 'Workflow',
description: 'AI-generated workflow app',
mode: 'workflow',
icon: RiNodeTree,
},
{
id: 'chatflow',
label: 'Chatflow',
description: 'AI-generated chatflow (advanced chat) app',
mode: 'advanced-chat',
icon: RiChat3Line,
},
]
/**
* `/create` command — generate a brand-new Workflow or Chatflow app from a
* natural-language description.
*
* This command is scoped to NEW-app creation only. Refining the current
* Studio draft is handled by the toolbar button in
* ``components/workflow-app/components/workflow-header/generate-trigger.tsx``,
* which opens the same modal with the app's real mode locked + currentAppId
* set. Keeping the two journeys separate avoids the mode-mismatch dead-end
* the URL-sniffing approach used to produce when /create was triggered from
* a Workflow Studio page with the "wrong" mode picked.
*/
export const createCommand: SlashCommandHandler = {
name: 'create',
aliases: ['new', 'generate'],
description: 'Create an AI-generated workflow',
mode: 'submenu',
async search(args: string) {
const query = args.trim().toLowerCase()
const filtered = OPTIONS.filter(
opt => !query || opt.id.includes(query) || opt.label.toLowerCase().includes(query),
)
return filtered.map(opt => ({
id: `create-${opt.id}`,
title: opt.label,
description: opt.description,
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
// No currentAppId / currentAppMode — /create is new-app only.
useWorkflowGeneratorStore.getState().openGenerator({ mode })
},
})
},
unregister() {
unregisterCommands(['create.open'])
},
}

View File

@ -7,6 +7,7 @@ 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'
@ -50,6 +51,7 @@ const registerSlashCommands = (deps: Record<string, any>) => {
slashCommandRegistry.register(accountCommand, {})
slashCommandRegistry.register(zenCommand, {})
slashCommandRegistry.register(goCommand, {})
slashCommandRegistry.register(createCommand, {})
}
const unregisterSlashCommands = () => {
@ -62,6 +64,7 @@ const unregisterSlashCommands = () => {
slashCommandRegistry.unregister('account')
slashCommandRegistry.unregister('zen')
slashCommandRegistry.unregister('go')
slashCommandRegistry.unregister('create')
}
export const SlashCommandProvider = () => {

View File

@ -0,0 +1,94 @@
import type { App } from '@/types/app'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useStore as useAppStore } from '@/app/components/app/store'
import { AppModeEnum } from '@/types/app'
import GenerateTrigger from '../generate-trigger'
const mockOpenGenerator = vi.fn()
const mockUseNodesReadOnly = vi.fn()
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesReadOnly: () => mockUseNodesReadOnly(),
}))
vi.mock('@/app/components/workflow/workflow-generator/store', () => ({
useWorkflowGeneratorStore: { getState: () => ({ openGenerator: mockOpenGenerator }) },
}))
const setAppDetail = (mode: AppModeEnum | undefined, id = 'app-1') => {
useAppStore.setState({
appDetail: mode === undefined
? undefined
: ({ id, mode, name: 'Test', icon: '🤖', icon_background: '#FFF' } as unknown as App),
})
}
describe('GenerateTrigger', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false })
setAppDetail(undefined)
})
describe('rendering', () => {
// The button is the AI shortcut for the two graph-based Studios. Anything
// else (Chat, Completion, Agent-Chat, no app loaded) MUST not surface it
// — those apps have no Workflow draft to overwrite.
it('should render for Workflow apps', () => {
setAppDetail(AppModeEnum.WORKFLOW)
render(<GenerateTrigger />)
expect(screen.getByRole('button', { name: /workflowGenerator\.studioButton/i })).toBeInTheDocument()
})
it('should render for Advanced-Chat (Chatflow) apps', () => {
setAppDetail(AppModeEnum.ADVANCED_CHAT)
render(<GenerateTrigger />)
expect(screen.getByRole('button', { name: /workflowGenerator\.studioButton/i })).toBeInTheDocument()
})
it('should render nothing while appDetail is loading', () => {
setAppDetail(undefined)
const { container } = render(<GenerateTrigger />)
expect(container.firstChild).toBeNull()
})
it.each([AppModeEnum.CHAT, AppModeEnum.COMPLETION, AppModeEnum.AGENT_CHAT])(
'should render nothing for non-graph app mode %s',
(mode) => {
setAppDetail(mode)
const { container } = render(<GenerateTrigger />)
expect(container.firstChild).toBeNull()
},
)
})
describe('disabled state', () => {
// Mirrors the Env / Global Var rule — never allow draft mutation while the
// canvas is in read-only mode (running / viewing a published version).
it('should be disabled when nodesReadOnly is true', () => {
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: true })
setAppDetail(AppModeEnum.WORKFLOW)
render(<GenerateTrigger />)
expect(screen.getByRole('button', { name: /workflowGenerator\.studioButton/i })).toBeDisabled()
})
})
describe('click', () => {
// Studio button MUST lock the requested mode to the app's actual mode and
// pass currentAppId so the modal renders the "Apply" (overwrite) flow.
it('should open the generator with the current app id + mode locked', async () => {
const user = userEvent.setup()
setAppDetail(AppModeEnum.ADVANCED_CHAT, 'cf-99')
render(<GenerateTrigger />)
await user.click(screen.getByRole('button', { name: /workflowGenerator\.studioButton/i }))
expect(mockOpenGenerator).toHaveBeenCalledWith({
mode: 'advanced-chat',
currentAppId: 'cf-99',
currentAppMode: 'advanced-chat',
})
})
})
})

View File

@ -0,0 +1,55 @@
import type { WorkflowGeneratorMode } from '@/app/components/workflow/workflow-generator/types'
import { Button } from '@langgenius/dify-ui/button'
import { RiSparkling2Line } from '@remixicon/react'
import { memo, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useNodesReadOnly } from '@/app/components/workflow/hooks'
import { useWorkflowGeneratorStore } from '@/app/components/workflow/workflow-generator/store'
import { AppModeEnum } from '@/types/app'
/**
* Studio toolbar button that opens the AI workflow generator with the app's
* mode locked to whatever the current app actually is. Only renders for
* Workflow / Advanced-Chat apps (the only modes that have a graph-based
* Studio), so we can never produce the mode-mismatch dead-end that the old
* cmd+k `/create` URL-sniffing path used to.
*
* The button is disabled whenever the canvas is in read-only mode (run in
* progress / published version being viewed), mirroring the disable rule the
* other Studio mutators (Env / Global Var) follow.
*/
const GenerateTrigger = () => {
const { t } = useTranslation('workflow')
const appDetail = useAppStore(s => s.appDetail)
const { nodesReadOnly } = useNodesReadOnly()
const mode: WorkflowGeneratorMode | null = useMemo(() => {
if (appDetail?.mode === AppModeEnum.WORKFLOW)
return 'workflow'
if (appDetail?.mode === AppModeEnum.ADVANCED_CHAT)
return 'advanced-chat'
return null
}, [appDetail?.mode])
if (!appDetail || !mode)
return null
return (
<Button
variant="secondary"
disabled={nodesReadOnly}
onClick={() =>
useWorkflowGeneratorStore.getState().openGenerator({
mode,
currentAppId: appDetail.id,
currentAppMode: mode,
})}
>
<RiSparkling2Line className="mr-1 size-4" />
{t('workflowGenerator.studioButton')}
</Button>
)
}
export default memo(GenerateTrigger)

View File

@ -11,6 +11,7 @@ import { useResetWorkflowVersionHistory } from '@/service/use-workflow'
import { useIsChatMode } from '../../hooks'
import ChatVariableTrigger from './chat-variable-trigger'
import FeaturesTrigger from './features-trigger'
import GenerateTrigger from './generate-trigger'
const WorkflowHeader = () => {
const { appDetail, setCurrentLogItem, setShowMessageLogModal } = useAppStore(useShallow(state => ({
@ -37,7 +38,12 @@ const WorkflowHeader = () => {
return {
normal: {
components: {
middle: <FeaturesTrigger />,
middle: (
<div className="flex items-center gap-2">
<GenerateTrigger />
<FeaturesTrigger />
</div>
),
chatVariableTrigger: <ChatVariableTrigger />,
},
runAndHistoryProps: {

View File

@ -0,0 +1,187 @@
import type { GeneratedGraph } from '../types'
import { AppModeEnum } from '@/types/app'
import { applyToCurrentApp, applyToNewApp } 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()
vi.mock('@/service/apps', () => ({
createApp: (params: unknown) => mockCreateApp(params),
}))
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: '🤖',
}))
})
})
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')
})
})

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,71 @@
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 />)
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 />)
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 />)
// 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 />)
// Sanity: pending timer should exist.
expect(vi.getTimerCount()).toBeGreaterThan(0)
unmount()
expect(vi.getTimerCount()).toBe(0)
})
})

View File

@ -0,0 +1,105 @@
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')
})
})
})

View File

@ -0,0 +1,120 @@
import type { GeneratedGraph, WorkflowGeneratorMode } from './types'
import { createApp } 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,
}
// 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),
})
await syncWorkflowDraft({
url: `apps/${app.id}/workflows/draft`,
params: {
graph,
features: {},
environment_variables: [],
conversation_variables: [],
},
})
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 are created with no
// draft and Studio lazy-creates one on the first save). fetchWorkflowDraft
// is silent — on a 404 it returns null/undefined, so we treat missing 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
}
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'],
})
}

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,52 @@
'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 planner→builder 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
const GenerationPhases = () => {
const { t } = useTranslation('workflow')
const [phaseIndex, setPhaseIndex] = useState(0)
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,375 @@
'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, 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 { Generator } from '@/app/components/base/icons/src/vender/other'
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 { useRouter } from '@/next/navigation'
import { generateWorkflow } from '@/service/debug'
import { getRedirectionPath } from '@/utils/app-redirection'
import { applyToCurrentApp, applyToNewApp } 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 renderPlaceholder = (label: string) => (
<div className="flex h-full w-0 grow flex-col items-center justify-center space-y-3 px-8">
<Generator className="size-8 text-text-quaternary" />
<div className="text-center text-[13px] leading-5 font-normal text-text-tertiary">
{label}
</div>
</div>
)
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 currentAppId = useWorkflowGeneratorStore(s => s.currentAppId)
const currentAppMode = useWorkflowGeneratorStore(s => s.currentAppMode)
const closeGenerator = useWorkflowGeneratorStore(s => s.closeGenerator)
const storedModel = (() => {
if (typeof window === 'undefined')
return null
try {
const raw = localStorage.getItem(STORAGE_MODEL_KEY)
return raw ? JSON.parse(raw) as Model : null
}
catch {
return null
}
})()
const [model, setModel] = useState<Model>(storedModel || {
name: '',
provider: '',
mode: 'chat' as unknown as ModelModeType.chat,
completion_params: {} as CompletionParams,
})
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) {
// eslint-disable-next-line react/set-state-in-effect
setModel(prev => ({
...prev,
name: defaultModel.model,
provider: defaultModel.provider.provider,
}))
}
}, [defaultModel, model.name])
const handleModelChange = useCallback((newValue: { modelId: string, provider: string, mode?: string, features?: string[] }) => {
const newModel: Model = {
...model,
provider: newValue.provider,
name: newValue.modelId,
mode: newValue.mode as ModelModeType,
}
setModel(newModel)
localStorage.setItem(STORAGE_MODEL_KEY, JSON.stringify(newModel))
}, [model])
const handleCompletionParamsChange = useCallback((newParams: FormValue) => {
const newModel: Model = {
...model,
completion_params: newParams as CompletionParams,
}
setModel(newModel)
localStorage.setItem(STORAGE_MODEL_KEY, JSON.stringify(newModel))
}, [model])
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)
// Confirmation dialog for "Apply to current draft"
const [isShowConfirmOverwrite, { setTrue: showConfirmOverwrite, setFalse: hideConfirmOverwrite }] = useBoolean(false)
// 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
}
return true
}
const onGenerate = async () => {
if (!isValid() || isLoading)
return
setLoadingTrue()
try {
const res = await generateWorkflow({
mode,
instruction,
ideal_output: ideaOutput,
model_config: model,
})
if (res.error) {
toast.error(res.error)
return
}
addVersion(res)
}
catch (e: unknown) {
const message = e instanceof Error ? e.message : ''
toast.error(message || t('workflowGenerator.generateFailed'))
}
finally {
setLoadingFalse()
}
}
const canApplyToCurrent = !!currentAppId && currentAppMode === mode
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) {
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) {
const message = e instanceof Error ? e.message : ''
toast.error(message || t('workflowGenerator.applyFailed'))
}
finally {
setApplyingFalse()
}
}, [current, currentAppId, hideConfirmOverwrite, closeGenerator, t, isApplying, setApplyingTrue, setApplyingFalse])
const modeLabel = mode === 'workflow' ? t('workflowGenerator.modes.workflow') : t('workflowGenerator.modes.chatflow')
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open)
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">
{t('workflowGenerator.title', { mode: modeLabel })}
</div>
<div className="mt-1 text-[13px] font-normal text-text-tertiary">
{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={t('workflowGenerator.instructionPlaceholder')}
value={instruction}
onValueChange={setInstruction}
/>
<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>
<Button
className="flex space-x-1"
variant="primary"
onClick={onGenerate}
disabled={isLoading}
>
<Generator className="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 />}
{!isLoading && !current?.graph?.nodes?.length && renderPlaceholder(t('workflowGenerator.placeholder'))}
</div>
<AlertDialog open={isShowConfirmOverwrite} onOpenChange={open => !open && hideConfirmOverwrite()}>
<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">
{t('workflowGenerator.overwriteTitle')}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('workflowGenerator.overwriteMessage')}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={handleApplyToCurrentConfirmed}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</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,26 @@
'use client'
import type { WorkflowGeneratorMode } from './types'
import { create } from 'zustand'
type WorkflowGeneratorStore = {
isOpen: boolean
mode: WorkflowGeneratorMode
currentAppId: string | null
currentAppMode: WorkflowGeneratorMode | null
openGenerator: (params: {
mode: WorkflowGeneratorMode
currentAppId?: string | null
currentAppMode?: WorkflowGeneratorMode | null
}) => void
closeGenerator: () => void
}
export const useWorkflowGeneratorStore = create<WorkflowGeneratorStore>(set => ({
isOpen: false,
mode: 'workflow',
currentAppId: null,
currentAppMode: null,
openGenerator: ({ mode, currentAppId = null, currentAppMode = null }) =>
set({ isOpen: true, mode, currentAppId, currentAppMode }),
closeGenerator: () => set({ isOpen: false }),
}))

View File

@ -0,0 +1,23 @@
import type { Viewport } from 'reactflow'
import type { Edge, Node } from '@/app/components/workflow/types'
export type WorkflowGeneratorMode = 'workflow' | 'advanced-chat'
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

@ -1221,5 +1221,36 @@
"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.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.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.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.studioApply": "Apply",
"workflowGenerator.studioButton": "Generate",
"workflowGenerator.title": "Generate {{mode}}"
}

View File

@ -1221,5 +1221,36 @@
"versionHistory.nameThisVersion": "命名",
"versionHistory.releaseNotesPlaceholder": "请描述变更",
"versionHistory.restorationTip": "版本回滚后,当前草稿将被覆盖。",
"versionHistory.title": "版本"
"versionHistory.title": "版本",
"workflowGenerator.applied": "已应用",
"workflowGenerator.applyFailed": "应用工作流失败",
"workflowGenerator.applyToCurrent": "应用到当前草稿",
"workflowGenerator.applyToNew": "创建新应用",
"workflowGenerator.description": "描述你希望工作流完成的任务。选择模型、撰写指令,预览生成的图后再应用到 Studio。",
"workflowGenerator.dismiss": "关闭",
"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.modes.chatflow": "Chatflow",
"workflowGenerator.modes.workflow": "Workflow",
"workflowGenerator.overwriteMessage": "应用此工作流将覆盖当前草稿,操作不可撤销。",
"workflowGenerator.overwriteTitle": "覆盖当前草稿?",
"workflowGenerator.phases.building": "正在构建节点……",
"workflowGenerator.phases.planning": "正在规划工作流……",
"workflowGenerator.phases.validating": "正在校验图……",
"workflowGenerator.placeholder": "在左侧填写指令,点击「生成」预览工作流图。",
"workflowGenerator.studioApply": "应用",
"workflowGenerator.studioButton": "生成",
"workflowGenerator.title": "生成 {{mode}}"
}

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

@ -0,0 +1,49 @@
// 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 })
})
})

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,39 @@ export const generateRule = (body: Record<string, any>) => {
})
}
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
error?: string
}
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> }
}
export const generateWorkflow = (body: GenerateWorkflowBody) => {
return post<GenerateWorkflowResponse>('/workflow-generate', {
body,
})
}
export const fetchPromptTemplate = ({
appMode,
mode,