From db1aa683bca5d5b7a813e075a7272bfbfa48d820 Mon Sep 17 00:00:00 2001 From: Crazywoola <100913391+crazywoola@users.noreply.github.com> Date: Mon, 8 Jun 2026 10:32:52 +0800 Subject: [PATCH] feat(web): gate /create and /refine slash commands behind feature preview flag (#37094) Co-authored-by: Claude Opus 4.8 (1M context) --- .../generator/prompts/builder_prompts.py | 203 +++++++++++----- api/core/workflow/generator/runner.py | 144 ++++++++++- .../core/workflow/generator/test_prompts.py | 55 +++++ .../core/workflow/generator/test_runner.py | 226 ++++++++++++++++++ docker/.env.example | 3 + docker/envs/core-services/web.env.example | 1 + web/.env.example | 4 + .../actions/commands/__tests__/slash.spec.tsx | 51 +++- .../goto-anything/actions/commands/slash.tsx | 9 +- web/config/index.ts | 1 + web/docker/entrypoint.sh | 1 + web/env.ts | 7 + 12 files changed, 643 insertions(+), 62 deletions(-) diff --git a/api/core/workflow/generator/prompts/builder_prompts.py b/api/core/workflow/generator/prompts/builder_prompts.py index 0f81fb4251..b4f7cec6d7 100644 --- a/api/core/workflow/generator/prompts/builder_prompts.py +++ b/api/core/workflow/generator/prompts/builder_prompts.py @@ -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": "", "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": ["", ""]} - ]} - + ]}""", + "answer": """\ - answer (Advanced Chat mode only): {"variables": [], - "answer": ".#}} placeholders>"} - + "answer": ".#}} placeholders>"}""", + "llm": """\ - llm: {"model": {"provider": "", "name": "", "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": ["", ""], "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": ["", ""]}], - "outputs": {"result": {"type": "string", "children": null}}} - + "outputs": {"result": {"type": "string", "children": null}}}""", + "template-transform": """\ - template-transform: {"template": "Hello {{ name }}", - "variables": [{"variable": "name", "value_selector": ["", ""]}]} - + "variables": [{"variable": "name", "value_selector": ["", ""]}]}""", + "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 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 ["", ""] - "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": ""}]} ]} - 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": ["", ""], "model": {"provider": "

", "name": "", "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": [["", ""]], # array of value_selector arrays "model": {"provider": "

", "name": "", "mode": "chat", @@ -179,8 +215,8 @@ Children of iteration / loop containers additionally need "description": "", "required": true}], "reasoning_mode": "prompt", "vision": {"enabled": false}, - "instruction": ""} - + "instruction": ""}""", + "document-extractor": """\ - document-extractor: {"variable_selector": ["", ""], # 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": ["", ""], "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) diff --git a/api/core/workflow/generator/runner.py b/api/core/workflow/generator/runner.py index ed7411eb8d..a0d52546e7 100644 --- a/api/core/workflow/generator/runner.py +++ b/api/core/workflow/generator/runner.py @@ -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.""" diff --git a/api/tests/unit_tests/core/workflow/generator/test_prompts.py b/api/tests/unit_tests/core/workflow/generator/test_prompts.py index 05d97327ce..e1ba146c2a 100644 --- a/api/tests/unit_tests/core/workflow/generator/test_prompts.py +++ b/api/tests/unit_tests/core/workflow/generator/test_prompts.py @@ -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): diff --git a/api/tests/unit_tests/core/workflow/generator/test_runner.py b/api/tests/unit_tests/core/workflow/generator/test_runner.py index b1d8f21fa7..0117f7d27c 100644 --- a/api/tests/unit_tests/core/workflow/generator/test_runner.py +++ b/api/tests/unit_tests/core/workflow/generator/test_runner.py @@ -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] diff --git a/docker/.env.example b/docker/.env.example index 33809eac2f..76d04b4b0c 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -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 diff --git a/docker/envs/core-services/web.env.example b/docker/envs/core-services/web.env.example index d366cd87ba..4c11910631 100644 --- a/docker/envs/core-services/web.env.example +++ b/docker/envs/core-services/web.env.example @@ -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= diff --git a/web/.env.example b/web/.env.example index 05e3ce4faa..77beee1417 100644 --- a/web/.env.example +++ b/web/.env.example @@ -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 diff --git a/web/app/components/goto-anything/actions/commands/__tests__/slash.spec.tsx b/web/app/components/goto-anything/actions/commands/__tests__/slash.spec.tsx index dd02e84f02..b58821b7dd 100644 --- a/web/app/components/goto-anything/actions/commands/__tests__/slash.spec.tsx +++ b/web/app/components/goto-anything/actions/commands/__tests__/slash.spec.tsx @@ -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() + + 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() 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() diff --git a/web/app/components/goto-anything/actions/commands/slash.tsx b/web/app/components/goto-anything/actions/commands/slash.tsx index 0f8bc0af26..9c1f17c6f0 100644 --- a/web/app/components/goto-anything/actions/commands/slash.tsx +++ b/web/app/components/goto-anything/actions/commands/slash.tsx @@ -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) => { 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') } diff --git a/web/config/index.ts b/web/config/index.ts index 15999a56e6..dcd3822940 100644 --- a/web/config/index.ts +++ b/web/config/index.ts @@ -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 = '@@@' diff --git a/web/docker/entrypoint.sh b/web/docker/entrypoint.sh index 0fed7b033a..1ace459065 100755 --- a/web/docker/entrypoint.sh +++ b/web/docker/entrypoint.sh @@ -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} diff --git a/web/env.ts b/web/env.ts index 22b33b1089..0bf5d4c313 100644 --- a/web/env.ts +++ b/web/env.ts @@ -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.