feat(web): gate /create and /refine slash commands behind feature preview flag (#37094)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Crazywoola 2026-06-08 10:32:52 +08:00 committed by GitHub
parent a88c15c906
commit db1aa683bc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 643 additions and 62 deletions

View File

@ -12,6 +12,7 @@ examples accurate or the LLM will invent fields.
"""
import json
from collections.abc import Iterable
from typing import Any
# Per-node-type configuration cheatsheet.
@ -22,11 +23,24 @@ from typing import Any
# both ``WorkflowService.sync_draft_workflow``'s structural checks and the
# runtime entity validation each node performs when the workflow runs.
#
# The cheatsheet is assembled DYNAMICALLY per request: the planner decides
# which node types the workflow needs, and ``build_node_config_cheatsheet``
# stitches together only the snippets for those types (plus the always-needed
# wrapper / shared-field / edge-handle preamble, and the containers section
# when an iteration / loop is planned). This keeps the builder prompt tight —
# a 3-node summariser no longer carries the schema for 12 unrelated node
# types — and lets each snippet document its FULL schema (e.g. a "file" start
# variable's required ``allowed_file_types``) without bloating every prompt.
#
# 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 = """\
# Always-included preamble: the node/edge wrapper shape and the shared
# ``data`` fields that apply to every node type, plus the "## Per type" header
# the per-type snippets slot under.
_CHEATSHEET_PREAMBLE = """\
## Node wrapper (every node, top-level)
{"id": "node1" (digits + letters only see "Node IDs" below),
@ -46,14 +60,26 @@ Children of iteration / loop containers additionally need
"desc": "<one-liner>",
"selected": false}
## Per type — additional "data" fields
## Per type — additional "data" fields (only the node types in your plan are shown)"""
# node_type → its per-type schema snippet. Keyed by the exact ``node_type``
# string the planner emits so ``build_node_config_cheatsheet`` can look each
# one up directly. Iteration / loop are documented in the Containers section
# (they are subgraphs, not leaf nodes) rather than here.
_NODE_SNIPPETS: dict[str, str] = {
"start": """\
- 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": []}
"required": false, "max_length": 4096, "options": []},
{"variable": "doc", "label": "Document", "type": "file",
"required": true,
"allowed_file_types": ["document"],
"allowed_file_upload_methods": ["local_file", "remote_url"],
"allowed_file_extensions": []}
]}
EVERY user-supplied value referenced by a downstream node
(``{{#node-id.var#}}`` in a prompt / answer / template, or
@ -62,19 +88,29 @@ Children of iteration / loop containers additionally need
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.
For a "file" or "file-list" variable you MUST also set
``allowed_file_types`` to a NON-EMPTY subset of
["document", "image", "audio", "video", "custom"] it is a REQUIRED
field and the draft fails to load (showing "supported file types is
required") without it. Choose by purpose: ["document"] for text
extraction (PDF / Word / PPT / Markdown / ), ["image"] for vision,
etc. Always set ``allowed_file_upload_methods`` to
["local_file", "remote_url"]. Only when you include "custom" must you
also set ``allowed_file_extensions`` to a non-empty list like
[".epub", ".rtf"]; otherwise leave it [].
In Advanced-Chat mode ``sys.query`` and ``sys.files`` are automatic
system variables downstream nodes may reference them; do NOT add
them to ``variables``.
them to ``variables``.""",
"end": """\
- end (Workflow mode only):
{"outputs": [
{"variable": "result", "value_selector": ["<src-node-id>", "<out-var>"]}
]}
]}""",
"answer": """\
- answer (Advanced Chat mode only):
{"variables": [],
"answer": "<text with {{#<src>.<var>#}} placeholders>"}
"answer": "<text with {{#<src>.<var>#}} placeholders>"}""",
"llm": """\
- llm:
{"model": {"provider": "<provider>", "name": "<model>", "mode": "chat",
"completion_params": {"temperature": 0.7}},
@ -100,26 +136,26 @@ Children of iteration / loop containers additionally need
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.
it cannot be a Jinja template or call a function.""",
"knowledge-retrieval": """\
- 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}}
"reranking_enable": false}}""",
"code": """\
- 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}}}
"outputs": {"result": {"type": "string", "children": null}}}""",
"template-transform": """\
- template-transform:
{"template": "Hello {{ name }}",
"variables": [{"variable": "name", "value_selector": ["<src>", "<var>"]}]}
"variables": [{"variable": "name", "value_selector": ["<src>", "<var>"]}]}""",
"http-request": """\
- http-request (escape hatch only if no installed tool fits):
{"variables": [], "method": "get", "url": "https://example.com",
"authorization": {"type": "no-auth", "config": null},
@ -129,8 +165,8 @@ Children of iteration / loop containers additionally need
"timeout": {"max_connect_timeout": 0, "max_read_timeout": 0,
"max_write_timeout": 0},
"retry_config": {"retry_enabled": true, "max_retries": 3,
"retry_interval": 100}}
"retry_interval": 100}}""",
"tool": """\
- 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
@ -144,8 +180,8 @@ Children of iteration / loop containers additionally need
Parameter ``type`` is one of:
"mixed" string template referencing variables ({{#...#}})
"variable" direct reference, value is ["<src>", "<var>"]
"constant" literal value
"constant" literal value""",
"if-else": """\
- if-else:
{"_targetBranches": [{"id": "true", "name": "IF"},
{"id": "false", "name": "ELSE"}],
@ -158,8 +194,8 @@ Children of iteration / loop containers additionally need
"comparison_operator": "is",
"value": "<value>"}]}
]}
Source handle for downstream edges = the case_id ("true" / "false").
Source handle for downstream edges = the case_id ("true" / "false").""",
"question-classifier": """\
- question-classifier:
{"query_variable_selector": ["<src>", "<var>"],
"model": {"provider": "<p>", "name": "<m>", "mode": "chat",
@ -169,8 +205,8 @@ Children of iteration / loop containers additionally need
"_targetBranches": [{"id": "1", "name": ""}, {"id": "2", "name": ""}],
"vision": {"enabled": false},
"instruction": ""}
Source handle for downstream edges = the class_id ("1" / "2" / ...).
Source handle for downstream edges = the class_id ("1" / "2" / ...).""",
"parameter-extractor": """\
- parameter-extractor:
{"query": [["<src>", "<var>"]], # array of value_selector arrays
"model": {"provider": "<p>", "name": "<m>", "mode": "chat",
@ -179,8 +215,8 @@ Children of iteration / loop containers additionally need
"description": "<purpose>", "required": true}],
"reasoning_mode": "prompt",
"vision": {"enabled": false},
"instruction": ""}
"instruction": ""}""",
"document-extractor": """\
- document-extractor:
{"variable_selector": ["<src>", "<file-var>"], # a file / file-list input
"is_array_file": false} # true when the input is a
@ -188,8 +224,9 @@ Children of iteration / loop containers additionally need
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).
(or ``sys.files`` in Advanced-Chat mode). That start variable MUST set a
non-empty ``allowed_file_types`` (use ["document"] for document text).""",
"variable-aggregator": """\
- variable-aggregator (merge mutually-exclusive branches into one output):
{"output_type": "string", # VarType of the merged value — one of
# string | number | object | array[string] |
@ -200,8 +237,8 @@ Children of iteration / loop containers additionally need
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.
array, NOT a placeholder string.""",
"list-operator": """\
- list-operator (filter / sort / slice an array variable):
{"variable": ["<src>", "<array-var>"],
"filter_by": {"enabled": false, "conditions": []},
@ -210,8 +247,12 @@ Children of iteration / loop containers additionally need
"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``.
(the processed array), ``first_record``, ``last_record``.""",
}
# Pulled into the cheatsheet only when an iteration / loop appears in the plan.
_CONTAINERS_SECTION = """\
## Containers — iteration / loop
These are SUBGRAPH nodes. To use one you MUST emit, in order:
@ -270,16 +311,59 @@ These are SUBGRAPH nodes. To use one you MUST emit, in order:
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``.
``nodeKstart``."""
# Always-included trailer: edge handle conventions for every graph.
_EDGE_HANDLES_SECTION = """\
## 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.
"""
loop-start: is what kicks off the first inner step."""
# Container node types are described in ``_CONTAINERS_SECTION`` rather than as
# leaf snippets; their presence in a plan pulls that section in.
_CONTAINER_NODE_TYPES = frozenset({"iteration", "loop"})
def build_node_config_cheatsheet(node_types: Iterable[str] | None = None) -> str:
"""
Assemble the builder cheatsheet for exactly the node types in the plan.
``node_types`` is the set of ``node_type`` strings the planner chose. We
emit the always-on preamble (wrapper / shared fields), then only the
per-type snippets for the requested types (``start`` is always included
every graph has one), the Containers section when an iteration / loop is
planned, and the edge-handles trailer. Unknown / unrecognised type strings
are ignored (the runtime / structural validator catches genuinely bogus
types).
``None`` returns the FULL cheatsheet (every snippet + containers) used to
build the static back-compat constants below and as a safe fallback.
"""
if node_types is None:
requested: set[str] = set(_NODE_SNIPPETS) | set(_CONTAINER_NODE_TYPES)
else:
requested = {str(t).strip() for t in node_types if str(t).strip()}
requested.add("start") # every workflow has exactly one start node
parts: list[str] = [_CHEATSHEET_PREAMBLE]
# Iterate _NODE_SNIPPETS (not ``requested``) to keep a stable, readable order.
parts.extend(snippet for node_type, snippet in _NODE_SNIPPETS.items() if node_type in requested)
if requested & _CONTAINER_NODE_TYPES:
parts.append(_CONTAINERS_SECTION)
parts.append(_EDGE_HANDLES_SECTION)
return "\n\n".join(parts) + "\n"
# Full cheatsheet (all node types) — retained as a module constant so callers
# and tests that want the complete reference can import it directly. The
# dynamic per-request prompt is built by ``get_builder_system_prompt``.
NODE_CONFIG_CHEATSHEET = build_node_config_cheatsheet()
_BASE_SYSTEM_PROMPT_HEAD = """You are a Dify workflow builder.
@ -402,21 +486,24 @@ _ADVANCED_CHAT_MODE_RULES = """# Mode-specific rules — Advanced Chat (Chatflow
"""
BUILDER_SYSTEM_PROMPT_WORKFLOW = (
_BASE_SYSTEM_PROMPT_HEAD
+ _WORKFLOW_MODE_RULES
+ _BASE_SYSTEM_PROMPT_TAIL
+ NODE_CONFIG_CHEATSHEET
+ _BASE_SYSTEM_PROMPT_FOOTER
)
def _assemble_builder_system_prompt(mode: str, node_types: Iterable[str] | None) -> str:
"""Stitch the builder system prompt for ``mode`` around a cheatsheet built
for ``node_types`` (``None`` full cheatsheet)."""
mode_rules = _ADVANCED_CHAT_MODE_RULES if mode == "advanced-chat" else _WORKFLOW_MODE_RULES
return (
_BASE_SYSTEM_PROMPT_HEAD
+ mode_rules
+ _BASE_SYSTEM_PROMPT_TAIL
+ build_node_config_cheatsheet(node_types)
+ _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
)
# Static full-cheatsheet prompts — the back-compat default returned by
# ``get_builder_system_prompt`` when the caller doesn't pin a node-type set.
BUILDER_SYSTEM_PROMPT_WORKFLOW = _assemble_builder_system_prompt("workflow", None)
BUILDER_SYSTEM_PROMPT_ADVANCED_CHAT = _assemble_builder_system_prompt("advanced-chat", None)
BUILDER_USER_PROMPT = """# User instruction
@ -546,8 +633,16 @@ def format_plan_block(plan_nodes: list[dict[str, Any]]) -> str:
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
def get_builder_system_prompt(mode: str, node_types: Iterable[str] | None = None) -> str:
"""
Build the builder system prompt for ``mode``, with a cheatsheet scoped to
``node_types`` (the planner's chosen node types).
When ``node_types`` is ``None`` we return the cached full-cheatsheet
constant (back-compat default). When the runner passes the plan's node-type
set we assemble a fresh prompt carrying only the relevant per-type schemas,
so the builder isn't handed config for node types the workflow never uses.
"""
if node_types is None:
return BUILDER_SYSTEM_PROMPT_ADVANCED_CHAT if mode == "advanced-chat" else BUILDER_SYSTEM_PROMPT_WORKFLOW
return _assemble_builder_system_prompt(mode, node_types)

View File

@ -74,6 +74,21 @@ _DEFAULT_VIEWPORT: GraphViewportDict = {"x": 0.0, "y": 0.0, "zoom": 0.7}
_DEFAULT_NODE_WIDTH = 244
_DEFAULT_NODE_HEIGHT = 100
# Start-node input variable types that carry file uploads. Mirrors
# ``graphon.variables.input_entities.VariableEntityType.FILE / FILE_LIST``.
_FILE_VARIABLE_TYPES = frozenset({"file", "file-list"})
# Backstop defaults for a file / file-list start variable when the builder
# omits the required upload config. ``allowed_file_types`` is a REQUIRED field
# (Studio rejects the draft with "supported file types is required" when it's
# empty — see ``config-var/config-modal/utils.ts``); we default to every
# standard type so no valid upload is rejected. ``custom`` is intentionally
# excluded because it would in turn require a non-empty
# ``allowed_file_extensions``. The real fix is the builder now documenting and
# emitting these fields; this is the safety net that guarantees a loadable draft.
_DEFAULT_ALLOWED_FILE_TYPES = ("document", "image", "audio", "video")
_DEFAULT_FILE_UPLOAD_METHODS = ("local_file", "remote_url")
# Token ceiling for the planner call when the caller didn't pin one. The plan
# is a short JSON node list (a handful of nodes with labels/purposes), so this
# is generous headroom while still bounding a runaway response. The builder is
@ -512,8 +527,15 @@ class WorkflowGenerator:
tool_catalogue_section=format_builder_tool_catalogue_section(tool_catalogue_text),
start_inputs_section=format_start_inputs_section(start_inputs or []),
)
# Scope the builder cheatsheet to exactly the node types the planner
# chose, so the prompt carries each type's FULL schema (e.g. a file
# start variable's required ``allowed_file_types``) without dragging in
# config for unrelated node types.
plan_node_types = {
str(node.get("node_type") or "").strip() for node in plan_nodes if str(node.get("node_type") or "").strip()
}
messages = [
SystemPromptMessage(content=get_builder_system_prompt(mode)),
SystemPromptMessage(content=get_builder_system_prompt(mode, plan_node_types)),
UserPromptMessage(content=user_prompt),
]
parsed = cls._invoke_and_parse_json(
@ -658,6 +680,13 @@ class WorkflowGenerator:
# variables before we surface them as errors.
cls._reconcile_variable_references(nodes=nodes, mode=mode)
# Schema backstop: a "file" / "file-list" start variable MUST carry a
# non-empty ``allowed_file_types`` or Studio refuses to load the draft
# ("supported file types is required"). The builder is now told to set
# it, but we fill safe defaults for any variable that still lacks it so
# the generated workflow always loads and runs.
cls._normalize_start_file_variables(nodes=nodes)
return cast(GraphDict, {"nodes": nodes, "edges": deduped_edges, "viewport": viewport})
# ------------------------------------------------------------------
@ -693,6 +722,21 @@ class WorkflowGenerator:
# remapping when we defensively strip hyphens out of LLM-emitted ids.
_ID_FIELDS: ClassVar = frozenset({"start_node_id", "iteration_id", "loop_id", "parentId"})
# ``data`` keys whose value is a plain string list, never a
# ``[node_id, var]`` value-selector — so the reference walker must not read
# a 2-element one as a selector. ``default`` holds an input's default value;
# ``options`` holds select choices; the ``allowed_file_*`` keys hold a file
# variable's upload config (types / extensions / methods).
_NON_SELECTOR_LIST_KEYS: ClassVar = frozenset(
{
"default",
"options",
"allowed_file_types",
"allowed_file_extensions",
"allowed_file_upload_methods",
}
)
@classmethod
def _reconcile_variable_references(cls, *, nodes: list[dict[str, Any]], mode: WorkflowGenerationMode) -> None:
"""
@ -747,12 +791,16 @@ class WorkflowGenerator:
# Known selector shapes: 2-element [node_id, var] lists.
for k, v in value.items():
# ``value_selector`` / ``query_variable_selector`` / etc.: a
# flat 2-element list of strings.
# flat 2-element list of strings. Skip keys whose value is a
# plain string list that merely HAPPENS to have two entries —
# a 2-option ``select`` or a file variable's two allowed upload
# methods are NOT ``[node_id, var]`` selectors and must not be
# mistaken for references.
if (
isinstance(v, list)
and len(v) == 2
and all(isinstance(x, str) for x in v)
and k != "default" # default values for input variables are not selectors
and k not in cls._NON_SELECTOR_LIST_KEYS
):
node_id, var = v[0].strip(), v[1].strip()
if node_id and var:
@ -923,6 +971,96 @@ class WorkflowGenerator:
}
)
@classmethod
def _normalize_start_file_variables(cls, *, nodes: list[dict[str, Any]]) -> None:
"""
Fill the required upload config on every file / file-list start variable.
A start variable of type ``file`` / ``file-list`` is invalid without a
non-empty ``allowed_file_types`` Studio rejects the draft with
"supported file types is required" (see the front-end validator in
``config-var/config-modal/utils.ts``) and the workflow never runs. The
builder prompt now documents these fields, but LLMs still drop them, so
we backfill safe defaults here:
* a start variable a ``document-extractor`` consumes but that wasn't
declared as a file type promoted to ``file`` (or ``file-list``
when the extractor's ``is_array_file`` is set), defaulting its
allowed types to ``["document"]`` (what extraction needs);
* empty / missing ``allowed_file_types`` every standard file type;
* ``custom`` present without ``allowed_file_extensions`` drop
``custom`` (it would otherwise require a non-empty extension list);
* empty / missing ``allowed_file_upload_methods`` local + remote;
* ensure ``allowed_file_extensions`` is at least an empty list.
Idempotent: a variable that already declares valid file config is left
untouched.
"""
start_node = next(
(n for n in nodes if (n.get("data") or {}).get("type") == BuiltinNodeTypes.START),
None,
)
if start_node is None:
return
variables = (start_node.get("data") or {}).get("variables")
if not isinstance(variables, list):
return
# Start variables a document-extractor reads → whether it wants an
# array (file-list). These MUST be file inputs even if the builder
# mistyped them (e.g. declared "paragraph"), or the extractor fails at
# run time. ``["document"]`` is the right default for text extraction.
extractor_file_vars = cls._document_extractor_start_vars(nodes=nodes, start_id=start_node.get("id", ""))
for var in variables:
if not isinstance(var, dict):
continue
name = var.get("variable")
if name in extractor_file_vars and var.get("type") not in _FILE_VARIABLE_TYPES:
var["type"] = "file-list" if extractor_file_vars[name] else "file"
var.setdefault("allowed_file_types", ["document"])
if var.get("type") not in _FILE_VARIABLE_TYPES:
continue
allowed_types = var.get("allowed_file_types")
if not isinstance(allowed_types, list) or not allowed_types:
allowed_types = list(_DEFAULT_ALLOWED_FILE_TYPES)
var["allowed_file_types"] = allowed_types
# ``custom`` demands a non-empty extension list; without one, drop it
# so the variable doesn't trip the "file extensions required" check.
extensions = var.get("allowed_file_extensions")
has_extensions = isinstance(extensions, list) and bool(extensions)
if "custom" in allowed_types and not has_extensions:
pruned = [t for t in allowed_types if t != "custom"]
var["allowed_file_types"] = pruned or list(_DEFAULT_ALLOWED_FILE_TYPES)
methods = var.get("allowed_file_upload_methods")
if not isinstance(methods, list) or not methods:
var["allowed_file_upload_methods"] = list(_DEFAULT_FILE_UPLOAD_METHODS)
if not isinstance(var.get("allowed_file_extensions"), list):
var["allowed_file_extensions"] = []
@classmethod
def _document_extractor_start_vars(cls, *, nodes: list[dict[str, Any]], start_id: str) -> dict[str, bool]:
"""
Map start-variable name ``is_array_file`` for every start variable a
``document-extractor`` node reads via its ``variable_selector``.
When two extractors read the same variable we keep ``True`` (file-list)
if any of them wants an array, since a file-list also satisfies a
single-file read.
"""
out: dict[str, bool] = {}
if not start_id:
return out
for node in nodes:
data = node.get("data") or {}
if data.get("type") != BuiltinNodeTypes.DOCUMENT_EXTRACTOR:
continue
selector = data.get("variable_selector")
if isinstance(selector, list) and len(selector) == 2 and selector[0] == start_id:
var_name = selector[1]
out[var_name] = out.get(var_name, False) or bool(data.get("is_array_file"))
return out
@classmethod
def _fill_node_defaults(cls, node: dict[str, Any]) -> None:
"""Ensure every node has the wrapper-level fields the Studio canvas needs."""

View File

@ -95,6 +95,61 @@ class TestGetBuilderSystemPrompt:
assert prompt is BUILDER_SYSTEM_PROMPT_ADVANCED_CHAT
assert 'exactly one "answer" node' in prompt
def test_scopes_cheatsheet_to_planned_node_types(self):
# When the runner pins the plan's node-type set, the builder prompt
# carries ONLY those types' schemas — no schema for unrelated nodes.
prompt = get_builder_system_prompt("workflow", {"start", "llm", "end"})
assert "- start:" in prompt
assert "- llm:" in prompt
assert "- if-else:" not in prompt
assert "- tool" not in prompt
assert "## Containers" not in prompt
# Still a valid, mode-correct prompt.
assert 'exactly one "end" node' in prompt
def test_scoped_prompt_pulls_in_containers_for_iteration(self):
prompt = get_builder_system_prompt("workflow", {"start", "iteration", "llm", "end"})
assert "## Containers" in prompt
def test_scoped_prompt_is_smaller_than_full(self):
# The whole point of dynamic assembly: a small plan ships a smaller
# builder prompt than the full cheatsheet.
scoped = get_builder_system_prompt("workflow", {"start", "llm", "end"})
assert len(scoped) < len(BUILDER_SYSTEM_PROMPT_WORKFLOW)
class TestBuildNodeConfigCheatsheet:
def test_none_returns_full_cheatsheet(self):
from core.workflow.generator.prompts.builder_prompts import (
NODE_CONFIG_CHEATSHEET,
build_node_config_cheatsheet,
)
full = build_node_config_cheatsheet(None)
assert full == NODE_CONFIG_CHEATSHEET
# Full cheatsheet documents every node type + containers.
assert "- tool" in full
assert "- if-else:" in full
assert "## Containers" in full
def test_always_includes_start_even_when_omitted(self):
# Every workflow has a start node; the assembler force-includes it so
# the builder can always declare input variables.
from core.workflow.generator.prompts.builder_prompts import build_node_config_cheatsheet
out = build_node_config_cheatsheet({"llm", "end"})
assert "- start:" in out
def test_start_snippet_documents_file_upload_schema(self):
# The bug this fixes: a file start variable needs allowed_file_types,
# which the builder never knew about. The snippet must now teach it.
from core.workflow.generator.prompts.builder_prompts import build_node_config_cheatsheet
out = build_node_config_cheatsheet({"start", "document-extractor", "llm", "end"})
assert "allowed_file_types" in out
assert "allowed_file_upload_methods" in out
assert "supported file types" in out # the exact Studio error wording
class TestFormatPlanBlockParentHints:
def test_resolves_parent_label_to_node_id(self):

View File

@ -2269,3 +2269,229 @@ class TestWorkflowGeneratorRefine:
assert result["error"] == ""
types = [n["data"]["type"] for n in result["graph"]["nodes"]]
assert types == ["start", "llm", "end"]
class TestWorkflowGeneratorFileVariables:
"""The reported bug: a file-input workflow ("takes in a file, extract its
content, summarize") generated a start node whose file variable lacked the
required ``allowed_file_types``, so Studio rejected the draft with
"supported file types is required". The builder now documents the field and
the postprocessor backfills it as a final safety net."""
@staticmethod
def _planner() -> str:
return json.dumps(
{
"title": "File Summarizer",
"description": "Summarize an uploaded document into bullet points",
"app_name": "File Summarizer",
"icon": "📄",
"start_inputs": [{"variable": "doc", "label": "Document", "type": "file"}],
"nodes": [
{"label": "Start", "node_type": "start", "purpose": "Take a file"},
{"label": "Extract", "node_type": "document-extractor", "purpose": "Extract text"},
{"label": "Summarize", "node_type": "llm", "purpose": "Bullet points"},
{"label": "End", "node_type": "end", "purpose": "Return"},
],
}
)
@staticmethod
def _builder_file_var_missing_allowed_types() -> str:
# The builder declares a file variable but (the bug) omits
# allowed_file_types / upload methods.
return json.dumps(
{
"nodes": [
{
"id": "node1",
"type": "custom",
"position": {"x": 0, "y": 0},
"data": {
"type": "start",
"title": "Start",
"variables": [{"variable": "doc", "label": "Document", "type": "file"}],
},
},
{
"id": "node2",
"type": "custom",
"position": {"x": 0, "y": 0},
"data": {
"type": "document-extractor",
"title": "Extract",
"variable_selector": ["node1", "doc"],
"is_array_file": False,
},
},
{
"id": "node3",
"type": "custom",
"position": {"x": 0, "y": 0},
"data": {
"type": "llm",
"title": "Summarize",
"prompt_template": [{"role": "user", "text": "Summarize {{#node2.text#}}"}],
},
},
{
"id": "node4",
"type": "custom",
"position": {"x": 0, "y": 0},
"data": {
"type": "end",
"title": "End",
"outputs": [{"variable": "summary", "value_selector": ["node3", "text"]}],
},
},
],
"edges": [
{"id": "e1", "source": "node1", "target": "node2", "type": "custom"},
{"id": "e2", "source": "node2", "target": "node3", "type": "custom"},
{"id": "e3", "source": "node3", "target": "node4", "type": "custom"},
],
"viewport": {"x": 0, "y": 0, "zoom": 0.7},
}
)
def test_backfills_allowed_file_types_so_draft_loads(self):
model_instance = MagicMock()
model_instance.invoke_llm.side_effect = [
_llm_result(self._planner()),
_llm_result(self._builder_file_var_missing_allowed_types()),
]
result = WorkflowGenerator.generate_workflow_graph(
model_instance=model_instance,
model_parameters={},
provider="openai",
model_name="gpt-4o",
model_mode="chat",
mode="workflow",
instruction="takes in a file, extracting its content, then summarizing into bullet points",
)
assert result["error"] == ""
start = next(n for n in result["graph"]["nodes"] if n["data"]["type"] == "start")
doc = next(v for v in start["data"]["variables"] if v["variable"] == "doc")
assert doc["type"] == "file"
# The required field is now present and non-empty — the whole point.
assert doc["allowed_file_types"]
assert doc["allowed_file_upload_methods"] == ["local_file", "remote_url"]
def test_builder_prompt_is_scoped_to_planned_node_types(self):
# Capture the system prompt handed to the builder and assert it only
# carries the planned node types' schemas (dynamic assembly).
captured: list[str] = []
def _capture(*args, **kwargs):
prompt_messages = kwargs.get("prompt_messages") or (args[1] if len(args) > 1 else [])
captured.append("\n".join(getattr(m, "content", "") or "" for m in prompt_messages))
# Return planner then builder responses in order.
idx = len(captured) - 1
return _llm_result(self._planner() if idx == 0 else self._builder_file_var_missing_allowed_types())
model_instance = MagicMock()
model_instance.invoke_llm.side_effect = _capture
WorkflowGenerator.generate_workflow_graph(
model_instance=model_instance,
model_parameters={},
provider="openai",
model_name="gpt-4o",
model_mode="chat",
mode="workflow",
instruction="Summarize an uploaded file",
)
builder_prompt = captured[1]
assert "- document-extractor:" in builder_prompt
assert "- start:" in builder_prompt
# No schema for node types absent from the plan.
assert "- if-else:" not in builder_prompt
assert "- tool" not in builder_prompt
def test_promotes_mistyped_var_consumed_by_document_extractor(self):
# A direct unit test of the backstop: a document-extractor reading a
# paragraph-typed start var forces it to a file type.
nodes = [
{
"id": "s",
"data": {
"type": "start",
"variables": [{"variable": "doc", "label": "Doc", "type": "paragraph"}],
},
},
{
"id": "x",
"data": {
"type": "document-extractor",
"variable_selector": ["s", "doc"],
"is_array_file": False,
},
},
]
WorkflowGenerator._normalize_start_file_variables(nodes=nodes)
doc = nodes[0]["data"]["variables"][0]
assert doc["type"] == "file"
assert doc["allowed_file_types"] == ["document"]
def test_drops_custom_file_type_without_extensions(self):
nodes = [
{
"id": "s",
"data": {
"type": "start",
"variables": [
{
"variable": "f",
"label": "F",
"type": "file",
"allowed_file_types": ["custom"],
"allowed_file_extensions": [],
}
],
},
}
]
WorkflowGenerator._normalize_start_file_variables(nodes=nodes)
f = nodes[0]["data"]["variables"][0]
assert "custom" not in f["allowed_file_types"]
assert f["allowed_file_types"] # non-empty fallback
def test_leaves_valid_file_variable_untouched(self):
nodes = [
{
"id": "s",
"data": {
"type": "start",
"variables": [
{
"variable": "img",
"label": "Img",
"type": "file",
"allowed_file_types": ["image"],
"allowed_file_upload_methods": ["local_file"],
"allowed_file_extensions": [],
}
],
},
}
]
WorkflowGenerator._normalize_start_file_variables(nodes=nodes)
img = nodes[0]["data"]["variables"][0]
assert img["allowed_file_types"] == ["image"]
assert img["allowed_file_upload_methods"] == ["local_file"]
def test_ignores_non_file_variables(self):
nodes = [
{
"id": "s",
"data": {
"type": "start",
"variables": [{"variable": "t", "label": "T", "type": "text-input"}],
},
}
]
WorkflowGenerator._normalize_start_file_variables(nodes=nodes)
assert "allowed_file_types" not in nodes[0]["data"]["variables"][0]

View File

@ -148,6 +148,9 @@ ENABLE_WEBSITE_JINAREADER=true
ENABLE_WEBSITE_FIRECRAWL=true
ENABLE_WEBSITE_WATERCRAWL=true
NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX=false
# Enable preview features still in development (currently the /create and
# /refine slash commands in the "Go to Anything" command palette).
NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW=false
EXPERIMENTAL_ENABLE_VINEXT=false
# Storage and default vector store

View File

@ -24,6 +24,7 @@ ENABLE_WEBSITE_JINAREADER=true
ENABLE_WEBSITE_FIRECRAWL=true
ENABLE_WEBSITE_WATERCRAWL=true
NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX=false
NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW=false
NEXT_PUBLIC_COOKIE_DOMAIN=
NEXT_PUBLIC_BATCH_CONCURRENCY=5
CSP_WHITELIST=

View File

@ -84,6 +84,10 @@ NEXT_PUBLIC_ENABLE_WEBSITE_WATERCRAWL=true
# Default is false for security reasons to prevent conflicts with regular text
NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX=false
# Enable preview features still in development (currently the /create and
# /refine slash commands in the "Go to Anything" command palette)
NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW=false
# The maximum number of tree node depth for workflow
NEXT_PUBLIC_MAX_TREE_DEPTH=50

View File

@ -9,6 +9,7 @@ const {
mockRegister,
mockSearch,
mockUnregister,
featureFlag,
} = vi.hoisted(() => ({
mockSetTheme: vi.fn(),
mockSetLocale: vi.fn(),
@ -16,6 +17,14 @@ const {
mockRegister: vi.fn(),
mockSearch: vi.fn(),
mockUnregister: vi.fn(),
// Mutable holder so each test can flip the feature-preview flag before render.
featureFlag: { enabled: false },
}))
vi.mock('@/config', () => ({
get ENABLE_FEATURE_PREVIEW() {
return featureFlag.enabled
},
}))
vi.mock('next-themes', () => ({
@ -92,9 +101,47 @@ describe('slashAction', () => {
describe('SlashCommandProvider', () => {
beforeEach(() => {
vi.clearAllMocks()
// Default: feature preview off, so /create and /refine are NOT registered.
featureFlag.enabled = false
})
it('should register commands on mount and unregister them on unmount', () => {
it('should not register the /create and /refine preview commands when the feature flag is off', () => {
const { unmount } = render(<SlashCommandProvider />)
expect(mockRegister.mock.calls.map(call => call[0].name)).toEqual([
'theme',
'language',
'forum',
'docs',
'community',
'account',
'zen',
'go',
])
expect(mockRegister).toHaveBeenCalledWith(expect.objectContaining({ name: 'theme' }), { setTheme: mockSetTheme })
expect(mockRegister).toHaveBeenCalledWith(expect.objectContaining({ name: 'language' }), { setLocale: mockSetLocale })
unmount()
// Unregister is always called for the preview commands (a no-op when they
// were never registered) so toggling the flag off mid-session stays clean.
expect(mockUnregister.mock.calls.map(call => call[0])).toEqual([
'theme',
'language',
'forum',
'docs',
'community',
'account',
'zen',
'go',
'create',
'refine',
])
})
it('should register the /create and /refine preview commands when the feature flag is on', () => {
featureFlag.enabled = true
const { unmount } = render(<SlashCommandProvider />)
expect(mockRegister.mock.calls.map(call => call[0].name)).toEqual([
@ -109,8 +156,6 @@ describe('SlashCommandProvider', () => {
'create',
'refine',
])
expect(mockRegister).toHaveBeenCalledWith(expect.objectContaining({ name: 'theme' }), { setTheme: mockSetTheme })
expect(mockRegister).toHaveBeenCalledWith(expect.objectContaining({ name: 'language' }), { setLocale: mockSetLocale })
unmount()

View File

@ -3,6 +3,7 @@ import type { ActionItem } from '../types'
import { useTheme } from 'next-themes'
import { useEffect } from 'react'
import { getI18n } from 'react-i18next'
import { ENABLE_FEATURE_PREVIEW } from '@/config'
import { setLocaleOnClient } from '@/i18n-config'
import { accountCommand } from './account'
import { executeCommand } from './command-bus'
@ -52,8 +53,11 @@ const registerSlashCommands = (deps: Record<string, any>) => {
slashCommandRegistry.register(accountCommand, {})
slashCommandRegistry.register(zenCommand, {})
slashCommandRegistry.register(goCommand, {})
slashCommandRegistry.register(createCommand, {})
slashCommandRegistry.register(refineCommand, {})
// `/create` and `/refine` are preview features, gated behind a flag.
if (ENABLE_FEATURE_PREVIEW) {
slashCommandRegistry.register(createCommand, {})
slashCommandRegistry.register(refineCommand, {})
}
}
const unregisterSlashCommands = () => {
@ -66,6 +70,7 @@ const unregisterSlashCommands = () => {
slashCommandRegistry.unregister('account')
slashCommandRegistry.unregister('zen')
slashCommandRegistry.unregister('go')
// No-op when the preview flag is off and these were never registered.
slashCommandRegistry.unregister('create')
slashCommandRegistry.unregister('refine')
}

View File

@ -314,6 +314,7 @@ export const ENABLE_WEBSITE_JINAREADER = env.NEXT_PUBLIC_ENABLE_WEBSITE_JINAREAD
export const ENABLE_WEBSITE_FIRECRAWL = env.NEXT_PUBLIC_ENABLE_WEBSITE_FIRECRAWL
export const ENABLE_WEBSITE_WATERCRAWL = env.NEXT_PUBLIC_ENABLE_WEBSITE_WATERCRAWL
export const ENABLE_SINGLE_DOLLAR_LATEX = env.NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX
export const ENABLE_FEATURE_PREVIEW = env.NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW
export const VALUE_SELECTOR_DELIMITER = '@@@'

View File

@ -55,6 +55,7 @@ export NEXT_PUBLIC_ENABLE_WEBSITE_JINAREADER=${ENABLE_WEBSITE_JINAREADER:-true}
export NEXT_PUBLIC_ENABLE_WEBSITE_FIRECRAWL=${ENABLE_WEBSITE_FIRECRAWL:-true}
export NEXT_PUBLIC_ENABLE_WEBSITE_WATERCRAWL=${ENABLE_WEBSITE_WATERCRAWL:-true}
export NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX=${NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX:-false}
export NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW=${NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW:-false}
export NEXT_PUBLIC_LOOP_NODE_MAX_COUNT=${LOOP_NODE_MAX_COUNT}
export NEXT_PUBLIC_MAX_PARALLEL_LIMIT=${MAX_PARALLEL_LIMIT}
export NEXT_PUBLIC_MAX_ITERATIONS_NUM=${MAX_ITERATIONS_NUM}

View File

@ -63,6 +63,12 @@ const clientSchema = {
* The deployment edition, SELF_HOSTED
*/
NEXT_PUBLIC_EDITION: z.enum(['SELF_HOSTED', 'CLOUD']).default('SELF_HOSTED'),
/**
* Enable preview features that are still in development.
* Currently gates the `/create` and `/refine` slash commands in the
* "Go to Anything" command palette (Cmd/Ctrl+K).
*/
NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW: coercedBoolean.default(false),
/**
* Cloud-only system-features defaults.
@ -190,6 +196,7 @@ export const env = createEnv({
NEXT_PUBLIC_DEPLOY_ENV: isServer ? process.env.NEXT_PUBLIC_DEPLOY_ENV : getRuntimeEnvFromBody('deployEnv'),
NEXT_PUBLIC_DISABLE_UPLOAD_IMAGE_AS_ICON: isServer ? process.env.NEXT_PUBLIC_DISABLE_UPLOAD_IMAGE_AS_ICON : getRuntimeEnvFromBody('disableUploadImageAsIcon'),
NEXT_PUBLIC_EDITION: isServer ? process.env.NEXT_PUBLIC_EDITION : getRuntimeEnvFromBody('edition'),
NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW: isServer ? process.env.NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW : getRuntimeEnvFromBody('enableFeaturePreview'),
/**
* Cloud-only system-features defaults.