mirror of
https://github.com/langgenius/dify.git
synced 2026-05-31 14:16:23 +08:00
Compare commits
29 Commits
codex/dify
...
feat/go-to
| Author | SHA1 | Date | |
|---|---|---|---|
| 5707bf994a | |||
| 2962b6fbff | |||
| 28d4bfa081 | |||
| 84dd31a1e3 | |||
| 8c837dbe56 | |||
| f3337fe088 | |||
| 0870b8e1d4 | |||
| 278197f94e | |||
| 77cb1ca430 | |||
| d921f134c6 | |||
| 4e1c129ceb | |||
| f53340ad17 | |||
| 63bdbdbd67 | |||
| 441ee884bb | |||
| f671a741da | |||
| d2a429acc1 | |||
| 9136f27c84 | |||
| 96164bef83 | |||
| 8688c0adee | |||
| 02bcec2dca | |||
| 5de8812f6f | |||
| 7457508197 | |||
| cad2af2de5 | |||
| 332bb27e51 | |||
| 976d1d900b | |||
| 1bd52368d9 | |||
| d6bec58bfa | |||
| 2a67c7e92d | |||
| a1cc7c555e |
@ -1,8 +0,0 @@
|
||||
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
|
||||
version = 1
|
||||
name = "dify"
|
||||
|
||||
[setup]
|
||||
script = '''
|
||||
pnpm install --frozen-lockfile --prefer-offline
|
||||
'''
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
|
||||
20
api/core/workflow/generator/__init__.py
Normal file
20
api/core/workflow/generator/__init__.py
Normal 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"]
|
||||
1
api/core/workflow/generator/prompts/__init__.py
Normal file
1
api/core/workflow/generator/prompts/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Prompt templates for the workflow generator (planner + builder)."""
|
||||
488
api/core/workflow/generator/prompts/builder_prompts.py
Normal file
488
api/core/workflow/generator/prompts/builder_prompts.py
Normal 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
|
||||
140
api/core/workflow/generator/prompts/planner_prompts.py
Normal file
140
api/core/workflow/generator/prompts/planner_prompts.py
Normal 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 3–6 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"
|
||||
)
|
||||
746
api/core/workflow/generator/runner.py
Normal file
746
api/core/workflow/generator/runner.py
Normal 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
|
||||
138
api/core/workflow/generator/tool_catalogue.py
Normal file
138
api/core/workflow/generator/tool_catalogue.py
Normal 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 ""
|
||||
108
api/core/workflow/generator/types.py
Normal file
108
api/core/workflow/generator/types.py
Normal 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
|
||||
@ -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 |
|
||||
|
||||
82
api/services/workflow_generator_service.py
Normal file
82
api/services/workflow_generator_service.py
Normal 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,
|
||||
)
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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."
|
||||
|
||||
129
api/tests/unit_tests/core/workflow/generator/test_prompts.py
Normal file
129
api/tests/unit_tests/core/workflow/generator/test_prompts.py
Normal 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
|
||||
1476
api/tests/unit_tests/core/workflow/generator/test_runner.py
Normal file
1476
api/tests/unit_tests/core/workflow/generator/test_runner.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -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"] == ""
|
||||
137
api/tests/unit_tests/services/test_workflow_generator_service.py
Normal file
137
api/tests/unit_tests/services/test_workflow_generator_service.py
Normal 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"
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
@ -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]
|
||||
@ -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())
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
83
web/app/components/goto-anything/actions/commands/create.tsx
Normal file
83
web/app/components/goto-anything/actions/commands/create.tsx
Normal 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'])
|
||||
},
|
||||
}
|
||||
@ -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 = () => {
|
||||
|
||||
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
@ -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: {
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
120
web/app/components/workflow/workflow-generator/apply.ts
Normal file
120
web/app/components/workflow/workflow-generator/apply.ts
Normal 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'],
|
||||
})
|
||||
}
|
||||
@ -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)
|
||||
@ -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
|
||||
* 15–18 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)
|
||||
375
web/app/components/workflow/workflow-generator/index.tsx
Normal file
375
web/app/components/workflow/workflow-generator/index.tsx
Normal 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)
|
||||
22
web/app/components/workflow/workflow-generator/mount.tsx
Normal file
22
web/app/components/workflow/workflow-generator/mount.tsx
Normal 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
|
||||
26
web/app/components/workflow/workflow-generator/store.ts
Normal file
26
web/app/components/workflow/workflow-generator/store.ts
Normal 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 }),
|
||||
}))
|
||||
23
web/app/components/workflow/workflow-generator/types.ts
Normal file
23
web/app/components/workflow/workflow-generator/types.ts
Normal 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
|
||||
}
|
||||
@ -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
|
||||
@ -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}}"
|
||||
}
|
||||
|
||||
@ -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
49
web/service/debug.spec.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user