mirror of
https://github.com/langgenius/dify.git
synced 2026-06-07 16:23:44 +08:00
feat: enhance go to anything (#32130)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
parent
c8abb11bf0
commit
0bfbd2061e
@ -1,4 +1,5 @@
|
||||
from collections.abc import Sequence
|
||||
from typing import Literal
|
||||
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
@ -25,6 +26,7 @@ from graphon.model_runtime.entities.llm_entities import LLMMode
|
||||
from graphon.model_runtime.errors.invoke import InvokeError
|
||||
from libs.login import login_required
|
||||
from models import App
|
||||
from services.workflow_generator_service import WorkflowGeneratorService
|
||||
from services.workflow_service import WorkflowService
|
||||
|
||||
|
||||
@ -42,6 +44,24 @@ class InstructionTemplatePayload(BaseModel):
|
||||
type: str = Field(..., description="Instruction template type")
|
||||
|
||||
|
||||
class WorkflowGeneratePayload(BaseModel):
|
||||
"""Payload for the cmd+k `/create` and `/refine` workflow generator endpoint.
|
||||
|
||||
See ``services/workflow_generator_service.py`` for behaviour. Errors are
|
||||
surfaced through the same envelope as ``/rule-generate`` so the frontend
|
||||
can reuse its existing handler.
|
||||
"""
|
||||
|
||||
mode: Literal["workflow", "advanced-chat"] = Field(..., description="Target app mode for the generated graph")
|
||||
instruction: str = Field(..., description="Natural-language workflow description")
|
||||
ideal_output: str = Field(default="", description="Optional sample output for grounding")
|
||||
model_config_data: ModelConfig = Field(..., alias="model_config", description="Model configuration")
|
||||
current_graph: dict | None = Field(
|
||||
default=None,
|
||||
description="Existing draft graph to refine (cmd+k `/refine`); omit for create-from-scratch",
|
||||
)
|
||||
|
||||
|
||||
register_enum_models(console_ns, LLMMode)
|
||||
register_schema_models(
|
||||
console_ns,
|
||||
@ -50,6 +70,7 @@ register_schema_models(
|
||||
RuleStructuredOutputPayload,
|
||||
InstructionGeneratePayload,
|
||||
InstructionTemplatePayload,
|
||||
WorkflowGeneratePayload,
|
||||
ModelConfig,
|
||||
)
|
||||
|
||||
@ -265,3 +286,56 @@ class InstructionGenerationTemplateApi(Resource):
|
||||
return {"data": INSTRUCTION_GENERATE_TEMPLATE_CODE}
|
||||
case _:
|
||||
raise ValueError(f"Invalid type: {args.type}")
|
||||
|
||||
|
||||
@console_ns.route("/workflow-generate")
|
||||
class WorkflowGenerateApi(Resource):
|
||||
"""Generate a Workflow / Chatflow draft graph from a natural-language description.
|
||||
|
||||
Triggered by the cmd+k `/create` slash command. Returns a graph payload
|
||||
shaped exactly like ``WorkflowService.sync_draft_workflow``'s input, so the
|
||||
frontend can hand it straight to ``/apps/{id}/workflows/draft``.
|
||||
"""
|
||||
|
||||
@console_ns.doc("generate_workflow_graph")
|
||||
@console_ns.doc(description="Generate a Dify workflow graph from natural language")
|
||||
@console_ns.expect(console_ns.models[WorkflowGeneratePayload.__name__])
|
||||
@console_ns.response(200, "Workflow graph generated successfully")
|
||||
@console_ns.response(400, "Invalid request parameters")
|
||||
@console_ns.response(402, "Provider quota exceeded")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str):
|
||||
args = WorkflowGeneratePayload.model_validate(console_ns.payload)
|
||||
|
||||
# Reject obviously-empty instructions at the boundary — Pydantic only
|
||||
# validates ``instruction`` is a str, but a whitespace-only string
|
||||
# would still hit the LLM and waste a planner+builder roundtrip on a
|
||||
# response that the postprocess validator would reject anyway.
|
||||
if not args.instruction.strip():
|
||||
return {
|
||||
"error": "Instruction is required",
|
||||
"errors": [{"code": "EMPTY_INSTRUCTION", "detail": "Instruction is required"}],
|
||||
}, 400
|
||||
|
||||
try:
|
||||
result = WorkflowGeneratorService.generate_workflow_graph(
|
||||
tenant_id=current_tenant_id,
|
||||
mode=args.mode,
|
||||
instruction=args.instruction,
|
||||
model_config=args.model_config_data,
|
||||
ideal_output=args.ideal_output,
|
||||
current_graph=args.current_graph,
|
||||
)
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ProviderNotInitializeError(ex.description)
|
||||
except QuotaExceededError:
|
||||
raise ProviderQuotaExceededError()
|
||||
except ModelCurrentlyNotSupportError:
|
||||
raise ProviderModelCurrentlyNotSupportError()
|
||||
except InvokeError as e:
|
||||
raise CompletionRequestError(e.description)
|
||||
|
||||
return result
|
||||
|
||||
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)."""
|
||||
553
api/core/workflow/generator/prompts/builder_prompts.py
Normal file
553
api/core/workflow/generator/prompts/builder_prompts.py
Normal file
@ -0,0 +1,553 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
# Per-node-type configuration cheatsheet.
|
||||
#
|
||||
# Each entry mirrors the production ``defaultValue`` from
|
||||
# ``web/app/components/workflow/nodes/<type>/default.ts`` so the generated
|
||||
# graph loads in Studio identically to a manually-created node and survives
|
||||
# both ``WorkflowService.sync_draft_workflow``'s structural checks and the
|
||||
# runtime entity validation each node performs when the workflow runs.
|
||||
#
|
||||
# The postprocessor in ``runner.py`` fills missing wrapper fields (``type``,
|
||||
# ``positionAbsolute``, ``width``, ``height``, ``sourcePosition`` /
|
||||
# ``targetPosition``, edge ``data.sourceType`` / ``data.targetType``), so the
|
||||
# LLM only needs to emit semantically meaningful fields.
|
||||
NODE_CONFIG_CHEATSHEET = """\
|
||||
## Node wrapper (every node, top-level)
|
||||
|
||||
{"id": "node1" (digits + letters only — see "Node IDs" below),
|
||||
"type": "custom", # ReactFlow renderer key. Iteration/loop
|
||||
# *start* children use special types
|
||||
# (see Containers below).
|
||||
"position": {"x": <number>, "y": <number>},
|
||||
"data": { ... per-type fields ... }}
|
||||
|
||||
Children of iteration / loop containers additionally need
|
||||
``parentId``, ``zIndex: 1002`` and ``extent: "parent"`` — see Containers.
|
||||
|
||||
## Shared "data" fields (every node)
|
||||
|
||||
{"type": "<node-type>", # e.g. "llm", "start", "if-else"
|
||||
"title": "<short label>",
|
||||
"desc": "<one-liner>",
|
||||
"selected": false}
|
||||
|
||||
## Per type — additional "data" fields
|
||||
|
||||
- start:
|
||||
{"variables": [
|
||||
{"variable": "url", "label": "URL", "type": "text-input",
|
||||
"required": true, "max_length": 256, "options": []},
|
||||
{"variable": "topic", "label": "Topic", "type": "paragraph",
|
||||
"required": false, "max_length": 4096, "options": []}
|
||||
]}
|
||||
EVERY user-supplied value referenced by a downstream node
|
||||
(``{{#node-id.var#}}`` in a prompt / answer / template, or
|
||||
``["node-id", "var"]`` in a value_selector / iterator_selector /
|
||||
tool_parameters) MUST be declared here as an entry of ``variables``.
|
||||
If the planner's ``start_inputs`` list is non-empty, use it verbatim
|
||||
(the user prompt section "Start inputs" surfaces it). Types:
|
||||
text-input | paragraph | select | number | file | file-list.
|
||||
In Advanced-Chat mode ``sys.query`` and ``sys.files`` are automatic
|
||||
system variables — downstream nodes may reference them; do NOT add
|
||||
them to ``variables``.
|
||||
|
||||
- end (Workflow mode only):
|
||||
{"outputs": [
|
||||
{"variable": "result", "value_selector": ["<src-node-id>", "<out-var>"]}
|
||||
]}
|
||||
|
||||
- answer (Advanced Chat mode only):
|
||||
{"variables": [],
|
||||
"answer": "<text with {{#<src>.<var>#}} placeholders>"}
|
||||
|
||||
- llm:
|
||||
{"model": {"provider": "<provider>", "name": "<model>", "mode": "chat",
|
||||
"completion_params": {"temperature": 0.7}},
|
||||
"prompt_template": [
|
||||
{"role": "system", "text": "<system prompt>"},
|
||||
{"role": "user", "text": "<user prompt with {{#<src>.<var>#}}>"}
|
||||
],
|
||||
"context": {"enabled": false, "variable_selector": []},
|
||||
"vision": {"enabled": false}}
|
||||
|
||||
Prompt-writing rules for the user-message text:
|
||||
* ``{{#node.var#}}`` placeholders are interpolated by Dify BEFORE the
|
||||
LLM sees them — at run time the model only sees the resolved value.
|
||||
So an instruction like "Translate this: {{#node1.text#}}" is read
|
||||
by the LLM as "Translate this: <the actual text>".
|
||||
* NEVER include placeholder syntax inside an "example output" block
|
||||
in your prompt — the LLM will treat the example as the literal
|
||||
answer template and echo placeholders back as output. Wrong:
|
||||
Output JSON: {"en": "{{#node1.text#}}", "es": "{{#node1.text#}}"}
|
||||
Right:
|
||||
Translate the input into English, Spanish, French, German.
|
||||
Output a JSON object with keys "en", "es", "fr", "de" whose
|
||||
values are the translations.
|
||||
Input: {{#node1.text#}}
|
||||
* Each placeholder only resolves the variable from its source node —
|
||||
it cannot be a Jinja template or call a function.
|
||||
|
||||
- knowledge-retrieval:
|
||||
{"query_variable_selector": ["<src>", "<var>"],
|
||||
"query_attachment_selector": [],
|
||||
"dataset_ids": [],
|
||||
"retrieval_mode": "multiple",
|
||||
"multiple_retrieval_config": {"top_k": 4, "score_threshold": null,
|
||||
"reranking_enable": false}}
|
||||
|
||||
- code (escape hatch — only if no installed tool fits):
|
||||
{"code_language": "python3",
|
||||
"code": "def main(arg1: str) -> dict:\\n return {'result': arg1}",
|
||||
"variables": [{"variable": "arg1", "value_selector": ["<src>", "<var>"]}],
|
||||
"outputs": {"result": {"type": "string", "children": null}}}
|
||||
|
||||
- template-transform:
|
||||
{"template": "Hello {{ name }}",
|
||||
"variables": [{"variable": "name", "value_selector": ["<src>", "<var>"]}]}
|
||||
|
||||
- http-request (escape hatch — only if no installed tool fits):
|
||||
{"variables": [], "method": "get", "url": "https://example.com",
|
||||
"authorization": {"type": "no-auth", "config": null},
|
||||
"headers": "", "params": "",
|
||||
"body": {"type": "none", "data": []},
|
||||
"ssl_verify": true,
|
||||
"timeout": {"max_connect_timeout": 0, "max_read_timeout": 0,
|
||||
"max_write_timeout": 0},
|
||||
"retry_config": {"retry_enabled": true, "max_retries": 3,
|
||||
"retry_interval": 100}}
|
||||
|
||||
- tool (PREFERRED for external actions when listed in Available tools):
|
||||
{"provider_id": "<provider>", # provider portion of provider/tool
|
||||
"provider_type": "builtin", # exact value from catalogue
|
||||
"provider_name": "<provider>", # usually same as provider_id
|
||||
"tool_name": "<tool>", # tool portion of provider/tool
|
||||
"tool_label": "<Tool>",
|
||||
"tool_node_version": "2",
|
||||
"tool_configurations": {},
|
||||
"tool_parameters": {"<param>": {"type": "mixed",
|
||||
"value": "{{#<src>.<var>#}}"}}}
|
||||
Parameter ``type`` is one of:
|
||||
"mixed" — string template referencing variables ({{#...#}})
|
||||
"variable" — direct reference, value is ["<src>", "<var>"]
|
||||
"constant" — literal value
|
||||
|
||||
- if-else:
|
||||
{"_targetBranches": [{"id": "true", "name": "IF"},
|
||||
{"id": "false", "name": "ELSE"}],
|
||||
"logical_operator": "and",
|
||||
"cases": [
|
||||
{"case_id": "true",
|
||||
"logical_operator": "and",
|
||||
"conditions": [{"id": "c1",
|
||||
"variable_selector": ["<src>", "<var>"],
|
||||
"comparison_operator": "is",
|
||||
"value": "<value>"}]}
|
||||
]}
|
||||
Source handle for downstream edges = the case_id ("true" / "false").
|
||||
|
||||
- question-classifier:
|
||||
{"query_variable_selector": ["<src>", "<var>"],
|
||||
"model": {"provider": "<p>", "name": "<m>", "mode": "chat",
|
||||
"completion_params": {"temperature": 0.7}},
|
||||
"classes": [{"id": "1", "name": "Topic A", "label": "CLASS 1"},
|
||||
{"id": "2", "name": "Topic B", "label": "CLASS 2"}],
|
||||
"_targetBranches": [{"id": "1", "name": ""}, {"id": "2", "name": ""}],
|
||||
"vision": {"enabled": false},
|
||||
"instruction": ""}
|
||||
Source handle for downstream edges = the class_id ("1" / "2" / ...).
|
||||
|
||||
- parameter-extractor:
|
||||
{"query": [["<src>", "<var>"]], # array of value_selector arrays
|
||||
"model": {"provider": "<p>", "name": "<m>", "mode": "chat",
|
||||
"completion_params": {"temperature": 0.7}},
|
||||
"parameters": [{"name": "topic", "type": "string",
|
||||
"description": "<purpose>", "required": true}],
|
||||
"reasoning_mode": "prompt",
|
||||
"vision": {"enabled": false},
|
||||
"instruction": ""}
|
||||
|
||||
- document-extractor:
|
||||
{"variable_selector": ["<src>", "<file-var>"], # a file / file-list input
|
||||
"is_array_file": false} # true when the input is a
|
||||
# file-list (array of files)
|
||||
Single output variable ``text``: a string when ``is_array_file`` is false,
|
||||
an array of strings (one per file) when it is true. ``variable_selector``
|
||||
MUST point at a ``start`` variable declared with type "file" / "file-list"
|
||||
(or ``sys.files`` in Advanced-Chat mode).
|
||||
|
||||
- variable-aggregator (merge mutually-exclusive branches into one output):
|
||||
{"output_type": "string", # VarType of the merged value — one of
|
||||
# string | number | object | array[string] |
|
||||
# array[number] | array[object] | file |
|
||||
# array[file] | any. Match the branch vars.
|
||||
"variables": [["<branchA-node>", "<var>"],
|
||||
["<branchB-node>", "<var>"]]}
|
||||
Output variable: ``output`` (the first branch that actually ran). Place it
|
||||
after an ``if-else`` / ``question-classifier`` to rejoin paths before the
|
||||
``end`` / ``answer`` node. Each entry of ``variables`` is a value_selector
|
||||
array, NOT a placeholder string.
|
||||
|
||||
- list-operator (filter / sort / slice an array variable):
|
||||
{"variable": ["<src>", "<array-var>"],
|
||||
"filter_by": {"enabled": false, "conditions": []},
|
||||
"extract_by": {"enabled": false, "serial": "1"},
|
||||
"order_by": {"enabled": false, "key": "", "value": "asc"},
|
||||
"limit": {"enabled": false, "size": 10}}
|
||||
Enable only the sub-features you need; ``conditions`` reuse the if-else
|
||||
condition shape (key / comparison_operator / value). Outputs: ``result``
|
||||
(the processed array), ``first_record``, ``last_record``.
|
||||
|
||||
## Containers — iteration / loop
|
||||
|
||||
These are SUBGRAPH nodes. To use one you MUST emit, in order:
|
||||
|
||||
1. The container node itself, e.g. for iteration:
|
||||
id: "nodeK"
|
||||
type: "custom"
|
||||
data: {"type": "iteration",
|
||||
"title": "<label>",
|
||||
"desc": "",
|
||||
"selected": false,
|
||||
"start_node_id": "nodeKstart",
|
||||
"iterator_selector": ["<src>", "<list-var>"],
|
||||
"output_selector": ["<inner-last-node>", "<out-var>"],
|
||||
"is_parallel": false,
|
||||
"parallel_nums": 10,
|
||||
"error_handle_mode": "terminated",
|
||||
"flatten_output": true}
|
||||
width: 808
|
||||
height: 204
|
||||
zIndex: 1
|
||||
|
||||
For loop, swap "iteration" → "loop" and use:
|
||||
data: {"type": "loop", "title": "...", "desc": "",
|
||||
"selected": false, "start_node_id": "nodeKstart",
|
||||
"break_conditions": [], "loop_count": 10,
|
||||
"logical_operator": "and"}
|
||||
|
||||
2. The auto-start child (one per container):
|
||||
id: "nodeKstart"
|
||||
type: "custom-iteration-start" # loop → "custom-loop-start"
|
||||
parentId: "nodeK"
|
||||
extent: "parent"
|
||||
draggable: false
|
||||
selectable: false
|
||||
zIndex: 1002
|
||||
position: {"x": 60, "y": 78} # relative to parent
|
||||
data: {"type": "iteration-start", # loop → "loop-start"
|
||||
"title": "", "desc": "",
|
||||
"isInIteration": true, # loop → "isInLoop": true
|
||||
"selected": false}
|
||||
|
||||
3. Each inner-pipeline node (any node type, follows normal data rules) MUST add:
|
||||
parentId: "nodeK"
|
||||
extent: "parent"
|
||||
zIndex: 1002
|
||||
position: {x, y} # relative to parent
|
||||
data: {..., "isInIteration": true, # loop → "isInLoop": true
|
||||
"iteration_id": "nodeK"} # loop → "loop_id"
|
||||
|
||||
4. Edges INSIDE a container must add to ``data``:
|
||||
"isInIteration": true # loop → "isInLoop": true
|
||||
"iteration_id": "nodeK" # loop → "loop_id"
|
||||
and use ``zIndex: 1002``. Edges OUTSIDE containers use the default
|
||||
``isInIteration: false`` / ``isInLoop: false``.
|
||||
|
||||
5. The container's incoming/outgoing edges connect to the container's id
|
||||
(``nodeK``), NOT to inner nodes. The first inner edge connects from
|
||||
``nodeKstart``.
|
||||
|
||||
## Edge handles
|
||||
|
||||
- Most nodes: sourceHandle "source", targetHandle "target".
|
||||
- if-else cases: sourceHandle is the case_id ("true" / "false" / ...).
|
||||
- question-classifier: sourceHandle is the class_id ("1" / "2" / ...).
|
||||
- iteration-start / sourceHandle "source"; the edge from the *start node
|
||||
loop-start: is what kicks off the first inner step.
|
||||
"""
|
||||
|
||||
|
||||
_BASE_SYSTEM_PROMPT_HEAD = """You are a Dify workflow builder.
|
||||
|
||||
You are given:
|
||||
1. A user instruction (what the workflow should do).
|
||||
2. A node plan from the planner (which nodes to use, in execution order).
|
||||
|
||||
Your job: emit a complete Dify workflow graph as JSON. The graph will be written
|
||||
directly into a Studio draft, so it must be syntactically valid and structurally
|
||||
correct.
|
||||
|
||||
# Hard rules
|
||||
|
||||
1. The output is a single JSON object — no prose, no Markdown, no code fences.
|
||||
2. NODE IDs MUST USE ONLY ALPHANUMERICS + UNDERSCORES — never hyphens.
|
||||
Dify's run-time placeholder regex (see ``variable_pool.VARIABLE_PATTERN``)
|
||||
is ``\\{\\{#([a-zA-Z0-9_]{1,50}(?:\\.[a-zA-Z_][a-zA-Z0-9_]{0,29}){1,10})#\\}\\}``,
|
||||
so any placeholder pointing at a hyphenated id (e.g. ``{{#node-1.text#}}``)
|
||||
silently fails to match at run time and the literal string survives into
|
||||
the prompt — the user then sees ``{{#node-1.text#}}`` in their output.
|
||||
Use the EXACT ids from the plan, formatted as ``node1``, ``node2``, ... in
|
||||
plan order. Edge ``source`` / ``target`` must reference these ids.
|
||||
3. Every node has top-level fields: id, type, position, data.
|
||||
- "type" is always "custom" (ReactFlow node renderer).
|
||||
- "data.type" is the actual node type ("llm", "start", etc.).
|
||||
4. Every edge has top-level fields: id, source, target, type, sourceHandle, targetHandle.
|
||||
- "type" is always "custom".
|
||||
- "sourceHandle"/"targetHandle" follow the cheatsheet (default: "source"/"target").
|
||||
- Edge id format: "<source>-<sourceHandle>-<target>-<targetHandle>".
|
||||
5. Use the model from the planner context for ALL "llm" / "question-classifier" /
|
||||
"parameter-extractor" nodes (provider, name, mode, completion_params).
|
||||
6. Reference upstream outputs with the literal placeholder syntax
|
||||
``{{#<node-id>.<output-var>#}}`` — that's DOUBLE curly braces with ``#``
|
||||
markers inside (matching Dify's runtime placeholder regex
|
||||
``\\{\\{#[^#]+#\\}\\}``). NEVER emit single-brace ``{#…#}`` — Dify will
|
||||
not interpolate it, so the LLM at run time would see the literal
|
||||
placeholder string in its prompt and echo it back as output. Use
|
||||
``["<node-id>", "<output-var>"]`` for ``value_selector`` /
|
||||
``query_variable_selector`` / etc.
|
||||
7. The "start" node owns input variables; downstream nodes reference them as
|
||||
``["<start-node-id>", "<var-name>"]`` for selectors or
|
||||
``{{#<start-node-id>.<var-name>#}}`` inside prompt strings.
|
||||
8. NEVER emit "code" or "http-request" nodes if a tool from the "Available tools"
|
||||
section below covers the same task — replace them with a "tool" node referencing
|
||||
the exact provider/tool identifier from the catalogue. "code" / "http-request"
|
||||
are last-resort escape hatches for arbitrary transformations and APIs that no
|
||||
installed tool can express.
|
||||
9. EVERY variable reference MUST resolve to a real, declared variable on the
|
||||
source node — never invent a variable name. Specifically:
|
||||
- ``{{#<node-id>.<var>#}}`` inside a prompt / ``answer`` / ``template-transform``
|
||||
template (DOUBLE braces — single ``{#…#}`` is NOT a Dify placeholder
|
||||
and will NOT be substituted), AND ``["<node-id>", "<var>"]`` inside a
|
||||
``value_selector`` /
|
||||
``query_variable_selector`` / ``iterator_selector`` / ``output_selector`` /
|
||||
``tool_parameters[*].value`` (when ``type: "variable"``), MUST point at a
|
||||
value that the source node actually exposes:
|
||||
* ``start`` → one of the ``data.variables[*].variable`` entries you
|
||||
declared on the start node. Add an entry if you need a new input.
|
||||
* ``llm`` → ``text`` (the default LLM output) or, when structured
|
||||
output is enabled, a key from its schema.
|
||||
* ``code`` → a key in ``data.outputs``.
|
||||
* ``knowledge-retrieval`` → ``result`` (the standard array output).
|
||||
* ``parameter-extractor`` → one of the ``data.parameters[*].name``.
|
||||
* ``document-extractor`` → ``text`` (extracted file text; an array of
|
||||
strings when ``is_array_file`` is true).
|
||||
* ``variable-aggregator`` → ``output``.
|
||||
* ``list-operator`` → ``result`` (array), ``first_record``,
|
||||
``last_record``.
|
||||
* ``tool`` → any parameter declared by the tool — the run time
|
||||
validates these, so you can name them freely, but pick from the
|
||||
documented provider/tool.
|
||||
If the planner's "Start inputs" list (see user prompt) is non-empty,
|
||||
copy each entry verbatim into ``start.data.variables`` so the
|
||||
downstream references resolve.
|
||||
- In Advanced-Chat mode you may also reference ``sys.query`` and
|
||||
``sys.files`` without declaring them.
|
||||
|
||||
"""
|
||||
|
||||
|
||||
_BASE_SYSTEM_PROMPT_TAIL = """\
|
||||
|
||||
# Layout
|
||||
|
||||
- Place nodes left-to-right with x=80 + 320 * index, y=280.
|
||||
- Viewport: {"x": 0, "y": 0, "zoom": 0.7}.
|
||||
|
||||
"""
|
||||
|
||||
|
||||
_BASE_SYSTEM_PROMPT_FOOTER = """
|
||||
|
||||
# Output schema
|
||||
|
||||
{
|
||||
"nodes": [...],
|
||||
"edges": [...],
|
||||
"viewport": {"x": 0, "y": 0, "zoom": 0.7}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
_WORKFLOW_MODE_RULES = """# Mode-specific rules — Workflow
|
||||
|
||||
- The graph MUST start with exactly one "start" node and end with exactly one "end" node.
|
||||
- Do NOT use "answer" nodes (those are for Advanced Chat only).
|
||||
- The "end" node's outputs[].value_selector must point at a real upstream output.
|
||||
"""
|
||||
|
||||
|
||||
_ADVANCED_CHAT_MODE_RULES = """# Mode-specific rules — Advanced Chat (Chatflow)
|
||||
|
||||
- The graph MUST start with exactly one "start" node and end with exactly one "answer" node.
|
||||
- Do NOT use "end" nodes (those are for plain Workflow apps).
|
||||
- The "start" node should expose "sys.query" / "sys.files" automatically; user-defined
|
||||
variables go in start.data.variables.
|
||||
- The "answer" node's "answer" field references upstream outputs as
|
||||
{{#<node-id>.<var>#}} and is what the user sees in chat.
|
||||
"""
|
||||
|
||||
|
||||
BUILDER_SYSTEM_PROMPT_WORKFLOW = (
|
||||
_BASE_SYSTEM_PROMPT_HEAD
|
||||
+ _WORKFLOW_MODE_RULES
|
||||
+ _BASE_SYSTEM_PROMPT_TAIL
|
||||
+ NODE_CONFIG_CHEATSHEET
|
||||
+ _BASE_SYSTEM_PROMPT_FOOTER
|
||||
)
|
||||
|
||||
BUILDER_SYSTEM_PROMPT_ADVANCED_CHAT = (
|
||||
_BASE_SYSTEM_PROMPT_HEAD
|
||||
+ _ADVANCED_CHAT_MODE_RULES
|
||||
+ _BASE_SYSTEM_PROMPT_TAIL
|
||||
+ NODE_CONFIG_CHEATSHEET
|
||||
+ _BASE_SYSTEM_PROMPT_FOOTER
|
||||
)
|
||||
|
||||
|
||||
BUILDER_USER_PROMPT = """# User instruction
|
||||
|
||||
{instruction}
|
||||
|
||||
{ideal_output_section}\
|
||||
{existing_graph_section}\
|
||||
# Selected model (use for all LLM-based nodes)
|
||||
|
||||
provider={provider}, name={name}, mode={mode_label}
|
||||
|
||||
{tool_catalogue_section}\
|
||||
{start_inputs_section}\
|
||||
# Node plan (from planner — use these labels and node_types in this order)
|
||||
|
||||
{plan_block}
|
||||
|
||||
Now emit the complete workflow graph JSON.
|
||||
"""
|
||||
|
||||
|
||||
def format_builder_existing_graph_section(current_graph: dict | None) -> str:
|
||||
"""
|
||||
Refine mode: give the builder the FULL existing graph JSON so it can keep
|
||||
every node and edge the user's change does not touch byte-for-byte — same
|
||||
ids, same config, same prompt templates. Without the full config the
|
||||
builder would regenerate untouched nodes from scratch and silently drop
|
||||
the user's hand-tuned settings.
|
||||
|
||||
Returns an empty string in create mode (no ``current_graph``); the builder
|
||||
then behaves exactly as before, constructing the graph purely from the
|
||||
planner's node plan.
|
||||
"""
|
||||
if not current_graph:
|
||||
return ""
|
||||
graph_json = json.dumps(current_graph, ensure_ascii=False, separators=(",", ":"))
|
||||
return (
|
||||
"# Existing graph to refine (JSON)\n\n"
|
||||
"You are REFINING this existing graph, NOT building from scratch. Apply "
|
||||
"ONLY the change the user instruction describes. Every node and edge the "
|
||||
"change does not affect MUST be preserved verbatim — keep the same node "
|
||||
"ids, the same `data` config, and the same prompt templates. The node "
|
||||
"plan below is the target node set after your change; use the existing "
|
||||
"graph as the source of truth for the config of nodes that carry over.\n\n"
|
||||
f"```json\n{graph_json}\n```\n\n"
|
||||
)
|
||||
|
||||
|
||||
def format_start_inputs_section(start_inputs: list[dict[str, Any]]) -> str:
|
||||
"""
|
||||
Surface the planner's ``start_inputs`` list to the builder so it can
|
||||
populate ``start.data.variables`` with the exact set of inputs every
|
||||
downstream variable reference will need. Empty list → empty section,
|
||||
because the builder may then declare no input variables (e.g. an
|
||||
Advanced-Chat workflow that only consumes ``sys.query``).
|
||||
"""
|
||||
if not start_inputs:
|
||||
return ""
|
||||
lines = ["# Start inputs (copy each entry verbatim into start.data.variables)"]
|
||||
lines.append("")
|
||||
for inp in start_inputs:
|
||||
variable = str(inp.get("variable") or "").strip()
|
||||
label = str(inp.get("label") or "").strip()
|
||||
type_ = str(inp.get("type") or "paragraph").strip()
|
||||
if not variable:
|
||||
continue
|
||||
lines.append(f"- variable={variable!r} label={label!r} type={type_!r}")
|
||||
lines.append("")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def format_builder_tool_catalogue_section(catalogue_text: str) -> str:
|
||||
"""
|
||||
Builder-facing catalogue block. The builder needs the same identifiers
|
||||
the planner saw, plus a stern reminder that ``tool`` nodes MUST set
|
||||
``provider_id`` / ``provider_name`` / ``tool_name`` to entries that
|
||||
actually exist in this list — hallucinated tools fail at draft sync.
|
||||
"""
|
||||
if not catalogue_text.strip():
|
||||
return ""
|
||||
return (
|
||||
"# Available tools (use these exact provider/tool identifiers — "
|
||||
"for each 'tool' node, set provider_id and provider_name to the "
|
||||
"provider portion and tool_name to the tool portion)\n\n"
|
||||
f"{catalogue_text}\n\n"
|
||||
)
|
||||
|
||||
|
||||
def format_plan_block(plan_nodes: list[dict[str, Any]]) -> str:
|
||||
"""
|
||||
Render the planner output as a numbered list the builder can quote.
|
||||
|
||||
Node IDs use no separator (``node1``, ``node2``, ...) because Dify's
|
||||
run-time placeholder regex requires ``[a-zA-Z0-9_]`` in the node-id
|
||||
slot — a hyphenated id like ``node-1`` would silently fail to match
|
||||
at run time and the literal ``{{#node-1.var#}}`` survives into the
|
||||
LLM prompt.
|
||||
|
||||
For container children (planner emitted a ``"parent": "<label>"`` key),
|
||||
we resolve the parent label to its ``nodeN`` id and surface it on the
|
||||
same line so the builder knows to set ``parentId`` and the
|
||||
``isInIteration`` / ``isInLoop`` markers on inner nodes.
|
||||
"""
|
||||
# First pass: label → node-id so we can resolve "parent" hints.
|
||||
label_to_id: dict[str, str] = {}
|
||||
for idx, node in enumerate(plan_nodes, start=1):
|
||||
label = str(node.get("label") or "")
|
||||
if label and label not in label_to_id:
|
||||
label_to_id[label] = f"node{idx}"
|
||||
|
||||
lines = []
|
||||
for idx, node in enumerate(plan_nodes, start=1):
|
||||
node_id = f"node{idx}"
|
||||
label = node.get("label", "")
|
||||
node_type = node.get("node_type", "")
|
||||
purpose = node.get("purpose", "")
|
||||
parent_label = str(node.get("parent") or "")
|
||||
parent_clause = ""
|
||||
if parent_label:
|
||||
parent_id = label_to_id.get(parent_label, "")
|
||||
if parent_id:
|
||||
parent_clause = f" parent={parent_id}"
|
||||
else:
|
||||
parent_clause = f" parent={parent_label!r}"
|
||||
lines.append(f"{idx}. id={node_id} type={node_type} label={label!r}{parent_clause}\n purpose: {purpose}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def get_builder_system_prompt(mode: str) -> str:
|
||||
"""Pick the system prompt branch for Workflow vs Advanced Chat."""
|
||||
if mode == "advanced-chat":
|
||||
return BUILDER_SYSTEM_PROMPT_ADVANCED_CHAT
|
||||
return BUILDER_SYSTEM_PROMPT_WORKFLOW
|
||||
189
api/core/workflow/generator/prompts/planner_prompts.py
Normal file
189
api/core/workflow/generator/prompts/planner_prompts.py
Normal file
@ -0,0 +1,189 @@
|
||||
"""
|
||||
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.
|
||||
- "document-extractor" — extract plain text from uploaded files (PDF, Word, PPT,
|
||||
Markdown, etc.). Feed its "text" output into an "llm" /
|
||||
"code" node. Requires a "file" or "file-list" input.
|
||||
- "variable-aggregator" — merge several branch outputs into one "output" variable;
|
||||
use after "if-else" / "question-classifier" to rejoin
|
||||
mutually-exclusive paths before "end" / "answer".
|
||||
- "list-operator" — filter / sort / slice an array variable (e.g. the items
|
||||
fed into or produced by an "iteration").
|
||||
|
||||
# Rules
|
||||
|
||||
1. Always start with exactly one "start" node.
|
||||
2. End with exactly one "end" (Workflow mode) or "answer" (Advanced Chat mode).
|
||||
3. Keep it minimal — prefer 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}
|
||||
|
||||
{existing_graph_section}{ideal_output_section}{tool_catalogue_section}\
|
||||
Return the JSON plan now.
|
||||
"""
|
||||
|
||||
|
||||
def format_existing_graph_section(current_graph: dict | None) -> str:
|
||||
"""
|
||||
Refine mode: surface a compact summary of the graph the user is editing so
|
||||
the planner amends the existing node set rather than inventing one from
|
||||
scratch. Returns an empty string in create mode (no ``current_graph``), in
|
||||
which case the planner behaves exactly as before.
|
||||
|
||||
We pass only ids / node-types / titles + edge endpoints here — the planner
|
||||
decides *which nodes* exist, so it needs the shape, not the per-node config.
|
||||
The builder gets the full graph JSON to preserve untouched node config.
|
||||
"""
|
||||
if not current_graph:
|
||||
return ""
|
||||
nodes = current_graph.get("nodes") or []
|
||||
edges = current_graph.get("edges") or []
|
||||
node_lines = []
|
||||
for node in nodes:
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
data = node.get("data") or {}
|
||||
node_lines.append(f"- id={node.get('id', '')!r} type={data.get('type', '')!r} title={data.get('title', '')!r}")
|
||||
edge_lines = []
|
||||
for edge in edges:
|
||||
if not isinstance(edge, dict):
|
||||
continue
|
||||
edge_lines.append(f"- {edge.get('source', '')} -> {edge.get('target', '')}")
|
||||
nodes_block = "\n".join(node_lines) or "(none)"
|
||||
edges_block = "\n".join(edge_lines) or "(none)"
|
||||
return (
|
||||
"# Existing graph to refine\n\n"
|
||||
"You are REFINING an existing workflow, NOT building one from scratch. "
|
||||
"The user instruction above describes the change they want. Re-plan the "
|
||||
"node list to reflect that change while keeping everything the "
|
||||
"instruction does not mention — preserve existing nodes, their order, "
|
||||
"and their labels wherever the change leaves them untouched. Only add, "
|
||||
"remove, or rename nodes the requested change actually requires.\n\n"
|
||||
f"Current nodes:\n{nodes_block}\n\n"
|
||||
f"Current edges:\n{edges_block}\n\n"
|
||||
)
|
||||
|
||||
|
||||
def format_ideal_output_section(ideal_output: str) -> str:
|
||||
"""Return an empty string when the user did not provide ideal output."""
|
||||
if not ideal_output.strip():
|
||||
return ""
|
||||
return f"# Ideal output\n\n{ideal_output}\n\n"
|
||||
|
||||
|
||||
def format_tool_catalogue_section(catalogue_text: str) -> str:
|
||||
"""
|
||||
Embed the installed-tool catalogue so the planner can pick concrete
|
||||
``tool`` nodes by exact ``provider/tool`` identifier instead of inventing
|
||||
names. Returns an empty string when no tools are installed.
|
||||
"""
|
||||
if not catalogue_text.strip():
|
||||
return ""
|
||||
return (
|
||||
"# Available tools (planner: when picking 'tool' nodes, choose "
|
||||
"from this list and reference them by exact provider/tool name)\n\n"
|
||||
f"{catalogue_text}\n\n"
|
||||
)
|
||||
1253
api/core/workflow/generator/runner.py
Normal file
1253
api/core/workflow/generator/runner.py
Normal file
File diff suppressed because it is too large
Load Diff
153
api/core/workflow/generator/tool_catalogue.py
Normal file
153
api/core/workflow/generator/tool_catalogue.py
Normal file
@ -0,0 +1,153 @@
|
||||
"""
|
||||
Tool catalogue for the workflow generator.
|
||||
|
||||
Returns a compact, LLM-readable inventory of the tools currently installed for
|
||||
a tenant (both hardcoded built-in providers and plugin providers). The planner
|
||||
uses this to recommend ``tool`` nodes by exact ``provider/tool`` identifier;
|
||||
the builder consumes the same list so it can emit a syntactically correct
|
||||
``tool`` node ``data`` block (provider_id, provider_type, tool_name,
|
||||
tool_label).
|
||||
|
||||
Format: one tool per line, ``- <provider>/<tool> — <one-line description>``.
|
||||
|
||||
The list is intentionally capped — if a tenant has hundreds of plugin tools,
|
||||
sending the full catalogue blows past LLM context windows. We sort by
|
||||
provider name and truncate to ``_MAX_TOOLS`` lines so the prompt stays
|
||||
bounded. Tools beyond the cap are dropped silently; if quality suffers, the
|
||||
fix is a planner-time relevance filter, not a bigger dump.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from operator import itemgetter
|
||||
from typing import TypedDict
|
||||
|
||||
from core.tools.builtin_tool.provider import BuiltinToolProviderController
|
||||
from core.tools.plugin_tool.provider import PluginToolProviderController
|
||||
from core.tools.tool_manager import ToolManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_MAX_TOOLS = 80
|
||||
|
||||
|
||||
class ToolCatalogueEntry(TypedDict):
|
||||
provider_name: str
|
||||
provider_type: str # "builtin" | "plugin" — what the workflow tool node uses
|
||||
plugin_id: str # empty string for hardcoded built-ins
|
||||
tool_name: str
|
||||
tool_label: str
|
||||
description: str # one-line LLM-friendly description
|
||||
|
||||
|
||||
def build_tool_catalogue(tenant_id: str) -> list[ToolCatalogueEntry]:
|
||||
"""
|
||||
Enumerate installed tools for the given tenant.
|
||||
|
||||
Failures inside a single provider (mis-declared tool, plugin runtime
|
||||
error) are logged and skipped — one bad provider must not break the
|
||||
whole generator. Returns at most ``_MAX_TOOLS`` entries.
|
||||
"""
|
||||
entries: list[ToolCatalogueEntry] = []
|
||||
|
||||
for provider in ToolManager.list_builtin_providers(tenant_id):
|
||||
provider_name = provider.entity.identity.name
|
||||
plugin_id = ""
|
||||
# Hardcoded built-ins return "builtin"; plugin providers return "plugin".
|
||||
# Use the provider's own declared value so the catalogue matches what
|
||||
# ``tool`` workflow nodes need in their ``data.provider_type`` field.
|
||||
provider_type = provider.provider_type.value
|
||||
if isinstance(provider, PluginToolProviderController):
|
||||
plugin_id = provider.plugin_id or ""
|
||||
elif not isinstance(provider, BuiltinToolProviderController):
|
||||
# Unknown provider class — skip rather than guess.
|
||||
continue
|
||||
|
||||
try:
|
||||
tools = list(provider.get_tools())
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Workflow generator: failed to list tools for provider %s",
|
||||
provider_name,
|
||||
)
|
||||
continue
|
||||
|
||||
for tool in tools:
|
||||
try:
|
||||
tool_name = tool.entity.identity.name
|
||||
tool_label = _i18n_text(tool.entity.identity.label)
|
||||
description = _tool_description(tool.entity.description)
|
||||
entries.append(
|
||||
ToolCatalogueEntry(
|
||||
provider_name=provider_name,
|
||||
provider_type=provider_type,
|
||||
plugin_id=plugin_id,
|
||||
tool_name=tool_name,
|
||||
tool_label=tool_label,
|
||||
description=description,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Workflow generator: failed to describe tool %s in provider %s",
|
||||
getattr(getattr(tool, "entity", None), "identity", None),
|
||||
provider_name,
|
||||
)
|
||||
continue
|
||||
|
||||
entries.sort(key=itemgetter("provider_name", "tool_name"))
|
||||
return entries[:_MAX_TOOLS]
|
||||
|
||||
|
||||
def installed_tool_keys(entries: list[ToolCatalogueEntry]) -> set[tuple[str, str]]:
|
||||
"""
|
||||
Return the set of ``(provider_name, tool_name)`` pairs available for the
|
||||
tenant. The validator in ``runner.py`` consults this set so a planner /
|
||||
builder that hallucinates a tool name fails loudly at generation time
|
||||
instead of producing a runtime-broken graph.
|
||||
|
||||
The set is keyed on ``provider_name`` (not ``provider_id``) because the
|
||||
builder prompt is instructed to put the provider's catalogue name into
|
||||
BOTH ``data.provider_id`` and ``data.provider_name`` on tool nodes —
|
||||
they are the same value for both built-in and plugin providers.
|
||||
"""
|
||||
return {(e["provider_name"], e["tool_name"]) for e in entries}
|
||||
|
||||
|
||||
def format_tool_catalogue(entries: list[ToolCatalogueEntry]) -> str:
|
||||
"""
|
||||
Render the catalogue as a compact multi-line block for prompt injection.
|
||||
Returns an empty string when no tools are installed — callers should skip
|
||||
the section entirely in that case.
|
||||
"""
|
||||
if not entries:
|
||||
return ""
|
||||
lines = []
|
||||
for e in entries:
|
||||
desc = e["description"].replace("\n", " ").strip()
|
||||
if len(desc) > 120:
|
||||
desc = desc[:117] + "..."
|
||||
line = f"- {e['provider_name']}/{e['tool_name']}"
|
||||
if e["tool_label"] and e["tool_label"] != e["tool_name"]:
|
||||
line += f" ({e['tool_label']})"
|
||||
if desc:
|
||||
line += f" — {desc}"
|
||||
lines.append(line)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _i18n_text(label) -> str:
|
||||
"""Pull the English label out of an I18nObject (falls back to .name)."""
|
||||
if label is None:
|
||||
return ""
|
||||
en = getattr(label, "en_US", None)
|
||||
if en:
|
||||
return en
|
||||
return getattr(label, "zh_Hans", "") or ""
|
||||
|
||||
|
||||
def _tool_description(description) -> str:
|
||||
"""Pull the LLM-facing description (``.llm``) from a ToolDescription."""
|
||||
if description is None:
|
||||
return ""
|
||||
return getattr(description, "llm", "") or ""
|
||||
147
api/core/workflow/generator/types.py
Normal file
147
api/core/workflow/generator/types.py
Normal file
@ -0,0 +1,147 @@
|
||||
"""
|
||||
Typed payloads for workflow generation.
|
||||
|
||||
These TypedDicts describe the shape that the planner and builder LLM calls are
|
||||
required to return after ``json_repair`` parsing. They mirror the runtime
|
||||
``graph`` shape consumed by ``WorkflowService.sync_draft_workflow`` so the output
|
||||
can be written straight into a draft workflow without further translation.
|
||||
"""
|
||||
|
||||
from typing import Final, Literal, NotRequired, TypedDict
|
||||
|
||||
WorkflowGenerationMode = Literal["workflow", "advanced-chat"]
|
||||
|
||||
|
||||
# Machine-readable error codes returned in ``WorkflowGenerateResultDict.errors``.
|
||||
# Frontend maps these to localised copy via ``workflow.generator.errors.<code>``
|
||||
# i18n keys, so any change here MUST be mirrored in the FE i18n map.
|
||||
class WorkflowGenerateErrorCode:
|
||||
INVALID_JSON: Final = "INVALID_JSON"
|
||||
INVALID_SCHEMA: Final = "INVALID_SCHEMA"
|
||||
EMPTY_INSTRUCTION: Final = "EMPTY_INSTRUCTION"
|
||||
EMPTY_PLAN: Final = "EMPTY_PLAN"
|
||||
UNKNOWN_NODE_REFERENCE: Final = "UNKNOWN_NODE_REFERENCE"
|
||||
INVALID_CONTAINER: Final = "INVALID_CONTAINER"
|
||||
UNRESOLVED_REFERENCE: Final = "UNRESOLVED_REFERENCE"
|
||||
UNKNOWN_TOOL: Final = "UNKNOWN_TOOL"
|
||||
MISSING_TERMINAL: Final = "MISSING_TERMINAL"
|
||||
MISSING_START: Final = "MISSING_START"
|
||||
DANGLING_EDGE: Final = "DANGLING_EDGE"
|
||||
MODEL_ERROR: Final = "MODEL_ERROR"
|
||||
|
||||
|
||||
class WorkflowGenerateErrorDict(TypedDict):
|
||||
"""One structured error from the workflow generator.
|
||||
|
||||
``code`` is the stable machine-readable identifier listed in
|
||||
``WorkflowGenerateErrorCode``. ``detail`` is the raw human-readable
|
||||
diagnostic (English). ``node_id`` is set when the error is tied to a
|
||||
specific node so the frontend can highlight it on the preview canvas.
|
||||
"""
|
||||
|
||||
code: str
|
||||
detail: str
|
||||
node_id: NotRequired[str]
|
||||
|
||||
|
||||
class PlannerNodeDict(TypedDict):
|
||||
"""One node from the planner's high-level plan."""
|
||||
|
||||
label: str
|
||||
node_type: str
|
||||
purpose: str
|
||||
|
||||
|
||||
class PlannerStartInputDict(TypedDict):
|
||||
"""One user-supplied input the start node will declare.
|
||||
|
||||
The planner emits this list so the builder can populate
|
||||
``start.data.variables`` and downstream ``{#start.<var>#}`` references
|
||||
resolve at run time. Optional — older prompts may omit it; the runner's
|
||||
postprocess walker still auto-fixes missing references.
|
||||
"""
|
||||
|
||||
variable: str
|
||||
label: str
|
||||
type: str # "text-input" | "paragraph" | "number" | "select" | "file" | "file-list"
|
||||
|
||||
|
||||
class PlannerResultDict(TypedDict):
|
||||
"""Top-level planner response."""
|
||||
|
||||
title: str
|
||||
description: str
|
||||
app_name: NotRequired[str]
|
||||
icon: NotRequired[str]
|
||||
start_inputs: NotRequired[list[PlannerStartInputDict]]
|
||||
nodes: list[PlannerNodeDict]
|
||||
|
||||
|
||||
class GraphNodePositionDict(TypedDict):
|
||||
x: float
|
||||
y: float
|
||||
|
||||
|
||||
class GraphNodeDict(TypedDict):
|
||||
"""A workflow graph node as serialised in the draft graph JSON."""
|
||||
|
||||
id: str
|
||||
type: str # ReactFlow custom-node key, e.g. "custom"
|
||||
position: GraphNodePositionDict
|
||||
data: dict
|
||||
width: NotRequired[int]
|
||||
height: NotRequired[int]
|
||||
positionAbsolute: NotRequired[GraphNodePositionDict]
|
||||
sourcePosition: NotRequired[str]
|
||||
targetPosition: NotRequired[str]
|
||||
selected: NotRequired[bool]
|
||||
dragging: NotRequired[bool]
|
||||
|
||||
|
||||
class GraphEdgeDict(TypedDict):
|
||||
"""A workflow graph edge as serialised in the draft graph JSON."""
|
||||
|
||||
id: str
|
||||
source: str
|
||||
target: str
|
||||
type: str # always "custom" for Dify's custom-edge renderer
|
||||
sourceHandle: NotRequired[str]
|
||||
targetHandle: NotRequired[str]
|
||||
data: NotRequired[dict]
|
||||
|
||||
|
||||
class GraphViewportDict(TypedDict):
|
||||
x: float
|
||||
y: float
|
||||
zoom: float
|
||||
|
||||
|
||||
class GraphDict(TypedDict):
|
||||
"""Full graph payload — matches ``WorkflowService.sync_draft_workflow``."""
|
||||
|
||||
nodes: list[GraphNodeDict]
|
||||
edges: list[GraphEdgeDict]
|
||||
viewport: GraphViewportDict
|
||||
|
||||
|
||||
class WorkflowGenerateResultDict(TypedDict):
|
||||
"""What the runner returns. ``error`` is "" on success.
|
||||
|
||||
``app_name`` and ``icon`` are populated from the planner output when the
|
||||
LLM emits them (newer prompts) and default to empty strings when it
|
||||
doesn't. The frontend's ``applyToNewApp`` consumes them with its own
|
||||
fallback so old prompts and missing fields stay safe.
|
||||
|
||||
``errors`` is the structured-error sibling of ``error``. ``error`` is a
|
||||
human-readable concatenation kept for backwards compat with the original
|
||||
envelope; ``errors`` carries the machine-readable codes so the frontend
|
||||
can localise the message and tie failures to specific nodes. On success
|
||||
both ``error == ""`` and ``errors == []``.
|
||||
"""
|
||||
|
||||
graph: GraphDict
|
||||
message: str
|
||||
app_name: str
|
||||
icon: str
|
||||
error: str
|
||||
errors: list[WorkflowGenerateErrorDict]
|
||||
@ -8493,6 +8493,27 @@ Get website crawl status
|
||||
| 400 | Invalid provider |
|
||||
| 404 | Crawl job not found |
|
||||
|
||||
### /workflow-generate
|
||||
|
||||
#### POST
|
||||
##### Description
|
||||
|
||||
Generate a Dify workflow graph from natural language
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| payload | body | | Yes | [WorkflowGeneratePayload](#workflowgeneratepayload) |
|
||||
|
||||
##### Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 200 | Workflow graph generated successfully |
|
||||
| 400 | Invalid request parameters |
|
||||
| 402 | Provider quota exceeded |
|
||||
|
||||
### /workflow/{workflow_run_id}/events
|
||||
|
||||
#### GET
|
||||
@ -16658,6 +16679,22 @@ How a workflow node is bound to an Agent.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| features | object | Workflow feature configuration | Yes |
|
||||
|
||||
#### WorkflowGeneratePayload
|
||||
|
||||
Payload for the cmd+k `/create` and `/refine` workflow generator endpoint.
|
||||
|
||||
See ``services/workflow_generator_service.py`` for behaviour. Errors are
|
||||
surfaced through the same envelope as ``/rule-generate`` so the frontend
|
||||
can reuse its existing handler.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| current_graph | object | Existing draft graph to refine (cmd+k `/refine`); omit for create-from-scratch | No |
|
||||
| ideal_output | string | Optional sample output for grounding | No |
|
||||
| instruction | string | Natural-language workflow description | Yes |
|
||||
| mode | string | Target app mode for the generated graph<br>*Enum:* `"advanced-chat"`, `"workflow"` | Yes |
|
||||
| model_config | [ModelConfig](#modelconfig) | Model configuration | Yes |
|
||||
|
||||
#### WorkflowListQuery
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
|
||||
96
api/services/workflow_generator_service.py
Normal file
96
api/services/workflow_generator_service.py
Normal file
@ -0,0 +1,96 @@
|
||||
"""
|
||||
Workflow generator service.
|
||||
|
||||
Thin facade over ``core.workflow.generator.WorkflowGenerator`` that owns the
|
||||
model-manager / model-instance plumbing. Controllers call this; the pure
|
||||
domain class never touches the model registry directly.
|
||||
|
||||
Pattern mirrors ``LLMGenerator.generate_rule_config`` — see
|
||||
``core/llm_generator/llm_generator.py`` — but lives in ``services/`` because
|
||||
the generator output is consumed at the application layer (sync_draft_workflow,
|
||||
createApp) rather than from inside another workflow.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from core.app.app_config.entities import ModelConfig
|
||||
from core.model_manager import ModelManager
|
||||
from core.workflow.generator import WorkflowGenerator
|
||||
from core.workflow.generator.tool_catalogue import build_tool_catalogue, format_tool_catalogue, installed_tool_keys
|
||||
from core.workflow.generator.types import WorkflowGenerateResultDict, WorkflowGenerationMode
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WorkflowGeneratorService:
|
||||
"""
|
||||
Coordinates model resolution with the workflow generator domain logic.
|
||||
|
||||
Single public method (``generate_workflow_graph``) keeps the surface area
|
||||
minimal — the cmd+k `/create` flow is the only caller today.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def generate_workflow_graph(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
mode: WorkflowGenerationMode,
|
||||
instruction: str,
|
||||
model_config: ModelConfig,
|
||||
ideal_output: str = "",
|
||||
current_graph: dict[str, Any] | None = None,
|
||||
) -> WorkflowGenerateResultDict:
|
||||
"""
|
||||
Resolve a model instance for the tenant and run the generator.
|
||||
|
||||
``current_graph`` is the existing draft graph for the cmd+k `/refine`
|
||||
flow — when present the generator refines it instead of creating a new
|
||||
graph from scratch. ``None`` is the `/create` path.
|
||||
|
||||
Errors from the LLM call (auth, quota, invoke) propagate so the
|
||||
controller can map them to existing HTTP error envelopes (same
|
||||
envelope as ``/rule-generate``).
|
||||
"""
|
||||
model_manager = ModelManager.for_tenant(tenant_id=tenant_id)
|
||||
model_instance = model_manager.get_model_instance(
|
||||
tenant_id=tenant_id,
|
||||
model_type=ModelType.LLM,
|
||||
provider=model_config.provider,
|
||||
model=model_config.name,
|
||||
)
|
||||
|
||||
model_parameters: dict[str, Any] = dict(model_config.completion_params or {})
|
||||
|
||||
# Build the installed-tool catalogue for this tenant so the planner/
|
||||
# builder can pick concrete tools instead of inventing names, AND so
|
||||
# the runner's validator can reject hallucinated tool names BEFORE
|
||||
# the user clicks Apply. A failure here (plugin daemon unreachable,
|
||||
# etc.) must not block generation — log and fall back to the no-tool
|
||||
# path, which also disables tool validation in the runner (None
|
||||
# sentinel rather than empty set, so we don't reject every tool
|
||||
# node just because we couldn't enumerate the catalogue).
|
||||
tool_catalogue_text = ""
|
||||
installed_tools: set[tuple[str, str]] | None = None
|
||||
try:
|
||||
entries = build_tool_catalogue(tenant_id)
|
||||
tool_catalogue_text = format_tool_catalogue(entries)
|
||||
installed_tools = installed_tool_keys(entries)
|
||||
except Exception:
|
||||
logger.exception("Workflow generator: failed to build tool catalogue for tenant %s", tenant_id)
|
||||
|
||||
return WorkflowGenerator.generate_workflow_graph(
|
||||
model_instance=model_instance,
|
||||
model_parameters=model_parameters,
|
||||
provider=model_config.provider,
|
||||
model_name=model_config.name,
|
||||
model_mode=model_config.mode.value,
|
||||
mode=mode,
|
||||
instruction=instruction,
|
||||
ideal_output=ideal_output,
|
||||
tool_catalogue_text=tool_catalogue_text,
|
||||
installed_tools=installed_tools,
|
||||
current_graph=current_graph,
|
||||
)
|
||||
@ -254,3 +254,208 @@ def test_instruction_template_invalid_type(app) -> None:
|
||||
):
|
||||
with pytest.raises(ValueError):
|
||||
method()
|
||||
|
||||
|
||||
# ─ /workflow-generate ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _workflow_generate_payload() -> dict:
|
||||
return {
|
||||
"mode": "workflow",
|
||||
"instruction": "Summarize a URL",
|
||||
"ideal_output": "A 3-sentence summary.",
|
||||
"model_config": _model_config_payload(),
|
||||
}
|
||||
|
||||
|
||||
def _stub_workflow_service(monkeypatch: pytest.MonkeyPatch, returns=None, raises: Exception | None = None):
|
||||
def _call(**_kwargs):
|
||||
if raises is not None:
|
||||
raise raises
|
||||
return returns or {
|
||||
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
|
||||
"message": "",
|
||||
"error": "",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(generator_module.WorkflowGeneratorService, "generate_workflow_graph", _call)
|
||||
|
||||
|
||||
def test_workflow_generate_returns_service_result(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
api = generator_module.WorkflowGenerateApi()
|
||||
method = _unwrap(api.post)
|
||||
|
||||
expected = {
|
||||
"graph": {"nodes": [{"id": "node-1"}], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
|
||||
"message": "Summarize",
|
||||
"error": "",
|
||||
}
|
||||
_stub_workflow_service(monkeypatch, returns=expected)
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/workflow-generate",
|
||||
method="POST",
|
||||
json=_workflow_generate_payload(),
|
||||
):
|
||||
response = method("t1")
|
||||
|
||||
assert response == expected
|
||||
|
||||
|
||||
def test_workflow_generate_maps_provider_token_error(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""ProviderTokenNotInitError → ProviderNotInitializeError so the frontend
|
||||
can render the same "provider missing" UX as /rule-generate."""
|
||||
api = generator_module.WorkflowGenerateApi()
|
||||
method = _unwrap(api.post)
|
||||
|
||||
_stub_workflow_service(monkeypatch, raises=ProviderTokenNotInitError("missing token"))
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/workflow-generate",
|
||||
method="POST",
|
||||
json=_workflow_generate_payload(),
|
||||
):
|
||||
with pytest.raises(ProviderNotInitializeError):
|
||||
method("t1")
|
||||
|
||||
|
||||
def test_workflow_generate_maps_quota_error(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from controllers.console.app.error import ProviderQuotaExceededError
|
||||
from core.errors.error import QuotaExceededError
|
||||
|
||||
api = generator_module.WorkflowGenerateApi()
|
||||
method = _unwrap(api.post)
|
||||
|
||||
_stub_workflow_service(monkeypatch, raises=QuotaExceededError())
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/workflow-generate",
|
||||
method="POST",
|
||||
json=_workflow_generate_payload(),
|
||||
):
|
||||
with pytest.raises(ProviderQuotaExceededError):
|
||||
method("t1")
|
||||
|
||||
|
||||
def test_workflow_generate_maps_model_not_support_error(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from controllers.console.app.error import ProviderModelCurrentlyNotSupportError
|
||||
from core.errors.error import ModelCurrentlyNotSupportError
|
||||
|
||||
api = generator_module.WorkflowGenerateApi()
|
||||
method = _unwrap(api.post)
|
||||
|
||||
_stub_workflow_service(monkeypatch, raises=ModelCurrentlyNotSupportError("not supported"))
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/workflow-generate",
|
||||
method="POST",
|
||||
json=_workflow_generate_payload(),
|
||||
):
|
||||
with pytest.raises(ProviderModelCurrentlyNotSupportError):
|
||||
method("t1")
|
||||
|
||||
|
||||
def test_workflow_generate_maps_invoke_error(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from controllers.console.app.error import CompletionRequestError
|
||||
from graphon.model_runtime.errors.invoke import InvokeError
|
||||
|
||||
api = generator_module.WorkflowGenerateApi()
|
||||
method = _unwrap(api.post)
|
||||
|
||||
_stub_workflow_service(monkeypatch, raises=InvokeError("LLM unreachable"))
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/workflow-generate",
|
||||
method="POST",
|
||||
json=_workflow_generate_payload(),
|
||||
):
|
||||
with pytest.raises(CompletionRequestError):
|
||||
method("t1")
|
||||
|
||||
|
||||
def test_workflow_generate_accepts_advanced_chat_mode(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The payload Literal must accept advanced-chat as well as workflow."""
|
||||
api = generator_module.WorkflowGenerateApi()
|
||||
method = _unwrap(api.post)
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
def _capture(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return {
|
||||
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
|
||||
"message": "",
|
||||
"error": "",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(generator_module.WorkflowGeneratorService, "generate_workflow_graph", _capture)
|
||||
|
||||
payload = _workflow_generate_payload()
|
||||
payload["mode"] = "advanced-chat"
|
||||
with app.test_request_context(
|
||||
"/console/api/workflow-generate",
|
||||
method="POST",
|
||||
json=payload,
|
||||
):
|
||||
method("t1")
|
||||
|
||||
assert captured["mode"] == "advanced-chat"
|
||||
assert captured["instruction"] == "Summarize a URL"
|
||||
assert captured["ideal_output"] == "A 3-sentence summary."
|
||||
|
||||
|
||||
def test_workflow_generate_forwards_current_graph_for_refine(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""cmd+k `/refine`: the optional current_graph field reaches the service."""
|
||||
api = generator_module.WorkflowGenerateApi()
|
||||
method = _unwrap(api.post)
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
def _capture(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return {
|
||||
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
|
||||
"message": "",
|
||||
"error": "",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(generator_module.WorkflowGeneratorService, "generate_workflow_graph", _capture)
|
||||
|
||||
graph = {"nodes": [{"id": "node1"}], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}}
|
||||
payload = _workflow_generate_payload()
|
||||
payload["current_graph"] = graph
|
||||
with app.test_request_context(
|
||||
"/console/api/workflow-generate",
|
||||
method="POST",
|
||||
json=payload,
|
||||
):
|
||||
method("t1")
|
||||
|
||||
assert captured["current_graph"] == graph
|
||||
|
||||
|
||||
def test_workflow_generate_current_graph_defaults_to_none(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Omitting current_graph (the `/create` path) forwards None to the service."""
|
||||
api = generator_module.WorkflowGenerateApi()
|
||||
method = _unwrap(api.post)
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
def _capture(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return {
|
||||
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
|
||||
"message": "",
|
||||
"error": "",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(generator_module.WorkflowGeneratorService, "generate_workflow_graph", _capture)
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/workflow-generate",
|
||||
method="POST",
|
||||
json=_workflow_generate_payload(),
|
||||
):
|
||||
method("t1")
|
||||
|
||||
assert captured["current_graph"] is None
|
||||
|
||||
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
|
||||
2271
api/tests/unit_tests/core/workflow/generator/test_runner.py
Normal file
2271
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,349 @@
|
||||
"""Unit tests for the tool catalogue helpers."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from core.workflow.generator.tool_catalogue import (
|
||||
ToolCatalogueEntry,
|
||||
_i18n_text,
|
||||
_tool_description,
|
||||
build_tool_catalogue,
|
||||
format_tool_catalogue,
|
||||
installed_tool_keys,
|
||||
)
|
||||
|
||||
|
||||
def _entry(provider: str, tool: str, *, label: str = "", description: str = "") -> ToolCatalogueEntry:
|
||||
return ToolCatalogueEntry(
|
||||
provider_name=provider,
|
||||
provider_type="builtin",
|
||||
plugin_id="",
|
||||
tool_name=tool,
|
||||
tool_label=label,
|
||||
description=description,
|
||||
)
|
||||
|
||||
|
||||
class TestInstalledToolKeys:
|
||||
"""The validator in ``runner.py`` looks up tool nodes against this set.
|
||||
|
||||
Keys MUST be ``(provider_name, tool_name)`` tuples — the builder prompt
|
||||
is instructed to put ``provider_name`` into both ``data.provider_id``
|
||||
and ``data.provider_name`` on tool nodes, so the runner's check accepts
|
||||
either field. The set therefore keys on ``provider_name``, not
|
||||
``plugin_id`` or any other identifier.
|
||||
"""
|
||||
|
||||
def test_empty_input_returns_empty_set(self):
|
||||
assert installed_tool_keys([]) == set()
|
||||
|
||||
def test_returns_provider_tool_tuples(self):
|
||||
keys = installed_tool_keys(
|
||||
[
|
||||
_entry("google", "search"),
|
||||
_entry("github", "list_issues"),
|
||||
]
|
||||
)
|
||||
assert keys == {("google", "search"), ("github", "list_issues")}
|
||||
|
||||
def test_dedupes_duplicate_entries(self):
|
||||
# Defensive — the catalogue builder dedupes on read, but a duplicate
|
||||
# entry slipping through should collapse rather than break the set
|
||||
# type contract.
|
||||
keys = installed_tool_keys([_entry("x", "y"), _entry("x", "y")])
|
||||
assert keys == {("x", "y")}
|
||||
|
||||
|
||||
class TestFormatToolCatalogue:
|
||||
def test_empty_input_returns_empty_string(self):
|
||||
assert format_tool_catalogue([]) == ""
|
||||
|
||||
def test_renders_provider_slash_tool_per_line(self):
|
||||
out = format_tool_catalogue(
|
||||
[
|
||||
_entry("google", "search", description="Search the web with Google."),
|
||||
_entry("time", "current_time", description="Return the current time."),
|
||||
]
|
||||
)
|
||||
lines = out.split("\n")
|
||||
assert lines == [
|
||||
"- google/search — Search the web with Google.",
|
||||
"- time/current_time — Return the current time.",
|
||||
]
|
||||
|
||||
def test_includes_label_when_different_from_tool_name(self):
|
||||
out = format_tool_catalogue(
|
||||
[
|
||||
_entry("google", "search", label="Google Search", description="Search."),
|
||||
]
|
||||
)
|
||||
assert out == "- google/search (Google Search) — Search."
|
||||
|
||||
def test_omits_label_when_identical_to_tool_name(self):
|
||||
out = format_tool_catalogue(
|
||||
[
|
||||
_entry("time", "current_time", label="current_time", description="Now."),
|
||||
]
|
||||
)
|
||||
assert out == "- time/current_time — Now."
|
||||
|
||||
def test_truncates_long_descriptions(self):
|
||||
long_desc = "x" * 200
|
||||
out = format_tool_catalogue([_entry("p", "t", description=long_desc)])
|
||||
# Truncated to 117 chars + "..."
|
||||
assert out.endswith("...")
|
||||
assert len(out.split(" — ", 1)[1]) == 120
|
||||
|
||||
def test_strips_newlines_from_descriptions(self):
|
||||
out = format_tool_catalogue([_entry("p", "t", description="line1\nline2\nline3")])
|
||||
assert "\n" not in out.split(" — ", 1)[1]
|
||||
assert "line1 line2 line3" in out
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _FakeI18n(SimpleNamespace):
|
||||
"""Minimal stand-in for ``I18nObject`` — only the attrs we read."""
|
||||
|
||||
|
||||
class _FakeToolEntity(SimpleNamespace):
|
||||
"""Tool entity exposing ``identity`` + ``description`` like the real thing."""
|
||||
|
||||
|
||||
class _FakeToolIdentity(SimpleNamespace):
|
||||
"""Identity holding ``name`` + ``label`` like ``ToolIdentity``."""
|
||||
|
||||
|
||||
class _FakeToolDescription(SimpleNamespace):
|
||||
"""Description with the ``llm`` attribute we read for prompts."""
|
||||
|
||||
|
||||
class _FakeTool:
|
||||
"""Tool stand-in: ``.entity`` is the only attribute the catalogue reads."""
|
||||
|
||||
def __init__(self, entity):
|
||||
self.entity = entity
|
||||
|
||||
|
||||
def _make_tool(name: str, label_en: str = "", description_llm: str = "") -> _FakeTool:
|
||||
return _FakeTool(
|
||||
entity=_FakeToolEntity(
|
||||
identity=_FakeToolIdentity(
|
||||
name=name,
|
||||
label=_FakeI18n(en_US=label_en, zh_Hans=""),
|
||||
),
|
||||
description=_FakeToolDescription(llm=description_llm),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class _FakeProviderType(SimpleNamespace):
|
||||
"""Stand-in for ``ToolProviderType`` — only ``.value`` is read."""
|
||||
|
||||
|
||||
def _make_builtin_provider(name: str, tools: list, raises_on_get_tools: bool = False):
|
||||
"""
|
||||
Build something ``isinstance(..., BuiltinToolProviderController)`` will
|
||||
answer True to without actually constructing one (those require real
|
||||
on-disk plugin metadata). We patch the isinstance call sites instead.
|
||||
"""
|
||||
provider = SimpleNamespace(
|
||||
entity=SimpleNamespace(identity=SimpleNamespace(name=name)),
|
||||
provider_type=_FakeProviderType(value="builtin"),
|
||||
get_tools=((lambda: (_ for _ in ()).throw(RuntimeError("boom"))) if raises_on_get_tools else (lambda: tools)),
|
||||
)
|
||||
provider._is_builtin = True
|
||||
return provider
|
||||
|
||||
|
||||
def _make_plugin_provider(name: str, plugin_id: str, tools: list):
|
||||
provider = SimpleNamespace(
|
||||
entity=SimpleNamespace(identity=SimpleNamespace(name=name)),
|
||||
provider_type=_FakeProviderType(value="plugin"),
|
||||
plugin_id=plugin_id,
|
||||
get_tools=lambda: tools,
|
||||
)
|
||||
provider._is_plugin = True
|
||||
return provider
|
||||
|
||||
|
||||
def _make_unknown_provider(name: str):
|
||||
"""A provider matching neither class — must be skipped."""
|
||||
return SimpleNamespace(
|
||||
entity=SimpleNamespace(identity=SimpleNamespace(name=name)),
|
||||
provider_type=_FakeProviderType(value="weird"),
|
||||
get_tools=lambda: [_make_tool("ghost")],
|
||||
)
|
||||
|
||||
|
||||
def _patched_isinstance(obj, cls):
|
||||
"""
|
||||
Reroute isinstance checks the catalogue uses to the fake providers built
|
||||
above. Anything else falls through to the real isinstance.
|
||||
"""
|
||||
from core.tools.builtin_tool.provider import BuiltinToolProviderController
|
||||
from core.tools.plugin_tool.provider import PluginToolProviderController
|
||||
|
||||
if cls is BuiltinToolProviderController:
|
||||
return bool(getattr(obj, "_is_builtin", False))
|
||||
if cls is PluginToolProviderController:
|
||||
return bool(getattr(obj, "_is_plugin", False))
|
||||
import builtins as _b
|
||||
|
||||
return _b.isinstance(obj, cls)
|
||||
|
||||
|
||||
# ── _i18n_text / _tool_description ───────────────────────────────────────────
|
||||
|
||||
|
||||
class TestI18nText:
|
||||
def test_returns_empty_string_when_label_is_none(self):
|
||||
assert _i18n_text(None) == ""
|
||||
|
||||
def test_returns_en_us_when_present(self):
|
||||
assert _i18n_text(_FakeI18n(en_US="Search", zh_Hans="搜索")) == "Search"
|
||||
|
||||
def test_falls_back_to_zh_hans_when_en_us_blank(self):
|
||||
# Some plugins ship only Chinese metadata; falling back keeps the
|
||||
# planner aware of those tools instead of dropping them silently.
|
||||
assert _i18n_text(_FakeI18n(en_US="", zh_Hans="搜索")) == "搜索"
|
||||
|
||||
def test_returns_empty_when_both_locales_missing(self):
|
||||
assert _i18n_text(_FakeI18n()) == ""
|
||||
|
||||
|
||||
class TestToolDescription:
|
||||
def test_returns_empty_string_for_none_description(self):
|
||||
# ToolEntity.description is Optional — must not raise on absent.
|
||||
assert _tool_description(None) == ""
|
||||
|
||||
def test_returns_llm_attribute(self):
|
||||
assert _tool_description(_FakeToolDescription(llm="Web search")) == "Web search"
|
||||
|
||||
def test_returns_empty_when_llm_missing(self):
|
||||
assert _tool_description(SimpleNamespace()) == ""
|
||||
|
||||
|
||||
# ── build_tool_catalogue ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildToolCatalogue:
|
||||
"""
|
||||
The builder iterates the ``ToolManager.list_builtin_providers`` generator
|
||||
(which already covers both hardcoded and plugin providers in production).
|
||||
We patch the generator + isinstance so the tests can exercise every branch
|
||||
without standing up real plugin daemon state.
|
||||
"""
|
||||
|
||||
@patch("core.workflow.generator.tool_catalogue.isinstance", side_effect=_patched_isinstance)
|
||||
@patch("core.workflow.generator.tool_catalogue.ToolManager.list_builtin_providers")
|
||||
def test_returns_empty_list_for_tenant_with_no_tools(self, mock_list, mock_isinstance):
|
||||
mock_list.return_value = iter([])
|
||||
|
||||
assert build_tool_catalogue("tenant-1") == []
|
||||
|
||||
@patch("core.workflow.generator.tool_catalogue.isinstance", side_effect=_patched_isinstance)
|
||||
@patch("core.workflow.generator.tool_catalogue.ToolManager.list_builtin_providers")
|
||||
def test_collects_hardcoded_and_plugin_tools(self, mock_list, mock_isinstance):
|
||||
# Mixed-tenant scenario: hardcoded provider plus a plugin provider,
|
||||
# each carrying one tool. The catalogue must include all four fields
|
||||
# the workflow tool node will need (provider_name / provider_type /
|
||||
# plugin_id / tool_name).
|
||||
hardcoded = _make_builtin_provider(
|
||||
"time",
|
||||
[_make_tool("current_time", label_en="Current Time", description_llm="Return now.")],
|
||||
)
|
||||
plugin = _make_plugin_provider(
|
||||
"google",
|
||||
plugin_id="langgenius/google",
|
||||
tools=[_make_tool("search", label_en="Google Search", description_llm="Search the web.")],
|
||||
)
|
||||
mock_list.return_value = iter([hardcoded, plugin])
|
||||
|
||||
entries = build_tool_catalogue("tenant-1")
|
||||
|
||||
# Sorted alphabetically by provider_name.
|
||||
assert [(e["provider_name"], e["tool_name"]) for e in entries] == [
|
||||
("google", "search"),
|
||||
("time", "current_time"),
|
||||
]
|
||||
google = entries[0]
|
||||
assert google["provider_type"] == "plugin"
|
||||
assert google["plugin_id"] == "langgenius/google"
|
||||
assert google["tool_label"] == "Google Search"
|
||||
assert google["description"] == "Search the web."
|
||||
time_entry = entries[1]
|
||||
assert time_entry["provider_type"] == "builtin"
|
||||
assert time_entry["plugin_id"] == ""
|
||||
|
||||
@patch("core.workflow.generator.tool_catalogue.isinstance", side_effect=_patched_isinstance)
|
||||
@patch("core.workflow.generator.tool_catalogue.ToolManager.list_builtin_providers")
|
||||
def test_skips_unknown_provider_classes(self, mock_list, mock_isinstance):
|
||||
# If ToolManager ever yields a provider the catalogue doesn't know how
|
||||
# to label, we must continue (not raise) and leave it out of the
|
||||
# output rather than guessing at provider_type.
|
||||
unknown = _make_unknown_provider("mystery")
|
||||
hardcoded = _make_builtin_provider("time", [_make_tool("now")])
|
||||
mock_list.return_value = iter([unknown, hardcoded])
|
||||
|
||||
entries = build_tool_catalogue("tenant-1")
|
||||
|
||||
assert [e["provider_name"] for e in entries] == ["time"]
|
||||
|
||||
@patch("core.workflow.generator.tool_catalogue.isinstance", side_effect=_patched_isinstance)
|
||||
@patch("core.workflow.generator.tool_catalogue.ToolManager.list_builtin_providers")
|
||||
def test_continues_when_a_provider_get_tools_raises(self, mock_list, mock_isinstance):
|
||||
# A buggy plugin must not break the whole catalogue. Resilient
|
||||
# per-provider try/except is what keeps generation usable in tenants
|
||||
# with broken installs.
|
||||
bad = _make_builtin_provider("broken", [], raises_on_get_tools=True)
|
||||
good = _make_builtin_provider("time", [_make_tool("now")])
|
||||
mock_list.return_value = iter([bad, good])
|
||||
|
||||
entries = build_tool_catalogue("tenant-1")
|
||||
|
||||
assert [e["provider_name"] for e in entries] == ["time"]
|
||||
|
||||
@patch("core.workflow.generator.tool_catalogue.isinstance", side_effect=_patched_isinstance)
|
||||
@patch("core.workflow.generator.tool_catalogue.ToolManager.list_builtin_providers")
|
||||
def test_skips_individual_tools_when_their_metadata_is_broken(self, mock_list, mock_isinstance):
|
||||
# Per-tool try/except — a single mis-declared tool inside an otherwise
|
||||
# healthy provider gets dropped, the rest still surface.
|
||||
good_tool = _make_tool("ok", label_en="Ok", description_llm="Healthy tool.")
|
||||
# Bad tool: accessing .entity.identity raises because entity is None.
|
||||
bad_tool = SimpleNamespace(entity=None)
|
||||
hardcoded = _make_builtin_provider("p", [bad_tool, good_tool])
|
||||
mock_list.return_value = iter([hardcoded])
|
||||
|
||||
entries = build_tool_catalogue("tenant-1")
|
||||
|
||||
assert [e["tool_name"] for e in entries] == ["ok"]
|
||||
|
||||
@patch("core.workflow.generator.tool_catalogue.isinstance", side_effect=_patched_isinstance)
|
||||
@patch("core.workflow.generator.tool_catalogue.ToolManager.list_builtin_providers")
|
||||
def test_truncates_to_max_tools_to_keep_prompt_bounded(self, mock_list, mock_isinstance):
|
||||
# A tenant with hundreds of plugin tools would blow the LLM context
|
||||
# window. The catalogue caps the output at ``_MAX_TOOLS``.
|
||||
big_provider = _make_builtin_provider(
|
||||
"p",
|
||||
[_make_tool(f"t{i:03d}") for i in range(200)],
|
||||
)
|
||||
mock_list.return_value = iter([big_provider])
|
||||
|
||||
entries = build_tool_catalogue("tenant-1")
|
||||
|
||||
assert len(entries) == 80
|
||||
|
||||
@patch("core.workflow.generator.tool_catalogue.isinstance", side_effect=_patched_isinstance)
|
||||
@patch("core.workflow.generator.tool_catalogue.ToolManager.list_builtin_providers")
|
||||
def test_defaults_plugin_id_to_empty_string_when_missing(self, mock_list, mock_isinstance):
|
||||
# Plugin provider whose plugin_id is None should serialise to "" so
|
||||
# the consumer can safely index ``e["plugin_id"]`` without a None
|
||||
# check at every callsite.
|
||||
plugin = _make_plugin_provider("p", plugin_id=None, tools=[_make_tool("t")])
|
||||
mock_list.return_value = iter([plugin])
|
||||
|
||||
entries = build_tool_catalogue("tenant-1")
|
||||
|
||||
assert entries[0]["plugin_id"] == ""
|
||||
201
api/tests/unit_tests/services/test_workflow_generator_service.py
Normal file
201
api/tests/unit_tests/services/test_workflow_generator_service.py
Normal file
@ -0,0 +1,201 @@
|
||||
"""
|
||||
Unit tests for ``WorkflowGeneratorService``.
|
||||
|
||||
The service is a thin facade — its job is (1) hand the tenant model_config to
|
||||
``ModelManager`` to get a model_instance, (2) build the tool catalogue, and
|
||||
(3) delegate to ``WorkflowGenerator``. We mock both dependencies so the tests
|
||||
stay fast and focus on the wiring itself.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from core.app.app_config.entities import ModelConfig
|
||||
from graphon.model_runtime.entities.llm_entities import LLMMode
|
||||
from services.workflow_generator_service import WorkflowGeneratorService
|
||||
|
||||
|
||||
def _model_config() -> ModelConfig:
|
||||
return ModelConfig(
|
||||
provider="openai",
|
||||
name="gpt-4o",
|
||||
mode=LLMMode.CHAT,
|
||||
completion_params={"temperature": 0.4},
|
||||
)
|
||||
|
||||
|
||||
class TestWorkflowGeneratorService:
|
||||
@patch("services.workflow_generator_service.WorkflowGenerator")
|
||||
@patch("services.workflow_generator_service.ModelManager")
|
||||
@patch("services.workflow_generator_service.build_tool_catalogue")
|
||||
@patch("services.workflow_generator_service.format_tool_catalogue")
|
||||
def test_forwards_model_instance_and_catalogue_text_to_generator(
|
||||
self,
|
||||
mock_format_catalogue,
|
||||
mock_build_catalogue,
|
||||
mock_model_manager,
|
||||
mock_workflow_generator,
|
||||
):
|
||||
"""Happy path: model_instance + catalogue text + payload flow through."""
|
||||
# Arrange
|
||||
instance = MagicMock(name="model_instance")
|
||||
mock_model_manager.for_tenant.return_value.get_model_instance.return_value = instance
|
||||
mock_build_catalogue.return_value = [{"provider_name": "google"}]
|
||||
mock_format_catalogue.return_value = "- google/search — Search."
|
||||
mock_workflow_generator.generate_workflow_graph.return_value = {
|
||||
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
|
||||
"message": "ok",
|
||||
"error": "",
|
||||
}
|
||||
|
||||
# Act
|
||||
result = WorkflowGeneratorService.generate_workflow_graph(
|
||||
tenant_id="t-1",
|
||||
mode="workflow",
|
||||
instruction="Summarize a URL",
|
||||
model_config=_model_config(),
|
||||
ideal_output="A 3-sentence summary",
|
||||
)
|
||||
|
||||
# Assert
|
||||
mock_model_manager.for_tenant.assert_called_once_with(tenant_id="t-1")
|
||||
mock_workflow_generator.generate_workflow_graph.assert_called_once()
|
||||
call_kwargs = mock_workflow_generator.generate_workflow_graph.call_args.kwargs
|
||||
assert call_kwargs["model_instance"] is instance
|
||||
assert call_kwargs["provider"] == "openai"
|
||||
assert call_kwargs["model_name"] == "gpt-4o"
|
||||
assert call_kwargs["mode"] == "workflow"
|
||||
assert call_kwargs["instruction"] == "Summarize a URL"
|
||||
assert call_kwargs["ideal_output"] == "A 3-sentence summary"
|
||||
assert call_kwargs["tool_catalogue_text"] == "- google/search — Search."
|
||||
assert call_kwargs["model_parameters"] == {"temperature": 0.4}
|
||||
assert result["error"] == ""
|
||||
|
||||
@patch("services.workflow_generator_service.WorkflowGenerator")
|
||||
@patch("services.workflow_generator_service.ModelManager")
|
||||
@patch("services.workflow_generator_service.build_tool_catalogue")
|
||||
def test_catalogue_build_failure_falls_back_to_empty_text(
|
||||
self,
|
||||
mock_build_catalogue,
|
||||
mock_model_manager,
|
||||
mock_workflow_generator,
|
||||
):
|
||||
"""
|
||||
A plugin-daemon outage must not block generation — the catalogue helper
|
||||
is wrapped in try/except so a failure downgrades to an empty catalogue.
|
||||
"""
|
||||
# Arrange
|
||||
mock_model_manager.for_tenant.return_value.get_model_instance.return_value = MagicMock()
|
||||
mock_build_catalogue.side_effect = RuntimeError("plugin daemon unreachable")
|
||||
mock_workflow_generator.generate_workflow_graph.return_value = {
|
||||
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
|
||||
"message": "",
|
||||
"error": "",
|
||||
}
|
||||
|
||||
# Act
|
||||
WorkflowGeneratorService.generate_workflow_graph(
|
||||
tenant_id="t-1",
|
||||
mode="workflow",
|
||||
instruction="Summarize a URL",
|
||||
model_config=_model_config(),
|
||||
)
|
||||
|
||||
# Assert: generation still ran, catalogue text was empty.
|
||||
call_kwargs = mock_workflow_generator.generate_workflow_graph.call_args.kwargs
|
||||
assert call_kwargs["tool_catalogue_text"] == ""
|
||||
|
||||
@patch("services.workflow_generator_service.WorkflowGenerator")
|
||||
@patch("services.workflow_generator_service.ModelManager")
|
||||
@patch("services.workflow_generator_service.build_tool_catalogue")
|
||||
@patch("services.workflow_generator_service.format_tool_catalogue")
|
||||
def test_defaults_ideal_output_to_empty_string(
|
||||
self,
|
||||
mock_format_catalogue,
|
||||
mock_build_catalogue,
|
||||
mock_model_manager,
|
||||
mock_workflow_generator,
|
||||
):
|
||||
"""Callers can omit ideal_output; the runner should still receive ""."""
|
||||
mock_model_manager.for_tenant.return_value.get_model_instance.return_value = MagicMock()
|
||||
mock_build_catalogue.return_value = []
|
||||
mock_format_catalogue.return_value = ""
|
||||
mock_workflow_generator.generate_workflow_graph.return_value = {
|
||||
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
|
||||
"message": "",
|
||||
"error": "",
|
||||
}
|
||||
|
||||
WorkflowGeneratorService.generate_workflow_graph(
|
||||
tenant_id="t-1",
|
||||
mode="advanced-chat",
|
||||
instruction="A chat bot",
|
||||
model_config=_model_config(),
|
||||
)
|
||||
|
||||
call_kwargs = mock_workflow_generator.generate_workflow_graph.call_args.kwargs
|
||||
assert call_kwargs["ideal_output"] == ""
|
||||
assert call_kwargs["mode"] == "advanced-chat"
|
||||
|
||||
@patch("services.workflow_generator_service.WorkflowGenerator")
|
||||
@patch("services.workflow_generator_service.ModelManager")
|
||||
@patch("services.workflow_generator_service.build_tool_catalogue")
|
||||
@patch("services.workflow_generator_service.format_tool_catalogue")
|
||||
def test_forwards_current_graph_for_refine(
|
||||
self,
|
||||
mock_format_catalogue,
|
||||
mock_build_catalogue,
|
||||
mock_model_manager,
|
||||
mock_workflow_generator,
|
||||
):
|
||||
"""The cmd+k `/refine` path passes the existing draft graph through to the runner."""
|
||||
mock_model_manager.for_tenant.return_value.get_model_instance.return_value = MagicMock()
|
||||
mock_build_catalogue.return_value = []
|
||||
mock_format_catalogue.return_value = ""
|
||||
mock_workflow_generator.generate_workflow_graph.return_value = {
|
||||
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
|
||||
"message": "",
|
||||
"error": "",
|
||||
}
|
||||
current_graph = {"nodes": [{"id": "node1"}], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}}
|
||||
|
||||
WorkflowGeneratorService.generate_workflow_graph(
|
||||
tenant_id="t-1",
|
||||
mode="workflow",
|
||||
instruction="Add a translation step",
|
||||
model_config=_model_config(),
|
||||
current_graph=current_graph,
|
||||
)
|
||||
|
||||
call_kwargs = mock_workflow_generator.generate_workflow_graph.call_args.kwargs
|
||||
assert call_kwargs["current_graph"] is current_graph
|
||||
|
||||
@patch("services.workflow_generator_service.WorkflowGenerator")
|
||||
@patch("services.workflow_generator_service.ModelManager")
|
||||
@patch("services.workflow_generator_service.build_tool_catalogue")
|
||||
@patch("services.workflow_generator_service.format_tool_catalogue")
|
||||
def test_defaults_current_graph_to_none_for_create(
|
||||
self,
|
||||
mock_format_catalogue,
|
||||
mock_build_catalogue,
|
||||
mock_model_manager,
|
||||
mock_workflow_generator,
|
||||
):
|
||||
"""Omitting current_graph (the `/create` path) forwards None to the runner."""
|
||||
mock_model_manager.for_tenant.return_value.get_model_instance.return_value = MagicMock()
|
||||
mock_build_catalogue.return_value = []
|
||||
mock_format_catalogue.return_value = ""
|
||||
mock_workflow_generator.generate_workflow_graph.return_value = {
|
||||
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
|
||||
"message": "",
|
||||
"error": "",
|
||||
}
|
||||
|
||||
WorkflowGeneratorService.generate_workflow_graph(
|
||||
tenant_id="t-1",
|
||||
mode="workflow",
|
||||
instruction="Summarize a URL",
|
||||
model_config=_model_config(),
|
||||
)
|
||||
|
||||
call_kwargs = mock_workflow_generator.generate_workflow_graph.call_args.kwargs
|
||||
assert call_kwargs["current_graph"] is None
|
||||
@ -46,6 +46,7 @@ import { test } from './test/orpc.gen'
|
||||
import { trialApps } from './trial-apps/orpc.gen'
|
||||
import { trialModels } from './trial-models/orpc.gen'
|
||||
import { website } from './website/orpc.gen'
|
||||
import { workflowGenerate } from './workflow-generate/orpc.gen'
|
||||
import { workflow } from './workflow/orpc.gen'
|
||||
import { workspaces } from './workspaces/orpc.gen'
|
||||
|
||||
@ -97,5 +98,6 @@ export const contract = {
|
||||
trialModels,
|
||||
website,
|
||||
workflow,
|
||||
workflowGenerate,
|
||||
workspaces,
|
||||
}
|
||||
|
||||
@ -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,53 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
export type ClientOptions = {
|
||||
baseUrl: `${string}://${string}/console/api` | (string & {})
|
||||
}
|
||||
|
||||
export type WorkflowGeneratePayload = {
|
||||
current_graph?: {
|
||||
[key: string]: unknown
|
||||
} | null
|
||||
ideal_output?: string
|
||||
instruction: string
|
||||
mode: 'advanced-chat' | 'workflow'
|
||||
model_config: ModelConfig
|
||||
}
|
||||
|
||||
export type ModelConfig = {
|
||||
completion_params?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
mode: LlmMode
|
||||
name: string
|
||||
provider: string
|
||||
}
|
||||
|
||||
export type LlmMode = 'chat' | 'completion'
|
||||
|
||||
export type PostWorkflowGenerateData = {
|
||||
body: WorkflowGeneratePayload
|
||||
path?: never
|
||||
query?: never
|
||||
url: '/workflow-generate'
|
||||
}
|
||||
|
||||
export type PostWorkflowGenerateErrors = {
|
||||
400: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
402: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type PostWorkflowGenerateError = PostWorkflowGenerateErrors[keyof PostWorkflowGenerateErrors]
|
||||
|
||||
export type PostWorkflowGenerateResponses = {
|
||||
200: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type PostWorkflowGenerateResponse
|
||||
= PostWorkflowGenerateResponses[keyof PostWorkflowGenerateResponses]
|
||||
@ -0,0 +1,44 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import * as z from 'zod'
|
||||
|
||||
/**
|
||||
* LLMMode
|
||||
*
|
||||
* Enum class for large language model mode.
|
||||
*/
|
||||
export const zLlmMode = z.enum(['chat', 'completion'])
|
||||
|
||||
/**
|
||||
* ModelConfig
|
||||
*/
|
||||
export const zModelConfig = z.object({
|
||||
completion_params: z.record(z.string(), z.unknown()).optional(),
|
||||
mode: zLlmMode,
|
||||
name: z.string(),
|
||||
provider: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* WorkflowGeneratePayload
|
||||
*
|
||||
* Payload for the cmd+k `/create` and `/refine` workflow generator endpoint.
|
||||
*
|
||||
* See ``services/workflow_generator_service.py`` for behaviour. Errors are
|
||||
* surfaced through the same envelope as ``/rule-generate`` so the frontend
|
||||
* can reuse its existing handler.
|
||||
*/
|
||||
export const zWorkflowGeneratePayload = z.object({
|
||||
current_graph: z.record(z.string(), z.unknown()).nullish(),
|
||||
ideal_output: z.string().optional().default(''),
|
||||
instruction: z.string(),
|
||||
mode: z.enum(['advanced-chat', 'workflow']),
|
||||
model_config: zModelConfig,
|
||||
})
|
||||
|
||||
export const zPostWorkflowGenerateBody = zWorkflowGeneratePayload
|
||||
|
||||
/**
|
||||
* Workflow graph generated successfully
|
||||
*/
|
||||
export const zPostWorkflowGenerateResponse = z.record(z.string(), z.unknown())
|
||||
@ -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,178 @@
|
||||
import { executeCommand } from '../command-bus'
|
||||
import { createCommand } from '../create'
|
||||
|
||||
// Stub the icon imports — these are React components we don't render here.
|
||||
vi.mock('@remixicon/react', () => ({
|
||||
RiChat3Line: () => null,
|
||||
RiNodeTree: () => null,
|
||||
}))
|
||||
|
||||
// search() localises its labels via getI18n(); echo the key back so the
|
||||
// filtering/payload assertions stay deterministic without a real i18n init.
|
||||
vi.mock('react-i18next', () => ({
|
||||
getI18n: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
// We spy on the store at module scope so the `create.open` handler that
|
||||
// register() pushes into the command bus can be observed by the tests.
|
||||
const mockOpenGenerator = vi.fn()
|
||||
vi.mock('@/app/components/workflow/workflow-generator/store', () => ({
|
||||
useWorkflowGeneratorStore: {
|
||||
getState: () => ({ openGenerator: mockOpenGenerator }),
|
||||
},
|
||||
}))
|
||||
|
||||
// Controllable app-store state — the handler reads `appDetail` to decide
|
||||
// whether to thread the current Studio app through to the generator. Mutated
|
||||
// per-test; getState() reads it lazily so updates land after the mock factory.
|
||||
const mockAppStore: { appDetail: { id: string, mode: string } | undefined } = {
|
||||
appDetail: undefined,
|
||||
}
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: {
|
||||
getState: () => ({ appDetail: mockAppStore.appDetail }),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('/create slash command', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('handler metadata', () => {
|
||||
// The slash registry relies on this metadata to route /create through the
|
||||
// submenu UX rather than executing immediately.
|
||||
it('should expose submenu mode with the expected name and aliases', () => {
|
||||
expect(createCommand.mode).toBe('submenu')
|
||||
expect(createCommand.name).toBe('create')
|
||||
expect(createCommand.aliases).toEqual(['new', 'generate'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('search()', () => {
|
||||
// An empty arg list should surface every option; the submenu uses this to
|
||||
// render its initial list when the user types just `/create`.
|
||||
it('should surface both workflow and chatflow when args is empty', async () => {
|
||||
const results = await createCommand.search('')
|
||||
expect(results.map(r => r.id)).toEqual(['create-workflow', 'create-chatflow'])
|
||||
})
|
||||
|
||||
// Typing a partial keyword should narrow the list and each result should
|
||||
// carry the right command-bus payload so the navigation hook can fire it.
|
||||
it('should filter by query and attach the right command payload', async () => {
|
||||
const results = await createCommand.search('chat')
|
||||
expect(results.map(r => r.id)).toEqual(['create-chatflow'])
|
||||
expect(results[0]!.data.command).toBe('create.open')
|
||||
expect(results[0]!.data.args).toEqual({ mode: 'advanced-chat' })
|
||||
})
|
||||
|
||||
// A non-matching query returns an empty list rather than throwing, so the
|
||||
// goto-anything dialog can render an empty-state without special-casing.
|
||||
it('should return an empty list when the query matches nothing', async () => {
|
||||
const results = await createCommand.search('zzz-no-match')
|
||||
expect(results).toEqual([])
|
||||
})
|
||||
|
||||
// Labels/descriptions must be localised through i18n (ns: 'app') rather
|
||||
// than hardcoded English, so the palette renders in the user's language.
|
||||
it('should source titles and descriptions from i18n keys', async () => {
|
||||
const results = await createCommand.search('')
|
||||
expect(results[0]!.title).toBe('gotoAnything.actions.createWorkflow')
|
||||
expect(results[0]!.description).toBe('gotoAnything.actions.createWorkflowDesc')
|
||||
expect(results[1]!.title).toBe('gotoAnything.actions.createChatflow')
|
||||
expect(results[1]!.description).toBe('gotoAnything.actions.createChatflowDesc')
|
||||
})
|
||||
|
||||
// The localised label is also searchable, not just the id — a token that
|
||||
// appears only in the (mocked) title key still narrows the list, proving
|
||||
// the filter consults the translated label.
|
||||
it('should filter by the localised label, not just the id', async () => {
|
||||
const results = await createCommand.search('createChatflow')
|
||||
expect(results.map(r => r.id)).toEqual(['create-chatflow'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('register() — `create.open` command-bus handler', () => {
|
||||
// Register populates the global command bus; tests below rely on it so we
|
||||
// run it once per case and clean up via the symmetric unregister(). Reset
|
||||
// the app-store state so each case controls its own Studio context.
|
||||
beforeEach(() => {
|
||||
mockAppStore.appDetail = undefined
|
||||
createCommand.register?.({} as never)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
createCommand.unregister?.()
|
||||
})
|
||||
|
||||
// No Studio app open (e.g. /create from the apps list) — the modal opens
|
||||
// for new-app creation only, with just the requested mode.
|
||||
it('should open the generator with only the requested mode when no Studio app is open', async () => {
|
||||
await executeCommand('create.open', { mode: 'workflow' })
|
||||
|
||||
expect(mockOpenGenerator).toHaveBeenCalledWith({ mode: 'workflow' })
|
||||
})
|
||||
|
||||
// In-Studio create-and-apply: when a graph-based app is open and its mode
|
||||
// matches the picked mode, the handler threads id + mode through so the
|
||||
// modal can offer "Apply to current draft".
|
||||
it('should thread the current app context when a matching Studio app is open', async () => {
|
||||
mockAppStore.appDetail = { id: 'abc-123', mode: 'workflow' }
|
||||
|
||||
await executeCommand('create.open', { mode: 'workflow' })
|
||||
|
||||
expect(mockOpenGenerator).toHaveBeenCalledWith({
|
||||
mode: 'workflow',
|
||||
currentAppId: 'abc-123',
|
||||
currentAppMode: 'workflow',
|
||||
})
|
||||
})
|
||||
|
||||
// Mode mismatch (Workflow Studio open, but the user picked Chatflow) must
|
||||
// NOT capture currentAppId — applying a chatflow graph onto a workflow
|
||||
// draft is the dead-end we explicitly avoid, so it stays new-app only.
|
||||
it('should fall back to new-app only when the picked mode differs from the open app', async () => {
|
||||
mockAppStore.appDetail = { id: 'abc-123', mode: 'workflow' }
|
||||
|
||||
await executeCommand('create.open', { mode: 'advanced-chat' })
|
||||
|
||||
expect(mockOpenGenerator).toHaveBeenCalledWith({ mode: 'advanced-chat' })
|
||||
})
|
||||
|
||||
// Non-graph Studio apps (Chat / Agent / Completion) have no canvas to
|
||||
// apply onto, so the handler ignores them and opens new-app only.
|
||||
it('should ignore non-graph app modes and open new-app only', async () => {
|
||||
mockAppStore.appDetail = { id: 'abc-123', mode: 'chat' }
|
||||
|
||||
await executeCommand('create.open', { mode: 'workflow' })
|
||||
|
||||
expect(mockOpenGenerator).toHaveBeenCalledWith({ mode: 'workflow' })
|
||||
})
|
||||
|
||||
// Defensive fallback: if a caller forgets to pass a mode (or passes none),
|
||||
// the handler must still open the generator with a safe default rather
|
||||
// than crashing the goto-anything dialog.
|
||||
it('should default to workflow mode when no args are passed', async () => {
|
||||
await executeCommand('create.open')
|
||||
|
||||
expect(mockOpenGenerator).toHaveBeenCalledWith({ mode: 'workflow' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('unregister()', () => {
|
||||
// After unregister, the bus must drop the handler so a later execute call
|
||||
// becomes a silent no-op (prevents stale references between mounts).
|
||||
it('should remove the command-bus handler so it stops firing', async () => {
|
||||
createCommand.register?.({} as never)
|
||||
createCommand.unregister?.()
|
||||
|
||||
Object.defineProperty(window, 'location', {
|
||||
writable: true,
|
||||
value: { pathname: '/apps' },
|
||||
})
|
||||
|
||||
await executeCommand('create.open', { mode: 'workflow' })
|
||||
expect(mockOpenGenerator).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,151 @@
|
||||
import { executeCommand } from '../command-bus'
|
||||
import { refineCommand } from '../refine'
|
||||
|
||||
// Stub the icon import — it's a React component we don't render here.
|
||||
vi.mock('@remixicon/react', () => ({
|
||||
RiSparkling2Line: () => null,
|
||||
}))
|
||||
|
||||
// search() localises its title/description via getI18n(); echo the key back
|
||||
// so assertions stay deterministic without a real i18n init.
|
||||
vi.mock('react-i18next', () => ({
|
||||
getI18n: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
// Spy on the generator store so we can observe what /refine opens it with.
|
||||
const mockOpenGenerator = vi.fn()
|
||||
vi.mock('@/app/components/workflow/workflow-generator/store', () => ({
|
||||
useWorkflowGeneratorStore: {
|
||||
getState: () => ({ openGenerator: mockOpenGenerator }),
|
||||
},
|
||||
}))
|
||||
|
||||
// Controllable app-store state — /refine reads appDetail to gate availability
|
||||
// and to pick the mode + id it refines. Mutated per-test; read lazily.
|
||||
const mockAppStore: { appDetail: { id: string, mode: string } | undefined } = {
|
||||
appDetail: undefined,
|
||||
}
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: {
|
||||
getState: () => ({ appDetail: mockAppStore.appDetail }),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('/refine slash command', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAppStore.appDetail = undefined
|
||||
})
|
||||
|
||||
describe('handler metadata', () => {
|
||||
it('should be a direct command named refine with the expected alias', () => {
|
||||
expect(refineCommand.mode).toBe('direct')
|
||||
expect(refineCommand.name).toBe('refine')
|
||||
expect(refineCommand.aliases).toEqual(['improve'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAvailable()', () => {
|
||||
// /refine only makes sense inside a graph-based Studio — elsewhere there's
|
||||
// no draft graph to refine, so the command must hide itself.
|
||||
it('should be unavailable when no app is open', () => {
|
||||
expect(refineCommand.isAvailable?.()).toBe(false)
|
||||
})
|
||||
|
||||
it('should be available in a Workflow Studio', () => {
|
||||
mockAppStore.appDetail = { id: 'app-1', mode: 'workflow' }
|
||||
expect(refineCommand.isAvailable?.()).toBe(true)
|
||||
})
|
||||
|
||||
it('should be available in an Advanced-Chat Studio', () => {
|
||||
mockAppStore.appDetail = { id: 'app-1', mode: 'advanced-chat' }
|
||||
expect(refineCommand.isAvailable?.()).toBe(true)
|
||||
})
|
||||
|
||||
it('should be unavailable for non-graph apps (chat / agent / completion)', () => {
|
||||
mockAppStore.appDetail = { id: 'app-1', mode: 'chat' }
|
||||
expect(refineCommand.isAvailable?.()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('execute()', () => {
|
||||
// The core behaviour: open the generator in refine intent, threading the
|
||||
// current app's id + mode so the modal fetches its draft as context.
|
||||
it('should open the generator in refine intent for a Workflow Studio', () => {
|
||||
mockAppStore.appDetail = { id: 'app-1', mode: 'workflow' }
|
||||
|
||||
refineCommand.execute?.()
|
||||
|
||||
expect(mockOpenGenerator).toHaveBeenCalledWith({
|
||||
intent: 'refine',
|
||||
mode: 'workflow',
|
||||
currentAppId: 'app-1',
|
||||
currentAppMode: 'workflow',
|
||||
})
|
||||
})
|
||||
|
||||
it('should map advanced-chat apps to the advanced-chat generator mode', () => {
|
||||
mockAppStore.appDetail = { id: 'app-2', mode: 'advanced-chat' }
|
||||
|
||||
refineCommand.execute?.()
|
||||
|
||||
expect(mockOpenGenerator).toHaveBeenCalledWith({
|
||||
intent: 'refine',
|
||||
mode: 'advanced-chat',
|
||||
currentAppId: 'app-2',
|
||||
currentAppMode: 'advanced-chat',
|
||||
})
|
||||
})
|
||||
|
||||
it('should be a no-op when no graph-based app is open', () => {
|
||||
mockAppStore.appDetail = { id: 'app-3', mode: 'chat' }
|
||||
|
||||
refineCommand.execute?.()
|
||||
|
||||
expect(mockOpenGenerator).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('search()', () => {
|
||||
// The submenu/result list renders one localised entry carrying the
|
||||
// refine.open command-bus payload.
|
||||
it('should return a single localised refine result', async () => {
|
||||
const results = await refineCommand.search('')
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0]!.id).toBe('refine-current')
|
||||
expect(results[0]!.title).toBe('gotoAnything.actions.refineTitle')
|
||||
expect(results[0]!.data.command).toBe('refine.open')
|
||||
})
|
||||
})
|
||||
|
||||
describe('register() — `refine.open` command-bus handler', () => {
|
||||
beforeEach(() => {
|
||||
refineCommand.register?.({} as never)
|
||||
})
|
||||
afterEach(() => {
|
||||
refineCommand.unregister?.()
|
||||
})
|
||||
|
||||
it('should open the generator via the command bus too', async () => {
|
||||
mockAppStore.appDetail = { id: 'app-1', mode: 'workflow' }
|
||||
|
||||
await executeCommand('refine.open', {})
|
||||
|
||||
expect(mockOpenGenerator).toHaveBeenCalledWith({
|
||||
intent: 'refine',
|
||||
mode: 'workflow',
|
||||
currentAppId: 'app-1',
|
||||
currentAppMode: 'workflow',
|
||||
})
|
||||
})
|
||||
|
||||
it('should stop firing after unregister', async () => {
|
||||
mockAppStore.appDetail = { id: 'app-1', mode: 'workflow' }
|
||||
refineCommand.unregister?.()
|
||||
|
||||
await executeCommand('refine.open', {})
|
||||
|
||||
expect(mockOpenGenerator).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -106,6 +106,8 @@ describe('SlashCommandProvider', () => {
|
||||
'account',
|
||||
'zen',
|
||||
'go',
|
||||
'create',
|
||||
'refine',
|
||||
])
|
||||
expect(mockRegister).toHaveBeenCalledWith(expect.objectContaining({ name: 'theme' }), { setTheme: mockSetTheme })
|
||||
expect(mockRegister).toHaveBeenCalledWith(expect.objectContaining({ name: 'language' }), { setLocale: mockSetLocale })
|
||||
@ -121,6 +123,8 @@ describe('SlashCommandProvider', () => {
|
||||
'account',
|
||||
'zen',
|
||||
'go',
|
||||
'create',
|
||||
'refine',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
119
web/app/components/goto-anything/actions/commands/create.tsx
Normal file
119
web/app/components/goto-anything/actions/commands/create.tsx
Normal file
@ -0,0 +1,119 @@
|
||||
import type { SlashCommandHandler } from './types'
|
||||
import type { WorkflowGeneratorMode } from '@/app/components/workflow/workflow-generator/types'
|
||||
import { RiChat3Line, RiNodeTree } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { getI18n } from 'react-i18next'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { useWorkflowGeneratorStore } from '@/app/components/workflow/workflow-generator/store'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { registerCommands, unregisterCommands } from './command-bus'
|
||||
|
||||
type CreateOption = {
|
||||
id: string
|
||||
/** i18n key (ns: 'app') for the option's display label. */
|
||||
titleKey: string
|
||||
/** i18n key (ns: 'app') for the option's one-line description. */
|
||||
descKey: string
|
||||
mode: WorkflowGeneratorMode
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
}
|
||||
|
||||
// `as const` keeps titleKey/descKey as literal types so the typed `i18n.t`
|
||||
// accepts them as known keys; `satisfies` still validates the shape.
|
||||
const OPTIONS = [
|
||||
{
|
||||
id: 'workflow',
|
||||
titleKey: 'gotoAnything.actions.createWorkflow',
|
||||
descKey: 'gotoAnything.actions.createWorkflowDesc',
|
||||
mode: 'workflow',
|
||||
icon: RiNodeTree,
|
||||
},
|
||||
{
|
||||
id: 'chatflow',
|
||||
titleKey: 'gotoAnything.actions.createChatflow',
|
||||
descKey: 'gotoAnything.actions.createChatflowDesc',
|
||||
mode: 'advanced-chat',
|
||||
icon: RiChat3Line,
|
||||
},
|
||||
] as const satisfies readonly CreateOption[]
|
||||
|
||||
/**
|
||||
* `/create` command — generate a Workflow or Chatflow app from a
|
||||
* natural-language description.
|
||||
*
|
||||
* The user-picked mode is passed through to the generator modal explicitly
|
||||
* rather than sniffed from the URL, which avoids the mode-mismatch dead-end
|
||||
* the URL-sniffing approach used to produce.
|
||||
*
|
||||
* When triggered from inside a graph-based Studio (Workflow / Advanced-Chat)
|
||||
* whose app mode matches the picked mode, it threads the current app (id +
|
||||
* mode) through so the modal offers "Apply to current draft" — this is the
|
||||
* in-Studio create-and-apply journey that replaced the old toolbar button.
|
||||
* With no Studio app open, or when the picked mode differs from the open
|
||||
* app's mode, it falls back to new-app creation only.
|
||||
*/
|
||||
export const createCommand: SlashCommandHandler = {
|
||||
name: 'create',
|
||||
aliases: ['new', 'generate'],
|
||||
// Fallback only — the palette localises the root row via the slashKeyMap in
|
||||
// command-selector.tsx (gotoAnything.actions.createCategoryDesc).
|
||||
description: 'Create an AI-generated workflow or chatflow',
|
||||
mode: 'submenu',
|
||||
|
||||
async search(args: string, locale?: string) {
|
||||
const i18n = getI18n()
|
||||
const tr = (key: (typeof OPTIONS)[number]['titleKey' | 'descKey']) =>
|
||||
i18n.t(key, { ns: 'app', lng: locale })
|
||||
const query = args.trim().toLowerCase()
|
||||
const filtered = OPTIONS.filter(
|
||||
opt => !query || opt.id.includes(query) || tr(opt.titleKey).toLowerCase().includes(query),
|
||||
)
|
||||
return filtered.map(opt => ({
|
||||
id: `create-${opt.id}`,
|
||||
title: tr(opt.titleKey),
|
||||
description: tr(opt.descKey),
|
||||
type: 'command' as const,
|
||||
icon: (
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg">
|
||||
<opt.icon className="size-4 text-text-tertiary" />
|
||||
</div>
|
||||
),
|
||||
data: { command: 'create.open', args: { mode: opt.mode } },
|
||||
}))
|
||||
},
|
||||
|
||||
register() {
|
||||
registerCommands({
|
||||
'create.open': async (args) => {
|
||||
const mode: WorkflowGeneratorMode = (args?.mode ?? 'workflow') as WorkflowGeneratorMode
|
||||
|
||||
// If a graph-based Studio app is open and its mode matches the picked
|
||||
// mode, thread it through so the modal can offer "Apply to current
|
||||
// draft". A mode mismatch (or no app open) falls back to new-app only,
|
||||
// mirroring the precondition the modal uses for canApplyToCurrent.
|
||||
const appDetail = useAppStore.getState().appDetail
|
||||
const currentAppMode: WorkflowGeneratorMode | null
|
||||
= appDetail?.mode === AppModeEnum.WORKFLOW
|
||||
? 'workflow'
|
||||
: appDetail?.mode === AppModeEnum.ADVANCED_CHAT
|
||||
? 'advanced-chat'
|
||||
: null
|
||||
|
||||
if (appDetail && currentAppMode === mode) {
|
||||
useWorkflowGeneratorStore.getState().openGenerator({
|
||||
mode,
|
||||
currentAppId: appDetail.id,
|
||||
currentAppMode,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
useWorkflowGeneratorStore.getState().openGenerator({ mode })
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
unregister() {
|
||||
unregisterCommands(['create.open'])
|
||||
},
|
||||
}
|
||||
91
web/app/components/goto-anything/actions/commands/refine.tsx
Normal file
91
web/app/components/goto-anything/actions/commands/refine.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
import type { SlashCommandHandler } from './types'
|
||||
import type { WorkflowGeneratorMode } from '@/app/components/workflow/workflow-generator/types'
|
||||
import { RiSparkling2Line } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { getI18n } from 'react-i18next'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { useWorkflowGeneratorStore } from '@/app/components/workflow/workflow-generator/store'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { registerCommands, unregisterCommands } from './command-bus'
|
||||
|
||||
/**
|
||||
* Map the open app's mode to a generator mode, or null when the current app
|
||||
* isn't a graph-based Studio (Chat / Agent / Completion have no canvas to
|
||||
* refine). This is the single source of truth for both the availability gate
|
||||
* and the mode we open the generator with.
|
||||
*/
|
||||
const currentStudioMode = (): WorkflowGeneratorMode | null => {
|
||||
const appMode = useAppStore.getState().appDetail?.mode
|
||||
if (appMode === AppModeEnum.WORKFLOW)
|
||||
return 'workflow'
|
||||
if (appMode === AppModeEnum.ADVANCED_CHAT)
|
||||
return 'advanced-chat'
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the generator in `refine` intent for the current Studio. The modal
|
||||
* fetches the current draft graph and sends it as context so the LLM amends
|
||||
* what's on the canvas instead of starting from scratch. No-op when there's
|
||||
* no graph-based app open (the command is gated by `isAvailable`, but the
|
||||
* guard keeps a stray command-bus call safe).
|
||||
*/
|
||||
const openRefineGenerator = () => {
|
||||
const appDetail = useAppStore.getState().appDetail
|
||||
const mode = currentStudioMode()
|
||||
if (!appDetail || !mode)
|
||||
return
|
||||
useWorkflowGeneratorStore.getState().openGenerator({
|
||||
intent: 'refine',
|
||||
mode,
|
||||
currentAppId: appDetail.id,
|
||||
currentAppMode: mode,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* `/refine` command — refine the CURRENT Workflow / Chatflow draft graph from
|
||||
* a natural-language change description. Only available inside a graph-based
|
||||
* Studio; the mode is taken from the open app (no submenu to pick), and the
|
||||
* result always applies back to the current draft.
|
||||
*/
|
||||
export const refineCommand: SlashCommandHandler = {
|
||||
name: 'refine',
|
||||
aliases: ['improve'],
|
||||
// Fallback only — the palette localises the root row via the slashKeyMap in
|
||||
// command-selector.tsx (gotoAnything.actions.refineCategoryDesc).
|
||||
description: 'Refine the current workflow or chatflow graph',
|
||||
mode: 'direct',
|
||||
|
||||
// Only surface inside a Workflow / Advanced-Chat Studio — elsewhere there's
|
||||
// no draft graph to refine.
|
||||
isAvailable: () => currentStudioMode() !== null,
|
||||
|
||||
execute: openRefineGenerator,
|
||||
|
||||
async search(_args: string, locale?: string) {
|
||||
const i18n = getI18n()
|
||||
return [{
|
||||
id: 'refine-current',
|
||||
title: i18n.t('gotoAnything.actions.refineTitle', { ns: 'app', lng: locale }),
|
||||
description: i18n.t('gotoAnything.actions.refineDesc', { ns: 'app', lng: locale }),
|
||||
type: 'command' as const,
|
||||
icon: (
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg">
|
||||
<RiSparkling2Line className="size-4 text-text-tertiary" />
|
||||
</div>
|
||||
),
|
||||
data: { command: 'refine.open', args: {} },
|
||||
}]
|
||||
},
|
||||
|
||||
register() {
|
||||
registerCommands({
|
||||
'refine.open': async () => openRefineGenerator(),
|
||||
})
|
||||
},
|
||||
|
||||
unregister() {
|
||||
unregisterCommands(['refine.open'])
|
||||
},
|
||||
}
|
||||
@ -7,10 +7,12 @@ import { setLocaleOnClient } from '@/i18n-config'
|
||||
import { accountCommand } from './account'
|
||||
import { executeCommand } from './command-bus'
|
||||
import { communityCommand } from './community'
|
||||
import { createCommand } from './create'
|
||||
import { docsCommand } from './docs'
|
||||
import { forumCommand } from './forum'
|
||||
import { goCommand } from './go'
|
||||
import { languageCommand } from './language'
|
||||
import { refineCommand } from './refine'
|
||||
import { slashCommandRegistry } from './registry'
|
||||
import { themeCommand } from './theme'
|
||||
import { zenCommand } from './zen'
|
||||
@ -50,6 +52,8 @@ const registerSlashCommands = (deps: Record<string, any>) => {
|
||||
slashCommandRegistry.register(accountCommand, {})
|
||||
slashCommandRegistry.register(zenCommand, {})
|
||||
slashCommandRegistry.register(goCommand, {})
|
||||
slashCommandRegistry.register(createCommand, {})
|
||||
slashCommandRegistry.register(refineCommand, {})
|
||||
}
|
||||
|
||||
const unregisterSlashCommands = () => {
|
||||
@ -62,6 +66,8 @@ const unregisterSlashCommands = () => {
|
||||
slashCommandRegistry.unregister('account')
|
||||
slashCommandRegistry.unregister('zen')
|
||||
slashCommandRegistry.unregister('go')
|
||||
slashCommandRegistry.unregister('create')
|
||||
slashCommandRegistry.unregister('refine')
|
||||
}
|
||||
|
||||
export const SlashCommandProvider = () => {
|
||||
|
||||
@ -109,6 +109,8 @@ const CommandSelector: FC<Props> = ({ actions, onCommandSelect, searchFilter, co
|
||||
? (
|
||||
(() => {
|
||||
const slashKeyMap = {
|
||||
'/create': 'gotoAnything.actions.createCategoryDesc',
|
||||
'/refine': 'gotoAnything.actions.refineCategoryDesc',
|
||||
'/theme': 'gotoAnything.actions.themeCategoryDesc',
|
||||
'/language': 'gotoAnything.actions.languageChangeDesc',
|
||||
'/account': 'gotoAnything.actions.accountDesc',
|
||||
|
||||
@ -33,10 +33,10 @@ const FileUploadSetting: FC<Props> = ({
|
||||
const { t } = useTranslation()
|
||||
|
||||
const {
|
||||
allowed_file_upload_methods,
|
||||
allowed_file_upload_methods = [],
|
||||
max_length,
|
||||
allowed_file_types,
|
||||
allowed_file_extensions,
|
||||
allowed_file_types = [],
|
||||
allowed_file_extensions = [],
|
||||
} = payload
|
||||
const { data: fileUploadConfigResponse } = useFileUploadConfig()
|
||||
const {
|
||||
|
||||
@ -0,0 +1,265 @@
|
||||
import type { GeneratedGraph } from '../types'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { applyToCurrentApp, applyToNewApp, WorkflowApplyHashCollisionError, WorkflowApplyOrphanError } from '../apply'
|
||||
|
||||
// Stub the service calls so each test can assert what was POSTed without
|
||||
// touching real fetch / next router state.
|
||||
const mockCreateApp = vi.fn()
|
||||
const mockSyncWorkflowDraft = vi.fn()
|
||||
const mockFetchWorkflowDraft = vi.fn()
|
||||
const mockDeleteApp = vi.fn()
|
||||
|
||||
vi.mock('@/service/apps', () => ({
|
||||
createApp: (params: unknown) => mockCreateApp(params),
|
||||
deleteApp: (appId: string) => mockDeleteApp(appId),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/workflow', () => ({
|
||||
fetchWorkflowDraft: (url: string) => mockFetchWorkflowDraft(url),
|
||||
syncWorkflowDraft: (params: unknown) => mockSyncWorkflowDraft(params),
|
||||
}))
|
||||
|
||||
const makeGraph = (): GeneratedGraph => ({
|
||||
nodes: [
|
||||
{ id: 'node-1', type: 'custom', position: { x: 0, y: 0 }, data: { type: 'start', title: 'Start' } } as never,
|
||||
],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 0.7 },
|
||||
})
|
||||
|
||||
describe('applyToNewApp', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCreateApp.mockResolvedValue({ id: 'new-app-1', mode: AppModeEnum.WORKFLOW })
|
||||
mockSyncWorkflowDraft.mockResolvedValue({})
|
||||
})
|
||||
|
||||
// The new-app path must create the app, then sync the generated graph to
|
||||
// its draft and return the routing context the caller uses to navigate.
|
||||
it('should create the app, sync the draft and return the new app id and mode', async () => {
|
||||
const graph = makeGraph()
|
||||
const result = await applyToNewApp({ mode: 'workflow', graph, instruction: 'Summarize a URL' })
|
||||
|
||||
expect(mockCreateApp).toHaveBeenCalledWith(expect.objectContaining({
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
icon_type: 'emoji',
|
||||
}))
|
||||
expect(mockSyncWorkflowDraft).toHaveBeenCalledWith({
|
||||
url: 'apps/new-app-1/workflows/draft',
|
||||
params: {
|
||||
graph,
|
||||
features: {},
|
||||
environment_variables: [],
|
||||
conversation_variables: [],
|
||||
},
|
||||
})
|
||||
expect(result).toEqual({ appId: 'new-app-1', appMode: AppModeEnum.WORKFLOW })
|
||||
})
|
||||
|
||||
// Mode → AppModeEnum must round-trip for chatflow; the type-level guarantee
|
||||
// is verified at runtime so a regression here is caught before users hit it.
|
||||
it('should map advanced-chat mode to AppModeEnum.ADVANCED_CHAT', async () => {
|
||||
mockCreateApp.mockResolvedValueOnce({ id: 'cf-1', mode: AppModeEnum.ADVANCED_CHAT })
|
||||
|
||||
const result = await applyToNewApp({
|
||||
mode: 'advanced-chat',
|
||||
graph: makeGraph(),
|
||||
instruction: 'A chat bot that answers questions',
|
||||
})
|
||||
|
||||
expect(mockCreateApp).toHaveBeenCalledWith(expect.objectContaining({ mode: AppModeEnum.ADVANCED_CHAT }))
|
||||
expect(result.appMode).toBe(AppModeEnum.ADVANCED_CHAT)
|
||||
})
|
||||
|
||||
// The derived name keeps the user instruction recognisable in the apps list
|
||||
// — strip trailing punctuation and never produce an empty string.
|
||||
it('should derive a sensible app name from the instruction', async () => {
|
||||
await applyToNewApp({ mode: 'workflow', graph: makeGraph(), instruction: ' Build a translator. ' })
|
||||
|
||||
expect(mockCreateApp).toHaveBeenCalledWith(expect.objectContaining({ name: 'Build a translator' }))
|
||||
})
|
||||
|
||||
// Instruction-only-of-punctuation must still produce a usable, non-empty
|
||||
// app name so create-app doesn't fail validation.
|
||||
it('should fall back to "Generated Workflow" when the instruction is empty', async () => {
|
||||
await applyToNewApp({ mode: 'workflow', graph: makeGraph(), instruction: ' ' })
|
||||
|
||||
expect(mockCreateApp).toHaveBeenCalledWith(expect.objectContaining({ name: 'Generated Workflow' }))
|
||||
})
|
||||
|
||||
// When the planner picks a name + emoji, those win over the
|
||||
// instruction-derived fallback so users see a real product name in the
|
||||
// apps list (e.g. "URL Summarizer" + 📰 instead of "Summarize a URL" + 🤖).
|
||||
it('should prefer planner-supplied app_name and icon over the fallbacks', async () => {
|
||||
await applyToNewApp({
|
||||
mode: 'workflow',
|
||||
graph: makeGraph(),
|
||||
instruction: 'Summarize a URL',
|
||||
appName: 'URL Summarizer',
|
||||
icon: '📰',
|
||||
})
|
||||
|
||||
expect(mockCreateApp).toHaveBeenCalledWith(expect.objectContaining({
|
||||
name: 'URL Summarizer',
|
||||
icon: '📰',
|
||||
}))
|
||||
})
|
||||
|
||||
// When the planner returns whitespace-only values (older prompts / model
|
||||
// drift), the fallbacks must kick in so we never POST an empty string to
|
||||
// createApp.
|
||||
it('should fall back when planner-supplied app_name / icon are blank', async () => {
|
||||
await applyToNewApp({
|
||||
mode: 'workflow',
|
||||
graph: makeGraph(),
|
||||
instruction: 'Summarize a URL',
|
||||
appName: ' ',
|
||||
icon: '',
|
||||
})
|
||||
|
||||
expect(mockCreateApp).toHaveBeenCalledWith(expect.objectContaining({
|
||||
name: 'Summarize a URL',
|
||||
icon: '🤖',
|
||||
}))
|
||||
})
|
||||
|
||||
// Sync failure must roll back the createApp so the user isn't left with an
|
||||
// empty app in their /apps list. deleteApp is called with the new app id,
|
||||
// and the original sync error is re-thrown so the caller can toast it.
|
||||
it('should delete the new app when syncWorkflowDraft fails', async () => {
|
||||
mockCreateApp.mockResolvedValueOnce({ id: 'doomed', mode: AppModeEnum.WORKFLOW })
|
||||
const syncErr = new Error('sync exploded')
|
||||
mockSyncWorkflowDraft.mockRejectedValueOnce(syncErr)
|
||||
mockDeleteApp.mockResolvedValueOnce(undefined)
|
||||
|
||||
await expect(applyToNewApp({
|
||||
mode: 'workflow',
|
||||
graph: makeGraph(),
|
||||
instruction: 'x',
|
||||
})).rejects.toBe(syncErr)
|
||||
|
||||
expect(mockDeleteApp).toHaveBeenCalledWith('doomed')
|
||||
})
|
||||
|
||||
// The truly stuck path: sync fails AND the rollback delete also fails. We
|
||||
// throw WorkflowApplyOrphanError so the caller can route to /apps where
|
||||
// the orphan is at least discoverable for manual cleanup. The error
|
||||
// carries the orphan app id so the toast can name it.
|
||||
it('should throw WorkflowApplyOrphanError when both sync and rollback fail', async () => {
|
||||
mockCreateApp.mockResolvedValueOnce({ id: 'orphan-7', mode: AppModeEnum.WORKFLOW })
|
||||
mockSyncWorkflowDraft.mockRejectedValueOnce(new Error('sync exploded'))
|
||||
mockDeleteApp.mockRejectedValueOnce(new Error('delete also exploded'))
|
||||
|
||||
let caught: unknown
|
||||
try {
|
||||
await applyToNewApp({ mode: 'workflow', graph: makeGraph(), instruction: 'x' })
|
||||
}
|
||||
catch (e) {
|
||||
caught = e
|
||||
}
|
||||
expect(caught).toBeInstanceOf(WorkflowApplyOrphanError)
|
||||
expect((caught as WorkflowApplyOrphanError).orphanAppId).toBe('orphan-7')
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyToCurrentApp', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSyncWorkflowDraft.mockResolvedValue({})
|
||||
})
|
||||
|
||||
// Happy path: the fetch yields an existing draft so the sync MUST include
|
||||
// its hash. Without this, the backend rejects the write with
|
||||
// WorkflowHashNotEqualError (the original bug behind the manual fix).
|
||||
it('should fetch the current draft and forward its hash on sync', async () => {
|
||||
mockFetchWorkflowDraft.mockResolvedValue({
|
||||
hash: 'h-existing',
|
||||
features: { file_upload: { enabled: true } },
|
||||
environment_variables: [{ id: 'e1', name: 'API_KEY', value_type: 'secret', value: 'x' }],
|
||||
conversation_variables: [{ id: 'c1', name: 'memory', value_type: 'string', value: '' }],
|
||||
})
|
||||
|
||||
const graph = makeGraph()
|
||||
await applyToCurrentApp({ appId: 'app-42', graph })
|
||||
|
||||
expect(mockFetchWorkflowDraft).toHaveBeenCalledWith('apps/app-42/workflows/draft')
|
||||
expect(mockSyncWorkflowDraft).toHaveBeenCalledWith({
|
||||
url: 'apps/app-42/workflows/draft',
|
||||
params: expect.objectContaining({
|
||||
graph,
|
||||
features: { file_upload: { enabled: true } },
|
||||
hash: 'h-existing',
|
||||
}),
|
||||
})
|
||||
// Existing env vars and conversation vars must be preserved verbatim.
|
||||
const params = mockSyncWorkflowDraft.mock.calls[0]![0].params
|
||||
expect(params.environment_variables).toHaveLength(1)
|
||||
expect(params.conversation_variables).toHaveLength(1)
|
||||
})
|
||||
|
||||
// First-apply path: a freshly created Workflow app has no draft yet, so the
|
||||
// fetch resolves to undefined and we must sync without a hash field so the
|
||||
// backend lazy-creates the draft instead of raising.
|
||||
it('should sync without a hash when no draft yet exists', async () => {
|
||||
mockFetchWorkflowDraft.mockResolvedValue(undefined)
|
||||
|
||||
await applyToCurrentApp({ appId: 'fresh-app', graph: makeGraph() })
|
||||
|
||||
expect(mockSyncWorkflowDraft).toHaveBeenCalledTimes(1)
|
||||
const params = mockSyncWorkflowDraft.mock.calls[0]![0].params
|
||||
expect(params).not.toHaveProperty('hash')
|
||||
expect(params.features).toEqual({})
|
||||
expect(params.environment_variables).toEqual([])
|
||||
expect(params.conversation_variables).toEqual([])
|
||||
})
|
||||
|
||||
// Resilience: a fetch failure (network blip, transient 5xx) must not block
|
||||
// the apply — fall back to a hashless sync so the new draft can still land.
|
||||
it('should fall back to a hashless sync when fetchWorkflowDraft throws', async () => {
|
||||
mockFetchWorkflowDraft.mockRejectedValue(new Error('network down'))
|
||||
|
||||
await applyToCurrentApp({ appId: 'app-7', graph: makeGraph() })
|
||||
|
||||
expect(mockSyncWorkflowDraft).toHaveBeenCalledTimes(1)
|
||||
const params = mockSyncWorkflowDraft.mock.calls[0]![0].params
|
||||
expect(params).not.toHaveProperty('hash')
|
||||
})
|
||||
|
||||
// A 409 from syncWorkflowDraft is the backend's signal that another tab
|
||||
// edited the draft between our fetch and sync. The apply layer translates
|
||||
// that Response into a typed error so the caller can show a Reload
|
||||
// affordance instead of a generic "apply failed" toast.
|
||||
it('should translate a 409 sync rejection into WorkflowApplyHashCollisionError', async () => {
|
||||
mockFetchWorkflowDraft.mockResolvedValue({
|
||||
hash: 'h1',
|
||||
features: {},
|
||||
environment_variables: [],
|
||||
conversation_variables: [],
|
||||
})
|
||||
// ``base.ts`` rejects with the raw Response on non-401 — fake just the
|
||||
// ``status`` field, which is what ``isHashCollisionResponse`` consults.
|
||||
mockSyncWorkflowDraft.mockRejectedValueOnce({ status: 409, code: 'draft_workflow_not_sync' })
|
||||
|
||||
await expect(applyToCurrentApp({ appId: 'app-9', graph: makeGraph() }))
|
||||
.rejects
|
||||
.toBeInstanceOf(WorkflowApplyHashCollisionError)
|
||||
})
|
||||
|
||||
// Non-409 errors (5xx, network) MUST NOT be misclassified as hash
|
||||
// collisions — those still surface as the original rejection so the
|
||||
// generic "apply failed" toast fires.
|
||||
it('should NOT translate non-409 sync rejections', async () => {
|
||||
mockFetchWorkflowDraft.mockResolvedValue({
|
||||
hash: 'h1',
|
||||
features: {},
|
||||
environment_variables: [],
|
||||
conversation_variables: [],
|
||||
})
|
||||
const original = { status: 500, code: 'internal_server_error' }
|
||||
mockSyncWorkflowDraft.mockRejectedValueOnce(original)
|
||||
|
||||
await expect(applyToCurrentApp({ appId: 'app-9', graph: makeGraph() }))
|
||||
.rejects
|
||||
.toBe(original)
|
||||
})
|
||||
})
|
||||
@ -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,92 @@
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import GenerationPhases from '../generation-phases'
|
||||
|
||||
describe('GenerationPhases', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
// The first frame the user sees during generation must be the "planning"
|
||||
// phase — never an empty container or a different phase — so the perceived
|
||||
// latency starts dropping immediately.
|
||||
it('should start on the planning phase', () => {
|
||||
render(<GenerationPhases startedAt={1} />)
|
||||
expect(screen.getByText(/phases\.planning/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// After the planner timer elapses we move to "building". The component
|
||||
// doesn't reset to "planning" if the parent stays mounted — the timer
|
||||
// chain only steps forward.
|
||||
it('should advance to the building phase after the planning timer', () => {
|
||||
render(<GenerationPhases startedAt={1} />)
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(3500)
|
||||
})
|
||||
|
||||
expect(screen.getByText(/phases\.building/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/phases\.planning/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// The validating phase is the last in the schedule; once we get there we
|
||||
// stay there indefinitely so a slow LLM doesn't make the indicator loop
|
||||
// backwards and confuse the user.
|
||||
it('should land on validating and not loop back to planning even after long delays', () => {
|
||||
render(<GenerationPhases startedAt={1} />)
|
||||
|
||||
// Advance through phases in two steps — React schedules the next
|
||||
// ``setTimeout`` only after the prior effect re-runs with the new
|
||||
// ``phaseIndex``, so a single combined advance leaves us mid-phase.
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(3500)
|
||||
})
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(12000)
|
||||
})
|
||||
expect(screen.getByText(/phases\.validating/i)).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(60000)
|
||||
})
|
||||
// Still validating — no reset, no loop.
|
||||
expect(screen.getByText(/phases\.validating/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/phases\.planning/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Unmount cleanup matters because the modal is destroyed when the user
|
||||
// closes it mid-generation; lingering timers would keep firing setState on
|
||||
// an unmounted tree.
|
||||
it('should not leak a timer when unmounted before the next phase fires', () => {
|
||||
const { unmount } = render(<GenerationPhases startedAt={1} />)
|
||||
// Sanity: pending timer should exist.
|
||||
expect(vi.getTimerCount()).toBeGreaterThan(0)
|
||||
|
||||
unmount()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
// A second Generate click bumps ``startedAt``. The component must reset to
|
||||
// "Planning" so the indicator doesn't appear wedged on "Validating" from
|
||||
// the previous attempt. Without this the user thinks the system is stuck.
|
||||
it('should reset to the planning phase when startedAt changes', () => {
|
||||
const { rerender } = render(<GenerationPhases startedAt={1} />)
|
||||
// Drive the first attempt all the way to validating.
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(3500)
|
||||
})
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(12000)
|
||||
})
|
||||
expect(screen.getByText(/phases\.validating/i)).toBeInTheDocument()
|
||||
|
||||
// New attempt starts → bump startedAt. The component should snap back
|
||||
// to planning rather than staying on validating.
|
||||
rerender(<GenerationPhases startedAt={2} />)
|
||||
expect(screen.getByText(/phases\.planning/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/phases\.validating/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,155 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useWorkflowGeneratorStore } from '../store'
|
||||
|
||||
// Reset zustand state between tests so they don't share opener context.
|
||||
const resetStore = () => {
|
||||
useWorkflowGeneratorStore.setState({
|
||||
isOpen: false,
|
||||
mode: 'workflow',
|
||||
currentAppId: null,
|
||||
currentAppMode: null,
|
||||
})
|
||||
}
|
||||
|
||||
describe('useWorkflowGeneratorStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetStore()
|
||||
})
|
||||
|
||||
describe('initial state', () => {
|
||||
// Default snapshot: the generator modal is closed, mode is "workflow", and
|
||||
// there is no current-app context attached.
|
||||
it('should start closed in workflow mode with no current app', () => {
|
||||
const { result } = renderHook(() => useWorkflowGeneratorStore())
|
||||
|
||||
expect(result.current.isOpen).toBe(false)
|
||||
expect(result.current.mode).toBe('workflow')
|
||||
expect(result.current.currentAppId).toBeNull()
|
||||
expect(result.current.currentAppMode).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('openGenerator', () => {
|
||||
// Opening from a non-Studio surface (e.g. /apps page): only the requested
|
||||
// mode is set; currentAppId stays null so the modal hides "Apply to current".
|
||||
it('should open with the requested mode and no current app by default', () => {
|
||||
const { result } = renderHook(() => useWorkflowGeneratorStore())
|
||||
|
||||
act(() => {
|
||||
result.current.openGenerator({ mode: 'advanced-chat' })
|
||||
})
|
||||
|
||||
expect(result.current.isOpen).toBe(true)
|
||||
expect(result.current.mode).toBe('advanced-chat')
|
||||
expect(result.current.currentAppId).toBeNull()
|
||||
expect(result.current.currentAppMode).toBeNull()
|
||||
})
|
||||
|
||||
// Opening from inside Studio: caller passes currentAppId + currentAppMode
|
||||
// so the modal can show "Apply to current draft".
|
||||
it('should accept a current app id and mode when opened from Studio', () => {
|
||||
const { result } = renderHook(() => useWorkflowGeneratorStore())
|
||||
|
||||
act(() => {
|
||||
result.current.openGenerator({
|
||||
mode: 'workflow',
|
||||
currentAppId: 'app-123',
|
||||
currentAppMode: 'workflow',
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current.isOpen).toBe(true)
|
||||
expect(result.current.mode).toBe('workflow')
|
||||
expect(result.current.currentAppId).toBe('app-123')
|
||||
expect(result.current.currentAppMode).toBe('workflow')
|
||||
})
|
||||
|
||||
// Reopening with new parameters must overwrite the previous mode/context;
|
||||
// stale state would let the modal apply to the wrong app.
|
||||
it('should overwrite previous state on a subsequent open', () => {
|
||||
const { result } = renderHook(() => useWorkflowGeneratorStore())
|
||||
|
||||
act(() => {
|
||||
result.current.openGenerator({ mode: 'workflow', currentAppId: 'app-1', currentAppMode: 'workflow' })
|
||||
})
|
||||
act(() => {
|
||||
result.current.openGenerator({ mode: 'advanced-chat' })
|
||||
})
|
||||
|
||||
expect(result.current.mode).toBe('advanced-chat')
|
||||
expect(result.current.currentAppId).toBeNull()
|
||||
expect(result.current.currentAppMode).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('closeGenerator', () => {
|
||||
// Closing flips isOpen back to false but preserves mode / currentAppId so
|
||||
// a subsequent reopen can decide whether to keep or replace them.
|
||||
it('should close the modal without clearing the captured context', () => {
|
||||
const { result } = renderHook(() => useWorkflowGeneratorStore())
|
||||
|
||||
act(() => {
|
||||
result.current.openGenerator({ mode: 'workflow', currentAppId: 'app-9', currentAppMode: 'workflow' })
|
||||
})
|
||||
act(() => {
|
||||
result.current.closeGenerator()
|
||||
})
|
||||
|
||||
expect(result.current.isOpen).toBe(false)
|
||||
expect(result.current.mode).toBe('workflow')
|
||||
expect(result.current.currentAppId).toBe('app-9')
|
||||
expect(result.current.currentAppMode).toBe('workflow')
|
||||
})
|
||||
})
|
||||
|
||||
describe('history reset on /create open', () => {
|
||||
// /create must always start on the empty placeholder — the previous
|
||||
// session's versions belong to a different intent and confuse the user.
|
||||
// Without this, opening /create twice in a row would re-show the prior
|
||||
// generated graph. Studio-refine sessions (with currentAppId) keep
|
||||
// their history so close+reopen of the toolbar Generate doesn't lose
|
||||
// the versions the user was comparing.
|
||||
it('should clear new-app sessionStorage keys when opened without a currentAppId', () => {
|
||||
sessionStorage.setItem('workflow-gen-workflow-new-versions', JSON.stringify([{ ghost: true }]))
|
||||
sessionStorage.setItem('workflow-gen-workflow-new-version-index', '3')
|
||||
|
||||
const { result } = renderHook(() => useWorkflowGeneratorStore())
|
||||
act(() => {
|
||||
result.current.openGenerator({ mode: 'workflow' })
|
||||
})
|
||||
|
||||
expect(sessionStorage.getItem('workflow-gen-workflow-new-versions')).toBeNull()
|
||||
expect(sessionStorage.getItem('workflow-gen-workflow-new-version-index')).toBeNull()
|
||||
})
|
||||
|
||||
// Only the mode being opened gets cleared — opening /create for
|
||||
// workflow must not wipe a parallel advanced-chat /create session in
|
||||
// another tab's sessionStorage path (we share the same sessionStorage
|
||||
// namespace per tab, but only the corresponding mode key is wiped).
|
||||
it('should leave the other mode\'s new-app history alone', () => {
|
||||
sessionStorage.setItem('workflow-gen-advanced-chat-new-versions', JSON.stringify([{ keep: true }]))
|
||||
|
||||
const { result } = renderHook(() => useWorkflowGeneratorStore())
|
||||
act(() => {
|
||||
result.current.openGenerator({ mode: 'workflow' })
|
||||
})
|
||||
|
||||
expect(sessionStorage.getItem('workflow-gen-advanced-chat-new-versions')).not.toBeNull()
|
||||
})
|
||||
|
||||
// Studio refine sessions (currentAppId present) must NOT clear their
|
||||
// history — the user expects to find their previous versions when they
|
||||
// reopen the toolbar Generate button.
|
||||
it('should NOT clear history when opened with a currentAppId', () => {
|
||||
sessionStorage.setItem('workflow-gen-workflow-app-42-versions', JSON.stringify([{ keep: true }]))
|
||||
|
||||
const { result } = renderHook(() => useWorkflowGeneratorStore())
|
||||
act(() => {
|
||||
result.current.openGenerator({ mode: 'workflow', currentAppId: 'app-42', currentAppMode: 'workflow' })
|
||||
})
|
||||
|
||||
expect(sessionStorage.getItem('workflow-gen-workflow-app-42-versions')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
195
web/app/components/workflow/workflow-generator/apply.ts
Normal file
195
web/app/components/workflow/workflow-generator/apply.ts
Normal file
@ -0,0 +1,195 @@
|
||||
import type { GeneratedGraph, WorkflowGeneratorMode } from './types'
|
||||
import { createApp, deleteApp } from '@/service/apps'
|
||||
import { fetchWorkflowDraft, syncWorkflowDraft } from '@/service/workflow'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
const MODE_TO_APP_MODE: Record<WorkflowGeneratorMode, AppModeEnum> = {
|
||||
'workflow': AppModeEnum.WORKFLOW,
|
||||
'advanced-chat': AppModeEnum.ADVANCED_CHAT,
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown by ``applyToCurrentApp`` when the backend rejects the sync because
|
||||
* the draft's ``unique_hash`` doesn't match — typically another tab edited
|
||||
* the draft after we fetched it. The caller maps this to a dedicated
|
||||
* "Workspace was edited elsewhere" toast with a Reload affordance instead
|
||||
* of a generic "Apply failed".
|
||||
*
|
||||
* Backend surfaces this as HTTP 409 with ``error_code:
|
||||
* "draft_workflow_not_sync"`` (see
|
||||
* ``api/controllers/console/app/error.py::DraftWorkflowNotSync``).
|
||||
*/
|
||||
export class WorkflowApplyHashCollisionError extends Error {
|
||||
constructor() {
|
||||
super('Workflow draft was modified in another tab; reload required.')
|
||||
this.name = 'WorkflowApplyHashCollisionError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown by ``applyToNewApp`` when the freshly-created app's draft sync
|
||||
* failed AND we couldn't roll back by deleting the new app. The caller
|
||||
* routes the user to ``/apps`` so the orphan is at least discoverable and
|
||||
* surfaces a localised toast — without this an unrecoverable orphan would
|
||||
* silently sit in the app list with no graph.
|
||||
*/
|
||||
export class WorkflowApplyOrphanError extends Error {
|
||||
readonly orphanAppId: string
|
||||
|
||||
constructor(orphanAppId: string, cause?: unknown) {
|
||||
// ES2022 Error supports ``cause`` natively via the options bag — far
|
||||
// cleaner than reassigning a typed-cast property after construction.
|
||||
super(`Failed to apply graph; new app ${orphanAppId} may be orphaned.`, { cause })
|
||||
this.name = 'WorkflowApplyOrphanError'
|
||||
this.orphanAppId = orphanAppId
|
||||
}
|
||||
}
|
||||
|
||||
const isHashCollisionResponse = (e: unknown): boolean => {
|
||||
// The shared ``post()`` wrapper rejects with the raw ``Response`` for non-401
|
||||
// failures (see ``service/base.ts::request`` catch branch). At this layer the
|
||||
// only reliable signal is the HTTP status.
|
||||
if (!e || typeof e !== 'object')
|
||||
return false
|
||||
return (e as { status?: number }).status === 409
|
||||
}
|
||||
|
||||
// Derive a sane App name from the user's instruction: trim, cap at 40 chars,
|
||||
// strip trailing punctuation.
|
||||
const deriveAppName = (instruction: string): string => {
|
||||
const trimmed = instruction.trim().slice(0, 40)
|
||||
return trimmed.replace(/[.,!?;:。,!?;:]+$/, '').trim() || 'Generated Workflow'
|
||||
}
|
||||
|
||||
type ApplyToNewAppParams = {
|
||||
mode: WorkflowGeneratorMode
|
||||
graph: GeneratedGraph
|
||||
instruction: string
|
||||
/**
|
||||
* Planner-picked product-style name (e.g. "URL Summarizer"). When empty,
|
||||
* we fall back to ``deriveAppName(instruction)`` so the apps list never
|
||||
* shows an empty title.
|
||||
*/
|
||||
appName?: string
|
||||
/**
|
||||
* Planner-picked emoji (e.g. "📰"). When empty, we fall back to 🤖
|
||||
* which is the historical default.
|
||||
*/
|
||||
icon?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply path A — create a brand-new Workflow / Chatflow app and write the
|
||||
* generated graph into its draft. Returns the created app id so the caller
|
||||
* can route to ``/app/{id}/workflow``.
|
||||
*/
|
||||
export const applyToNewApp = async ({
|
||||
mode,
|
||||
graph,
|
||||
instruction,
|
||||
appName,
|
||||
icon,
|
||||
}: ApplyToNewAppParams): Promise<{ appId: string, appMode: AppModeEnum }> => {
|
||||
const appMode = MODE_TO_APP_MODE[mode]
|
||||
const name = (appName ?? '').trim() || deriveAppName(instruction)
|
||||
const appIcon = (icon ?? '').trim() || '🤖'
|
||||
const app = await createApp({
|
||||
name,
|
||||
mode: appMode,
|
||||
icon_type: 'emoji',
|
||||
icon: appIcon,
|
||||
icon_background: '#FFEAD5',
|
||||
description: instruction.trim().slice(0, 200),
|
||||
})
|
||||
|
||||
// Sync the generated graph into the brand-new app's draft. ``createApp``
|
||||
// already succeeded so the app exists; if the sync fails (network blip,
|
||||
// backend rejection of the graph) we MUST roll the createApp back so the
|
||||
// user isn't left with a discoverable-but-empty app sitting at the top
|
||||
// of their /apps list. ``deleteApp`` is best-effort — if that also fails
|
||||
// (it usually won't, it's a simple DELETE) we surface ``WorkflowApplyOrphanError``
|
||||
// so the caller can route to /apps where the orphan is still recoverable
|
||||
// by hand.
|
||||
try {
|
||||
await syncWorkflowDraft({
|
||||
url: `apps/${app.id}/workflows/draft`,
|
||||
params: {
|
||||
graph,
|
||||
features: {},
|
||||
environment_variables: [],
|
||||
conversation_variables: [],
|
||||
},
|
||||
})
|
||||
}
|
||||
catch (syncErr) {
|
||||
try {
|
||||
await deleteApp(app.id)
|
||||
}
|
||||
catch (deleteErr) {
|
||||
throw new WorkflowApplyOrphanError(app.id, deleteErr)
|
||||
}
|
||||
throw syncErr
|
||||
}
|
||||
|
||||
return { appId: app.id, appMode }
|
||||
}
|
||||
|
||||
type ApplyToCurrentAppParams = {
|
||||
appId: string
|
||||
graph: GeneratedGraph
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply path B — overwrite the current Workflow Studio's draft graph.
|
||||
*
|
||||
* The backend's ``sync_draft_workflow`` rejects writes whose ``hash`` doesn't
|
||||
* match the existing draft's ``unique_hash`` (WorkflowHashNotEqualError), so we
|
||||
* must read the current draft first to grab its hash. We also preserve the
|
||||
* existing ``features``, ``environment_variables`` and ``conversation_variables``
|
||||
* — only nodes / edges / viewport (the ``graph`` field) get replaced by the
|
||||
* generated graph.
|
||||
*
|
||||
* Caller is responsible for showing the overwrite confirmation dialog before
|
||||
* invoking this.
|
||||
*/
|
||||
export const applyToCurrentApp = async ({
|
||||
appId,
|
||||
graph,
|
||||
}: ApplyToCurrentAppParams): Promise<void> => {
|
||||
const url = `apps/${appId}/workflows/draft`
|
||||
|
||||
// First sync may have no existing draft (workflow apps can exist before Studio
|
||||
// has created/saved a draft). ``fetchWorkflowDraft`` rejects on non-2xx (e.g.
|
||||
// 404), so we treat any fetch failure as "no existing draft" and sync without
|
||||
// a hash.
|
||||
let existing: Awaited<ReturnType<typeof fetchWorkflowDraft>> | null = null
|
||||
try {
|
||||
existing = await fetchWorkflowDraft(url)
|
||||
}
|
||||
catch {
|
||||
existing = null
|
||||
}
|
||||
|
||||
try {
|
||||
await syncWorkflowDraft({
|
||||
url,
|
||||
params: {
|
||||
graph,
|
||||
features: existing?.features ?? {},
|
||||
environment_variables: existing?.environment_variables ?? [],
|
||||
conversation_variables: existing?.conversation_variables ?? [],
|
||||
// Field is accepted by the backend but not typed in the Pick<> shape of
|
||||
// ``syncWorkflowDraft``'s params — spread it in so it reaches the wire.
|
||||
...(existing?.hash ? { hash: existing.hash } : {}),
|
||||
} as Parameters<typeof syncWorkflowDraft>[0]['params'],
|
||||
})
|
||||
}
|
||||
catch (e) {
|
||||
// 409 → draft was edited in another tab between our fetch and sync.
|
||||
// Translate the raw Response rejection into a typed error so the caller
|
||||
// can show a Reload affordance instead of a generic "apply failed" toast.
|
||||
if (isHashCollisionResponse(e))
|
||||
throw new WorkflowApplyHashCollisionError()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
@ -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,72 @@
|
||||
'use client'
|
||||
import { memo, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
|
||||
/**
|
||||
* Approximate stage durations (ms) for the slim 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
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* Per-attempt nonce — typically ``Date.now()`` of when Generate was
|
||||
* clicked. The component resets ``phaseIndex`` whenever this changes so a
|
||||
* second Generate click starts the indicator from "Planning…" instead of
|
||||
* resuming wherever the previous attempt left off.
|
||||
*/
|
||||
startedAt: number
|
||||
}
|
||||
|
||||
const GenerationPhases = ({ startedAt }: Props) => {
|
||||
const { t } = useTranslation('workflow')
|
||||
const [phaseIndex, setPhaseIndex] = useState(0)
|
||||
|
||||
// Reset the indicator whenever a new attempt starts. Without this, a
|
||||
// failed first attempt followed by a quick retry would resume mid-phase
|
||||
// (or stuck on "Validating…") which looks like the system is wedged.
|
||||
// ``set-state-in-effect`` flags this pattern, but the reset is the
|
||||
// intent — driven by an external prop change, not by render-time state.
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react/set-state-in-effect
|
||||
setPhaseIndex(0)
|
||||
}, [startedAt])
|
||||
|
||||
useEffect(() => {
|
||||
if (phaseIndex === 0) {
|
||||
const timer = setTimeout(() => setPhaseIndex(1), PLANNING_MS)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
if (phaseIndex === 1) {
|
||||
const timer = setTimeout(() => setPhaseIndex(2), BUILDING_MS)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
// phaseIndex === 2 — terminal phase, no further timer.
|
||||
}, [phaseIndex])
|
||||
|
||||
const label = (() => {
|
||||
if (phaseIndex === 0)
|
||||
return t('workflowGenerator.phases.planning')
|
||||
if (phaseIndex === 1)
|
||||
return t('workflowGenerator.phases.building')
|
||||
return t('workflowGenerator.phases.validating')
|
||||
})()
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-0 grow flex-col items-center justify-center space-y-3">
|
||||
<Loading />
|
||||
<div className="text-[13px] text-text-tertiary">{label}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(GenerationPhases)
|
||||
592
web/app/components/workflow/workflow-generator/index.tsx
Normal file
592
web/app/components/workflow/workflow-generator/index.tsx
Normal file
@ -0,0 +1,592 @@
|
||||
'use client'
|
||||
import type { GeneratedGraph } from './types'
|
||||
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { CompletionParams, Model, ModelModeType } from '@/types/app'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import IdeaOutput from '@/app/components/app/configuration/config/automatic/idea-output'
|
||||
import VersionSelector from '@/app/components/app/configuration/config/automatic/version-selector'
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
|
||||
import WorkflowPreview from '@/app/components/workflow/workflow-preview'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useLocalStorage } from '@/hooks/use-local-storage'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { generateWorkflow } from '@/service/debug'
|
||||
import { fetchWorkflowDraft } from '@/service/workflow'
|
||||
import { getRedirectionPath } from '@/utils/app-redirection'
|
||||
import { applyToCurrentApp, applyToNewApp, WorkflowApplyHashCollisionError, WorkflowApplyOrphanError } from './apply'
|
||||
import ExamplePrompts from './example-prompts'
|
||||
import GenerationPhases from './generation-phases'
|
||||
import { useWorkflowGeneratorStore } from './store'
|
||||
import useGenGraph from './use-gen-graph'
|
||||
|
||||
const STORAGE_MODEL_KEY = 'workflow-gen-model'
|
||||
const FE_TIMEOUT_MS = 60_000
|
||||
|
||||
// Stable default used both as the SSR/empty-storage seed for the persisted
|
||||
// model and as the merge base when patching a partial update. Module-level so
|
||||
// the reference stays identical across renders (useLocalStorage uses it as the
|
||||
// server value, which must not change identity each render).
|
||||
const EMPTY_MODEL: Model = {
|
||||
name: '',
|
||||
provider: '',
|
||||
mode: 'chat' as unknown as ModelModeType.chat,
|
||||
completion_params: {} as CompletionParams,
|
||||
}
|
||||
|
||||
const renderPlaceholder = (label: string) => (
|
||||
<div className="flex h-full w-0 grow flex-col items-center justify-center space-y-3 px-8">
|
||||
<span className="i-custom-vender-other-generator size-8 text-text-quaternary" />
|
||||
<div className="text-center text-[13px] leading-5 font-normal text-text-tertiary">
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// AbortController throws a DOMException in modern browsers and a plain
|
||||
// Error in older / non-DOM environments — accept both so we don't toast
|
||||
// for an abort the user intentionally triggered.
|
||||
const isAbortError = (e: unknown): boolean =>
|
||||
(e instanceof DOMException || e instanceof Error) && e.name === 'AbortError'
|
||||
|
||||
type RecoveryDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
title: string
|
||||
description: string
|
||||
cancelLabel: string
|
||||
confirmLabel: string
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
// Shared shell for "we hit a snag — here's a Reload / Confirm button"
|
||||
// dialogs. The overwrite-confirm and hash-collision dialogs differ only in
|
||||
// copy and confirm handler; this collapses 30 lines of duplicate JSX to
|
||||
// one props bag and keeps the visual styling in lockstep across both.
|
||||
const RecoveryDialog = ({ open, onOpenChange, title, description, cancelLabel, confirmLabel, onConfirm }: RecoveryDialogProps) => (
|
||||
<AlertDialog open={open} onOpenChange={o => !o && onOpenChange(false)}>
|
||||
<AlertDialogContent>
|
||||
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
|
||||
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
|
||||
{title}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
|
||||
{description}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton>{cancelLabel}</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton onClick={onConfirm}>{confirmLabel}</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
|
||||
const WorkflowGeneratorModal: React.FC = () => {
|
||||
const { t } = useTranslation('workflow')
|
||||
const router = useRouter()
|
||||
const { isCurrentWorkspaceEditor } = useAppContext()
|
||||
|
||||
const isOpen = useWorkflowGeneratorStore(s => s.isOpen)
|
||||
const mode = useWorkflowGeneratorStore(s => s.mode)
|
||||
const intent = useWorkflowGeneratorStore(s => s.intent)
|
||||
const currentAppId = useWorkflowGeneratorStore(s => s.currentAppId)
|
||||
const currentAppMode = useWorkflowGeneratorStore(s => s.currentAppMode)
|
||||
const closeGenerator = useWorkflowGeneratorStore(s => s.closeGenerator)
|
||||
|
||||
const isRefine = intent === 'refine' && !!currentAppId
|
||||
|
||||
// Persisted model selection. ``useLocalStorage`` is the storage boundary
|
||||
// mandated for client-only preferences — the empty model is the SSR/seed
|
||||
// value so ``model`` is always a concrete ``Model`` (never null) here.
|
||||
const [model, setModel] = useLocalStorage<Model>(STORAGE_MODEL_KEY, EMPTY_MODEL)
|
||||
|
||||
const { defaultModel } = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textGeneration)
|
||||
|
||||
// Hydrate model from defaultModel once it loads (async). We deliberately set state
|
||||
// from an effect here because defaultModel only resolves after the workspace's model
|
||||
// catalogue fetch completes.
|
||||
useEffect(() => {
|
||||
if (defaultModel && !model.name) {
|
||||
setModel(prev => ({
|
||||
...(prev ?? EMPTY_MODEL),
|
||||
name: defaultModel.model,
|
||||
provider: defaultModel.provider.provider,
|
||||
}))
|
||||
}
|
||||
}, [defaultModel, model.name, setModel])
|
||||
|
||||
const handleModelChange = useCallback((newValue: { modelId: string, provider: string, mode?: string, features?: string[] }) => {
|
||||
setModel(prev => ({
|
||||
...(prev ?? EMPTY_MODEL),
|
||||
provider: newValue.provider,
|
||||
name: newValue.modelId,
|
||||
mode: newValue.mode as ModelModeType,
|
||||
}))
|
||||
}, [setModel])
|
||||
|
||||
const handleCompletionParamsChange = useCallback((newParams: FormValue) => {
|
||||
setModel(prev => ({
|
||||
...(prev ?? EMPTY_MODEL),
|
||||
completion_params: newParams as CompletionParams,
|
||||
}))
|
||||
}, [setModel])
|
||||
|
||||
const [instruction, setInstruction] = useState('')
|
||||
const [ideaOutput, setIdeaOutput] = useState('')
|
||||
|
||||
const storageKey = `${mode}-${currentAppId ?? 'new'}`
|
||||
const { addVersion, current, currentVersionIndex, setCurrentVersionIndex, versions } = useGenGraph({
|
||||
storageKey,
|
||||
})
|
||||
|
||||
const [isLoading, { setTrue: setLoadingTrue, setFalse: setLoadingFalse }] = useBoolean(false)
|
||||
const [isApplying, { setTrue: setApplyingTrue, setFalse: setApplyingFalse }] = useBoolean(false)
|
||||
|
||||
// Per-attempt nonce — bumped on each Generate click so ``GenerationPhases``
|
||||
// can reset its internal phase timer instead of resuming wherever the
|
||||
// previous attempt left off (which makes the UI look wedged).
|
||||
const [startedAt, setStartedAt] = useState(0)
|
||||
|
||||
// Confirmation dialog for "Apply to current draft"
|
||||
const [isShowConfirmOverwrite, { setTrue: showConfirmOverwrite, setFalse: hideConfirmOverwrite }] = useBoolean(false)
|
||||
|
||||
// Surfaced when the backend rejects the draft sync because another tab
|
||||
// edited the workspace after we fetched it. Dedicated dialog instead of a
|
||||
// toast because the user needs an explicit Reload action — without that,
|
||||
// a generic "apply failed" toast leaves them stuck and confused.
|
||||
const [isShowHashCollision, { setTrue: showHashCollision, setFalse: hideHashCollision }] = useBoolean(false)
|
||||
|
||||
// Holds the AbortController of the in-flight ``/workflow-generate`` request
|
||||
// so we can cancel it on (a) modal close, (b) a second Generate click
|
||||
// while loading, (c) the hard 60 s frontend timeout, or (d) the user
|
||||
// pressing Cancel. Without this an in-flight request outlives the modal
|
||||
// and can race a future Generate call.
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
// Companion timer so the timeout doesn't keep running after the response
|
||||
// lands. Cleared inside the same ``finally`` block that flips loading off.
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
// Mode the user generated against. If they switch app context mid-flight
|
||||
// (e.g. open the same modal from a different Studio in another tab) we
|
||||
// hide the "Apply to current" button so the wrong-mode graph never lands
|
||||
// in the wrong Studio. Captured at Generate time, not Apply time.
|
||||
const generatedModeRef = useRef<typeof mode | null>(null)
|
||||
|
||||
const clearTimers = useCallback(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const abortInFlight = useCallback(() => {
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort()
|
||||
abortRef.current = null
|
||||
}
|
||||
clearTimers()
|
||||
}, [clearTimers])
|
||||
|
||||
// Cleanup on unmount — a modal unmount mid-generation must NOT leave the
|
||||
// request running in the background (it would still resolve, mutate the
|
||||
// store, and toast "applied" against a stale modal).
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
abortInFlight()
|
||||
}
|
||||
// The cleanup function reads refs only, so it's stable; we intentionally
|
||||
// exclude ``abortInFlight`` from deps to avoid re-running this effect on
|
||||
// every render.
|
||||
// eslint-disable-next-line react/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// Note: the modal is mounted lazily by ``mount.tsx`` which unmounts it when
|
||||
// ``isOpen`` flips to false, so transient state (instruction / ideaOutput)
|
||||
// resets implicitly on the next open. No reset effect needed.
|
||||
|
||||
const isValid = () => {
|
||||
const trimmed = instruction.trim()
|
||||
if (!trimmed) {
|
||||
toast.error(t('workflowGenerator.instructionRequired'))
|
||||
return false
|
||||
}
|
||||
if (!model.name) {
|
||||
// No usable model resolved (provider catalogue empty or still
|
||||
// loading). Without this guard the request would fly with an empty
|
||||
// ``model_config.name`` and surface as a backend 400 — not actionable
|
||||
// for the user. Tell them to pick a model.
|
||||
toast.error(t('workflowGenerator.modelRequired'))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const onGenerate = async () => {
|
||||
if (!isValid())
|
||||
return
|
||||
// Cancel any previous in-flight request (double-click guard). The
|
||||
// previous promise will reject with AbortError which our catch swallows.
|
||||
abortInFlight()
|
||||
|
||||
setStartedAt(Date.now())
|
||||
generatedModeRef.current = mode
|
||||
setLoadingTrue()
|
||||
|
||||
// Hard frontend timeout — aborts the request and surfaces a localised
|
||||
// toast so the user sees something actionable instead of a perpetual
|
||||
// spinner if the backend hangs.
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
abortRef.current?.abort()
|
||||
abortRef.current = null
|
||||
toast.error(t('workflowGenerator.errors.timeout'))
|
||||
}, FE_TIMEOUT_MS)
|
||||
|
||||
try {
|
||||
// Refine mode: pull the current draft graph so the backend amends it
|
||||
// instead of starting from scratch. The modal mounts outside the Studio's
|
||||
// ReactFlow provider, so we read the persisted draft rather than the live
|
||||
// canvas. A fetch failure (no draft saved yet) degrades gracefully to a
|
||||
// from-scratch generation — better than blocking the user entirely.
|
||||
let currentGraph: Awaited<ReturnType<typeof fetchWorkflowDraft>>['graph'] | undefined
|
||||
if (isRefine && currentAppId) {
|
||||
try {
|
||||
const draft = await fetchWorkflowDraft(`apps/${currentAppId}/workflows/draft`)
|
||||
if (draft?.graph?.nodes?.length)
|
||||
currentGraph = draft.graph
|
||||
}
|
||||
catch {
|
||||
currentGraph = undefined
|
||||
}
|
||||
}
|
||||
|
||||
const res = await generateWorkflow({
|
||||
mode,
|
||||
instruction,
|
||||
ideal_output: ideaOutput,
|
||||
model_config: model,
|
||||
...(currentGraph ? { current_graph: currentGraph } : {}),
|
||||
}, {
|
||||
getAbortController: (c) => { abortRef.current = c },
|
||||
})
|
||||
const first = res.errors?.[0]
|
||||
if (first) {
|
||||
// Prefer the localised copy for the structured code; fall back to
|
||||
// the backend's human-readable ``detail`` for codes we don't have
|
||||
// a translation for yet.
|
||||
const i18nKey = `workflowGenerator.errors.${first.code}`
|
||||
const localised = t(i18nKey, { defaultValue: '' })
|
||||
toast.error(localised || first.detail || res.error || t('workflowGenerator.generateFailed'))
|
||||
return
|
||||
}
|
||||
if (res.error) {
|
||||
toast.error(res.error)
|
||||
return
|
||||
}
|
||||
addVersion(res)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
// Aborts are intentional (modal close, second click, timeout) — never
|
||||
// toast for them. The timeout path already showed its own toast.
|
||||
if (isAbortError(e))
|
||||
return
|
||||
const message = e instanceof Error ? e.message : ''
|
||||
toast.error(message || t('workflowGenerator.generateFailed'))
|
||||
}
|
||||
finally {
|
||||
setLoadingFalse()
|
||||
clearTimers()
|
||||
abortRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const onCancelGeneration = useCallback(() => {
|
||||
abortInFlight()
|
||||
setLoadingFalse()
|
||||
}, [abortInFlight, setLoadingFalse])
|
||||
|
||||
// "Apply to current" is valid only when the visible graph was generated
|
||||
// for the app we'd be writing to. We require: a current app exists, its
|
||||
// mode matches the current modal mode, AND the last Generate (if any)
|
||||
// ran in this same mode — otherwise the user switched tabs mid-flight
|
||||
// and we'd be writing a workflow graph into a chatflow draft (or vice
|
||||
// versa). Falls back to "Create new app" only.
|
||||
const generatedMode = generatedModeRef.current
|
||||
const generatedModeMatches = generatedMode === null || generatedMode === mode
|
||||
const canApplyToCurrent = !!currentAppId && currentAppMode === mode && generatedModeMatches
|
||||
|
||||
const handleApplyToNew = useCallback(async () => {
|
||||
if (!current?.graph || isApplying)
|
||||
return
|
||||
setApplyingTrue()
|
||||
try {
|
||||
const { appId, appMode } = await applyToNewApp({
|
||||
mode,
|
||||
graph: current.graph as GeneratedGraph,
|
||||
instruction,
|
||||
appName: current.app_name,
|
||||
icon: current.icon,
|
||||
})
|
||||
toast.success(t('workflowGenerator.applied'))
|
||||
closeGenerator()
|
||||
router.push(getRedirectionPath(isCurrentWorkspaceEditor, { id: appId, mode: appMode }))
|
||||
}
|
||||
catch (e: unknown) {
|
||||
if (e instanceof WorkflowApplyOrphanError) {
|
||||
// Sync failed AND we couldn't roll back. Route the user to /apps so
|
||||
// the orphan is still discoverable — they can delete it by hand.
|
||||
toast.error(t('workflowGenerator.errors.apply_failed_orphan'))
|
||||
closeGenerator()
|
||||
router.push('/apps')
|
||||
return
|
||||
}
|
||||
const message = e instanceof Error ? e.message : ''
|
||||
toast.error(message || t('workflowGenerator.applyFailed'))
|
||||
}
|
||||
finally {
|
||||
setApplyingFalse()
|
||||
}
|
||||
}, [current, instruction, mode, router, isCurrentWorkspaceEditor, closeGenerator, t, isApplying, setApplyingTrue, setApplyingFalse])
|
||||
|
||||
const handleApplyToCurrentConfirmed = useCallback(async () => {
|
||||
if (!current?.graph || !currentAppId || isApplying)
|
||||
return
|
||||
hideConfirmOverwrite()
|
||||
setApplyingTrue()
|
||||
try {
|
||||
await applyToCurrentApp({ appId: currentAppId, graph: current.graph as GeneratedGraph })
|
||||
toast.success(t('workflowGenerator.applied'))
|
||||
closeGenerator()
|
||||
// Hard reload the workflow page so the canvas picks up the new draft —
|
||||
// ``router.refresh()`` only revalidates server-rendered route data, and
|
||||
// the Studio canvas is hydrated client-side via react-query / zustand.
|
||||
if (typeof window !== 'undefined')
|
||||
window.location.reload()
|
||||
}
|
||||
catch (e: unknown) {
|
||||
if (e instanceof WorkflowApplyHashCollisionError) {
|
||||
// Another tab edited the draft after we fetched it. Show a
|
||||
// dedicated dialog with a Reload affordance instead of a generic
|
||||
// "apply failed" toast — the user needs to know what actually
|
||||
// happened so they can pick up the other tab's edits before
|
||||
// retrying.
|
||||
showHashCollision()
|
||||
return
|
||||
}
|
||||
const message = e instanceof Error ? e.message : ''
|
||||
toast.error(message || t('workflowGenerator.applyFailed'))
|
||||
}
|
||||
finally {
|
||||
setApplyingFalse()
|
||||
}
|
||||
}, [current, currentAppId, hideConfirmOverwrite, closeGenerator, t, isApplying, setApplyingTrue, setApplyingFalse, showHashCollision])
|
||||
|
||||
const modeLabel = mode === 'workflow' ? t('workflowGenerator.modes.workflow') : t('workflowGenerator.modes.chatflow')
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
// Cancel any in-flight request BEFORE closing the store — a
|
||||
// request that resolves after the modal closes would still toast
|
||||
// against the now-unmounted modal and pollute version history.
|
||||
abortInFlight()
|
||||
closeGenerator()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="h-[min(680px,calc(100dvh-2rem))] max-h-none! w-[1140px] max-w-none! min-w-[1140px] overflow-hidden! border-none p-0! text-left align-middle">
|
||||
<div className="flex h-full min-h-0 flex-wrap">
|
||||
{/* Left pane: instructions + ideal output + model selector */}
|
||||
<div className="h-full w-[570px] shrink-0 overflow-y-auto border-r border-divider-regular p-6">
|
||||
<div className="mb-5">
|
||||
<div className="text-lg leading-[28px] font-bold text-text-primary">
|
||||
{isRefine
|
||||
? t('workflowGenerator.refineTitle', { mode: modeLabel })
|
||||
: t('workflowGenerator.title', { mode: modeLabel })}
|
||||
</div>
|
||||
<div className="mt-1 text-[13px] font-normal text-text-tertiary">
|
||||
{isRefine ? t('workflowGenerator.refineDescription') : t('workflowGenerator.description')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<ModelParameterModal
|
||||
popupClassName="w-[520px]!"
|
||||
isAdvancedMode={true}
|
||||
provider={model.provider}
|
||||
completionParams={model.completion_params}
|
||||
modelId={model.name}
|
||||
setModel={handleModelChange}
|
||||
onCompletionParamsChange={handleCompletionParamsChange}
|
||||
hideDebugWithMultipleModel
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="mb-1.5 system-sm-semibold-uppercase text-text-secondary">
|
||||
{t('workflowGenerator.instruction')}
|
||||
</div>
|
||||
<Textarea
|
||||
className="h-[160px]"
|
||||
placeholder={isRefine
|
||||
? t('workflowGenerator.refineInstructionPlaceholder')
|
||||
: t('workflowGenerator.instructionPlaceholder')}
|
||||
value={instruction}
|
||||
onValueChange={setInstruction}
|
||||
/>
|
||||
|
||||
{/* Example prompts are create-from-scratch starters ("Summarize a
|
||||
URL"); they don't fit a refine-the-current-graph task, so hide
|
||||
them in refine mode. */}
|
||||
{!isRefine && <ExamplePrompts mode={mode} onSelect={setInstruction} />}
|
||||
|
||||
<IdeaOutput
|
||||
value={ideaOutput}
|
||||
onChange={setIdeaOutput}
|
||||
/>
|
||||
|
||||
<div className="mt-7 flex justify-end space-x-2">
|
||||
<Button onClick={closeGenerator}>
|
||||
{t('workflowGenerator.dismiss')}
|
||||
</Button>
|
||||
{isLoading
|
||||
? (
|
||||
// Cancel surfaces the abort affordance during the 60 s
|
||||
// window where the user might want to bail (slow
|
||||
// model, wrong instruction, etc.). Hidden when idle so
|
||||
// the row stays focused on the primary action.
|
||||
<Button
|
||||
className="flex space-x-1"
|
||||
variant="secondary"
|
||||
onClick={onCancelGeneration}
|
||||
>
|
||||
<span className="text-xs font-semibold">{t('workflowGenerator.cancel')}</span>
|
||||
</Button>
|
||||
)
|
||||
: (
|
||||
<Button
|
||||
className="flex space-x-1"
|
||||
variant="primary"
|
||||
onClick={onGenerate}
|
||||
disabled={!model.name}
|
||||
>
|
||||
<span className="i-custom-vender-other-generator size-4" />
|
||||
<span className="text-xs font-semibold">{t('workflowGenerator.generate')}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right pane: preview + version selector + apply */}
|
||||
{(!isLoading && current?.graph?.nodes?.length)
|
||||
? (
|
||||
<div className="flex h-full w-0 grow flex-col bg-background-default-subtle p-6">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<VersionSelector
|
||||
versionLen={versions?.length || 0}
|
||||
value={currentVersionIndex || 0}
|
||||
onChange={setCurrentVersionIndex}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
{canApplyToCurrent
|
||||
? (
|
||||
// Studio button entry — overwrite the current draft
|
||||
// is the only meaningful Apply action, so collapse
|
||||
// the two buttons into one primary "Apply".
|
||||
<Button
|
||||
size="small"
|
||||
variant="primary"
|
||||
onClick={showConfirmOverwrite}
|
||||
disabled={isApplying}
|
||||
>
|
||||
{t('workflowGenerator.studioApply')}
|
||||
</Button>
|
||||
)
|
||||
: (
|
||||
// cmd+k /create entry — no current-app context, so
|
||||
// the only path is "Create new app".
|
||||
<Button
|
||||
size="small"
|
||||
variant="primary"
|
||||
onClick={handleApplyToNew}
|
||||
disabled={isApplying}
|
||||
>
|
||||
{t('workflowGenerator.applyToNew')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative w-full grow overflow-hidden rounded-2xl border border-divider-subtle bg-background-default">
|
||||
<WorkflowPreview
|
||||
nodes={current.graph.nodes}
|
||||
edges={current.graph.edges}
|
||||
viewport={current.graph.viewport}
|
||||
miniMapToRight
|
||||
/>
|
||||
</div>
|
||||
{current.message && (
|
||||
<div className="mt-2 system-xs-regular text-text-tertiary">
|
||||
{current.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
|
||||
{isLoading && <GenerationPhases startedAt={startedAt} />}
|
||||
|
||||
{!isLoading && !current?.graph?.nodes?.length && renderPlaceholder(t('workflowGenerator.placeholder'))}
|
||||
</div>
|
||||
|
||||
<RecoveryDialog
|
||||
open={isShowConfirmOverwrite}
|
||||
onOpenChange={() => hideConfirmOverwrite()}
|
||||
title={t('workflowGenerator.overwriteTitle')}
|
||||
description={t('workflowGenerator.overwriteMessage')}
|
||||
cancelLabel={t('operation.cancel', { ns: 'common' })}
|
||||
confirmLabel={t('operation.confirm', { ns: 'common' })}
|
||||
onConfirm={handleApplyToCurrentConfirmed}
|
||||
/>
|
||||
|
||||
{/* Hash-collision recovery — surfaces when another tab edited the
|
||||
draft between our fetch and sync. Reload picks up those edits;
|
||||
Dismiss returns to the modal so the user can copy the generated
|
||||
graph manually before re-fetching. */}
|
||||
<RecoveryDialog
|
||||
open={isShowHashCollision}
|
||||
onOpenChange={() => hideHashCollision()}
|
||||
title={t('workflowGenerator.errors.hash_collision_title')}
|
||||
description={t('workflowGenerator.errors.hash_collision')}
|
||||
cancelLabel={t('operation.cancel', { ns: 'common' })}
|
||||
confirmLabel={t('workflowGenerator.reload')}
|
||||
onConfirm={() => {
|
||||
hideHashCollision()
|
||||
if (typeof window !== 'undefined')
|
||||
window.location.reload()
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(WorkflowGeneratorModal)
|
||||
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
|
||||
58
web/app/components/workflow/workflow-generator/store.ts
Normal file
58
web/app/components/workflow/workflow-generator/store.ts
Normal file
@ -0,0 +1,58 @@
|
||||
'use client'
|
||||
import type { WorkflowGeneratorIntent, WorkflowGeneratorMode } from './types'
|
||||
import { create } from 'zustand'
|
||||
|
||||
type WorkflowGeneratorStore = {
|
||||
isOpen: boolean
|
||||
mode: WorkflowGeneratorMode
|
||||
/** `create` = build a new app; `refine` = amend the current Studio draft. */
|
||||
intent: WorkflowGeneratorIntent
|
||||
currentAppId: string | null
|
||||
currentAppMode: WorkflowGeneratorMode | null
|
||||
openGenerator: (params: {
|
||||
mode: WorkflowGeneratorMode
|
||||
intent?: WorkflowGeneratorIntent
|
||||
currentAppId?: string | null
|
||||
currentAppMode?: WorkflowGeneratorMode | null
|
||||
}) => void
|
||||
closeGenerator: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Wipe the session-storage entries ``useGenGraph`` keeps for the new-app
|
||||
* (no ``currentAppId``) bucket. We do this every time ``/create`` opens so
|
||||
* the panel starts on the empty placeholder instead of showing whatever
|
||||
* graph the previous /create session produced — those generations belong
|
||||
* to a different intent and confusingly leak across opens.
|
||||
*
|
||||
* Studio-refine sessions (``currentAppId`` set) keep their history so the
|
||||
* user can close and reopen the generator from the same Studio without losing
|
||||
* the versions they were comparing.
|
||||
*/
|
||||
const resetNewAppHistory = (mode: WorkflowGeneratorMode) => {
|
||||
if (typeof window === 'undefined')
|
||||
return
|
||||
const storageKey = `${mode}-new`
|
||||
try {
|
||||
sessionStorage.removeItem(`workflow-gen-${storageKey}-versions`)
|
||||
sessionStorage.removeItem(`workflow-gen-${storageKey}-version-index`)
|
||||
}
|
||||
catch {
|
||||
// sessionStorage can throw in privacy-restricted contexts; the stale
|
||||
// state will still flush on tab close, so swallowing here is fine.
|
||||
}
|
||||
}
|
||||
|
||||
export const useWorkflowGeneratorStore = create<WorkflowGeneratorStore>(set => ({
|
||||
isOpen: false,
|
||||
mode: 'workflow',
|
||||
intent: 'create',
|
||||
currentAppId: null,
|
||||
currentAppMode: null,
|
||||
openGenerator: ({ mode, intent = 'create', currentAppId = null, currentAppMode = null }) => {
|
||||
if (!currentAppId)
|
||||
resetNewAppHistory(mode)
|
||||
set({ isOpen: true, mode, intent, currentAppId, currentAppMode })
|
||||
},
|
||||
closeGenerator: () => set({ isOpen: false }),
|
||||
}))
|
||||
30
web/app/components/workflow/workflow-generator/types.ts
Normal file
30
web/app/components/workflow/workflow-generator/types.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import type { Viewport } from 'reactflow'
|
||||
import type { Edge, Node } from '@/app/components/workflow/types'
|
||||
|
||||
export type WorkflowGeneratorMode = 'workflow' | 'advanced-chat'
|
||||
|
||||
/**
|
||||
* `create` builds a brand-new app from scratch; `refine` feeds the current
|
||||
* Studio draft graph to the generator as context so it amends what's already
|
||||
* on the canvas. Only `refine` requires an open Studio (a `currentAppId`).
|
||||
*/
|
||||
export type WorkflowGeneratorIntent = 'create' | 'refine'
|
||||
|
||||
export type GeneratedGraph = {
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
viewport: Viewport
|
||||
}
|
||||
|
||||
export type GenerateWorkflowResponse = {
|
||||
graph: GeneratedGraph
|
||||
message?: string
|
||||
/**
|
||||
* Planner-picked product-style name. Used by applyToNewApp; empty triggers
|
||||
* a deriveAppName(instruction) fallback.
|
||||
*/
|
||||
app_name?: string
|
||||
/** Planner-picked emoji icon for the new App. Empty triggers a 🤖 fallback. */
|
||||
icon?: string
|
||||
error?: string
|
||||
}
|
||||
@ -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
|
||||
@ -51,11 +51,20 @@
|
||||
"exportFailed": "Export DSL failed.",
|
||||
"gotoAnything.actions.accountDesc": "Navigate to account page",
|
||||
"gotoAnything.actions.communityDesc": "Open Discord community",
|
||||
"gotoAnything.actions.createCategoryDesc": "Create an AI-generated workflow or chatflow",
|
||||
"gotoAnything.actions.createCategoryTitle": "Create",
|
||||
"gotoAnything.actions.createChatflow": "Chatflow",
|
||||
"gotoAnything.actions.createChatflowDesc": "Generate a chatflow (advanced chat) app from a description",
|
||||
"gotoAnything.actions.createWorkflow": "Workflow",
|
||||
"gotoAnything.actions.createWorkflowDesc": "Generate a workflow app from a description",
|
||||
"gotoAnything.actions.docDesc": "Open help documentation",
|
||||
"gotoAnything.actions.feedbackDesc": "Open community feedback discussions",
|
||||
"gotoAnything.actions.languageCategoryDesc": "Switch interface language",
|
||||
"gotoAnything.actions.languageCategoryTitle": "Language",
|
||||
"gotoAnything.actions.languageChangeDesc": "Change UI language",
|
||||
"gotoAnything.actions.refineCategoryDesc": "Refine the current workflow or chatflow graph",
|
||||
"gotoAnything.actions.refineDesc": "Describe a change to apply to the current draft",
|
||||
"gotoAnything.actions.refineTitle": "Refine current graph",
|
||||
"gotoAnything.actions.runDesc": "Run quick commands (theme, language, ...)",
|
||||
"gotoAnything.actions.runTitle": "Commands",
|
||||
"gotoAnything.actions.searchApplications": "Search Applications",
|
||||
|
||||
@ -1226,5 +1226,58 @@
|
||||
"versionHistory.nameThisVersion": "Name this version",
|
||||
"versionHistory.releaseNotesPlaceholder": "Describe what changed",
|
||||
"versionHistory.restorationTip": "After version restoration, the current draft will be overwritten.",
|
||||
"versionHistory.title": "Versions"
|
||||
"versionHistory.title": "Versions",
|
||||
"workflowGenerator.applied": "Applied",
|
||||
"workflowGenerator.applyFailed": "Failed to apply workflow",
|
||||
"workflowGenerator.applyToCurrent": "Apply to current draft",
|
||||
"workflowGenerator.applyToNew": "Create new app",
|
||||
"workflowGenerator.cancel": "Cancel",
|
||||
"workflowGenerator.description": "Describe what you want the workflow to do. Pick a model, write an instruction, and preview the generated graph before applying it to Studio.",
|
||||
"workflowGenerator.dismiss": "Dismiss",
|
||||
"workflowGenerator.errors.DANGLING_EDGE": "The generated workflow has an edge pointing at a node that doesn't exist. Try again or refine your instruction.",
|
||||
"workflowGenerator.errors.EMPTY_INSTRUCTION": "Please write an instruction first.",
|
||||
"workflowGenerator.errors.EMPTY_PLAN": "The model returned an empty plan. Try a more specific instruction.",
|
||||
"workflowGenerator.errors.INVALID_CONTAINER": "The generated workflow has a malformed loop or iteration container. Try a simpler instruction or pick a more capable model.",
|
||||
"workflowGenerator.errors.INVALID_JSON": "The model returned a response we couldn't parse. Try again or pick a more capable model.",
|
||||
"workflowGenerator.errors.INVALID_SCHEMA": "The model returned a graph in an unexpected shape. Try again or pick a more capable model.",
|
||||
"workflowGenerator.errors.MISSING_START": "The generated workflow is missing its start node. Try regenerating.",
|
||||
"workflowGenerator.errors.MISSING_TERMINAL": "The generated workflow is missing an end or answer node. Try regenerating.",
|
||||
"workflowGenerator.errors.MODEL_ERROR": "The model call failed. Check your provider quota and try again.",
|
||||
"workflowGenerator.errors.UNKNOWN_NODE_REFERENCE": "A node in the generated workflow points at a node that doesn't exist. Try regenerating.",
|
||||
"workflowGenerator.errors.UNKNOWN_TOOL": "The workflow references a tool that isn't installed for this workspace. Install it from the Tools page or refine your instruction.",
|
||||
"workflowGenerator.errors.UNRESOLVED_REFERENCE": "A node in the generated workflow references a variable that isn't declared upstream. Try regenerating.",
|
||||
"workflowGenerator.errors.apply_failed_orphan": "We couldn't finish creating the app. An empty draft may have been left in your apps list — please remove it manually.",
|
||||
"workflowGenerator.errors.hash_collision": "The workflow draft was edited in another tab. Reload to pick up those changes, then try applying again.",
|
||||
"workflowGenerator.errors.hash_collision_title": "Workspace was edited elsewhere",
|
||||
"workflowGenerator.errors.timeout": "Generation took too long. The model might be slow or unavailable — try again.",
|
||||
"workflowGenerator.examples.chatflow.support": "Customer-support bot backed by a knowledge base",
|
||||
"workflowGenerator.examples.chatflow.triage": "Triage incoming questions and route to a specialist prompt",
|
||||
"workflowGenerator.examples.chatflow.tutor": "Multi-language tutor that explains step by step",
|
||||
"workflowGenerator.examples.label": "Try one of these",
|
||||
"workflowGenerator.examples.workflow.classify": "Fetch GitHub issues and classify them",
|
||||
"workflowGenerator.examples.workflow.rag": "Knowledge-base query, then format the answer as Markdown",
|
||||
"workflowGenerator.examples.workflow.summarize": "Summarize a URL",
|
||||
"workflowGenerator.examples.workflow.translate": "Translate text to multiple languages",
|
||||
"workflowGenerator.generate": "Generate",
|
||||
"workflowGenerator.generateFailed": "Failed to generate workflow",
|
||||
"workflowGenerator.instruction": "Instructions",
|
||||
"workflowGenerator.instructionPlaceholder": "Describe the workflow you want — what input, what processing, what output.",
|
||||
"workflowGenerator.instructionRequired": "Please write an instruction first",
|
||||
"workflowGenerator.loading": "Generating workflow…",
|
||||
"workflowGenerator.modelRequired": "Please pick a model before generating.",
|
||||
"workflowGenerator.modes.chatflow": "Chatflow",
|
||||
"workflowGenerator.modes.workflow": "Workflow",
|
||||
"workflowGenerator.overwriteMessage": "Applying this workflow will replace the current draft graph. This cannot be undone.",
|
||||
"workflowGenerator.overwriteTitle": "Overwrite the current draft?",
|
||||
"workflowGenerator.phases.building": "Building nodes…",
|
||||
"workflowGenerator.phases.planning": "Planning the workflow…",
|
||||
"workflowGenerator.phases.validating": "Validating the graph…",
|
||||
"workflowGenerator.placeholder": "Write an instruction on the left, then click Generate to preview the workflow graph.",
|
||||
"workflowGenerator.refineDescription": "Describe the change you want. The current draft is used as context; the generated graph replaces it when you apply.",
|
||||
"workflowGenerator.refineInstructionPlaceholder": "Describe the change — e.g. add a translation step, switch to a tool, add error handling.",
|
||||
"workflowGenerator.refineTitle": "Refine {{mode}}",
|
||||
"workflowGenerator.reload": "Reload",
|
||||
"workflowGenerator.studioApply": "Apply",
|
||||
"workflowGenerator.studioButton": "Generate",
|
||||
"workflowGenerator.title": "Generate {{mode}}"
|
||||
}
|
||||
|
||||
@ -51,11 +51,20 @@
|
||||
"exportFailed": "导出 DSL 失败",
|
||||
"gotoAnything.actions.accountDesc": "导航到账户页面",
|
||||
"gotoAnything.actions.communityDesc": "打开 Discord 社区",
|
||||
"gotoAnything.actions.createCategoryDesc": "创建由 AI 生成的工作流或 Chatflow",
|
||||
"gotoAnything.actions.createCategoryTitle": "创建",
|
||||
"gotoAnything.actions.createChatflow": "Chatflow",
|
||||
"gotoAnything.actions.createChatflowDesc": "根据描述生成一个 Chatflow(高级聊天)应用",
|
||||
"gotoAnything.actions.createWorkflow": "Workflow",
|
||||
"gotoAnything.actions.createWorkflowDesc": "根据描述生成一个工作流应用",
|
||||
"gotoAnything.actions.docDesc": "打开帮助文档",
|
||||
"gotoAnything.actions.feedbackDesc": "打开社区反馈讨论",
|
||||
"gotoAnything.actions.languageCategoryDesc": "切换界面语言",
|
||||
"gotoAnything.actions.languageCategoryTitle": "语言",
|
||||
"gotoAnything.actions.languageChangeDesc": "更改界面语言",
|
||||
"gotoAnything.actions.refineCategoryDesc": "优化当前的工作流或 Chatflow 图",
|
||||
"gotoAnything.actions.refineDesc": "描述要应用到当前草稿的修改",
|
||||
"gotoAnything.actions.refineTitle": "优化当前图",
|
||||
"gotoAnything.actions.runDesc": "快速执行命令(主题、语言等)",
|
||||
"gotoAnything.actions.runTitle": "命令",
|
||||
"gotoAnything.actions.searchApplications": "搜索应用程序",
|
||||
|
||||
@ -1226,5 +1226,58 @@
|
||||
"versionHistory.nameThisVersion": "命名",
|
||||
"versionHistory.releaseNotesPlaceholder": "请描述变更",
|
||||
"versionHistory.restorationTip": "版本回滚后,当前草稿将被覆盖。",
|
||||
"versionHistory.title": "版本"
|
||||
"versionHistory.title": "版本",
|
||||
"workflowGenerator.applied": "已应用",
|
||||
"workflowGenerator.applyFailed": "应用工作流失败",
|
||||
"workflowGenerator.applyToCurrent": "应用到当前草稿",
|
||||
"workflowGenerator.applyToNew": "创建新应用",
|
||||
"workflowGenerator.cancel": "取消",
|
||||
"workflowGenerator.description": "描述你希望工作流完成的任务。选择模型、撰写指令,预览生成的图后再应用到 Studio。",
|
||||
"workflowGenerator.dismiss": "关闭",
|
||||
"workflowGenerator.errors.DANGLING_EDGE": "生成的工作流中有连线指向了不存在的节点,请重试或细化指令。",
|
||||
"workflowGenerator.errors.EMPTY_INSTRUCTION": "请先填写指令。",
|
||||
"workflowGenerator.errors.EMPTY_PLAN": "模型没有返回任何规划,尝试写一个更具体的指令。",
|
||||
"workflowGenerator.errors.INVALID_CONTAINER": "生成的工作流中循环或迭代容器结构异常。尝试简化指令或更换更强的模型。",
|
||||
"workflowGenerator.errors.INVALID_JSON": "模型返回的内容无法解析,请重试或更换更强的模型。",
|
||||
"workflowGenerator.errors.INVALID_SCHEMA": "模型返回的图结构不符合预期,请重试或更换更强的模型。",
|
||||
"workflowGenerator.errors.MISSING_START": "生成的工作流缺少起始节点,请重新生成。",
|
||||
"workflowGenerator.errors.MISSING_TERMINAL": "生成的工作流缺少结束或回答节点,请重新生成。",
|
||||
"workflowGenerator.errors.MODEL_ERROR": "模型调用失败。请检查供应商配额后重试。",
|
||||
"workflowGenerator.errors.UNKNOWN_NODE_REFERENCE": "工作流中有节点引用了不存在的节点,请重新生成。",
|
||||
"workflowGenerator.errors.UNKNOWN_TOOL": "工作流引用了当前工作空间未安装的工具。请先在「工具」页面安装该工具,或细化指令。",
|
||||
"workflowGenerator.errors.UNRESOLVED_REFERENCE": "工作流中有节点引用了上游未声明的变量,请重新生成。",
|
||||
"workflowGenerator.errors.apply_failed_orphan": "未能完成应用,应用列表中可能残留一个空草稿——请手动删除。",
|
||||
"workflowGenerator.errors.hash_collision": "工作流草稿被另一个标签页修改过,请重新加载以同步那些改动后再尝试应用。",
|
||||
"workflowGenerator.errors.hash_collision_title": "工作空间在别处被修改",
|
||||
"workflowGenerator.errors.timeout": "生成耗时过长。模型可能较慢或不可用——请重试。",
|
||||
"workflowGenerator.examples.chatflow.support": "基于知识库的客服机器人",
|
||||
"workflowGenerator.examples.chatflow.triage": "分诊问题并路由到对应的专业 Prompt",
|
||||
"workflowGenerator.examples.chatflow.tutor": "多语言导师,分步骤讲解",
|
||||
"workflowGenerator.examples.label": "试试这些",
|
||||
"workflowGenerator.examples.workflow.classify": "拉取 GitHub Issue 并分类",
|
||||
"workflowGenerator.examples.workflow.rag": "查询知识库,然后以 Markdown 格式输出答案",
|
||||
"workflowGenerator.examples.workflow.summarize": "总结一个网址",
|
||||
"workflowGenerator.examples.workflow.translate": "把文本翻译成多种语言",
|
||||
"workflowGenerator.generate": "生成",
|
||||
"workflowGenerator.generateFailed": "生成工作流失败",
|
||||
"workflowGenerator.instruction": "指令",
|
||||
"workflowGenerator.instructionPlaceholder": "描述你想要的工作流——输入是什么、处理流程、输出形式。",
|
||||
"workflowGenerator.instructionRequired": "请先填写指令",
|
||||
"workflowGenerator.loading": "正在生成工作流……",
|
||||
"workflowGenerator.modelRequired": "请先选择一个模型再生成。",
|
||||
"workflowGenerator.modes.chatflow": "Chatflow",
|
||||
"workflowGenerator.modes.workflow": "Workflow",
|
||||
"workflowGenerator.overwriteMessage": "应用此工作流将覆盖当前草稿,操作不可撤销。",
|
||||
"workflowGenerator.overwriteTitle": "覆盖当前草稿?",
|
||||
"workflowGenerator.phases.building": "正在构建节点……",
|
||||
"workflowGenerator.phases.planning": "正在规划工作流……",
|
||||
"workflowGenerator.phases.validating": "正在校验图……",
|
||||
"workflowGenerator.placeholder": "在左侧填写指令,点击「生成」预览工作流图。",
|
||||
"workflowGenerator.refineDescription": "描述你想要的修改。当前草稿会作为上下文;应用时生成的图将替换它。",
|
||||
"workflowGenerator.refineInstructionPlaceholder": "描述修改内容——例如添加翻译步骤、改用某个工具、增加错误处理。",
|
||||
"workflowGenerator.refineTitle": "优化 {{mode}}",
|
||||
"workflowGenerator.reload": "重新加载",
|
||||
"workflowGenerator.studioApply": "应用",
|
||||
"workflowGenerator.studioButton": "生成",
|
||||
"workflowGenerator.title": "生成 {{mode}}"
|
||||
}
|
||||
|
||||
83
web/service/debug.spec.ts
Normal file
83
web/service/debug.spec.ts
Normal file
@ -0,0 +1,83 @@
|
||||
// service/base is the dependency we're mocking in this test; the
|
||||
// no-restricted-imports rule targets production imports, not test
|
||||
// instrumentation — mirrors sibling service specs (annotation.spec.ts etc.).
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { post } from './base'
|
||||
import { generateWorkflow } from './debug'
|
||||
|
||||
// Stub the shared `post` wrapper so tests verify only what `generateWorkflow`
|
||||
// composes on top of it — URL, body, and the typed response surface.
|
||||
vi.mock('./base', () => ({
|
||||
post: vi.fn(),
|
||||
get: vi.fn(),
|
||||
ssePost: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('debug service — generateWorkflow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// The new endpoint lives at /workflow-generate; the controller mirrors
|
||||
// /rule-generate so the body must flow through unchanged.
|
||||
it('should POST to /workflow-generate with the body verbatim', () => {
|
||||
const body = {
|
||||
mode: 'workflow' as const,
|
||||
instruction: 'Summarize a URL',
|
||||
ideal_output: 'A 3-sentence summary.',
|
||||
model_config: { provider: 'openai', name: 'gpt-4o', mode: 'chat', completion_params: {} },
|
||||
}
|
||||
|
||||
generateWorkflow(body)
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/workflow-generate', { body })
|
||||
})
|
||||
|
||||
// The optional fields must still POST cleanly — `ideal_output` defaulting
|
||||
// server-side requires the helper to forward the body as-is, not augment it.
|
||||
it('should pass the body through even when ideal_output is omitted', () => {
|
||||
const body = {
|
||||
mode: 'advanced-chat' as const,
|
||||
instruction: 'Friendly support bot',
|
||||
model_config: { provider: 'openai', name: 'gpt-4o', mode: 'chat' },
|
||||
}
|
||||
|
||||
generateWorkflow(body)
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/workflow-generate', { body })
|
||||
})
|
||||
|
||||
// When the caller threads a ``getAbortController`` callback (the modal's
|
||||
// pattern for cancelling the in-flight request on close / double-click /
|
||||
// 60 s timeout), it must reach ``post()`` as the third argument so the
|
||||
// shared fetch wrapper wires it into the AbortController plumbing.
|
||||
// Without this the modal cannot abort the request and a close-while-
|
||||
// loading leaks the request beyond its UI surface.
|
||||
it('should forward getAbortController to post when provided', () => {
|
||||
const body = {
|
||||
mode: 'workflow' as const,
|
||||
instruction: 'Long-running generation',
|
||||
model_config: { provider: 'openai', name: 'gpt-4o', mode: 'chat' },
|
||||
}
|
||||
const getAbortController = vi.fn()
|
||||
|
||||
generateWorkflow(body, { getAbortController })
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/workflow-generate', { body }, { getAbortController })
|
||||
})
|
||||
|
||||
// No options → no third argument. Keeps the call site clean and lets the
|
||||
// shared wrapper apply its own defaults without a phantom empty object.
|
||||
it('should NOT pass a third argument when no options are provided', () => {
|
||||
const body = {
|
||||
mode: 'workflow' as const,
|
||||
instruction: 'Plain call',
|
||||
model_config: { provider: 'openai', name: 'gpt-4o', mode: 'chat' },
|
||||
}
|
||||
|
||||
generateWorkflow(body)
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/workflow-generate', { body })
|
||||
expect(vi.mocked(post).mock.calls[0]).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@ -1,4 +1,6 @@
|
||||
import type { Viewport } from 'reactflow'
|
||||
import type { IOnCompleted, IOnData, IOnError, IOnMessageReplace } from './base'
|
||||
import type { Edge, Node } from '@/app/components/workflow/types'
|
||||
import type { ChatPromptConfig, CompletionPromptConfig } from '@/models/debug'
|
||||
import type { AppModeEnum, ModelModeType } from '@/types/app'
|
||||
import { get, post, ssePost } from './base'
|
||||
@ -68,6 +70,103 @@ export const generateRule = (body: Record<string, any>) => {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* One structured error from the workflow generator backend. ``code`` is a
|
||||
* stable machine-readable identifier the frontend maps to localised copy
|
||||
* via the ``workflowGenerator.errors.<code>`` i18n keys; ``detail`` is the
|
||||
* raw English diagnostic; ``node_id`` is set when the error is tied to a
|
||||
* specific node (the preview canvas can highlight it).
|
||||
*
|
||||
* Stable codes — adding a new one without updating the i18n map will fall
|
||||
* back to ``detail`` and that's fine, but every value listed here MUST
|
||||
* exist in both en-US and zh-Hans.
|
||||
*/
|
||||
// Not exported: knip flags unused exports and the modal looks codes up by
|
||||
// string interpolation (``workflowGenerator.errors.${code}``) rather than
|
||||
// importing the union. Kept here so the ``GenerateWorkflowResponse``
|
||||
// definition below documents the contract in one place.
|
||||
type GenerateWorkflowErrorCode
|
||||
= | 'INVALID_JSON'
|
||||
| 'INVALID_SCHEMA'
|
||||
| 'EMPTY_INSTRUCTION'
|
||||
| 'EMPTY_PLAN'
|
||||
| 'UNKNOWN_NODE_REFERENCE'
|
||||
| 'INVALID_CONTAINER'
|
||||
| 'UNRESOLVED_REFERENCE'
|
||||
| 'UNKNOWN_TOOL'
|
||||
| 'MISSING_TERMINAL'
|
||||
| 'MISSING_START'
|
||||
| 'DANGLING_EDGE'
|
||||
| 'MODEL_ERROR'
|
||||
|
||||
type GenerateWorkflowError = {
|
||||
code: GenerateWorkflowErrorCode | string
|
||||
detail: string
|
||||
node_id?: string
|
||||
}
|
||||
|
||||
export type GenerateWorkflowResponse = {
|
||||
graph: {
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
viewport: Viewport
|
||||
}
|
||||
message?: string
|
||||
/**
|
||||
* Planner-picked product-style name (e.g. "URL Summarizer"). Empty when
|
||||
* the planner omits it; the caller (applyToNewApp) supplies a fallback.
|
||||
*/
|
||||
app_name?: string
|
||||
/**
|
||||
* Planner-picked emoji that captures the workflow's purpose. Empty when
|
||||
* the planner omits it; the caller supplies a 🤖 fallback.
|
||||
*/
|
||||
icon?: string
|
||||
/** Human-readable concatenation of ``errors[].detail``. "" on success. */
|
||||
error?: string
|
||||
/** Structured errors with stable codes for FE-localised mapping. [] on success. */
|
||||
errors?: GenerateWorkflowError[]
|
||||
}
|
||||
|
||||
export type GenerateWorkflowBody = {
|
||||
mode: 'workflow' | 'advanced-chat'
|
||||
instruction: string
|
||||
ideal_output?: string
|
||||
model_config: { provider: string, name: string, mode: string, completion_params?: Record<string, unknown> }
|
||||
/**
|
||||
* Existing draft graph for the cmd+k `/refine` flow. When present the
|
||||
* backend refines this graph instead of generating from scratch. Omitted
|
||||
* for `/create`.
|
||||
*/
|
||||
current_graph?: {
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
viewport?: Viewport
|
||||
}
|
||||
}
|
||||
|
||||
export type GenerateWorkflowOptions = {
|
||||
/**
|
||||
* Callback receiving the ``AbortController`` for the in-flight request.
|
||||
* The caller stores it and aborts on modal close / second submit / hard
|
||||
* timeout. Pattern mirrors ``fetchSuggestedQuestions`` / ``fetchConversationMessages``
|
||||
* which already thread this through ``base.ts``.
|
||||
*/
|
||||
getAbortController?: (controller: AbortController) => void
|
||||
}
|
||||
|
||||
export const generateWorkflow = (body: GenerateWorkflowBody, options?: GenerateWorkflowOptions) => {
|
||||
// Only pass the third argument when the caller actually supplied one —
|
||||
// otherwise the shared ``post()`` wrapper sees ``undefined`` and that
|
||||
// breaks tests asserting the 2-arg call shape, with no behaviour upside.
|
||||
if (options?.getAbortController) {
|
||||
return post<GenerateWorkflowResponse>('/workflow-generate', { body }, {
|
||||
getAbortController: options.getAbortController,
|
||||
})
|
||||
}
|
||||
return post<GenerateWorkflowResponse>('/workflow-generate', { body })
|
||||
}
|
||||
|
||||
export const fetchPromptTemplate = ({
|
||||
appMode,
|
||||
mode,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user