Compare commits

...
Author SHA1 Message Date
-LAN- 88d694d51c fix(workflow): remove unused draft sync type
Remove the redundant exported SyncDraftOptions declaration flagged by Knip in Web Style.
2026-07-28 12:51:16 +08:00
-LAN- 82b2d2f76f chore(workflow): merge main into LLM environment variables
Resolve draft sync, collaboration, generated contract, environment panel, and export dialog conflicts while preserving atomic LLM environment-variable patches.
2026-07-28 11:34:19 +08:00
-LAN- 8811db1191 test(workflow): use real segment type in snippet env test 2026-07-16 19:42:55 +08:00
-LAN- 385d9a50fd fix(workflow): resolve CI regressions after main merge
Merge the latest main branch, retain both node-factory test imports, isolate workflow sync signals in service tests, and update E2E draft seeds to the patch-only environment variable contract.
2026-07-16 19:26:25 +08:00
autofix-ci[bot]andGitHub 6226d7634c [autofix.ci] apply automated fixes 2026-07-16 06:53:41 +00:00
-LAN- f873fbad2a fix(workflow): simplify draft environment sync
Remove the legacy full environment-variable payload from app draft synchronization and preserve environment variables unless an explicit patch is supplied. Consume the validated Pydantic payload directly and update app callers and regressions accordingly.
2026-07-16 14:49:44 +08:00
autofix-ci[bot]andGitHub fdc938e94d [autofix.ci] apply automated fixes 2026-07-16 06:17:16 +00:00
-LAN- 3d4423ceb3 feat(workflow): support LLM environment variables
Add reusable provider, model, mode, and completion-parameter configurations for workflow LLM nodes.

Resolve and validate shared model references across draft persistence, DSL, snippets, RAG pipelines, trials, and runtime execution while preserving collaborative environment-variable updates.
2026-07-16 14:12:54 +08:00
76 changed files with 5682 additions and 393 deletions
+72 -19
View File
@@ -2,11 +2,20 @@ import json
import logging
from collections.abc import Sequence
from datetime import datetime
from typing import Any, NotRequired, TypedDict
from typing import Any, NotRequired, Self, TypedDict
from flask import abort, request
from flask_restx import Resource, fields
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, RootModel, ValidationError, field_validator
from pydantic import (
AliasChoices,
BaseModel,
ConfigDict,
Field,
RootModel,
ValidationError,
field_validator,
model_validator,
)
from sqlalchemy.orm import Session, sessionmaker
from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotFound
@@ -54,6 +63,10 @@ from core.trigger.debug.event_selectors import (
create_event_poller,
select_trigger_debug_events,
)
from core.workflow.llm_environment_variable import (
LLM_ENVIRONMENT_VARIABLE_VALUE_TYPE,
LLMEnvironmentVariable,
)
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from factories import file_factory, variable_factory
@@ -101,14 +114,35 @@ class EnvironmentVariableResponseDict(TypedDict):
description: NotRequired[str | None]
class SyncEnvironmentVariablePatchPayload(BaseModel):
environment_variables: list[dict[str, Any]] = Field(default_factory=list)
deleted_environment_variable_ids: list[str] = Field(default_factory=list)
@model_validator(mode="after")
def validate_patch(self) -> Self:
"""Require stable, disjoint IDs so the service can merge the patch deterministically."""
upsert_ids = [variable.get("id") for variable in self.environment_variables]
if any(not isinstance(variable_id, str) or not variable_id for variable_id in upsert_ids):
raise ValueError("patched environment variables require an id")
if len(set(upsert_ids)) != len(upsert_ids):
raise ValueError("patched environment variable ids must be unique")
if any(not variable_id for variable_id in self.deleted_environment_variable_ids):
raise ValueError("deleted environment variable ids must not be empty")
if len(set(self.deleted_environment_variable_ids)) != len(self.deleted_environment_variable_ids):
raise ValueError("deleted environment variable ids must be unique")
if set(upsert_ids).intersection(self.deleted_environment_variable_ids):
raise ValueError("an environment variable cannot be upserted and deleted in the same patch")
return self
class SyncDraftWorkflowPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
graph: dict[str, Any]
features: dict[str, Any]
hash: str | None = None
is_collaborative: bool = Field(default=False, alias="_is_collaborative")
environment_variables: list[dict[str, Any]] = Field(
default_factory=list,
)
environment_variable_patch: SyncEnvironmentVariablePatchPayload | None = None
conversation_variables: list[dict[str, Any]] = Field(
default_factory=list,
)
@@ -481,6 +515,15 @@ def _parse_file(workflow: Workflow, files: list[dict] | None = None) -> Sequence
def _serialize_environment_variable(value: Any) -> EnvironmentVariableResponseDict | Any:
match value:
case LLMEnvironmentVariable():
return {
"id": value.id,
"name": value.name,
"value": value.value,
"value_type": LLM_ENVIRONMENT_VARIABLE_VALUE_TYPE,
"description": value.description,
}
case SecretVariable():
return {
"id": value.id,
@@ -505,6 +548,8 @@ def _serialize_environment_variable(value: Any) -> EnvironmentVariableResponseDi
raise TypeError(
f"unexpected type for value_type field, value={value_type_str}, type={type(value_type_str)}"
)
if value_type_str == LLM_ENVIRONMENT_VARIABLE_VALUE_TYPE:
return value
value_type = SegmentType(value_type_str).exposed_type()
if value_type not in ENVIRONMENT_VARIABLE_SUPPORTED_TYPES:
raise ValueError(f"Unsupported environment variable value type: {value_type}")
@@ -588,30 +633,38 @@ class DraftWorkflowApi(Resource):
return {"message": "Invalid JSON data"}, 400
else:
abort(415)
args = args_model.model_dump()
workflow_service = WorkflowService()
try:
environment_variables_list = Workflow.normalize_environment_variable_mappings(
args.get("environment_variables") or [],
)
environment_variables = [
variable_factory.build_environment_variable_from_mapping(obj) for obj in environment_variables_list
]
conversation_variables_list = args.get("conversation_variables") or []
environment_variable_patch = args_model.environment_variable_patch
environment_variable_upserts: list[VariableBase] | None = None
deleted_environment_variable_ids: list[str] = []
if environment_variable_patch is not None:
environment_variable_upsert_mappings = Workflow.normalize_environment_variable_mappings(
environment_variable_patch.environment_variables,
)
environment_variable_upserts = [
variable_factory.build_environment_variable_from_mapping(obj)
for obj in environment_variable_upsert_mappings
]
deleted_environment_variable_ids = environment_variable_patch.deleted_environment_variable_ids
conversation_variables = [
variable_factory.build_conversation_variable_from_mapping(obj) for obj in conversation_variables_list
variable_factory.build_conversation_variable_from_mapping(obj)
for obj in args_model.conversation_variables
]
workflow = workflow_service.sync_draft_workflow(
app_model=app_model,
graph=args["graph"],
features=args["features"],
unique_hash=args.get("hash"),
graph=args_model.graph,
features=args_model.features,
unique_hash=args_model.hash,
account=current_user,
environment_variables=environment_variables,
environment_variables=[],
conversation_variables=conversation_variables,
session=db.session(),
graph_only=args["is_collaborative"],
environment_variable_upserts=environment_variable_upserts,
deleted_environment_variable_ids=deleted_environment_variable_ids,
preserve_environment_variables=True,
graph_only=args_model.is_collaborative,
)
except WorkflowHashNotEqualError:
raise DraftWorkflowNotSync()
@@ -1,12 +1,12 @@
import logging
from collections.abc import Callable
from functools import wraps
from typing import Any, Concatenate, TypedDict, override
from typing import Any, Concatenate, Self, TypedDict, override
from uuid import UUID
from flask import Response, request
from flask_restx import Resource, fields, marshal, marshal_with
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, model_validator
from sqlalchemy.orm import sessionmaker
from controllers.common.errors import InvalidArgumentError, NotFoundError
@@ -27,6 +27,7 @@ from controllers.console.wraps import (
with_current_user,
)
from core.app.file_access import DatabaseFileAccessController
from core.workflow.llm_environment_variable import environment_variable_value_type
from core.workflow.variable_prefixes import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID
from extensions.ext_database import db
from factories import variable_factory
@@ -109,6 +110,35 @@ class EnvironmentVariableUpdatePayload(BaseModel):
...,
description="Environment variables for the draft workflow",
)
patch: bool = Field(
default=False,
description="Treat environment_variables as per-ID upserts instead of replacing the full collection",
)
deleted_environment_variable_ids: list[str] = Field(
default_factory=list,
description="Environment variable IDs to delete when patch is true",
)
@model_validator(mode="after")
def validate_patch(self) -> Self:
"""Validate the per-variable patch contract without changing legacy replacement requests."""
if not self.patch:
if self.deleted_environment_variable_ids:
raise ValueError("deleted_environment_variable_ids requires patch=true")
return self
upsert_ids = [variable.id for variable in self.environment_variables]
if any(not variable_id for variable_id in upsert_ids):
raise ValueError("patched environment variables require an id")
if len(set(upsert_ids)) != len(upsert_ids):
raise ValueError("patched environment variable ids must be unique")
if any(not variable_id for variable_id in self.deleted_environment_variable_ids):
raise ValueError("deleted environment variable ids must not be empty")
if len(set(self.deleted_environment_variable_ids)) != len(self.deleted_environment_variable_ids):
raise ValueError("deleted environment variable ids must be unique")
if set(upsert_ids).intersection(self.deleted_environment_variable_ids):
raise ValueError("an environment variable cannot be upserted and deleted in the same patch")
return self
class EnvironmentVariableItemResponse(ResponseModel):
@@ -698,7 +728,7 @@ class EnvironmentVariableCollectionApi(Resource):
"name": v.name,
"description": v.description,
"selector": v.selector,
"value_type": str(v.value_type.exposed_type()),
"value_type": environment_variable_value_type(v),
"value": v.value,
# Do not track edited for env vars.
"edited": False,
@@ -737,11 +767,20 @@ class EnvironmentVariableCollectionApi(Resource):
variable_factory.build_environment_variable_from_mapping(obj) for obj in environment_variables_list
]
workflow_service.update_draft_workflow_environment_variables(
app_model=app_model,
account=current_user,
environment_variables=environment_variables,
session=db.session(),
)
if payload.patch:
workflow_service.patch_draft_workflow_environment_variables(
app_model=app_model,
account=current_user,
environment_variables=environment_variables,
deleted_environment_variable_ids=payload.deleted_environment_variable_ids,
session=db.session(),
)
else:
workflow_service.update_draft_workflow_environment_variables(
app_model=app_model,
account=current_user,
environment_variables=environment_variables,
session=db.session(),
)
return {"result": "success"}
@@ -26,6 +26,7 @@ from controllers.console.app.workflow_draft_variable import (
from controllers.console.datasets.wraps import get_rag_pipeline
from controllers.console.wraps import account_initialization_required, setup_required, with_current_user
from core.app.file_access import DatabaseFileAccessController
from core.workflow.llm_environment_variable import LLMEnvironmentVariable, environment_variable_value_type
from core.workflow.variable_prefixes import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID
from extensions.ext_database import db
from factories.file_factory import build_from_mapping, build_from_mappings
@@ -362,7 +363,11 @@ class RagPipelineEnvironmentVariableCollectionApi(Resource):
"name": v.name,
"description": v.description,
"selector": v.selector,
"value_type": v.value_type.value,
"value_type": (
environment_variable_value_type(v)
if isinstance(v, LLMEnvironmentVariable)
else v.value_type.value
),
"value": v.value,
# Do not track edited for env vars.
"edited": False,
+23
View File
@@ -59,6 +59,8 @@ from core.errors.error import (
ProviderTokenNotInitError,
QuotaExceededError,
)
from core.helper import encrypter
from core.workflow.llm_environment_variable import LLMEnvironmentVariable, dump_environment_variable
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from fields.base import ResponseModel
@@ -67,6 +69,7 @@ from fields.file_fields import FileResponse, FileWithSignedUrl
from fields.message_fields import SuggestedQuestionsResponse
from graphon.graph_engine.manager import GraphEngineManager
from graphon.model_runtime.errors.invoke import InvokeError
from graphon.variables import SecretVariable, VariableBase
from libs import helper
from libs.helper import dump_response, to_timestamp, uuid_value
from models import Account, App
@@ -385,6 +388,26 @@ class TrialWorkflowResponse(ResponseModel):
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
return to_timestamp(value)
@field_validator("environment_variables", mode="before")
@classmethod
def _serialize_environment_variables(cls, value: Any) -> list[Any]:
if value is None:
return []
result: list[Any] = []
for item in value:
if isinstance(item, SecretVariable):
serialized = item.model_dump(mode="json")
serialized["value"] = encrypter.full_mask_token()
result.append(serialized)
elif isinstance(item, LLMEnvironmentVariable):
result.append(dump_environment_variable(item, mode="json"))
elif isinstance(item, VariableBase):
result.append(item.model_dump(mode="json"))
else:
result.append(item)
return result
@dataclass(frozen=True)
class TrialWorkflowResponseSource:
@@ -40,6 +40,7 @@ from controllers.console.wraps import (
with_current_user,
)
from core.app.file_access import DatabaseFileAccessController
from core.workflow.llm_environment_variable import environment_variable_value_type
from core.workflow.variable_prefixes import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID
from extensions.ext_database import db
from factories.file_factory import build_from_mapping, build_from_mappings
@@ -329,7 +330,7 @@ class SnippetEnvironmentVariableCollectionApi(Resource):
"name": v.name,
"description": v.description,
"selector": v.selector,
"value_type": v.value_type.exposed_type().value,
"value_type": environment_variable_value_type(v),
"value": v.value,
"edited": False,
"visible": True,
@@ -0,0 +1,166 @@
"""LLM environment-variable types and model-reference resolution helpers.
Graphon does not have an LLM segment type, so the runtime representation stays
an object variable. Workflow persistence and API boundaries use the semantic
``llm`` value type through the serialization helpers in this module.
"""
from collections.abc import Mapping, Sequence
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, field_validator
from core.workflow.variable_prefixes import ENVIRONMENT_VARIABLE_NODE_ID
from graphon.nodes.llm.entities import LLMMode, ModelConfig
from graphon.variables.variables import ObjectVariable, VariableBase
LLM_ENVIRONMENT_VARIABLE_VALUE_TYPE = "llm"
class LLMModelSelection(BaseModel):
"""The shared model configuration stored by an LLM environment variable."""
model_config = ConfigDict(extra="forbid", frozen=True, str_strip_whitespace=True)
provider: str = Field(min_length=1)
name: str = Field(min_length=1)
mode: LLMMode
completion_params: dict[str, Any] | None = None
class LLMEnvironmentVariable(ObjectVariable):
"""An object variable with a strictly validated LLM model-selection value."""
@field_validator("value", mode="before")
@classmethod
def validate_model_selection(cls, value: object) -> dict[str, Any]:
return LLMModelSelection.model_validate(value).model_dump(mode="json", exclude_none=True)
def dump_environment_variable(variable: VariableBase, *, mode: str = "python") -> dict[str, Any]:
"""Serialize a variable while preserving its workflow-level semantic type."""
result = variable.model_dump(mode=mode)
if isinstance(variable, LLMEnvironmentVariable):
result["value_type"] = LLM_ENVIRONMENT_VARIABLE_VALUE_TYPE
return result
def environment_variable_value_type(variable: VariableBase) -> str:
"""Return the public value type used by workflow environment-variable APIs."""
if isinstance(variable, LLMEnvironmentVariable):
return LLM_ENVIRONMENT_VARIABLE_VALUE_TYPE
return str(variable.value_type.exposed_type())
def parse_llm_model_selector(selector: object) -> tuple[str, str]:
"""Validate and normalize an LLM node's environment-variable selector."""
if (
not isinstance(selector, Sequence)
or isinstance(selector, str | bytes)
or len(selector) != 2
or selector[0] != ENVIRONMENT_VARIABLE_NODE_ID
or not isinstance(selector[1], str)
or not selector[1]
):
raise ValueError("LLM model selector must have the form ['env', '<variable-name>']")
return ENVIRONMENT_VARIABLE_NODE_ID, selector[1]
def should_resolve_llm_model_selector(selector: object) -> bool:
"""Return whether a selector uses the LLM environment-variable contract.
Older snippet graphs can contain selectors such as ``["start", "MODEL"]``.
They predate this contract and must keep using the node's static model.
Malformed selectors and selectors beginning with ``env`` are handled by the
strict parser so new invalid references still fail fast.
"""
if selector is None:
return False
if (
isinstance(selector, Sequence)
and not isinstance(selector, str | bytes)
and len(selector) > 0
and selector[0] != ENVIRONMENT_VARIABLE_NODE_ID
):
return False
return True
def resolve_llm_model_config(
*,
node_model: ModelConfig,
variable_name: str,
variable_value: Mapping[str, Any],
) -> ModelConfig:
"""Apply a shared model configuration, preserving legacy node-local parameters."""
try:
selection = LLMModelSelection.model_validate(variable_value)
except ValueError as exc:
raise ValueError(f"LLM environment variable '{variable_name}' has an invalid model selection: {exc}") from exc
if selection.mode != node_model.mode:
raise ValueError(
f"LLM environment variable '{variable_name}' uses mode '{selection.mode.value}', "
f"but the referencing node uses mode '{node_model.mode.value}'"
)
update: dict[str, Any] = {
"provider": selection.provider,
"name": selection.name,
}
if selection.completion_params is not None:
update["completion_params"] = selection.completion_params
return node_model.model_copy(update=update)
def resolve_llm_model_config_from_environment(
*,
node_model: ModelConfig,
selector: object,
environment_variables: Mapping[str, VariableBase],
) -> ModelConfig:
"""Resolve a persisted LLM environment reference against workflow variables."""
_, variable_name = parse_llm_model_selector(selector)
variable = environment_variables.get(variable_name)
if not isinstance(variable, LLMEnvironmentVariable):
raise ValueError(f"LLM environment variable '{variable_name}' was not found or is not an LLM variable")
return resolve_llm_model_config(
node_model=node_model,
variable_name=variable_name,
variable_value=variable.value,
)
def validate_llm_environment_model_references(
*,
graph: Mapping[str, Any],
environment_variables: Sequence[VariableBase],
) -> None:
"""Validate every LLM environment-model reference in a workflow graph."""
variables_by_name = {variable.name: variable for variable in environment_variables}
nodes = graph.get("nodes", [])
if not isinstance(nodes, Sequence) or isinstance(nodes, str | bytes):
return
for node in nodes:
if not isinstance(node, Mapping):
continue
node_data = node.get("data")
if not isinstance(node_data, Mapping) or node_data.get("type") != "llm":
continue
selector = node_data.get("model_selector")
if not should_resolve_llm_model_selector(selector):
continue
model = ModelConfig.model_validate(node_data.get("model", {}))
resolve_llm_model_config_from_environment(
node_model=model,
selector=selector,
environment_variables=variables_by_name,
)
+27 -1
View File
@@ -22,6 +22,11 @@ from core.model_manager import ModelInstance
from core.prompt.entities.advanced_prompt_entities import MemoryConfig
from core.trigger.constants import TRIGGER_NODE_TYPES
from core.workflow.human_input_adapter import adapt_node_config_for_graph
from core.workflow.llm_environment_variable import (
parse_llm_model_selector,
resolve_llm_model_config,
should_resolve_llm_model_selector,
)
from core.workflow.node_runtime import (
DifyFileReferenceFactory,
DifyHumanInputNodeRuntime,
@@ -63,7 +68,7 @@ from graphon.nodes.http_request import build_http_request_config
from graphon.nodes.llm.entities import LLMNodeData
from graphon.nodes.parameter_extractor.entities import ParameterExtractorNodeData
from graphon.nodes.question_classifier.entities import QuestionClassifierNodeData
from graphon.variables.segments import ArrayObjectSegment
from graphon.variables.segments import ArrayObjectSegment, ObjectSegment
from models.model import Conversation
if TYPE_CHECKING:
@@ -394,6 +399,8 @@ class DifyNodeFactory(NodeFactory):
# stay explicit and constructors receive the concrete typed payload.
resolved_node_data = self._validate_resolved_node_data(node_class, node_data)
node_type = node_data.type
if node_type == BuiltinNodeTypes.LLM:
resolved_node_data = self._resolve_llm_model_reference(cast(LLMNodeData, resolved_node_data))
node_init_kwargs_factories: Mapping[NodeType, Callable[[], dict[str, object]]] = {
BuiltinNodeTypes.CODE: lambda: {
"code_executor": self._code_executor,
@@ -479,6 +486,25 @@ class DifyNodeFactory(NodeFactory):
def _resolve_node_class(*, node_type: NodeType, node_version: str) -> type[Node]:
return resolve_workflow_node_class(node_type=node_type, node_version=node_version)
def _resolve_llm_model_reference(self, node_data: LLMNodeData) -> LLMNodeData:
"""Resolve an optional shared model selector from the workflow variable pool."""
model_selector = (node_data.model_extra or {}).get("model_selector")
if not should_resolve_llm_model_selector(model_selector):
return node_data
selector = parse_llm_model_selector(model_selector)
variable = self.graph_runtime_state.variable_pool.get(selector)
if not isinstance(variable, ObjectSegment):
raise ValueError(f"LLM environment variable '{selector[1]}' was not found or is not an LLM variable")
resolved_model = resolve_llm_model_config(
node_model=node_data.model,
variable_name=selector[1],
variable_value=variable.value,
)
return node_data.model_copy(update={"model": resolved_model})
def _build_agent_node_init_kwargs(self, *, node_class: type[Node]) -> dict[str, object]:
if issubclass(node_class, DifyAgentNode):
from clients.agent_backend import AgentBackendRunEventAdapter, AgentBackendRunRequestBuilder
+15
View File
@@ -9,6 +9,10 @@ from collections.abc import Mapping, Sequence
from typing import Any, cast
from configs import dify_config
from core.workflow.llm_environment_variable import (
LLM_ENVIRONMENT_VARIABLE_VALUE_TYPE,
LLMEnvironmentVariable,
)
from core.workflow.variable_prefixes import (
CONVERSATION_VARIABLE_NODE_ID,
ENVIRONMENT_VARIABLE_NODE_ID,
@@ -66,6 +70,17 @@ def build_conversation_variable_from_mapping(mapping: Mapping[str, Any], /) -> V
def build_environment_variable_from_mapping(mapping: Mapping[str, Any], /) -> VariableBase:
if not mapping.get("name"):
raise VariableError("missing name")
if mapping.get("value_type") == LLM_ENVIRONMENT_VARIABLE_VALUE_TYPE:
llm_mapping = dict(mapping)
llm_mapping["value_type"] = SegmentType.OBJECT
llm_mapping["selector"] = [ENVIRONMENT_VARIABLE_NODE_ID, mapping["name"]]
try:
result = LLMEnvironmentVariable.model_validate(llm_mapping)
except ValueError as exc:
raise VariableError(f"invalid LLM environment variable: {exc}") from exc
if result.size > dify_config.MAX_VARIABLE_SIZE:
raise VariableError(f"variable size {result.size} exceeds limit {dify_config.MAX_VARIABLE_SIZE}")
return result
return _build_variable_from_mapping(mapping=mapping, selector=[ENVIRONMENT_VARIABLE_NODE_ID, mapping["name"]])
+9 -8
View File
@@ -25,6 +25,7 @@ from typing_extensions import deprecated
from core.trigger.constants import TRIGGER_PLUGIN_NODE_TYPE
from core.workflow.human_input_adapter import adapt_node_config_for_graph
from core.workflow.llm_environment_variable import LLMEnvironmentVariable, dump_environment_variable
from core.workflow.nodes.human_input.pause_reason import (
HumanInputRequired,
)
@@ -581,7 +582,7 @@ class Workflow(Base): # bug
@property
def environment_variables(
self,
) -> Sequence[StringVariable | IntegerVariable | FloatVariable | SecretVariable]:
) -> Sequence[StringVariable | IntegerVariable | FloatVariable | SecretVariable | LLMEnvironmentVariable]:
# Use workflow.tenant_id to avoid relying on request user in background threads
tenant_id = self.tenant_id
@@ -596,21 +597,21 @@ class Workflow(Base): # bug
# decrypt secret variables value
def decrypt_func(
var: VariableBase,
) -> StringVariable | IntegerVariable | FloatVariable | SecretVariable:
) -> StringVariable | IntegerVariable | FloatVariable | SecretVariable | LLMEnvironmentVariable:
match var:
case SecretVariable():
return var.model_copy(
update={"value": encrypter.decrypt_token(tenant_id=tenant_id, token=var.value)}
)
case StringVariable() | IntegerVariable() | FloatVariable():
case StringVariable() | IntegerVariable() | FloatVariable() | LLMEnvironmentVariable():
return var
case _:
# Other variable types are not supported for environment variables
raise AssertionError(f"Unexpected variable type for environment variable: {type(var)}")
decrypted_results: list[SecretVariable | StringVariable | IntegerVariable | FloatVariable] = [
decrypt_func(var) for var in results
]
decrypted_results: list[
SecretVariable | StringVariable | IntegerVariable | FloatVariable | LLMEnvironmentVariable
] = [decrypt_func(var) for var in results]
return decrypted_results
@environment_variables.setter
@@ -646,7 +647,7 @@ class Workflow(Base): # bug
encrypted_vars = list(map(encrypt_func, value))
environment_variables_json = json.dumps(
{var.name: var.model_dump() for var in encrypted_vars},
{var.name: dump_environment_variable(var) for var in encrypted_vars},
ensure_ascii=False,
)
self._environment_variables = environment_variables_json
@@ -686,7 +687,7 @@ class Workflow(Base): # bug
result: WorkflowContentDict = {
"graph": self.graph_dict,
"features": self.features_dict,
"environment_variables": [var.model_dump(mode="json") for var in environment_variables],
"environment_variables": [dump_environment_variable(var, mode="json") for var in environment_variables],
"conversation_variables": [var.model_dump(mode="json") for var in self.conversation_variables],
"rag_pipeline_variables": self.rag_pipeline_variables,
}
+10 -1
View File
@@ -17902,7 +17902,9 @@ declaration of an endpoint group
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| deleted_environment_variable_ids | [ string ] | Environment variable IDs to delete when patch is true | No |
| environment_variables | [ [EnvironmentVariableItemPayload](#environmentvariableitempayload) ] | Environment variables for the draft workflow | Yes |
| patch | boolean | Treat environment_variables as per-ID upserts instead of replacing the full collection | No |
#### ErrorDocsResponse
@@ -22055,7 +22057,7 @@ The subscription constructor of the trigger provider
| ---- | ---- | ----------- | -------- |
| _is_collaborative | boolean | | No |
| conversation_variables | [ object ] | | No |
| environment_variables | [ object ] | | No |
| environment_variable_patch | [SyncEnvironmentVariablePatchPayload](#syncenvironmentvariablepatchpayload) | | No |
| features | object | | Yes |
| graph | object | | Yes |
| hash | string | | No |
@@ -22068,6 +22070,13 @@ The subscription constructor of the trigger provider
| result | string | | Yes |
| updated_at | integer | | Yes |
#### SyncEnvironmentVariablePatchPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| deleted_environment_variable_ids | [ string ] | | No |
| environment_variables | [ object ] | | No |
#### SystemConfigurationResponse
Model class for provider system configuration response.
+35 -1
View File
@@ -25,6 +25,12 @@ from core.trigger.constants import (
TRIGGER_SCHEDULE_NODE_TYPE,
TRIGGER_WEBHOOK_NODE_TYPE,
)
from core.workflow.llm_environment_variable import (
LLMEnvironmentVariable,
parse_llm_model_selector,
resolve_llm_model_config,
should_resolve_llm_model_selector,
)
from core.workflow.nodes.knowledge_retrieval.entities import KnowledgeRetrievalNodeData
from core.workflow.nodes.trigger_schedule.trigger_schedule_node import TriggerScheduleNode
from events.app_event import app_model_config_was_updated, app_was_created
@@ -32,7 +38,7 @@ from extensions.ext_redis import redis_client
from factories import variable_factory
from graphon.enums import BuiltinNodeTypes
from graphon.model_runtime.utils.encoders import jsonable_encoder
from graphon.nodes.llm.entities import LLMNodeData
from graphon.nodes.llm.entities import LLMNodeData, ModelConfig
from graphon.nodes.parameter_extractor.entities import ParameterExtractorNodeData
from graphon.nodes.question_classifier.entities import QuestionClassifierNodeData
from graphon.nodes.tool.entities import ToolNodeData
@@ -776,6 +782,34 @@ class AppDslService:
:return: dependencies list format like ["langgenius/google"]
"""
graph = workflow.graph_dict
referenced_llm_nodes = [
node.get("data", {})
for node in graph.get("nodes", [])
if node.get("data", {}).get("type") == BuiltinNodeTypes.LLM
and should_resolve_llm_model_selector(node.get("data", {}).get("model_selector"))
]
environment_variables = (
{variable.name: variable for variable in workflow.environment_variables} if referenced_llm_nodes else {}
)
for node_data in referenced_llm_nodes:
try:
selector = parse_llm_model_selector(node_data["model_selector"])
variable = environment_variables.get(selector[1])
if not isinstance(variable, LLMEnvironmentVariable):
raise ValueError(
f"LLM environment variable '{selector[1]}' was not found or is not an LLM variable"
)
node_data["model"] = resolve_llm_model_config(
node_model=ModelConfig.model_validate(node_data.get("model", {})),
variable_name=selector[1],
variable_value=variable.value,
).model_dump(mode="json")
except ValueError as exc:
logger.warning(
"Skipping unresolved LLM environment model while extracting dependencies for selector %r: %s",
node_data.get("model_selector"),
exc,
)
dependencies = cls._extract_dependencies_from_workflow_graph(graph)
return dependencies
+12 -1
View File
@@ -31,6 +31,7 @@ from core.helper import marketplace
from core.rag.entities import DatasourceCompletedEvent, DatasourceErrorEvent, DatasourceProcessingEvent
from core.repositories.factory import DifyCoreRepositoryFactory
from core.repositories.sqlalchemy_workflow_node_execution_repository import SQLAlchemyWorkflowNodeExecutionRepository
from core.workflow.llm_environment_variable import validate_llm_environment_model_references
from core.workflow.node_factory import LATEST_VERSION, get_node_type_classes_mapping
from core.workflow.system_variables import (
SystemVariableKey,
@@ -440,6 +441,11 @@ class RagPipelineService:
if not draft_workflow:
raise ValueError("No valid workflow found.")
validate_llm_environment_model_references(
graph=draft_workflow.graph_dict,
environment_variables=draft_workflow.environment_variables,
)
# create new workflow
workflow = Workflow.new(
tenant_id=pipeline.tenant_id,
@@ -568,7 +574,12 @@ class RagPipelineService:
node_id=node_id,
user_inputs=user_inputs,
user_id=account.id,
variable_pool=_build_seeded_variable_pool(default_system_variables()),
variable_pool=_build_seeded_variable_pool(
build_bootstrap_variables(
system_variables=default_system_variables(),
environment_variables=draft_workflow.environment_variables,
)
),
variable_loader=DraftVarLoader(
engine=db.engine,
app_id=pipeline.id,
@@ -21,6 +21,12 @@ from core.file import remote_fetcher
from core.helper.name_generator import generate_incremental_name
from core.plugin.entities.plugin import PluginDependency
from core.rag.index_processor.constant.index_type import IndexTechniqueType
from core.workflow.llm_environment_variable import (
LLMEnvironmentVariable,
parse_llm_model_selector,
resolve_llm_model_config,
should_resolve_llm_model_selector,
)
from core.workflow.nodes.datasource.entities import DatasourceNodeData
from core.workflow.nodes.knowledge_index import KNOWLEDGE_INDEX_NODE_TYPE
from core.workflow.nodes.knowledge_retrieval.entities import KnowledgeRetrievalNodeData
@@ -28,7 +34,7 @@ from extensions.ext_redis import redis_client
from factories import variable_factory
from graphon.enums import BuiltinNodeTypes
from graphon.model_runtime.utils.encoders import jsonable_encoder
from graphon.nodes.llm.entities import LLMNodeData
from graphon.nodes.llm.entities import LLMNodeData, ModelConfig
from graphon.nodes.parameter_extractor.entities import ParameterExtractorNodeData
from graphon.nodes.question_classifier.entities import QuestionClassifierNodeData
from graphon.nodes.tool.entities import ToolNodeData
@@ -707,6 +713,34 @@ class RagPipelineDslService:
:return: dependencies list format like ["langgenius/google"]
"""
graph = workflow.graph_dict
referenced_llm_nodes = [
node.get("data", {})
for node in graph.get("nodes", [])
if node.get("data", {}).get("type") == BuiltinNodeTypes.LLM
and should_resolve_llm_model_selector(node.get("data", {}).get("model_selector"))
]
environment_variables = (
{variable.name: variable for variable in workflow.environment_variables} if referenced_llm_nodes else {}
)
for node_data in referenced_llm_nodes:
try:
selector = parse_llm_model_selector(node_data["model_selector"])
variable = environment_variables.get(selector[1])
if not isinstance(variable, LLMEnvironmentVariable):
raise ValueError(
f"LLM environment variable '{selector[1]}' was not found or is not an LLM variable"
)
node_data["model"] = resolve_llm_model_config(
node_model=ModelConfig.model_validate(node_data.get("model", {})),
variable_name=selector[1],
variable_value=variable.value,
).model_dump(mode="json")
except ValueError as exc:
logger.warning(
"Skipping unresolved LLM environment model while extracting dependencies for selector %r: %s",
node_data.get("model_selector"),
exc,
)
dependencies = self._extract_dependencies_from_workflow_graph(graph)
return dependencies
+7
View File
@@ -693,6 +693,13 @@ class SnippetService:
SnippetService.validate_snippet_graph_forbidden_nodes(draft_workflow.graph_dict)
from core.workflow.llm_environment_variable import validate_llm_environment_model_references
validate_llm_environment_model_references(
graph=draft_workflow.graph_dict,
environment_variables=draft_workflow.environment_variables,
)
from services.agent.workflow_publish_service import WorkflowAgentPublishService
WorkflowAgentPublishService.validate_agent_nodes_for_publish(
+153 -8
View File
@@ -23,6 +23,13 @@ from core.workflow.human_input_adapter import (
adapt_human_input_node_data_for_graph,
parse_human_input_delivery_methods,
)
from core.workflow.llm_environment_variable import (
LLMEnvironmentVariable,
parse_llm_model_selector,
resolve_llm_model_config,
should_resolve_llm_model_selector,
validate_llm_environment_model_references,
)
from core.workflow.node_factory import (
LATEST_VERSION,
get_node_type_classes_mapping,
@@ -63,6 +70,7 @@ from graphon.node_events import NodeRunResult
from graphon.nodes import BuiltinNodeTypes
from graphon.nodes.base.node import Node
from graphon.nodes.http_request import HTTP_REQUEST_CONFIG_FILTER_KEY, build_http_request_config
from graphon.nodes.llm.entities import ModelConfig
from graphon.nodes.start.entities import StartNodeData
from graphon.runtime import VariablePool
from graphon.variable_loader import load_into_variable_pool
@@ -146,6 +154,48 @@ from .workflow_restore import apply_published_workflow_snapshot_to_draft
_file_access_controller = DatabaseFileAccessController()
def _merge_environment_variable_patch(
current_variables: Sequence[VariableBase],
environment_variable_upserts: Sequence[VariableBase],
deleted_environment_variable_ids: Sequence[str],
) -> list[VariableBase]:
"""Merge a per-ID environment-variable patch while preserving untouched server values."""
upserts_by_id: dict[str, VariableBase] = {}
for variable in environment_variable_upserts:
if not variable.id:
raise ValueError("Patched environment variables require an id.")
if variable.id in upserts_by_id:
raise ValueError(f"Duplicate patched environment variable id: {variable.id}")
upserts_by_id[variable.id] = variable
deleted_ids = set(deleted_environment_variable_ids)
if len(deleted_ids) != len(deleted_environment_variable_ids):
raise ValueError("Deleted environment variable ids must be unique.")
if "" in deleted_ids:
raise ValueError("Deleted environment variable ids must not be empty.")
if conflicting_ids := deleted_ids.intersection(upserts_by_id):
conflicting_id = min(conflicting_ids)
raise ValueError(f"Environment variable cannot be upserted and deleted in the same patch: {conflicting_id}")
existing_ids: set[str] = set()
merged_variables: list[VariableBase] = []
for variable in current_variables:
variable_id = variable.id
if variable_id:
existing_ids.add(variable_id)
if variable_id in deleted_ids:
continue
merged_variables.append(upserts_by_id.get(variable_id, variable))
merged_variables.extend(
variable for variable_id, variable in upserts_by_id.items() if variable_id not in existing_ids
)
names = [variable.name for variable in merged_variables]
if len(set(names)) != len(names):
raise ValueError("Environment variable names must be unique.")
return merged_variables
class WorkflowService:
"""
Workflow Service
@@ -213,6 +263,19 @@ class WorkflowService:
# return draft workflow
return workflow
def _get_draft_workflow_for_update(self, app_model: App, *, session: Session) -> Workflow | None:
"""Return the app draft while holding its row lock for the caller's transaction."""
return session.scalar(
select(Workflow)
.where(
Workflow.tenant_id == app_model.tenant_id,
Workflow.app_id == app_model.id,
Workflow.version == Workflow.VERSION_DRAFT,
)
.limit(1)
.with_for_update()
)
def get_published_workflow_by_id(self, app_model: App, workflow_id: str, *, session: Session) -> Workflow | None:
"""
fetch published workflow by workflow_id
@@ -320,6 +383,9 @@ class WorkflowService:
environment_variables: Sequence[VariableBase],
conversation_variables: Sequence[VariableBase],
session: Session,
environment_variable_upserts: Sequence[VariableBase] | None = None,
deleted_environment_variable_ids: Sequence[str] = (),
preserve_environment_variables: bool = False,
commit: bool = True,
sync_agent_bindings: bool = True,
graph_only: bool = False,
@@ -331,10 +397,17 @@ class WorkflowService:
portable package references can be materialized atomically after the
draft workflow has received its target-workspace id.
Existing drafts are row-locked before the hash check. Collaborative
graph-only saves preserve independently persisted draft fields, while
an explicit per-ID environment patch is merged with the graph.
:raises WorkflowHashNotEqualError
"""
if environment_variable_upserts is None and deleted_environment_variable_ids:
raise ValueError("Deleted environment variable ids require an environment variable patch.")
# fetch draft workflow by app_model
workflow = self.get_draft_workflow(app_model=app_model, session=session)
workflow = self._get_draft_workflow_for_update(app_model=app_model, session=session)
if workflow and workflow.unique_hash != unique_hash:
raise WorkflowHashNotEqualError()
@@ -349,6 +422,15 @@ class WorkflowService:
# create draft workflow if not found
if not workflow:
initial_environment_variables = (
_merge_environment_variable_patch(
environment_variables,
environment_variable_upserts,
deleted_environment_variable_ids,
)
if environment_variable_upserts is not None
else list(environment_variables)
)
workflow = Workflow(
tenant_id=app_model.tenant_id,
app_id=app_model.id,
@@ -357,7 +439,7 @@ class WorkflowService:
graph=json.dumps(graph),
features=json.dumps(features),
created_by=account.id,
environment_variables=environment_variables,
environment_variables=initial_environment_variables,
conversation_variables=conversation_variables,
)
session.add(workflow)
@@ -368,8 +450,15 @@ class WorkflowService:
workflow.updated_at = naive_utc_now()
if not graph_only:
workflow.features = json.dumps(features)
workflow.environment_variables = environment_variables
workflow.conversation_variables = conversation_variables
if environment_variable_upserts is not None:
workflow.environment_variables = _merge_environment_variable_patch(
workflow.environment_variables,
environment_variable_upserts,
deleted_environment_variable_ids,
)
elif not graph_only and not preserve_environment_variables:
workflow.environment_variables = environment_variables
from services.agent.workflow_publish_service import WorkflowAgentPublishService
@@ -403,10 +492,8 @@ class WorkflowService:
environment_variables: Sequence[VariableBase],
account: Account,
session: Session,
):
"""
Update draft workflow environment variables
"""
) -> None:
"""Replace every environment variable on a draft workflow and commit the transaction."""
# fetch draft workflow by app_model
workflow = self.get_draft_workflow(app_model=app_model, session=session)
@@ -420,6 +507,34 @@ class WorkflowService:
# commit db session changes
session.commit()
def patch_draft_workflow_environment_variables(
self,
*,
app_model: App,
environment_variables: Sequence[VariableBase],
deleted_environment_variable_ids: Sequence[str],
account: Account,
session: Session,
) -> None:
"""Atomically merge per-ID environment-variable upserts and deletions into a draft workflow.
The draft row is locked before reading its current variables so concurrent patches preserve
variables they do not touch. Existing variables keep their order and new variables are appended.
The transaction is committed before this method returns.
"""
workflow = self._get_draft_workflow_for_update(app_model=app_model, session=session)
if not workflow:
raise ValueError("No draft workflow found.")
workflow.environment_variables = _merge_environment_variable_patch(
workflow.environment_variables,
environment_variables,
deleted_environment_variable_ids,
)
workflow.updated_by = account.id
workflow.updated_at = naive_utc_now()
session.commit()
def update_draft_workflow_conversation_variables(
self,
*,
@@ -539,6 +654,11 @@ class WorkflowService:
if not draft_workflow:
raise ValueError("No valid workflow found.")
validate_llm_environment_model_references(
graph=draft_workflow.graph_dict,
environment_variables=draft_workflow.environment_variables,
)
# Validate credentials before publishing, for credential policy check
from services.feature_service import FeatureService
@@ -609,6 +729,14 @@ class WorkflowService:
"""
graph_dict = workflow.graph_dict
nodes = graph_dict.get("nodes", [])
has_llm_model_reference = any(
node.get("data", {}).get("type") == "llm"
and should_resolve_llm_model_selector(node.get("data", {}).get("model_selector"))
for node in nodes
)
environment_variables = (
{variable.name: variable for variable in workflow.environment_variables} if has_llm_model_reference else {}
)
for node in nodes:
node_data = node.get("data", {})
@@ -664,7 +792,22 @@ class WorkflowService:
self._check_default_tool_credential(workflow.tenant_id, provider, session=session)
elif node_type in ["llm", "knowledge_retrieval", "parameter_extractor", "question_classifier"]:
validation_node_data = node_data
model_config = node_data.get("model", {})
if node_type == "llm" and should_resolve_llm_model_selector(node_data.get("model_selector")):
selector = parse_llm_model_selector(node_data["model_selector"])
variable = environment_variables.get(selector[1])
if not isinstance(variable, LLMEnvironmentVariable):
raise ValueError(
f"LLM environment variable '{selector[1]}' was not found or is not an LLM variable"
)
resolved_model = resolve_llm_model_config(
node_model=ModelConfig.model_validate(model_config),
variable_name=selector[1],
variable_value=variable.value,
)
model_config = resolved_model.model_dump(mode="json")
validation_node_data = {**node_data, "model": model_config}
provider = model_config.get("provider")
model_name = model_config.get("name")
@@ -672,7 +815,9 @@ class WorkflowService:
# Validate that the provider+model combination can fetch valid credentials
self._validate_llm_model_config(workflow.tenant_id, provider, model_name)
# Validate load balancing credentials if load balancing is enabled
self._validate_load_balancing_credentials(workflow, node_data, node_id, session=session)
self._validate_load_balancing_credentials(
workflow, validation_node_data, node_id, session=session
)
else:
raise ValueError(f"Node {node_id} ({node_type}): Missing provider or model configuration")
@@ -57,7 +57,10 @@ from controllers.console.app.ops_trace import TraceConfigPayload, TraceProviderQ
from controllers.console.app.site import AppSiteUpdatePayload
from controllers.console.app.workflow import AdvancedChatWorkflowRunPayload, SyncDraftWorkflowPayload
from controllers.console.app.workflow_app_log import WorkflowAppLogQuery
from controllers.console.app.workflow_draft_variable import WorkflowDraftVariableUpdatePayload
from controllers.console.app.workflow_draft_variable import (
EnvironmentVariableUpdatePayload,
WorkflowDraftVariableUpdatePayload,
)
from controllers.console.app.workflow_statistic import WorkflowStatisticQuery
from controllers.console.app.workflow_trigger import Parser, ParserEnable
from models import App, Site
@@ -468,6 +471,64 @@ class TestWorkflowDraftVariableEndpoints:
assert result == {"items": [], "total": 0}
def test_environment_variable_update_payload_preserves_full_replace_default(self) -> None:
payload = EnvironmentVariableUpdatePayload(environment_variables=[])
assert payload.patch is False
assert payload.deleted_environment_variable_ids == []
@pytest.mark.parametrize(
"payload",
[
{"environment_variables": [], "deleted_environment_variable_ids": ["env-a"]},
{
"environment_variables": [{"name": "a", "value_type": "string", "value": "a"}],
"patch": True,
},
{
"environment_variables": [{"id": "env-a", "name": "a", "value_type": "string", "value": "a"}],
"patch": True,
"deleted_environment_variable_ids": ["env-a"],
},
],
)
def test_environment_variable_patch_payload_rejects_ambiguous_mutations(self, payload: dict) -> None:
with pytest.raises(ValidationError):
EnvironmentVariableUpdatePayload.model_validate(payload)
def test_environment_variable_collection_post_routes_patch_to_service(
self, database_app: Flask, monkeypatch: pytest.MonkeyPatch
) -> None:
api = workflow_draft_variable_module.EnvironmentVariableCollectionApi()
method = unwrap(api.post)
captured: dict = {}
class DummyWorkflowService:
def patch_draft_workflow_environment_variables(self, **kwargs) -> None:
captured.update(kwargs)
def update_draft_workflow_environment_variables(self, **_kwargs) -> None:
raise AssertionError("patch request must not use full replacement")
monkeypatch.setattr(workflow_draft_variable_module, "WorkflowService", DummyWorkflowService)
with database_app.test_request_context(
"/",
json={
"environment_variables": [{"id": "env-a", "name": "a", "value_type": "string", "value": "new-a"}],
"patch": True,
"deleted_environment_variable_ids": ["env-b"],
},
):
result = method(api, _make_account(), app_model=_make_app())
assert result == {"result": "success"}
assert [(variable.id, variable.value) for variable in captured["environment_variables"]] == [("env-a", "new-a")]
assert captured["deleted_environment_variable_ids"] == ["env-b"]
assert captured["app_model"].id == APP_ID
assert captured["account"].id == USER_ID
assert captured["session"].get_bind() is db.engine
class TestWorkflowStatisticEndpoints:
def test_workflow_statistic_time_range(self):
@@ -15,6 +15,7 @@ from werkzeug.exceptions import HTTPException, NotFound
from controllers.common.errors import InvalidArgumentError
from controllers.console.app import workflow as workflow_module
from controllers.console.app.error import DraftWorkflowNotExist, DraftWorkflowNotSync
from core.workflow.llm_environment_variable import LLMEnvironmentVariable
from graphon.file import File, FileTransferMethod, FileType
from graphon.variables import SecretVariable, StringVariable
from graphon.variables.variables import RAGPipelineVariable
@@ -145,7 +146,8 @@ def test_sync_draft_workflow_success(app: Flask, monkeypatch: pytest.MonkeyPatch
workflow_module.variable_factory, "build_conversation_variable_from_mapping", lambda *_args: "conv"
)
service = SimpleNamespace(sync_draft_workflow=lambda **_kwargs: workflow)
sync_draft_workflow = Mock(return_value=workflow)
service = SimpleNamespace(sync_draft_workflow=sync_draft_workflow)
monkeypatch.setattr(workflow_module, "WorkflowService", lambda: service)
api = workflow_module.DraftWorkflowApi()
@@ -159,6 +161,89 @@ def test_sync_draft_workflow_success(app: Flask, monkeypatch: pytest.MonkeyPatch
response = handler(api, "t1", app_model=SimpleNamespace(id="app"))
assert response["result"] == "success"
assert sync_draft_workflow.call_args.kwargs["environment_variables"] == []
assert sync_draft_workflow.call_args.kwargs["preserve_environment_variables"] is True
def test_sync_draft_workflow_passes_environment_patch(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
workflow = SimpleNamespace(
unique_hash="next-hash",
updated_at=None,
created_at=datetime(2024, 1, 1),
)
patched_variable = StringVariable(
id="env-model",
name="shared_model",
value="model",
selector=["env", "shared_model"],
)
build_environment_variable = Mock(return_value=patched_variable)
sync_draft_workflow = Mock(return_value=workflow)
monkeypatch.setattr(
workflow_module.variable_factory,
"build_environment_variable_from_mapping",
build_environment_variable,
)
monkeypatch.setattr(
workflow_module,
"WorkflowService",
lambda: SimpleNamespace(sync_draft_workflow=sync_draft_workflow),
)
api = workflow_module.DraftWorkflowApi()
handler = inspect.unwrap(api.post)
with app.test_request_context(
"/apps/app/workflows/draft",
method="POST",
json={
"graph": {},
"features": {},
"hash": "current-hash",
"environment_variable_patch": {
"environment_variables": [
{
"id": "env-model",
"name": "shared_model",
"value": "model",
"value_type": "string",
}
],
"deleted_environment_variable_ids": ["env-old"],
},
},
):
response = handler(api, "t1", app_model=SimpleNamespace(id="app"))
assert response["result"] == "success"
assert sync_draft_workflow.call_args.kwargs["preserve_environment_variables"] is True
assert sync_draft_workflow.call_args.kwargs["environment_variable_upserts"] == [patched_variable]
assert sync_draft_workflow.call_args.kwargs["deleted_environment_variable_ids"] == ["env-old"]
build_environment_variable.assert_called_once()
def test_sync_draft_workflow_rejects_overlapping_environment_patch_ids() -> None:
with pytest.raises(ValidationError, match="cannot be upserted and deleted"):
workflow_module.SyncDraftWorkflowPayload.model_validate(
{
"graph": {},
"features": {},
"environment_variable_patch": {
"environment_variables": [{"id": "env-model"}],
"deleted_environment_variable_ids": ["env-model"],
},
}
)
def test_sync_draft_workflow_rejects_legacy_environment_variables() -> None:
with pytest.raises(ValidationError, match="Extra inputs are not permitted"):
workflow_module.SyncDraftWorkflowPayload.model_validate(
{
"graph": {},
"features": {},
"environment_variables": [],
}
)
def test_sync_draft_workflow_hash_mismatch(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
@@ -521,6 +606,31 @@ def test_workflow_response_masks_secret_environment_variables() -> None:
]
def test_workflow_response_preserves_llm_environment_variable_type() -> None:
workflow = _make_workflow(
environment_variables=[
LLMEnvironmentVariable(
id="env-llm",
name="for_summarize",
value={"provider": "provider", "name": "model", "mode": "chat"},
selector=["env", "for_summarize"],
)
]
)
response = workflow_module.WorkflowResponse.model_validate(workflow, from_attributes=True).model_dump(mode="json")
assert response["environment_variables"] == [
{
"id": "env-llm",
"name": "for_summarize",
"value": {"provider": "provider", "name": "model", "mode": "chat"},
"value_type": "llm",
"description": "",
}
]
def test_workflow_response_rejects_invalid_environment_variable_dict() -> None:
workflow = _make_workflow(environment_variables=[{"value_type": "not-a-segment-type"}])
@@ -15,6 +15,7 @@ from controllers.console.datasets.rag_pipeline.rag_pipeline_draft_variable impor
RagPipelineVariableCollectionApi,
RagPipelineVariableResetApi,
)
from core.workflow.llm_environment_variable import LLMEnvironmentVariable
from core.workflow.variable_prefixes import SYSTEM_VARIABLE_NODE_ID
from graphon.variables.types import SegmentType
from models.account import Account, TenantAccountRole
@@ -315,3 +316,35 @@ class TestSystemAndEnvironmentVariablesApi:
result = method(api, editor_user, pipeline)
assert len(result["items"]) == 1
assert result["items"][0]["value_type"] == "string"
def test_environment_variables_preserve_number_subtype_and_llm_type(self, app: Flask, editor_user):
api = RagPipelineEnvironmentVariableCollectionApi()
method = unwrap(api.get)
number_var = MagicMock(
id="number",
name="NUMBER",
description="",
selector=["env", "NUMBER"],
value_type=MagicMock(value="integer"),
value=1,
)
llm_var = LLMEnvironmentVariable(
id="llm",
name="MODEL",
value={"provider": "provider", "name": "model", "mode": "chat"},
selector=["env", "MODEL"],
)
rag_srv = MagicMock()
rag_srv.get_draft_workflow.return_value = MagicMock(environment_variables=[number_var, llm_var])
with (
app.test_request_context("/"),
patch(
"controllers.console.datasets.rag_pipeline.rag_pipeline_draft_variable.RagPipelineService",
return_value=rag_srv,
),
):
result = method(api, editor_user, MagicMock(id="p1"))
assert [item["value_type"] for item in result["items"]] == ["integer", "llm"]
@@ -32,8 +32,10 @@ from core.errors.error import (
ProviderTokenNotInitError,
QuotaExceededError,
)
from core.helper import encrypter
from core.workflow.llm_environment_variable import LLMEnvironmentVariable
from graphon.model_runtime.errors.invoke import InvokeError
from graphon.variables import StringVariable
from graphon.variables import SecretVariable, StringVariable
from models import Account
from models.account import TenantStatus
from models.model import AppMode
@@ -1205,7 +1207,18 @@ class TestAppWorkflowApi:
marked_comment="",
created_at=datetime(2024, 1, 1, tzinfo=UTC),
updated_at=datetime(2024, 1, 2, tzinfo=UTC),
environment_variables=[],
environment_variables=[
SecretVariable(
id="env-secret",
name="api_key",
value="plaintext-secret",
),
LLMEnvironmentVariable(
id="env-llm",
name="shared_model",
value={"provider": "provider", "name": "model", "mode": "chat"},
),
],
conversation_variables=[
StringVariable(
id="conversation-variable-1",
@@ -1240,7 +1253,24 @@ class TestAppWorkflowApi:
"updated_by": None,
"updated_at": 1704153600,
"tool_published": True,
"environment_variables": [],
"environment_variables": [
{
"value_type": "secret",
"value": encrypter.full_mask_token(),
"id": "env-secret",
"name": "api_key",
"description": "",
"selector": [],
},
{
"value_type": "llm",
"value": {"provider": "provider", "name": "model", "mode": "chat"},
"id": "env-llm",
"name": "shared_model",
"description": "",
"selector": [],
},
],
"conversation_variables": [
{
"id": "conversation-variable-1",
@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session, scoped_session, sessionmaker
from controllers.console.snippets import snippet_workflow_draft_variable as module
from graphon.variables import StringSegment
from graphon.variables.types import SegmentType
from models.account import Account, AccountStatus
from models.workflow import WorkflowDraftVariable, WorkflowDraftVariableFile
from services.workflow_draft_variable_service import WorkflowDraftVariableList
@@ -331,7 +332,7 @@ def test_environment_variables_returns_workflow_environment_variables(
name="API_KEY",
description="secret",
selector=["env", "API_KEY"],
value_type=SimpleNamespace(exposed_type=Mock(return_value=SimpleNamespace(value="secret"))),
value_type=SegmentType.SECRET,
value="sk-test",
)
monkeypatch.setattr(
@@ -24,7 +24,7 @@ from graphon.nodes.llm.entities import LLMNodeData
from graphon.nodes.llm.node import LLMNode
from graphon.nodes.llm.runtime_protocols import LLMPollingCapableProtocol
from graphon.nodes.parameter_extractor.entities import ParameterExtractorNodeData
from graphon.variables.segments import ArrayObjectSegment, StringSegment
from graphon.variables.segments import ArrayObjectSegment, ObjectSegment, StringSegment
from models.base import TypeBase
from models.model import AppMode, Conversation, ConversationFromSource
@@ -705,6 +705,119 @@ class TestDifyNodeFactoryCreateNode:
)
assert kwargs["model_instance"] is wrapped_model_instance
def test_resolve_llm_model_reference_uses_shared_model_and_parameters(self, factory):
node_data = LLMNodeData.model_validate(
{
"type": BuiltinNodeTypes.LLM,
"title": "LLM",
"model": {
"provider": "old-provider",
"name": "old-model",
"mode": "chat",
"completion_params": {"temperature": 0.2},
},
"model_selector": ["env", "for_summarize"],
"prompt_template": [{"role": "system", "text": "x"}],
"context": {"enabled": False, "variable_selector": []},
"vision": {"enabled": False},
}
)
factory.graph_runtime_state.variable_pool.get.return_value = ObjectSegment(
value={
"provider": "new-provider",
"name": "new-model",
"mode": "chat",
"completion_params": {"temperature": 0.8},
}
)
result = factory._resolve_llm_model_reference(node_data)
assert result.model.provider == "new-provider"
assert result.model.name == "new-model"
assert result.model.mode == node_data.model.mode
assert result.model.completion_params == {"temperature": 0.8}
factory.graph_runtime_state.variable_pool.get.assert_called_once_with(("env", "for_summarize"))
def test_resolve_llm_model_reference_keeps_node_parameters_for_legacy_variable(self, factory):
node_data = LLMNodeData.model_validate(
{
"type": BuiltinNodeTypes.LLM,
"title": "LLM",
"model": {
"provider": "old-provider",
"name": "old-model",
"mode": "chat",
"completion_params": {"temperature": 0.2},
},
"model_selector": ["env", "for_summarize"],
"prompt_template": [{"role": "system", "text": "x"}],
"context": {"enabled": False, "variable_selector": []},
"vision": {"enabled": False},
}
)
factory.graph_runtime_state.variable_pool.get.return_value = ObjectSegment(
value={"provider": "new-provider", "name": "new-model", "mode": "chat"}
)
result = factory._resolve_llm_model_reference(node_data)
assert result.model.completion_params == {"temperature": 0.2}
def test_resolve_llm_model_reference_rejects_mode_mismatch(self, factory):
node_data = LLMNodeData.model_validate(
{
"type": BuiltinNodeTypes.LLM,
"title": "LLM",
"model": {"provider": "provider", "name": "model", "mode": "chat"},
"model_selector": ["env", "shared_model"],
"prompt_template": [{"role": "system", "text": "x"}],
"context": {"enabled": False, "variable_selector": []},
"vision": {"enabled": False},
}
)
factory.graph_runtime_state.variable_pool.get.return_value = ObjectSegment(
value={"provider": "provider", "name": "model", "mode": "completion"}
)
with pytest.raises(ValueError, match="uses mode 'completion'.*uses mode 'chat'"):
factory._resolve_llm_model_reference(node_data)
def test_resolve_llm_model_reference_rejects_missing_variable(self, factory):
node_data = LLMNodeData.model_validate(
{
"type": BuiltinNodeTypes.LLM,
"title": "LLM",
"model": {"provider": "provider", "name": "model", "mode": "chat"},
"model_selector": ["env", "shared_model"],
"prompt_template": [{"role": "system", "text": "x"}],
"context": {"enabled": False, "variable_selector": []},
"vision": {"enabled": False},
}
)
factory.graph_runtime_state.variable_pool.get.return_value = None
with pytest.raises(ValueError, match="shared_model.*not found"):
factory._resolve_llm_model_reference(node_data)
def test_resolve_llm_model_reference_keeps_static_model_for_legacy_non_environment_selector(self, factory):
node_data = LLMNodeData.model_validate(
{
"type": BuiltinNodeTypes.LLM,
"title": "LLM",
"model": {"provider": "provider", "name": "model", "mode": "chat"},
"model_selector": ["conversation", "shared_model"],
"prompt_template": [{"role": "system", "text": "x"}],
"context": {"enabled": False, "variable_selector": []},
"vision": {"enabled": False},
}
)
result = factory._resolve_llm_model_reference(node_data)
assert result is node_data
factory.graph_runtime_state.variable_pool.get.assert_not_called()
def test_build_llm_compatible_node_init_kwargs_uses_polling_wrapper_for_polling_llm_node(self, factory):
node_data = LLMNodeData.model_validate(
{
@@ -7,6 +7,7 @@ import pytest
from hypothesis import HealthCheck, given, settings
from hypothesis import strategies as st
from core.workflow.llm_environment_variable import LLMEnvironmentVariable, dump_environment_variable
from factories import variable_factory
from factories.variable_factory import TypeMismatchError, build_segment, build_segment_with_type
from graphon.file import File, FileTransferMethod, FileType
@@ -62,6 +63,57 @@ def test_secret_variable():
assert isinstance(result, SecretVariable)
def test_llm_environment_variable():
result = variable_factory.build_environment_variable_from_mapping(
{
"value_type": "llm",
"name": "for_summarize",
"value": {
"provider": "langgenius/openai/openai",
"name": "gpt-4o-mini",
"mode": "chat",
"completion_params": {"temperature": 0.8},
},
}
)
assert isinstance(result, LLMEnvironmentVariable)
assert result.value_type == SegmentType.OBJECT
assert result.selector == ["env", "for_summarize"]
dumped = dump_environment_variable(result, mode="json")
assert dumped["value_type"] == "llm"
assert dumped["value"]["completion_params"] == {"temperature": 0.8}
def test_llm_environment_variable_normalizes_selector_to_name():
result = variable_factory.build_environment_variable_from_mapping(
{
"value_type": "llm",
"name": "for_summarize",
"selector": ["env", "different_name"],
"value": {"provider": "langgenius/openai/openai", "name": "gpt-4o-mini", "mode": "chat"},
}
)
assert result.selector == ["env", "for_summarize"]
@pytest.mark.parametrize(
"value",
[
{"provider": "provider", "name": "model"},
{"provider": "provider", "name": "model", "mode": "embedding"},
{"provider": "", "name": "model", "mode": "chat"},
{"provider": "provider", "name": "model", "mode": "chat", "completion_params": []},
],
)
def test_llm_environment_variable_rejects_invalid_model_selection(value):
with pytest.raises(VariableError, match="invalid LLM environment variable"):
variable_factory.build_environment_variable_from_mapping(
{"value_type": "llm", "name": "shared_model", "value": value}
)
def test_invalid_value_type():
test_data = {"value_type": "unknown", "name": "test_invalid", "value": "value"}
with pytest.raises(VariableError):
@@ -6,6 +6,7 @@ from uuid import uuid4
from constants import HIDDEN_VALUE
from core.helper import encrypter
from core.workflow.file_reference import build_file_reference
from core.workflow.llm_environment_variable import LLMEnvironmentVariable
from factories.variable_factory import build_segment
from graphon.file import File, FileTransferMethod, FileType
from graphon.variables import FloatVariable, IntegerVariable, SecretVariable, StringVariable
@@ -53,6 +54,32 @@ def test_environment_variables():
assert workflow.environment_variables == variables
def test_llm_environment_variable_round_trip():
workflow = Workflow(
tenant_id="tenant_id",
app_id="app_id",
type="workflow",
version="draft",
graph="{}",
features="{}",
created_by="account_id",
environment_variables=[],
conversation_variables=[],
)
variable = LLMEnvironmentVariable(
name="for_research",
value={"provider": "langgenius/anthropic/anthropic", "name": "claude-sonnet", "mode": "chat"},
id=str(uuid4()),
selector=["env", "for_research"],
)
workflow.environment_variables = [variable]
assert workflow.environment_variables == [variable]
assert json.loads(workflow._environment_variables)["for_research"]["value_type"] == "llm"
assert workflow.to_dict()["environment_variables"][0]["value_type"] == "llm"
def test_update_environment_variables():
# tenant_id context variable removed - using current_user.current_tenant_id directly
@@ -7,8 +7,10 @@ import yaml
from pytest_mock import MockerFixture
from sqlalchemy.orm import Session
from core.workflow.llm_environment_variable import LLMEnvironmentVariable
from core.workflow.nodes.knowledge_index import KNOWLEDGE_INDEX_NODE_TYPE
from graphon.enums import BuiltinNodeTypes
from models.workflow import Workflow
from services.dsl_version import check_version_compatibility
from services.entities.knowledge_entities.rag_pipeline_entities import IconInfo, RagPipelineDatasetCreateEntity
from services.rag_pipeline import rag_pipeline_dsl_service
@@ -160,6 +162,78 @@ def test_extract_dependencies_from_model_config_empty_config() -> None:
# --- _extract_dependencies_from_workflow_graph ---
def test_extract_workflow_dependencies_uses_llm_environment_variable_provider(mocker: MockerFixture) -> None:
service = RagPipelineDslService(session=Mock())
workflow = SimpleNamespace(
graph_dict={
"nodes": [
{
"id": "llm-node",
"data": {
"type": "llm",
"title": "LLM",
"model": {"provider": "old-provider", "name": "old-model", "mode": "chat"},
"model_selector": ["env", "shared_model"],
"prompt_template": [{"role": "system", "text": "x"}],
"context": {"enabled": False, "variable_selector": []},
"vision": {"enabled": False},
},
}
]
},
environment_variables=[
LLMEnvironmentVariable(
name="shared_model",
value={"provider": "new-provider", "name": "new-model", "mode": "chat"},
)
],
)
analyze_dependency = mocker.patch(
"services.rag_pipeline.rag_pipeline_dsl_service.DependenciesAnalysisService.analyze_model_provider_dependency",
side_effect=lambda provider: provider,
)
result = service._extract_dependencies_from_workflow(cast(Workflow, workflow))
assert result == ["new-provider"]
analyze_dependency.assert_called_once_with("new-provider")
@pytest.mark.parametrize("model_selector", [[], ["env", "missing_model"]])
def test_extract_workflow_dependencies_tolerates_unresolved_llm_environment_reference(
mocker: MockerFixture, model_selector: list[str]
) -> None:
service = RagPipelineDslService(session=Mock())
workflow = SimpleNamespace(
graph_dict={
"nodes": [
{
"id": "llm-node",
"data": {
"type": "llm",
"title": "LLM",
"model": {"provider": "old-provider", "name": "old-model", "mode": "chat"},
"model_selector": model_selector,
"prompt_template": [{"role": "system", "text": "x"}],
"context": {"enabled": False, "variable_selector": []},
"vision": {"enabled": False},
},
}
]
},
environment_variables=[],
)
analyze_dependency = mocker.patch(
"services.rag_pipeline.rag_pipeline_dsl_service.DependenciesAnalysisService.analyze_model_provider_dependency",
side_effect=lambda provider: provider,
)
result = service._extract_dependencies_from_workflow(cast(Workflow, workflow))
assert result == ["old-provider"]
analyze_dependency.assert_called_once_with("old-provider")
def test_extract_dependencies_from_workflow_graph_ignores_unknown_types(mocker: MockerFixture) -> None:
service = RagPipelineDslService(session=Mock())
graph = {"nodes": [{"data": {"type": "some-unknown-type"}}]}
@@ -608,6 +608,34 @@ def test_publish_workflow_success(mocker: MockerFixture, rag_pipeline_service: R
mock_dataset_service_class.update_rag_pipeline_dataset_settings.assert_called_once()
def test_publish_workflow_rejects_missing_llm_environment_reference(
mocker: MockerFixture, rag_pipeline_service: RagPipelineServiceTestContext
) -> None:
draft_workflow = mocker.Mock()
draft_workflow.graph_dict = {
"nodes": [
{
"id": "llm-node",
"data": {
"type": "llm",
"model": {"provider": "provider", "name": "model", "mode": "chat"},
"model_selector": ["env", "missing_model"],
},
}
]
}
draft_workflow.environment_variables = []
session = mocker.Mock()
session.scalar.return_value = draft_workflow
with pytest.raises(ValueError, match="missing_model.*not found"):
rag_pipeline_service.service.publish_workflow(
session=session,
pipeline=mocker.Mock(id="pipeline", tenant_id="tenant"),
account=mocker.Mock(id="account"),
)
# --- run_datasource_workflow_node ---
@@ -1138,6 +1166,64 @@ def test_run_draft_workflow_node_raises_when_workflow_missing(
rag_pipeline_service.service.run_draft_workflow_node(pipeline, "node-1", {}, account)
def test_run_draft_workflow_node_seeds_llm_environment_variable(
mocker: MockerFixture, rag_pipeline_service: RagPipelineServiceTestContext
) -> None:
from factories import variable_factory
pipeline = _make_pipeline()
account = _make_account()
llm_environment_variable = variable_factory.build_environment_variable_from_mapping(
{
"id": "env-1",
"name": "for_summarize",
"value_type": "llm",
"value": {
"provider": "langgenius/openai/openai",
"name": "gpt-4o",
"mode": "chat",
},
"description": "Shared summarization model",
}
)
draft_workflow = mocker.Mock(id="wf-1", environment_variables=[llm_environment_variable])
draft_workflow.get_node_config_by_id.return_value = {"id": "node-1"}
draft_workflow.get_enclosing_node_type_and_id.return_value = None
mocker.patch.object(rag_pipeline_service.service, "get_draft_workflow", return_value=draft_workflow)
execution = SimpleNamespace(id="exec-1", node_id="node-1", node_type="llm", process_data={}, outputs={})
handle_node_run_result = mocker.patch.object(
rag_pipeline_service.service, "_handle_node_run_result", return_value=execution
)
single_step_run = mocker.patch("services.rag_pipeline.rag_pipeline.WorkflowEntry.single_step_run")
repo = mocker.Mock()
mocker.patch(
"services.rag_pipeline.rag_pipeline.DifyCoreRepositoryFactory.create_workflow_node_execution_repository",
return_value=repo,
)
rag_pipeline_service.service._node_execution_service_repo = mocker.Mock(
get_execution_by_id=mocker.Mock(return_value="db")
)
mocker.patch("services.rag_pipeline.rag_pipeline.DraftVariableSaver", return_value=mocker.Mock())
session_ctx = mocker.MagicMock()
session_ctx.begin.return_value = mocker.MagicMock()
mocker.patch("services.rag_pipeline.rag_pipeline.Session", return_value=session_ctx)
rag_pipeline_service.service.run_draft_workflow_node(pipeline, "node-1", {}, account)
getter = handle_node_run_result.call_args.kwargs["getter"]
getter()
variable_pool = single_step_run.call_args.kwargs["variable_pool"]
llm_model = variable_pool.get(["env", "for_summarize"])
assert llm_model is not None
assert llm_model.value == {
"provider": "langgenius/openai/openai",
"name": "gpt-4o",
"mode": "chat",
}
def test_run_draft_workflow_node_saves_execution_and_variables(
mocker: MockerFixture, rag_pipeline_service: RagPipelineServiceTestContext
) -> None:
@@ -1934,6 +2020,7 @@ def test_publish_workflow_skips_dataset_update_for_non_knowledge_nodes(
draft = SimpleNamespace(
type="workflow",
graph={"nodes": [{"data": {"type": "start"}}]},
graph_dict={"nodes": [{"data": {"type": "start"}}]},
features={},
environment_variables=[],
conversation_variables=[],
@@ -2224,6 +2311,7 @@ def test_publish_workflow_raises_when_knowledge_index_dataset_missing(
draft = SimpleNamespace(
type="workflow",
graph={"nodes": [{"data": {"type": "knowledge-index"}}]},
graph_dict={"nodes": [{"data": {"type": "knowledge-index"}}]},
features={},
environment_variables=[],
conversation_variables=[],
@@ -6,13 +6,87 @@ import pytest
from sqlalchemy.orm import Session
from core.rbac import RBACPermission
from core.workflow.llm_environment_variable import LLMEnvironmentVariable
from models import App, AppMode
from models.model import AppModelConfig, IconType
from models.workflow import Workflow
from services.app_dsl_service import AppDslService
from services.entities.dsl_entities import ImportStatus
from services.errors.account import NoPermissionError
def test_extract_workflow_dependencies_uses_llm_environment_variable_provider(monkeypatch: pytest.MonkeyPatch) -> None:
workflow = SimpleNamespace(
graph_dict={
"nodes": [
{
"id": "llm-node",
"data": {
"type": "llm",
"title": "LLM",
"model": {"provider": "old-provider", "name": "old-model", "mode": "chat"},
"model_selector": ["env", "shared_model"],
"prompt_template": [{"role": "system", "text": "x"}],
"context": {"enabled": False, "variable_selector": []},
"vision": {"enabled": False},
},
}
]
},
environment_variables=[
LLMEnvironmentVariable(
name="shared_model",
value={"provider": "new-provider", "name": "new-model", "mode": "chat"},
)
],
)
analyze_dependency = Mock(side_effect=lambda provider: provider)
monkeypatch.setattr(
"services.app_dsl_service.DependenciesAnalysisService.analyze_model_provider_dependency",
analyze_dependency,
)
result = AppDslService._extract_dependencies_from_workflow(cast(Workflow, workflow))
assert result == ["new-provider"]
analyze_dependency.assert_called_once_with("new-provider")
@pytest.mark.parametrize("model_selector", [[], ["env", "missing_model"]])
def test_extract_workflow_dependencies_tolerates_unresolved_llm_environment_reference(
monkeypatch: pytest.MonkeyPatch, model_selector: list[str]
) -> None:
workflow = SimpleNamespace(
graph_dict={
"nodes": [
{
"id": "llm-node",
"data": {
"type": "llm",
"title": "LLM",
"model": {"provider": "old-provider", "name": "old-model", "mode": "chat"},
"model_selector": model_selector,
"prompt_template": [{"role": "system", "text": "x"}],
"context": {"enabled": False, "variable_selector": []},
"vision": {"enabled": False},
},
}
]
},
environment_variables=[],
)
analyze_dependency = Mock(side_effect=lambda provider: provider)
monkeypatch.setattr(
"services.app_dsl_service.DependenciesAnalysisService.analyze_model_provider_dependency",
analyze_dependency,
)
result = AppDslService._extract_dependencies_from_workflow(cast(Workflow, workflow))
assert result == ["old-provider"]
analyze_dependency.assert_called_once_with("old-provider")
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_import_app_rejects_oversized_yaml_content_before_parsing(
monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
@@ -491,7 +491,19 @@ def test_publish_workflow_creates_snapshot_and_updates_snippet(monkeypatch: pyte
draft_workflow = _create_workflow(
workflow_id="draft-workflow",
version=Workflow.VERSION_DRAFT,
graph={"nodes": [{"id": "llm-1", "data": {"type": "llm"}}], "edges": []},
graph={
"nodes": [
{
"id": "llm-1",
"data": {
"type": "llm",
"model": {"provider": "provider", "name": "model", "mode": "chat"},
"model_selector": ["start", "MODEL_NAME"],
},
}
],
"edges": [],
},
features={"opening_statement": "hello"},
)
snippet = SimpleNamespace(
@@ -17,9 +17,11 @@ from unittest.mock import ANY, MagicMock, patch, sentinel
import pytest
from sqlalchemy import select
from sqlalchemy.dialects import postgresql
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session, sessionmaker
from core.workflow.llm_environment_variable import LLMEnvironmentVariable
from graphon.enums import (
BuiltinNodeTypes,
ErrorStrategy,
@@ -449,6 +451,112 @@ class TestWorkflowService:
assert workflow.features_dict == features
assert workflow.updated_by == account.id
def test_sync_draft_workflow_collaborative_save_preserves_environment_variables_and_locks_row(
self, workflow_service: WorkflowService
) -> None:
"""A collaborative graph-only save locks the draft and keeps server environment values."""
app = TestWorkflowAssociatedDataFactory.create_app()
account = TestWorkflowAssociatedDataFactory.create_account()
workflow = TestWorkflowAssociatedDataFactory.create_workflow()
remote_variable = StringVariable(
id="env-shared",
name="shared",
value="remote-value",
selector=["env", "shared"],
)
stale_variable = remote_variable.model_copy(update={"value": "stale-client-value"})
workflow.environment_variables = [remote_variable]
unique_hash = workflow.unique_hash
session = MagicMock(spec=Session)
session.scalar.return_value = workflow
result = workflow_service.sync_draft_workflow(
app_model=app,
graph=TestWorkflowAssociatedDataFactory.create_valid_workflow_graph(),
features={},
unique_hash=unique_hash,
account=account,
environment_variables=[stale_variable],
conversation_variables=[],
session=session,
preserve_environment_variables=True,
commit=False,
sync_agent_bindings=False,
)
statement = session.scalar.call_args.args[0]
assert "FOR UPDATE" in str(statement.compile(dialect=postgresql.dialect()))
assert result.environment_variables == [remote_variable]
session.commit.assert_not_called()
def test_sync_draft_workflow_merges_environment_patch_with_graph_update(
self, workflow_service: WorkflowService, sqlite_session: Session
) -> None:
"""Graph changes and a per-ID environment patch commit without replacing untouched aliases."""
app = TestWorkflowAssociatedDataFactory.create_app()
account = TestWorkflowAssociatedDataFactory.create_account()
workflow = TestWorkflowAssociatedDataFactory.create_workflow()
remote_variable = StringVariable(id="env-a", name="a", value="remote-a", selector=["env", "a"])
replaced_variable = StringVariable(id="env-b", name="b", value="old-b", selector=["env", "b"])
workflow.environment_variables = [remote_variable, replaced_variable]
sqlite_session.add(workflow)
sqlite_session.commit()
unique_hash = workflow.unique_hash
next_graph = TestWorkflowAssociatedDataFactory.create_valid_workflow_graph()
next_graph["viewport"] = {"x": 10, "y": 20, "zoom": 1}
with patch("services.workflow_service.app_draft_workflow_was_synced"):
result = workflow_service.sync_draft_workflow(
app_model=app,
graph=next_graph,
features={},
unique_hash=unique_hash,
account=account,
environment_variables=[
remote_variable.model_copy(update={"value": "stale-a"}),
replaced_variable,
],
conversation_variables=[],
session=sqlite_session,
environment_variable_upserts=[replaced_variable.model_copy(update={"value": "new-b"})],
deleted_environment_variable_ids=[],
preserve_environment_variables=True,
)
assert result.graph_dict == next_graph
assert [(variable.id, variable.value) for variable in result.environment_variables] == [
("env-a", "remote-a"),
("env-b", "new-b"),
]
def test_sync_draft_workflow_noncollaborative_save_replaces_environment_variables(
self, workflow_service: WorkflowService, sqlite_session: Session
) -> None:
"""Legacy full-draft saves retain their full environment replacement contract."""
app = TestWorkflowAssociatedDataFactory.create_app()
account = TestWorkflowAssociatedDataFactory.create_account()
workflow = TestWorkflowAssociatedDataFactory.create_workflow()
workflow.environment_variables = [
StringVariable(id="env-old", name="old", value="old", selector=["env", "old"])
]
sqlite_session.add(workflow)
sqlite_session.commit()
replacement = StringVariable(id="env-new", name="new", value="new", selector=["env", "new"])
with patch("services.workflow_service.app_draft_workflow_was_synced"):
result = workflow_service.sync_draft_workflow(
app_model=app,
graph=TestWorkflowAssociatedDataFactory.create_valid_workflow_graph(),
features={},
unique_hash=workflow.unique_hash,
account=account,
environment_variables=[replacement],
conversation_variables=[],
session=sqlite_session,
)
assert result.environment_variables == [replacement]
def test_sync_draft_workflow_graph_only_preserves_independently_updated_draft_fields(
self, workflow_service: WorkflowService, sqlite_session: Session
):
@@ -765,6 +873,100 @@ class TestWorkflowService:
session=sqlite_session,
)
def test_patch_draft_workflow_environment_variables_merges_by_id(
self, workflow_service: WorkflowService, sqlite_session: Session
) -> None:
"""A patch preserves untouched variables while applying ordered upserts and deletions."""
app = TestWorkflowAssociatedDataFactory.create_app()
account = TestWorkflowAssociatedDataFactory.create_account()
workflow = TestWorkflowAssociatedDataFactory.create_workflow()
workflow.environment_variables = [
StringVariable(id="env-a", name="a", value="remote-a", selector=["env", "a"]),
StringVariable(id="env-b", name="b", value="old-b", selector=["env", "b"]),
StringVariable(id="env-c", name="c", value="remote-c", selector=["env", "c"]),
]
sqlite_session.add(workflow)
sqlite_session.commit()
workflow_service.patch_draft_workflow_environment_variables(
app_model=app,
environment_variables=[
StringVariable(id="env-b", name="b", value="new-b", selector=["env", "b"]),
StringVariable(id="env-d", name="d", value="new-d", selector=["env", "d"]),
],
deleted_environment_variable_ids=["env-a"],
account=account,
session=sqlite_session,
)
sqlite_session.expire_all()
persisted_workflow = sqlite_session.get(Workflow, workflow.id)
assert persisted_workflow is not None
assert [(variable.id, variable.value) for variable in persisted_workflow.environment_variables] == [
("env-b", "new-b"),
("env-c", "remote-c"),
("env-d", "new-d"),
]
assert persisted_workflow.updated_by == account.id
def test_patch_draft_workflow_environment_variables_locks_draft_row(
self, workflow_service: WorkflowService
) -> None:
"""The merge reads the draft with a row lock before applying a partial update."""
app = TestWorkflowAssociatedDataFactory.create_app()
account = TestWorkflowAssociatedDataFactory.create_account()
workflow = TestWorkflowAssociatedDataFactory.create_workflow()
session = MagicMock(spec=Session)
session.scalar.return_value = workflow
workflow_service.patch_draft_workflow_environment_variables(
app_model=app,
environment_variables=[],
deleted_environment_variable_ids=[],
account=account,
session=session,
)
statement = session.scalar.call_args.args[0]
compiled_statement = str(statement.compile(dialect=postgresql.dialect()))
assert "FOR UPDATE" in compiled_statement
session.commit.assert_called_once_with()
def test_patch_draft_workflow_environment_variables_rejects_conflicting_ids(
self, workflow_service: WorkflowService, sqlite_session: Session
) -> None:
"""A patch cannot both upsert and delete the same variable ID."""
app = TestWorkflowAssociatedDataFactory.create_app()
account = TestWorkflowAssociatedDataFactory.create_account()
variable = StringVariable(id="env-a", name="a", value="a", selector=["env", "a"])
sqlite_session.add(TestWorkflowAssociatedDataFactory.create_workflow())
sqlite_session.commit()
with pytest.raises(ValueError, match="cannot be upserted and deleted"):
workflow_service.patch_draft_workflow_environment_variables(
app_model=app,
environment_variables=[variable],
deleted_environment_variable_ids=["env-a"],
account=account,
session=sqlite_session,
)
def test_patch_draft_workflow_environment_variables_raises_when_missing(
self, workflow_service: WorkflowService, sqlite_session: Session
) -> None:
"""A patch fails when the app has no draft workflow to lock."""
app = TestWorkflowAssociatedDataFactory.create_app()
account = TestWorkflowAssociatedDataFactory.create_account()
with pytest.raises(ValueError, match="No draft workflow found."):
workflow_service.patch_draft_workflow_environment_variables(
app_model=app,
environment_variables=[],
deleted_environment_variable_ids=[],
account=account,
session=sqlite_session,
)
def test_update_draft_workflow_conversation_variables_updates_workflow(
self, workflow_service: WorkflowService, sqlite_session: Session
):
@@ -859,6 +1061,53 @@ class TestWorkflowService:
with pytest.raises(ValueError, match="No valid workflow found"):
workflow_service.publish_workflow(session=sqlite_session, app_model=app, account=account)
@pytest.mark.parametrize(
("environment_variables", "error"),
[
([], "was not found or is not an LLM variable"),
(
[
LLMEnvironmentVariable(
name="shared_model",
value={"provider": "provider", "name": "model", "mode": "completion"},
)
],
"uses mode 'completion'.*uses mode 'chat'",
),
],
)
def test_publish_workflow_rejects_invalid_llm_environment_reference_without_credential_validation(
self,
workflow_service: WorkflowService,
sqlite_session: Session,
environment_variables: list[LLMEnvironmentVariable],
error: str,
) -> None:
app = TestWorkflowAssociatedDataFactory.create_app()
account = TestWorkflowAssociatedDataFactory.create_account()
graph = TestWorkflowAssociatedDataFactory.create_valid_workflow_graph()
llm_node = next(node for node in graph["nodes"] if node["data"]["type"] == BuiltinNodeTypes.LLM)
llm_node["data"]["model"] = {
"provider": "provider",
"name": "model",
"mode": "chat",
"completion_params": {},
}
llm_node["data"]["model_selector"] = ["env", "shared_model"]
draft = TestWorkflowAssociatedDataFactory.create_workflow(graph=graph)
draft.environment_variables = environment_variables
sqlite_session.add(draft)
sqlite_session.commit()
with (
patch(
"services.feature_service.FeatureService.get_system_features",
return_value=SimpleNamespace(plugin_manager=SimpleNamespace(enabled=False)),
),
pytest.raises(ValueError, match=error),
):
workflow_service.publish_workflow(session=sqlite_session, app_model=app, account=account)
def test_publish_workflow_trigger_limit_exceeded(self, workflow_service: WorkflowService, sqlite_session: Session):
"""
Test publish_workflow raises error when trigger node limit exceeded in SANDBOX plan.
@@ -1528,6 +1777,78 @@ class TestWorkflowServiceCredentialValidation:
# Assert
mock_llm.assert_called_once_with("tenant-1", "openai", "gpt-4")
def test_validate_workflow_credentials_should_use_llm_environment_variable_model(
self, service: WorkflowService
) -> None:
workflow = self._make_workflow(
[
{
"id": "llm-node",
"data": {
"type": "llm",
"model": {
"provider": "old-provider",
"name": "old-model",
"mode": "chat",
"completion_params": {"temperature": 0.2},
},
"model_selector": ["env", "shared_model"],
},
}
]
)
workflow.environment_variables = [
LLMEnvironmentVariable(
name="shared_model",
value={
"provider": "new-provider",
"name": "new-model",
"mode": "chat",
"completion_params": {"temperature": 0.8},
},
)
]
with (
patch.object(service, "_validate_llm_model_config") as validate_model,
patch.object(service, "_validate_load_balancing_credentials") as validate_load_balancing,
):
service._validate_workflow_credentials(workflow, session=MagicMock())
validate_model.assert_called_once_with("tenant-1", "new-provider", "new-model")
validated_node_data = validate_load_balancing.call_args.args[1]
assert validated_node_data["model"] == {
"provider": "new-provider",
"name": "new-model",
"mode": "chat",
"completion_params": {"temperature": 0.8},
}
def test_validate_workflow_credentials_should_reject_llm_environment_variable_mode_mismatch(
self, service: WorkflowService
) -> None:
workflow = self._make_workflow(
[
{
"id": "llm-node",
"data": {
"type": "llm",
"model": {"provider": "provider", "name": "model", "mode": "chat"},
"model_selector": ["env", "shared_model"],
},
}
]
)
workflow.environment_variables = [
LLMEnvironmentVariable(
name="shared_model",
value={"provider": "provider", "name": "model", "mode": "completion"},
)
]
with pytest.raises(ValueError, match="uses mode 'completion'.*uses mode 'chat'"):
service._validate_workflow_credentials(workflow, session=MagicMock())
def test_validate_workflow_credentials_should_raise_for_llm_node_missing_model(
self, service: WorkflowService, sqlite_session: Session
) -> None:
@@ -60,7 +60,6 @@ export async function syncAgentV2WorkflowDraft(
viewport: { x: 0, y: 0, zoom: 1 },
},
features: {},
environment_variables: [],
conversation_variables: [],
} satisfies SyncDraftWorkflowPayload
await client.apps.byAppId.workflows.draft.post({ body, params: { app_id: appId } })
-2
View File
@@ -19,7 +19,6 @@ export async function syncMinimalWorkflowDraft(
viewport: { x: 0, y: 0, zoom: 1 },
},
features: {},
environment_variables: [],
conversation_variables: [],
} satisfies SyncDraftWorkflowPayload
await client.apps.byAppId.workflows.draft.post({ body, params: { app_id: appId } })
@@ -63,7 +62,6 @@ export async function syncRunnableWorkflowDraft(
viewport: { x: 0, y: 0, zoom: 1 },
},
features: {},
environment_variables: [],
conversation_variables: [],
} satisfies SyncDraftWorkflowPayload
await client.apps.byAppId.workflows.draft.post({ body, params: { app_id: appId } })
+5 -19
View File
@@ -4937,9 +4937,6 @@
},
"web/app/components/workflow/nodes/llm/use-config.ts": {
"eslint-react/set-state-in-effect": {
"count": 2
},
"typescript/no-explicit-any": {
"count": 1
}
},
@@ -5450,28 +5447,17 @@
"count": 2
}
},
"web/app/components/workflow/panel/env-panel/index.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/workflow/panel/env-panel/variable-modal.tsx": {
"eslint-react/set-state-in-effect": {
"count": 4
},
"jsx_a11y/click-events-have-key-events": {
"count": 4
},
"jsx_a11y/no-static-element-interactions": {
"count": 4
},
"no-restricted-imports": {
"count": 1
},
"typescript/no-explicit-any": {
"jsx_a11y/no-static-element-interactions": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
@@ -5714,7 +5700,7 @@
"count": 3
},
"typescript/no-explicit-any": {
"count": 8
"count": 6
}
},
"web/app/components/workflow/update-dsl-modal.tsx": {
@@ -1011,9 +1011,7 @@ export type SyncDraftWorkflowPayload = {
conversation_variables?: Array<{
[key: string]: unknown
}>
environment_variables?: Array<{
[key: string]: unknown
}>
environment_variable_patch?: SyncEnvironmentVariablePatchPayload | null
features: {
[key: string]: unknown
}
@@ -1042,7 +1040,9 @@ export type EnvironmentVariableListResponse = {
}
export type EnvironmentVariableUpdatePayload = {
deleted_environment_variable_ids?: Array<string>
environment_variables: Array<EnvironmentVariableItemPayload>
patch?: boolean
}
export type WorkflowFeaturesPayload = {
@@ -1977,6 +1977,13 @@ export type PipelineVariableResponse = {
variable: string
}
export type SyncEnvironmentVariablePatchPayload = {
deleted_environment_variable_ids?: Array<string>
environment_variables?: Array<{
[key: string]: unknown
}>
}
export type ConversationVariableItemPayload = {
description?: string | null
id?: string | null
@@ -651,18 +651,6 @@ export const zDefaultBlockConfigsResponse = z.array(z.record(z.string(), z.unkno
*/
export const zDefaultBlockConfigResponse = z.record(z.string(), z.unknown())
/**
* SyncDraftWorkflowPayload
*/
export const zSyncDraftWorkflowPayload = z.object({
_is_collaborative: z.boolean().optional().default(false),
conversation_variables: z.array(z.record(z.string(), z.unknown())).optional(),
environment_variables: z.array(z.record(z.string(), z.unknown())).optional(),
features: z.record(z.string(), z.unknown()),
graph: z.record(z.string(), z.unknown()),
hash: z.string().nullish(),
})
/**
* SyncDraftWorkflowResponse
*/
@@ -2053,6 +2041,26 @@ export const zWorkflowPaginationResponse = z.object({
page: z.int(),
})
/**
* SyncEnvironmentVariablePatchPayload
*/
export const zSyncEnvironmentVariablePatchPayload = z.object({
deleted_environment_variable_ids: z.array(z.string()).optional(),
environment_variables: z.array(z.record(z.string(), z.unknown())).optional(),
})
/**
* SyncDraftWorkflowPayload
*/
export const zSyncDraftWorkflowPayload = z.object({
_is_collaborative: z.boolean().optional().default(false),
conversation_variables: z.array(z.record(z.string(), z.unknown())).optional(),
environment_variable_patch: zSyncEnvironmentVariablePatchPayload.nullish(),
features: z.record(z.string(), z.unknown()),
graph: z.record(z.string(), z.unknown()),
hash: z.string().nullish(),
})
/**
* ConversationVariableItemPayload
*/
@@ -2109,7 +2117,9 @@ export const zEnvironmentVariableItemPayload = z.object({
* EnvironmentVariableUpdatePayload
*/
export const zEnvironmentVariableUpdatePayload = z.object({
deleted_environment_variable_ids: z.array(z.string()).optional(),
environment_variables: z.array(zEnvironmentVariableItemPayload),
patch: z.boolean().optional().default(false),
})
/**
@@ -1,5 +1,5 @@
import type { FC, ReactNode } from 'react'
import type { DefaultModel, FormValue, ModelParameterRule } from '../declarations'
import type { DefaultModel, FormValue, Model, ModelParameterRule } from '../declarations'
import type { ParameterValue } from './parameter-item'
import type { TriggerProps } from './types'
import type { Node, NodeOutPutVar } from '@/app/components/workflow/types'
@@ -35,10 +35,12 @@ export type ModelParameterModalProps = {
onDebugWithMultipleModelChange?: () => void
renderTrigger?: (v: TriggerProps) => ReactNode
readonly?: boolean
modelSelectorReadonly?: boolean
isInWorkflow?: boolean
scope?: string
nodesOutputVars?: NodeOutPutVar[]
availableNodes?: Node[]
modelList?: Model[]
}
const ModelParameterModal: FC<ModelParameterModalProps> = ({
@@ -54,9 +56,11 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
onDebugWithMultipleModelChange,
renderTrigger,
readonly,
modelSelectorReadonly,
isInWorkflow,
nodesOutputVars,
availableNodes,
modelList,
}) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
@@ -64,6 +68,7 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
const isRulesLoading = !!provider && !!modelId && isLoading
const { currentProvider, currentModel, activeTextGenerationModelList } =
useTextGenerationCurrentProviderAndModelAndModelList({ provider, model: modelId })
const selectableModelList = modelList ?? activeTextGenerationModelList
const parameterRules: ModelParameterRule[] = useMemo(() => {
return parameterRulesData?.data || []
@@ -80,9 +85,7 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
}
const handleChangeModel = ({ provider, model }: DefaultModel) => {
const targetProvider = activeTextGenerationModelList.find(
(modelItem) => modelItem.provider === provider,
)
const targetProvider = selectableModelList.find((modelItem) => modelItem.provider === provider)
const targetModelItem = targetProvider?.models.find((modelItem) => modelItem.model === model)
setModel({
modelId: model,
@@ -146,8 +149,8 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
<div className="min-w-0 flex-1">
<ModelSelector
defaultModel={provider || modelId ? { provider, model: modelId } : undefined}
modelList={activeTextGenerationModelList}
readonly={readonly}
modelList={selectableModelList}
readonly={readonly || modelSelectorReadonly}
triggerClassName={cn(
'h-8! w-full rounded-r-none!',
isInWorkflow &&
@@ -187,7 +190,8 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
<div className="px-4 pt-2 pb-4">
<ModelSelector
defaultModel={hasSelectedModel ? { provider, model: modelId } : undefined}
modelList={activeTextGenerationModelList}
modelList={selectableModelList}
readonly={modelSelectorReadonly}
onSelect={handleChangeModel}
onHide={() => setOpen(false)}
/>
@@ -40,7 +40,7 @@ export const usePipelineInit = () => {
.filter((env) => env.value_type === 'secret')
.reduce(
(acc, env) => {
acc[env.id] = env.value
if (typeof env.value === 'string') acc[env.id] = env.value
return acc
},
{} as Record<string, string>,
@@ -36,7 +36,7 @@ export const usePipelineRefreshDraft = () => {
.filter((env) => env.value_type === 'secret')
.reduce(
(acc, env) => {
acc[env.id] = env.value
if (typeof env.value === 'string') acc[env.id] = env.value
return acc
},
{} as Record<string, string>,
@@ -1,6 +1,8 @@
import type { ReactElement } from 'react'
import type { Edge, Node } from '@/app/components/workflow/types'
import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types'
import type { Edge, EnvironmentVariable, Node } from '@/app/components/workflow/types'
import type { SnippetCanvasData, SnippetInputField } from '@/models/snippet'
import { toast } from '@langgenius/dify-ui/toast'
import { act, renderHook } from '@testing-library/react'
import { BlockEnum } from '@/app/components/workflow/types'
import { PipelineInputVarType } from '@/models/pipeline'
@@ -24,6 +26,10 @@ vi.mock('../use-create-snippet', () => ({
}),
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: { error: vi.fn() },
}))
type DialogProps = {
selectedGraph?: SnippetCanvasData
inputFields?: SnippetInputField[]
@@ -39,6 +45,19 @@ const createNode = (id: string, data: Record<string, unknown>): Node =>
data,
}) as unknown as Node
const llmEnvironmentVariable: EnvironmentVariable = {
id: 'model-env',
name: 'MODEL_NAME',
value: {
provider: 'new-provider',
name: 'new-model',
mode: 'chat',
completion_params: { temperature: 0.8 },
},
value_type: 'llm',
description: '',
}
describe('useCreateSnippetFromSelection', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -55,6 +74,7 @@ describe('useCreateSnippetFromSelection', () => {
'{{#rag.query#}}',
'{{#source.result#}}',
].join(' '),
model: { provider: 'old-provider', name: 'old-model', mode: 'chat', completion_params: {} },
model_selector: ['env', 'MODEL_NAME'],
}),
]
@@ -64,6 +84,7 @@ describe('useCreateSnippetFromSelection', () => {
const { result } = renderHook(() =>
useCreateSnippetFromSelection({
edges,
environmentVariables: [llmEnvironmentVariable],
selectedNodes,
onClose,
}),
@@ -100,12 +121,6 @@ describe('useCreateSnippetFromSelection', () => {
type: PipelineInputVarType.textInput,
required: true,
},
{
label: 'MODEL_NAME',
variable: 'MODEL_NAME',
type: PipelineInputVarType.textInput,
required: true,
},
])
const nodeData = dialogProps.selectedGraph?.nodes[0]?.data as
| Record<string, unknown>
@@ -120,10 +135,78 @@ describe('useCreateSnippetFromSelection', () => {
`{{#${SNIPPET_INPUT_FIELD_NODE_ID}.result#}}`,
].join(' '),
)
expect(nodeData?.model_selector).toEqual([SNIPPET_INPUT_FIELD_NODE_ID, 'MODEL_NAME'])
expect(nodeData?.model_selector).toBeUndefined()
expect(nodeData?.model).toEqual({
provider: 'new-provider',
name: 'new-model',
mode: 'chat',
completion_params: { temperature: 0.8 },
})
expect(onClose).toHaveBeenCalled()
})
it('should preserve legacy non-environment model selectors', () => {
const selectedNodes = [
createNode('llm', {
type: BlockEnum.LLM,
model: {
provider: 'static-provider',
name: 'static-model',
mode: 'chat',
completion_params: {},
},
model_selector: ['start', 'MODEL_NAME'],
}),
]
const { result } = renderHook(() =>
useCreateSnippetFromSelection({
edges: [],
environmentVariables: [],
selectedNodes,
onClose: vi.fn(),
}),
)
act(() => result.current.handleOpenCreateSnippet())
const dialogProps = (result.current.createSnippetDialog as ReactElement<DialogProps>).props
expect(dialogProps.inputFields).toEqual([])
expect((dialogProps.selectedGraph?.nodes[0]?.data as LLMNodeType).model_selector).toEqual([
'start',
'MODEL_NAME',
])
})
it('should reject an unresolved LLM environment model when creating a snippet', () => {
const selectedNodes = [
createNode('llm', {
type: BlockEnum.LLM,
model: {
provider: 'stale-provider',
name: 'stale-model',
mode: 'chat',
completion_params: {},
},
model_selector: [],
}),
]
const onClose = vi.fn()
const { result } = renderHook(() =>
useCreateSnippetFromSelection({
edges: [],
environmentVariables: [],
selectedNodes,
onClose,
}),
)
act(() => result.current.handleOpenCreateSnippet())
expect(toast.error).toHaveBeenCalled()
expect(mockHandleOpenCreateSnippetDialog).not.toHaveBeenCalled()
expect(onClose).not.toHaveBeenCalled()
})
it('should convert system variables used by if-else and variable aggregator nodes', () => {
const selectedNodes = [
createNode('llm', {
@@ -169,6 +252,7 @@ describe('useCreateSnippetFromSelection', () => {
const { result } = renderHook(() =>
useCreateSnippetFromSelection({
edges: [],
environmentVariables: [],
selectedNodes,
onClose,
}),
@@ -249,6 +333,7 @@ describe('useCreateSnippetFromSelection', () => {
const { result } = renderHook(() =>
useCreateSnippetFromSelection({
edges: [],
environmentVariables: [],
selectedNodes,
onClose,
}),
@@ -1,8 +1,18 @@
import type { Edge, Node, ValueSelector } from '@/app/components/workflow/types'
import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types'
import type {
Edge,
EnvironmentVariable,
Node,
ValueSelector,
} from '@/app/components/workflow/types'
import type { SnippetCanvasData, SnippetInputField } from '@/models/snippet'
import { toast } from '@langgenius/dify-ui/toast'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { getNodesBounds } from 'reactflow'
import { CreateSnippetDialog } from '@/app/components/snippets/create-snippet-dialog'
import { resolveLLMNodeModel } from '@/app/components/workflow/nodes/llm/utils'
import { BlockEnum } from '@/app/components/workflow/types'
import { PipelineInputVarType } from '@/models/pipeline'
import { useCreateSnippet } from './use-create-snippet'
@@ -23,7 +33,7 @@ const isValueSelector = (value: unknown): value is ValueSelector => {
}
const isSelectorKey = (key?: string) => {
return key === 'selector' || !!key?.endsWith('_selector')
return key !== 'model_selector' && (key === 'selector' || !!key?.endsWith('_selector'))
}
const isValueSelectorListKey = (key?: string) => {
@@ -210,13 +220,35 @@ const rewriteVariableReferences = (
)
}
const getSelectedSnippetGraph = (selectedNodes: Node[], edges: Edge[]) => {
const selectedNodeIds = new Set(selectedNodes.map((node) => node.id))
const inlineLLMEnvironmentModels = (nodes: Node[], environmentVariables: EnvironmentVariable[]) => {
return nodes.map((node) => {
if (node.data.type !== BlockEnum.LLM) return node
const data = node.data as LLMNodeType
if (data.model_selector === undefined) return node
if (data.model_selector.length > 0 && data.model_selector[0] !== 'env') return node
const resolvedModel = resolveLLMNodeModel(data.model, data.model_selector, environmentVariables)
if (!resolvedModel) throw new Error('LLM environment model reference could not be resolved')
const nextData = { ...data, model: resolvedModel }
delete nextData.model_selector
return { ...node, data: nextData }
})
}
const getSelectedSnippetGraph = (
selectedNodes: Node[],
edges: Edge[],
environmentVariables: EnvironmentVariable[],
) => {
const nodesWithInlineModels = inlineLLMEnvironmentModels(selectedNodes, environmentVariables)
const selectedNodeIds = new Set(nodesWithInlineModels.map((node) => node.id))
const { inputFields, selectorMap } = getExternalVariableInputFields(
selectedNodes,
nodesWithInlineModels,
selectedNodeIds,
)
const nodes = selectedNodes.map((node) => ({
const nodes = nodesWithInlineModels.map((node) => ({
...node,
data: rewriteVariableReferences(node.data, selectorMap) as Node['data'],
selected: false,
@@ -239,15 +271,18 @@ const getSelectedSnippetGraph = (selectedNodes: Node[], edges: Edge[]) => {
type UseCreateSnippetFromSelectionParams = {
edges: Edge[]
environmentVariables: EnvironmentVariable[]
selectedNodes: Node[]
onClose: () => void
}
export const useCreateSnippetFromSelection = ({
edges,
environmentVariables,
selectedNodes,
onClose,
}: UseCreateSnippetFromSelectionParams) => {
const { t } = useTranslation()
const [selectedSnippetGraph, setSelectedSnippetGraph] = useState<SnippetCanvasData>()
const [selectedSnippetInputFields, setSelectedSnippetInputFields] = useState<SnippetInputField[]>(
[],
@@ -262,12 +297,26 @@ export const useCreateSnippetFromSelection = ({
} = useCreateSnippet()
const handleOpenCreateSnippet = useCallback(() => {
const { graph, inputFields } = getSelectedSnippetGraph(selectedNodes, edges)
let graph: SnippetCanvasData
let inputFields: SnippetInputField[]
try {
const result = getSelectedSnippetGraph(selectedNodes, edges, environmentVariables)
graph = result.graph
inputFields = result.inputFields
} catch {
toast.error(
t(($) => $['errorMsg.fieldRequired'], {
ns: 'workflow',
field: t(($) => $['errorMsg.fields.model'], { ns: 'workflow' }),
}),
)
return
}
setSelectedSnippetGraph(graph)
setSelectedSnippetInputFields(inputFields)
handleOpenCreateSnippetDialog()
onClose()
}, [edges, handleOpenCreateSnippetDialog, onClose, selectedNodes])
}, [edges, environmentVariables, handleOpenCreateSnippetDialog, onClose, selectedNodes, t])
const handleCloseCreateSnippet = useCallback(() => {
setSelectedSnippetGraph(undefined)
@@ -10,11 +10,13 @@ import WorkflowMain from '../workflow-main'
const mockSetFeatures = vi.fn()
const mockSetConversationVariables = vi.fn()
const mockSetEnvironmentVariables = vi.fn()
const mockSetEnvSecrets = vi.fn()
const mockHandleUpdateWorkflowCanvas = vi.hoisted(() => vi.fn())
const mockFetchWorkflowDraft = vi.hoisted(() => vi.fn())
const mockOnVarsAndFeaturesUpdate = vi.hoisted(() => vi.fn())
const mockOnWorkflowUpdate = vi.hoisted(() => vi.fn())
const mockOnSyncRequest = vi.hoisted(() => vi.fn())
const mockGetIsLeader = vi.hoisted(() => vi.fn(() => true))
const mockOnGraphReloadRequired = vi.hoisted(() => vi.fn())
const mockOnGraphReadyChange = vi.hoisted(() => vi.fn())
const mockRefreshGraphSynchronously = vi.hoisted(() => vi.fn())
@@ -115,8 +117,10 @@ vi.mock('@/app/components/workflow/store', () => ({
}),
useWorkflowStore: () => ({
getState: () => ({
envSecrets: {},
setConversationVariables: mockSetConversationVariables,
setEnvironmentVariables: mockSetEnvironmentVariables,
setEnvSecrets: mockSetEnvSecrets,
}),
}),
}))
@@ -185,6 +189,7 @@ vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', ()
canPersistLocalGraph: mockCanPersistLocalGraph,
isGraphReloadCurrent: mockIsGraphReloadCurrent,
retryGraphReload: mockRetryGraphReload,
getIsLeader: mockGetIsLeader,
},
}))
@@ -235,8 +240,8 @@ vi.mock('@/app/components/workflow', () => ({
{
id: 'env-1',
name: 'env-1',
value: '',
value_type: 'string',
value: '********************',
value_type: 'secret',
description: '',
},
],
@@ -437,6 +442,8 @@ describe('WorkflowMain', () => {
collaborationListeners.graphReloadRequired = null
collaborationListeners.graphReadyChange = null
mockFetchWorkflowDraft.mockReset()
hookFns.doSyncWorkflowDraft.mockReset()
mockGetIsLeader.mockReturnValue(true)
mockCanPersistLocalGraph.mockReturnValue(true)
mockIsGraphReloadCurrent.mockReturnValue(true)
mockReplaceGraphFromReactFlow.mockReturnValue(true)
@@ -491,6 +498,17 @@ describe('WorkflowMain', () => {
expect(mockSetEnvironmentVariables).not.toHaveBeenCalled()
})
it('normalizes secret masks from collaboration refreshes', () => {
render(<WorkflowMain nodes={[]} edges={[]} viewport={{ x: 0, y: 0, zoom: 1 }} />)
fireEvent.click(screen.getByRole('button', { name: /update-workflow-data/i }))
expect(mockSetEnvSecrets).toHaveBeenCalledWith({ 'env-1': '********************' })
expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([
expect.objectContaining({ id: 'env-1', value: '[__HIDDEN__]' }),
])
})
it('should ignore empty workflow data updates', () => {
render(<WorkflowMain nodes={[]} edges={[]} viewport={{ x: 0, y: 0, zoom: 1 }} />)
@@ -622,6 +640,272 @@ describe('WorkflowMain', () => {
})
})
it('syncs the leader only after applying a follower environment update', async () => {
collaborationRuntime.isEnabled = true
mockFetchWorkflowDraft.mockResolvedValue({
features: {},
conversation_variables: [],
environment_variables: [
{
id: 'env-model',
name: 'shared_model',
value_type: 'llm',
value: { provider: 'openai', name: 'gpt-4o', mode: 'chat' },
description: '',
},
],
})
render(<WorkflowMain nodes={[]} edges={[]} viewport={{ x: 0, y: 0, zoom: 1 }} />)
hookFns.doSyncWorkflowDraft.mockClear()
await collaborationListeners.varsAndFeaturesUpdate?.({
data: { syncWorkflowDraft: true },
})
expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([
expect.objectContaining({ id: 'env-model' }),
])
expect(hookFns.doSyncWorkflowDraft).toHaveBeenCalledTimes(1)
expect(mockSetEnvironmentVariables.mock.invocationCallOrder[0]).toBeLessThan(
hookFns.doSyncWorkflowDraft.mock.invocationCallOrder[0]!,
)
})
it('ignores an older environment refresh that resolves after the latest update', async () => {
collaborationRuntime.isEnabled = true
let resolveFirst!: (value: Record<string, unknown>) => void
let resolveSecond!: (value: Record<string, unknown>) => void
mockFetchWorkflowDraft
.mockReturnValueOnce(
new Promise((resolve) => {
resolveFirst = resolve
}),
)
.mockReturnValueOnce(
new Promise((resolve) => {
resolveSecond = resolve
}),
)
render(<WorkflowMain nodes={[]} edges={[]} viewport={{ x: 0, y: 0, zoom: 1 }} />)
const firstUpdate = collaborationListeners.varsAndFeaturesUpdate?.({
data: { syncWorkflowDraft: true },
})
const secondUpdate = collaborationListeners.varsAndFeaturesUpdate?.({})
resolveSecond({
features: {},
conversation_variables: [],
environment_variables: [
{
id: 'env-model',
name: 'shared_model',
value_type: 'llm',
value: { provider: 'openai', name: 'gpt-latest', mode: 'chat' },
description: '',
},
],
})
await secondUpdate
expect(mockSetEnvironmentVariables).toHaveBeenLastCalledWith([
expect.objectContaining({ value: expect.objectContaining({ name: 'gpt-latest' }) }),
])
expect(hookFns.doSyncWorkflowDraft).toHaveBeenCalledTimes(1)
resolveFirst({
features: {},
conversation_variables: [],
environment_variables: [
{
id: 'env-model',
name: 'shared_model',
value_type: 'llm',
value: { provider: 'openai', name: 'gpt-stale', mode: 'chat' },
description: '',
},
],
})
await firstUpdate
expect(mockSetEnvironmentVariables).toHaveBeenCalledTimes(1)
expect(hookFns.doSyncWorkflowDraft).toHaveBeenCalledTimes(1)
})
it('applies the latest successful refresh when a newer refresh fails', async () => {
collaborationRuntime.isEnabled = true
let resolveFirst!: (value: Record<string, unknown>) => void
let rejectSecond!: (reason: Error) => void
mockFetchWorkflowDraft
.mockReturnValueOnce(
new Promise((resolve) => {
resolveFirst = resolve
}),
)
.mockReturnValueOnce(
new Promise((_resolve, reject) => {
rejectSecond = reject
}),
)
render(<WorkflowMain nodes={[]} edges={[]} viewport={{ x: 0, y: 0, zoom: 1 }} />)
const firstUpdate = collaborationListeners.varsAndFeaturesUpdate?.({
data: { syncWorkflowDraft: true },
})
const secondUpdate = collaborationListeners.varsAndFeaturesUpdate?.({})
resolveFirst({
features: {},
conversation_variables: [],
environment_variables: [
{
id: 'env-model',
name: 'shared_model',
value_type: 'llm',
value: { provider: 'openai', name: 'gpt-recovered', mode: 'chat' },
description: '',
},
],
})
await firstUpdate
expect(mockSetEnvironmentVariables).not.toHaveBeenCalled()
rejectSecond(new Error('refresh failed'))
await secondUpdate
expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([
expect.objectContaining({ value: expect.objectContaining({ name: 'gpt-recovered' }) }),
])
expect(hookFns.doSyncWorkflowDraft).toHaveBeenCalledTimes(1)
})
it('applies a slow successful refresh after all newer refresh attempts fail', async () => {
collaborationRuntime.isEnabled = true
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
let resolveFirst!: (value: Record<string, unknown>) => void
mockFetchWorkflowDraft
.mockReturnValueOnce(
new Promise((resolve) => {
resolveFirst = resolve
}),
)
.mockRejectedValueOnce(new Error('refresh failed'))
.mockRejectedValueOnce(new Error('retry failed'))
render(<WorkflowMain nodes={[]} edges={[]} viewport={{ x: 0, y: 0, zoom: 1 }} />)
const firstUpdate = collaborationListeners.varsAndFeaturesUpdate?.({
data: { syncWorkflowDraft: true },
})
const secondUpdate = collaborationListeners.varsAndFeaturesUpdate?.({})
await secondUpdate
expect(mockSetEnvironmentVariables).not.toHaveBeenCalled()
resolveFirst({
features: {},
conversation_variables: [],
environment_variables: [
{
id: 'env-model',
name: 'shared_model',
value_type: 'llm',
value: { provider: 'openai', name: 'gpt-slow-success', mode: 'chat' },
description: '',
},
],
})
await firstUpdate
expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([
expect.objectContaining({ value: expect.objectContaining({ name: 'gpt-slow-success' }) }),
])
expect(hookFns.doSyncWorkflowDraft).toHaveBeenCalledTimes(1)
consoleError.mockRestore()
})
it('applies a slow retry after all newer refresh attempts fail', async () => {
collaborationRuntime.isEnabled = true
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
mockFetchWorkflowDraft.mockResolvedValueOnce({
features: {},
conversation_variables: [],
environment_variables: [],
})
render(<WorkflowMain nodes={[]} edges={[]} viewport={{ x: 0, y: 0, zoom: 1 }} />)
await collaborationListeners.varsAndFeaturesUpdate?.({})
mockFetchWorkflowDraft.mockReset()
mockSetEnvironmentVariables.mockClear()
hookFns.doSyncWorkflowDraft.mockClear()
let resolveSlowRetry!: (value: Record<string, unknown>) => void
mockFetchWorkflowDraft
.mockRejectedValueOnce(new Error('older refresh failed'))
.mockReturnValueOnce(
new Promise((resolve) => {
resolveSlowRetry = resolve
}),
)
.mockRejectedValueOnce(new Error('newer refresh failed'))
.mockRejectedValueOnce(new Error('newer retry failed'))
const olderUpdate = collaborationListeners.varsAndFeaturesUpdate?.({
data: { syncWorkflowDraft: true },
})
await waitFor(() => expect(mockFetchWorkflowDraft).toHaveBeenCalledTimes(2))
const newerUpdate = collaborationListeners.varsAndFeaturesUpdate?.({})
await newerUpdate
expect(mockSetEnvironmentVariables).not.toHaveBeenCalled()
resolveSlowRetry({
features: {},
conversation_variables: [],
environment_variables: [
{
id: 'env-model',
name: 'shared_model',
value_type: 'llm',
value: { provider: 'openai', name: 'gpt-slow-retry', mode: 'chat' },
description: '',
},
],
})
await olderUpdate
expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([
expect.objectContaining({ value: expect.objectContaining({ name: 'gpt-slow-retry' }) }),
])
expect(hookFns.doSyncWorkflowDraft).toHaveBeenCalledTimes(1)
consoleError.mockRestore()
})
it('retries a pending follower sync request after the first leader sync fails', async () => {
collaborationRuntime.isEnabled = true
mockFetchWorkflowDraft.mockResolvedValue({
features: {},
conversation_variables: [],
environment_variables: [],
})
hookFns.doSyncWorkflowDraft
.mockImplementationOnce(async (_notRefresh, callback) => {
callback?.onError?.()
})
.mockImplementationOnce(async (_notRefresh, callback) => {
callback?.onSuccess?.()
})
render(<WorkflowMain nodes={[]} edges={[]} viewport={{ x: 0, y: 0, zoom: 1 }} />)
await collaborationListeners.varsAndFeaturesUpdate?.({
data: { syncWorkflowDraft: true },
})
await collaborationListeners.varsAndFeaturesUpdate?.({})
expect(hookFns.doSyncWorkflowDraft).toHaveBeenCalledTimes(2)
})
it('reloads the HTTP draft before trusting a reconnected leader document', async () => {
collaborationRuntime.isEnabled = true
const request = { generation: 2, token: 1, attempt: 0 }
@@ -39,6 +39,12 @@ type WorkflowDataUpdatePayload = Pick<
FetchWorkflowDraftResponse,
'features' | 'conversation_variables' | 'environment_variables'
>
type VarsUpdateSnapshot = {
generation: number
response: FetchWorkflowDraftResponse
syncRequest: number
}
const HIDDEN_SECRET_VALUE = '[__HIDDEN__]'
const GRAPH_RELOAD_RETRY_BASE_DELAY = 1000
const GRAPH_RELOAD_RETRY_MAX_DELAY = 30_000
@@ -164,8 +170,19 @@ const WorkflowMain = ({ nodes, edges, viewport }: WorkflowMainProps) => {
setConversationVariables(conversation_variables)
}
if (environment_variables) {
const { setEnvironmentVariables } = workflowStore.getState()
setEnvironmentVariables(environment_variables)
const { envSecrets, setEnvironmentVariables, setEnvSecrets } = workflowStore.getState()
const nextEnvSecrets: Record<string, string> = {}
const normalizedEnvironmentVariables = environment_variables.map((environmentVariable) => {
if (environmentVariable.value_type !== 'secret') return environmentVariable
nextEnvSecrets[environmentVariable.id] =
environmentVariable.value === HIDDEN_SECRET_VALUE
? envSecrets[environmentVariable.id] || HIDDEN_SECRET_VALUE
: String(environmentVariable.value)
return { ...environmentVariable, value: HIDDEN_SECRET_VALUE }
})
setEnvSecrets(nextEnvSecrets)
setEnvironmentVariables(normalizedEnvironmentVariables)
}
},
[featuresStore, workflowStore],
@@ -174,6 +191,12 @@ const WorkflowMain = ({ nodes, edges, viewport }: WorkflowMainProps) => {
const { doSyncWorkflowDraft, syncWorkflowDraftWhenPageClose } = useNodesSyncDraftByCanEdit(
appACLCapabilities.canEdit,
)
const varsUpdateGenerationRef = useRef(0)
const varsUpdateAppliedGenerationRef = useRef(0)
const varsUpdateFailedGenerationRef = useRef(0)
const varsUpdateLatestSuccessfulRef = useRef<VarsUpdateSnapshot | null>(null)
const varsUpdateSyncRequestRef = useRef(0)
const varsUpdateCompletedSyncRef = useRef(0)
const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft()
const { handleUpdateWorkflowCanvas } = useWorkflowUpdate()
const {
@@ -187,19 +210,102 @@ const WorkflowMain = ({ nodes, edges, viewport }: WorkflowMainProps) => {
useEffect(() => {
if (!appId || !isCollaborationEnabled) return
const applySnapshot = async (snapshot: VarsUpdateSnapshot) => {
if (snapshot.generation <= varsUpdateAppliedGenerationRef.current) return
handleWorkflowDataUpdate(snapshot.response)
varsUpdateAppliedGenerationRef.current = snapshot.generation
if (
snapshot.syncRequest > varsUpdateCompletedSyncRef.current &&
collaborationManager.getIsLeader()
) {
let syncSucceeded = false
await doSyncWorkflowDraft(false, {
onSuccess: () => {
syncSucceeded = true
},
})
if (syncSucceeded)
varsUpdateCompletedSyncRef.current = Math.max(
varsUpdateCompletedSyncRef.current,
snapshot.syncRequest,
)
}
}
const applyLatestSuccessfulSnapshot = async () => {
const latestSuccessful = varsUpdateLatestSuccessfulRef.current
if (latestSuccessful && latestSuccessful.generation > varsUpdateAppliedGenerationRef.current)
await applySnapshot(latestSuccessful)
}
const unsubscribe = collaborationManager.onVarsAndFeaturesUpdate(
async (_update: CollaborationUpdate) => {
if (_update.data?.syncWorkflowDraft) varsUpdateSyncRequestRef.current++
const updateGeneration = ++varsUpdateGenerationRef.current
const syncRequest = varsUpdateSyncRequestRef.current
try {
const response = await fetchWorkflowDraft(`/apps/${appId}/workflows/draft`)
handleWorkflowDataUpdate(response)
const snapshot = { generation: updateGeneration, response, syncRequest }
if (
!varsUpdateLatestSuccessfulRef.current ||
updateGeneration > varsUpdateLatestSuccessfulRef.current.generation
)
varsUpdateLatestSuccessfulRef.current = snapshot
if (varsUpdateGenerationRef.current !== updateGeneration) {
if (varsUpdateFailedGenerationRef.current === varsUpdateGenerationRef.current)
await applyLatestSuccessfulSnapshot()
return
}
await applySnapshot(snapshot)
} catch (error) {
console.error('workflow vars and features update failed:', error)
if (varsUpdateGenerationRef.current !== updateGeneration) return
const latestSuccessful = varsUpdateLatestSuccessfulRef.current
if (
latestSuccessful &&
latestSuccessful.generation > varsUpdateAppliedGenerationRef.current
)
await applySnapshot(latestSuccessful)
const needsFreshSnapshot =
syncRequest > varsUpdateCompletedSyncRef.current &&
(!latestSuccessful || latestSuccessful.syncRequest < syncRequest)
if (varsUpdateGenerationRef.current !== updateGeneration) return
if (!needsFreshSnapshot) {
varsUpdateFailedGenerationRef.current = updateGeneration
await applyLatestSuccessfulSnapshot()
if (!latestSuccessful) console.error('workflow vars and features update failed:', error)
return
}
try {
const response = await fetchWorkflowDraft(`/apps/${appId}/workflows/draft`)
const snapshot = { generation: updateGeneration, response, syncRequest }
if (
!varsUpdateLatestSuccessfulRef.current ||
updateGeneration > varsUpdateLatestSuccessfulRef.current.generation
)
varsUpdateLatestSuccessfulRef.current = snapshot
if (varsUpdateGenerationRef.current !== updateGeneration) {
if (varsUpdateFailedGenerationRef.current === varsUpdateGenerationRef.current)
await applyLatestSuccessfulSnapshot()
return
}
await applySnapshot(snapshot)
} catch (retryError) {
if (varsUpdateGenerationRef.current === updateGeneration) {
varsUpdateFailedGenerationRef.current = updateGeneration
await applyLatestSuccessfulSnapshot()
}
console.error('workflow vars and features update failed:', retryError)
}
}
},
)
return unsubscribe
}, [appId, handleWorkflowDataUpdate, isCollaborationEnabled])
}, [appId, doSyncWorkflowDraft, handleWorkflowDataUpdate, isCollaborationEnabled])
// Listen for workflow updates from other users
useEffect(() => {
@@ -1,3 +1,4 @@
import type { EnvironmentVariablePatch } from '@/service/workflow'
import { act } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { BlockEnum } from '@/app/components/workflow/types'
@@ -27,7 +28,6 @@ let workflowStoreState: {
appId: string
isWorkflowDataLoaded: boolean
syncWorkflowDraftHash: string | null
environmentVariables: Array<Record<string, unknown>>
conversationVariables: Array<Record<string, unknown>>
setSyncWorkflowDraftHash: typeof mockSetSyncWorkflowDraftHash
setDraftUpdatedAt: typeof mockSetDraftUpdatedAt
@@ -117,7 +117,6 @@ describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () =>
appId: 'app-1',
isWorkflowDataLoaded: true,
syncWorkflowDraftHash: 'hash-123',
environmentVariables: [],
conversationVariables: [],
setSyncWorkflowDraftHash: mockSetSyncWorkflowDraftHash,
setDraftUpdatedAt: mockSetDraftUpdatedAt,
@@ -293,7 +292,6 @@ describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () =>
workflowStoreState = {
...workflowStoreState,
syncWorkflowDraftHash: 'latest-hash',
environmentVariables: [{ id: 'env-1', value: 'env' }],
conversationVariables: [{ id: 'conversation-1', value: 'conversation' }],
}
featuresState = {
@@ -340,7 +338,6 @@ describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () =>
sensitive_word_avoidance: { enabled: false },
file_upload: { enabled: true },
},
environment_variables: [{ id: 'env-1', value: 'env' }],
conversation_variables: [{ id: 'conversation-1', value: 'conversation' }],
hash: 'latest-hash',
},
@@ -352,6 +349,42 @@ describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () =>
expect(callbacks.onSettled).toHaveBeenCalled()
})
it('should include an environment variable patch in a full draft sync', async () => {
const environmentVariablePatch: EnvironmentVariablePatch = {
environmentVariables: [
{
id: 'env-1',
name: 'for_summarize',
description: '',
value_type: 'llm',
value: {
provider: 'langgenius/openai/openai',
name: 'gpt-4.1',
mode: 'chat',
},
},
],
deletedEnvironmentVariableIds: ['env-2'],
}
const { result } = renderUseNodesSyncDraft()
await act(async () => {
await result.current.doSyncWorkflowDraft(false, undefined, { environmentVariablePatch })
})
expect(mockSyncWorkflowDraft).toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({
environment_variable_patch: {
environment_variables: environmentVariablePatch.environmentVariables,
deleted_environment_variable_ids:
environmentVariablePatch.deletedEnvironmentVariableIds,
},
}),
}),
)
})
it('should keep pending inline Agent v2 nodes in draft without incomplete bindings', async () => {
reactFlowState = {
...reactFlowState,
@@ -440,7 +473,6 @@ describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () =>
}
workflowStoreState = {
...workflowStoreState,
environmentVariables: [{ id: 'env-1' }],
conversationVariables: [{ id: 'conversation-1' }],
}
@@ -59,13 +59,8 @@ const useNodesSyncDraftBase = (getNodesReadOnly: () => boolean) => {
.map((node) => node.id),
)
const [x, y, zoom] = transform
const {
appId,
conversationVariables,
environmentVariables,
syncWorkflowDraftHash,
isWorkflowDataLoaded,
} = workflowStore.getState()
const { appId, conversationVariables, syncWorkflowDraftHash, isWorkflowDataLoaded } =
workflowStore.getState()
if (!appId || !isWorkflowDataLoaded) return null
@@ -118,7 +113,6 @@ const useNodesSyncDraftBase = (getNodesReadOnly: () => boolean) => {
},
},
features: featuresPayload,
environment_variables: environmentVariables,
conversation_variables: conversationVariables,
hash: syncWorkflowDraftHash,
...(isCollaborationEnabled ? { _is_collaborative: true } : {}),
@@ -144,6 +138,7 @@ const useNodesSyncDraftBase = (getNodesReadOnly: () => boolean) => {
async (
notRefreshWhenSyncError?: boolean,
callback?: SyncDraftCallback,
options?: SyncDraftOptions,
): Promise<SyncDraftResult | null> => {
if (getNodesReadOnly()) return null
@@ -168,6 +163,15 @@ const useNodesSyncDraftBase = (getNodesReadOnly: () => boolean) => {
params: {
...baseParams.params,
hash: latestHash || null,
...(options?.environmentVariablePatch
? {
environment_variable_patch: {
environment_variables: options.environmentVariablePatch.environmentVariables,
deleted_environment_variable_ids:
options.environmentVariablePatch.deletedEnvironmentVariableIds,
},
}
: {}),
},
}
@@ -220,7 +224,8 @@ const useNodesSyncDraftBase = (getNodesReadOnly: () => boolean) => {
!collaborationManager.getIsLeader() &&
!options?.forceLocal
if (!shouldRequestLeader) return doSyncWorkflowDraftLocally(notRefreshWhenSyncError, callback)
if (!shouldRequestLeader)
return doSyncWorkflowDraftLocally(notRefreshWhenSyncError, callback, options)
try {
const result = await collaborationManager.requestWorkflowSync()
@@ -106,7 +106,7 @@ export const useWorkflowInit = () => {
.filter((env) => env.value_type === 'secret')
.reduce(
(acc, env) => {
acc[env.id] = env.value
if (typeof env.value === 'string') acc[env.id] = env.value
return acc
},
{} as Record<string, string>,
@@ -121,7 +121,10 @@ export const useWorkflowInit = () => {
setSyncWorkflowDraftHash(initialData.hash)
setIsLoading(false)
} catch (error: unknown) {
const responseError = error as { bodyUsed?: boolean; json?: () => Promise<{ code?: string }> }
const responseError = error as {
bodyUsed?: boolean
json?: () => Promise<{ code?: string }>
}
if (responseError.json && !responseError.bodyUsed && appDetail) {
responseError.json().then((err) => {
if (err.code === 'draft_workflow_not_exist') {
@@ -163,7 +166,6 @@ export const useWorkflowInit = () => {
features: {
retriever_resource: { enabled: true },
},
environment_variables: [],
conversation_variables: [],
},
}).then((res) => {
@@ -51,7 +51,7 @@ export const useWorkflowRefreshDraft = () => {
.filter((env) => env.value_type === 'secret')
.reduce(
(acc, env) => {
acc[env.id] = env.value
if (typeof env.value === 'string') acc[env.id] = env.value
return acc
},
{} as Record<string, string>,
@@ -1,5 +1,4 @@
'use client'
import type { EnvironmentVariable } from '@/app/components/workflow/types'
import {
AlertDialog,
AlertDialogActions,
@@ -15,7 +14,7 @@ import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
export type DSLExportConfirmModalProps = {
envList: Pick<EnvironmentVariable, 'name' | 'value'>[]
envList: Array<{ name: string; value: unknown }>
onConfirm: (state: boolean) => void | Promise<void>
onClose: () => void
}
@@ -98,7 +97,9 @@ export const DSLExportConfirmContent = ({
)}
>
<div className="truncate system-xs-regular text-text-secondary">
{env.value}
{typeof env.value === 'object'
? JSON.stringify(env.value)
: String(env.value)}
</div>
</td>
</tr>
@@ -8,6 +8,7 @@ import type {
} from '@/app/components/workflow/types'
import type { IOtherOptions } from '@/service/base'
import type { SchemaTypeDefinition } from '@/service/use-common'
import type { EnvironmentVariablePatch } from '@/service/workflow'
import type { FlowType } from '@/types/common'
import type { VarInInspect } from '@/types/workflow'
import { noop } from 'es-toolkit/function'
@@ -32,6 +33,7 @@ export type SyncDraftResult = {
}
export type SyncDraftOptions = {
environmentVariablePatch?: EnvironmentVariablePatch
forceLocal?: boolean
}
@@ -20,7 +20,7 @@ const createModel = (overrides: Partial<ModelConfig> = {}): ModelConfig => ({
provider: 'openai',
name: 'gpt-4o',
mode: 'chat',
completion_params: [],
completion_params: {},
...overrides,
})
@@ -2,13 +2,14 @@ import type { AgentNodeType } from '../nodes/agent/types'
import type { DataSourceNodeType } from '../nodes/data-source/types'
import type { KnowledgeBaseNodeType } from '../nodes/knowledge-base/types'
import type { KnowledgeRetrievalNodeType } from '../nodes/knowledge-retrieval/types'
import type { LLMNodeType } from '../nodes/llm/types'
import type { ToolNodeType } from '../nodes/tool/types'
import type { PluginTriggerNodeType } from '../nodes/trigger-plugin/types'
import type {
CommonEdgeType,
CommonNodeType,
Edge,
ModelConfig,
EnvironmentVariable,
Node,
ValueSelector,
} from '../types'
@@ -52,6 +53,7 @@ import {
getLLMModelIssue,
isLLMModelProviderInstalled,
LLMModelIssueCode,
resolveLLMNodeModel,
} from '../nodes/llm/utils'
import { useStore, useWorkflowStore } from '../store'
import { BlockEnum } from '../types'
@@ -85,6 +87,8 @@ export type ChecklistItem = {
type CheckValidExtraData = Record<string, unknown> | undefined
const EMPTY_ENVIRONMENT_VARIABLES: EnvironmentVariable[] = []
const withFlowType = (moreDataForCheckValid: CheckValidExtraData, flowType?: FlowType) => {
if (!flowType) return moreDataForCheckValid
@@ -144,6 +148,8 @@ export const useChecklist = (nodes: Node[], edges: Edge[], options?: { flowType?
const { data: workflowTools } = useAllWorkflowTools()
const { data: mcpTools } = useAllMCPTools()
const dataSourceList = useStore((s) => s.dataSourceList)
const environmentVariables =
useStore((s) => s.environmentVariables) ?? EMPTY_ENVIRONMENT_VARIABLES
const { data: strategyProviders } = useStrategyProviders()
const { data: triggerPlugins } = useAllTriggerPlugins()
const datasetsDetail = useDatasetsDetailStore((s) => s.datasetsDetail)
@@ -339,6 +345,13 @@ export const useChecklist = (nodes: Node[], edges: Edge[], options?: { flowType?
usedVars = getNodeUsedVars(node!).filter((v) => v.length > 0)
}
if (node!.data.type === BlockEnum.LLM) {
moreDataForCheckValid = {
...(moreDataForCheckValid ?? {}),
environmentVariables,
}
}
if (node!.type === CUSTOM_NODE) {
const checkData = getCheckData(node!.data)
const validator = nodesExtraData?.[getNodeCatalogType(node!.data)]?.checkValid
@@ -357,8 +370,12 @@ export const useChecklist = (nodes: Node[], edges: Edge[], options?: { flowType?
errorMessages.push(t(($) => $['nodes.common.pluginNotInstalled'], { ns: 'workflow' }))
} else {
if (node!.data.type === BlockEnum.LLM) {
const modelProvider = (node!.data as CommonNodeType<{ model?: ModelConfig }>).model
?.provider
const llmNodeData = node!.data as LLMNodeType
const modelProvider = resolveLLMNodeModel(
llmNodeData.model,
llmNodeData.model_selector,
environmentVariables,
)?.provider
const modelIssue = getLLMModelIssue({
modelProvider,
isModelProviderInstalled: isLLMModelProviderInstalled(
@@ -490,6 +507,7 @@ export const useChecklist = (nodes: Node[], edges: Edge[], options?: { flowType?
mcpTools,
language,
dataSourceList,
environmentVariables,
triggerPlugins,
getToolIcon,
strategyProviders,
@@ -573,7 +591,7 @@ export const useChecklistBeforePublish = () => {
const handleCheckBeforePublish = useCallback(async () => {
const { getNodes, edges } = store.getState()
const { dataSourceList } = workflowStore.getState()
const { dataSourceList, environmentVariables = [] } = workflowStore.getState()
const nodes = getNodes()
const filteredNodes = nodes.filter((node) => node.type === CUSTOM_NODE)
const duplicateEndOutputMessages = getDuplicateEndOutputMessages(filteredNodes, t)
@@ -684,8 +702,19 @@ export const useChecklistBeforePublish = () => {
}
if (node!.data.type === BlockEnum.LLM) {
const modelProvider = (node!.data as CommonNodeType<{ model?: ModelConfig }>).model
?.provider
moreDataForCheckValid = {
...(moreDataForCheckValid ?? {}),
environmentVariables,
}
}
if (node!.data.type === BlockEnum.LLM) {
const llmNodeData = node!.data as LLMNodeType
const modelProvider = resolveLLMNodeModel(
llmNodeData.model,
llmNodeData.model_selector,
environmentVariables,
)?.provider
const modelIssue = getLLMModelIssue({
modelProvider,
isModelProviderInstalled: isLLMModelProviderInstalled(modelProvider, installedPluginIds),
@@ -8,9 +8,10 @@ import { VarType } from '@/app/components/workflow/nodes/tool/types'
type Props = Readonly<{
value: VarType
onChange: (value: VarType) => void
readonly?: boolean
}>
const FormInputTypeSwitch: FC<Props> = ({ value, onChange }) => {
const FormInputTypeSwitch: FC<Props> = ({ value, onChange, readonly = false }) => {
const { t } = useTranslation()
const variableLabel = t(($) => $['nodes.common.typeSwitch.variable'], { ns: 'workflow' })
const inputLabel = t(($) => $['nodes.common.typeSwitch.input'], { ns: 'workflow' })
@@ -21,7 +22,9 @@ const FormInputTypeSwitch: FC<Props> = ({ value, onChange }) => {
<button
type="button"
aria-label={variableLabel}
className="cursor-pointer rounded-lg bg-components-segmented-control-item-active-bg px-2.5 py-1.5 text-text-secondary shadow-xs hover:bg-components-segmented-control-item-active-bg"
aria-pressed={true}
disabled={readonly}
className="cursor-pointer rounded-lg bg-components-segmented-control-item-active-bg px-2.5 py-1.5 text-text-secondary shadow-xs hover:bg-components-segmented-control-item-active-bg disabled:cursor-not-allowed"
onClick={() => onChange(VarType.variable)}
>
<Variable02 className="size-4" />
@@ -33,7 +36,9 @@ const FormInputTypeSwitch: FC<Props> = ({ value, onChange }) => {
<button
type="button"
aria-label={variableLabel}
className="cursor-pointer rounded-lg px-2.5 py-1.5 text-text-tertiary hover:bg-state-base-hover"
aria-pressed={false}
disabled={readonly}
className="cursor-pointer rounded-lg px-2.5 py-1.5 text-text-tertiary hover:bg-state-base-hover disabled:cursor-not-allowed"
onClick={() => onChange(VarType.variable)}
>
<Variable02 className="size-4" />
@@ -47,7 +52,9 @@ const FormInputTypeSwitch: FC<Props> = ({ value, onChange }) => {
<button
type="button"
aria-label={inputLabel}
className="cursor-pointer rounded-lg bg-components-segmented-control-item-active-bg px-2.5 py-1.5 text-text-secondary shadow-xs hover:bg-components-segmented-control-item-active-bg"
aria-pressed={true}
disabled={readonly}
className="cursor-pointer rounded-lg bg-components-segmented-control-item-active-bg px-2.5 py-1.5 text-text-secondary shadow-xs hover:bg-components-segmented-control-item-active-bg disabled:cursor-not-allowed"
onClick={() => onChange(VarType.constant)}
>
<span aria-hidden className="i-ri-edit-line size-4" />
@@ -59,7 +66,9 @@ const FormInputTypeSwitch: FC<Props> = ({ value, onChange }) => {
<button
type="button"
aria-label={inputLabel}
className="cursor-pointer rounded-lg px-2.5 py-1.5 text-text-tertiary hover:bg-state-base-hover"
aria-pressed={false}
disabled={readonly}
className="cursor-pointer rounded-lg px-2.5 py-1.5 text-text-tertiary hover:bg-state-base-hover disabled:cursor-not-allowed"
onClick={() => onChange(VarType.constant)}
>
<span aria-hidden className="i-ri-edit-line size-4" />
@@ -2,7 +2,7 @@ import type { AgentV2NodeType } from '@/app/components/workflow/nodes/agent-v2/t
import type { AnswerNodeType } from '@/app/components/workflow/nodes/answer/types'
import type { HumanInputNodeType } from '@/app/components/workflow/nodes/human-input/types'
import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types'
import type { Node, PromptItem } from '@/app/components/workflow/types'
import type { EnvironmentVariable, Node, PromptItem } from '@/app/components/workflow/types'
import { describe, expect, it } from 'vitest'
import { DeliveryMethodType } from '@/app/components/workflow/nodes/human-input/types'
import {
@@ -50,6 +50,37 @@ const createLLMNodeData = (promptTemplate: PromptItem[]): LLMNodeType => ({
describe('variable utils', () => {
describe('toNodeAvailableVars', () => {
it('excludes LLM environment aliases from generic variable pickers', () => {
const environmentVariables: EnvironmentVariable[] = [
{
id: 'text-env',
name: 'query',
value: 'hello',
value_type: 'string',
description: '',
},
{
id: 'llm-env',
name: 'shared_model',
value: { provider: 'provider', name: 'model', mode: 'chat' },
value_type: 'llm',
description: '',
},
]
const availableVars = toNodeAvailableVars({
beforeNodes: [],
isChatMode: false,
environmentVariables,
filterVar: () => true,
allPluginInfoList: {},
})
expect(availableVars.find((item) => item.nodeId === 'env')?.vars).toEqual([
expect.objectContaining({ variable: 'env.query', type: 'string' }),
])
})
it('uses Agent v2 default declared outputs for agent nodes', () => {
const node = createNode<AgentV2NodeType>({
type: BlockEnum.Agent,
@@ -146,6 +177,15 @@ describe('variable utils', () => {
expect(getNodeUsedVars(node)).toContainEqual(['env', 'API_KEY'])
})
it('should read an LLM model environment selector', () => {
const node = createNode<LLMNodeType>({
...createLLMNodeData([]),
model_selector: ['env', 'for_summarize'],
})
expect(getNodeUsedVars(node)).toContainEqual(['env', 'for_summarize'])
})
it('should read variables from human input email body', () => {
const node = createNode<HumanInputNodeType>({
type: BlockEnum.HumanInput,
@@ -247,6 +287,17 @@ describe('variable utils', () => {
})
})
it('should replace an LLM model environment selector', () => {
const node = createNode<LLMNodeType>({
...createLLMNodeData([]),
model_selector: ['env', 'for_summarize'],
})
const updatedNode = updateNodeVars(node, ['env', 'for_summarize'], ['env', 'for_research'])
expect((updatedNode.data as LLMNodeType).model_selector).toEqual(['env', 'for_research'])
})
it('should replace agent task references', () => {
const node = createNode<AgentV2NodeType>({
type: BlockEnum.Agent,
@@ -622,13 +622,15 @@ const formatItem = (
}
case 'env': {
res.vars = data.envList.map((env: EnvironmentVariable) => {
return {
variable: `env.${env.name}`,
type: env.value_type,
description: env.description,
}
}) as Var[]
res.vars = data.envList
.filter((env: EnvironmentVariable) => env.value_type !== 'llm')
.map((env: EnvironmentVariable) => {
return {
variable: `env.${env.name}`,
type: env.value_type,
description: env.description,
}
}) as Var[]
break
}
@@ -1258,7 +1260,8 @@ export const getNodeUsedVars = (node: Node): ValueSelector[] => {
const contextVar = (data as LLMNodeType).context?.variable_selector
? [(data as LLMNodeType).context?.variable_selector]
: []
res = [...inputVars, ...contextVar]
const modelSelector = payload.model_selector?.[0] === 'env' ? [payload.model_selector] : []
res = [...inputVars, ...contextVar, ...modelSelector]
break
}
case BlockEnum.KnowledgeRetrieval: {
@@ -1594,6 +1597,11 @@ export const updateNodeVars = (
if (payload.context?.variable_selector?.join('.') === oldVarSelector.join('.')) {
payload.context.variable_selector = newVarSelector
}
if (
payload.model_selector?.[0] === 'env' &&
payload.model_selector.join('.') === oldVarSelector.join('.')
)
payload.model_selector = newVarSelector
break
}
@@ -53,6 +53,78 @@ describe('llm default node validation', () => {
expect(result.errorMessage).toBe('')
})
it('should validate a configured LLM environment model reference', () => {
const result = nodeDefault.checkValid(
createPayload({ model_selector: ['env', 'for_summarize'] }),
t,
{
environmentVariables: [
{
id: 'env-1',
name: 'for_summarize',
value_type: 'llm',
value: {
provider: 'langgenius/anthropic/anthropic',
name: 'claude-sonnet',
mode: AppModeEnum.CHAT,
},
description: '',
},
],
},
)
expect(result.isValid).toBe(true)
expect(result.errorMessage).toBe('')
})
it('should reject a missing LLM environment model reference', () => {
const result = nodeDefault.checkValid(
createPayload({ model_selector: ['env', 'missing'] }),
t,
{ environmentVariables: [] },
)
expect(result.isValid).toBe(false)
expect(result.errorMessage).toBe('errorMsg.fieldRequired')
})
it('should preserve legacy non-environment model selectors as static models', () => {
const result = nodeDefault.checkValid(
createPayload({ model_selector: ['start', 'MODEL_NAME'] }),
t,
{ environmentVariables: [] },
)
expect(result.isValid).toBe(true)
expect(result.errorMessage).toBe('')
})
it('should reject an LLM environment model whose mode differs from the node mode', () => {
const result = nodeDefault.checkValid(
createPayload({ model_selector: ['env', 'for_summarize'] }),
t,
{
environmentVariables: [
{
id: 'env-1',
name: 'for_summarize',
value_type: 'llm',
value: {
provider: 'langgenius/anthropic/anthropic',
name: 'claude-sonnet',
mode: AppModeEnum.COMPLETION,
},
description: '',
},
],
},
)
expect(result.isValid).toBe(false)
expect(result.errorMessage).toBe('errorMsg.fieldRequired')
})
it('should require sys.query in memory user prompt outside snippet flows', () => {
const result = nodeDefault.checkValid(
createPayload({
@@ -70,4 +70,20 @@ describe('llm/node', () => {
expect(screen.queryByText('openai:gpt-4o')).not.toBeInTheDocument()
})
it('renders a cached environment model without requiring workflow context', () => {
const data = createData({
model: {
provider: 'anthropic',
name: 'claude-sonnet',
mode: AppModeEnum.CHAT,
completion_params: {},
},
model_selector: ['env', 'for_summarize'],
})
render(<Node id="llm-node" data={data} />)
expect(screen.getByText('anthropic:claude-sonnet')).toBeInTheDocument()
})
})
@@ -1,7 +1,9 @@
import type { LLMNodeType } from '../types'
import type { ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { ModelParameterModalProps } from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
import type { PanelProps } from '@/types/workflow'
import { screen } from '@testing-library/react'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
import {
ConfigurationMethodEnum,
@@ -13,19 +15,43 @@ import {
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { ProviderContext } from '@/context/provider-context'
import { AppModeEnum } from '@/types/app'
import { FlowType } from '@/types/common'
import { fetchAndMergeValidCompletionParams } from '@/utils/completion-params'
import { BlockEnum } from '../../../types'
import Panel from '../panel'
const mockUseConfig = vi.fn()
const mockFetchAndMergeValidCompletionParams = vi.mocked(fetchAndMergeValidCompletionParams)
vi.mock('../use-config', () => ({
default: (...args: unknown[]) => mockUseConfig(...args),
}))
vi.mock('@/utils/completion-params', () => ({
fetchAndMergeValidCompletionParams: vi.fn().mockResolvedValue({
params: {},
removedDetails: {},
}),
}))
vi.mock(
'@/app/components/header/account-setting/model-provider-page/model-parameter-modal',
() => ({
default: () => <div data-testid="model-parameter-modal" />,
default: ({ modelSelectorReadonly, readonly, setModel }: ModelParameterModalProps) => (
<div>
<div
data-testid="model-parameter-modal"
data-model-selector-readonly={modelSelectorReadonly}
data-readonly={readonly}
/>
<button
type="button"
onClick={() => setModel({ provider: 'anthropic', modelId: 'claude-3-5-sonnet' })}
>
Select direct model
</button>
</div>
),
}),
)
@@ -111,12 +137,17 @@ const panelProps = {} as PanelProps
const buildUseConfigResult = (overrides?: Partial<MockUseConfigReturn>) => ({
readOnly: false,
inputs: baseNodeData,
model: baseNodeData.model,
environmentVariables: [],
isEnvironmentModelSource: false,
isChatModel: true,
isChatMode: true,
isCompletionModel: false,
shouldShowContextTip: false,
isVisionModel: false,
handleModelChanged: vi.fn(),
handleModelSourceChange: vi.fn(),
handleModelSelectorChange: vi.fn(),
hasSetBlockStatus: false,
handleCompletionParamsChange: vi.fn(),
handleContextVarChange: vi.fn(),
@@ -144,20 +175,24 @@ const buildUseConfigResult = (overrides?: Partial<MockUseConfigReturn>) => ({
...overrides,
})
const renderPanel = (data?: Partial<LLMNodeType>) => {
return renderWorkflowFlowComponent(
<ProviderContext.Provider
value={createMockProviderContextValue({
modelProviders: [createMockModelProvider('openai')],
isFetchedPlan: true,
})}
>
<Panel id="llm-node" data={{ ...baseNodeData, ...data }} panelProps={panelProps} />
</ProviderContext.Provider>,
{
hooksStoreProps: {},
},
)
const renderPanelElement = (data?: Partial<LLMNodeType>) => (
// oxlint-disable-next-line eslint-react/no-context-provider -- use-context-selector requires its special provider.
<ProviderContext.Provider
value={createMockProviderContextValue({
modelProviders: [createMockModelProvider('openai')],
isFetchedPlan: true,
})}
>
<Panel id="llm-node" data={{ ...baseNodeData, ...data }} panelProps={panelProps} />
</ProviderContext.Provider>
)
const renderPanel = (data?: Partial<LLMNodeType>, flowType?: FlowType) => {
return renderWorkflowFlowComponent(renderPanelElement(data), {
hooksStoreProps: flowType
? { configsMap: { flowId: 'test-flow', flowType, fileSettings: {} } }
: {},
})
}
describe('LLM Panel', () => {
@@ -177,6 +212,11 @@ describe('LLM Panel', () => {
it('should show the model warning dot when the model is not configured', () => {
mockUseConfig.mockReturnValue(
buildUseConfigResult({
model: {
...baseNodeData.model,
provider: '',
name: '',
},
inputs: {
...baseNodeData,
model: {
@@ -200,4 +240,247 @@ describe('LLM Panel', () => {
expect(modelField?.querySelector('.bg-text-warning-secondary')).toBeInTheDocument()
})
})
it('switches from direct model input to an environment reference source', async () => {
const user = userEvent.setup()
const handleModelSourceChange = vi.fn()
mockUseConfig.mockReturnValue(buildUseConfigResult({ handleModelSourceChange }))
renderPanel()
await user.click(
screen.getByRole('button', { name: 'workflow.nodes.common.typeSwitch.variable' }),
)
expect(handleModelSourceChange).toHaveBeenCalledWith(true)
})
it('does not offer an environment model source in snippet flows', () => {
renderPanel(undefined, FlowType.snippet)
expect(
screen.queryByRole('button', { name: 'workflow.nodes.common.typeSwitch.variable' }),
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'workflow.nodes.common.typeSwitch.input' }),
).not.toBeInTheDocument()
})
it('allows a pasted environment-bound snippet node to recover to a direct model', async () => {
const user = userEvent.setup()
const handleModelSourceChange = vi.fn()
mockUseConfig.mockReturnValue(
buildUseConfigResult({
inputs: { ...baseNodeData, model_selector: ['env', 'shared_model'] },
isEnvironmentModelSource: true,
handleModelSourceChange,
}),
)
renderPanel({ model_selector: ['env', 'shared_model'] }, FlowType.snippet)
await user.click(screen.getByRole('button', { name: 'workflow.nodes.common.typeSwitch.input' }))
expect(handleModelSourceChange).toHaveBeenCalledWith(false)
})
it('lists only LLM environment variables and binds the selected reference', async () => {
const user = userEvent.setup()
const handleModelSelectorChange = vi.fn()
mockUseConfig.mockReturnValue(
buildUseConfigResult({
inputs: {
...baseNodeData,
model_selector: [],
},
isEnvironmentModelSource: true,
environmentVariables: [
{
id: 'env-llm',
name: 'for_summarize',
value_type: 'llm',
value: {
provider: 'openai',
name: 'gpt-4o',
mode: AppModeEnum.CHAT,
},
description: '',
},
{
id: 'env-string',
name: 'API_KEY',
value_type: 'string',
value: 'secret',
description: '',
},
],
handleModelSelectorChange,
}),
)
renderPanel({ model_selector: [] })
expect(screen.getByTestId('model-parameter-modal')).toHaveAttribute(
'data-model-selector-readonly',
'true',
)
expect(screen.getByTestId('model-parameter-modal')).toHaveAttribute('data-readonly', 'true')
await user.click(screen.getByText('workflow.nodes.common.typeSwitch.variable'))
expect(screen.getByText('for_summarize')).toBeInTheDocument()
expect(screen.queryByText('API_KEY')).not.toBeInTheDocument()
await user.click(screen.getByText('for_summarize'))
await waitFor(() => {
expect(handleModelSelectorChange).toHaveBeenCalledWith(['env', 'for_summarize'], {})
})
})
it('does not apply stale model parameters after switching back to direct input', async () => {
const user = userEvent.setup()
let resolveParameters!: (value: {
params: Record<string, never>
removedDetails: Record<string, never>
}) => void
mockFetchAndMergeValidCompletionParams.mockReturnValueOnce(
new Promise((resolve) => {
resolveParameters = resolve
}),
)
const handleModelSelectorChange = vi.fn()
const handleModelSourceChange = vi.fn()
const handleCompletionParamsChange = vi.fn()
mockUseConfig.mockReturnValue(
buildUseConfigResult({
inputs: { ...baseNodeData, model_selector: [] },
isEnvironmentModelSource: true,
environmentVariables: [
{
id: 'env-llm',
name: 'for_summarize',
value_type: 'llm',
value: { provider: 'openai', name: 'gpt-4o', mode: AppModeEnum.CHAT },
description: '',
},
],
handleModelSelectorChange,
handleModelSourceChange,
handleCompletionParamsChange,
}),
)
renderPanel({ model_selector: [] })
await user.click(screen.getByText('workflow.nodes.common.typeSwitch.variable'))
await user.click(screen.getByText('for_summarize'))
expect(handleModelSelectorChange).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: 'workflow.nodes.common.typeSwitch.input' }))
resolveParameters({ params: {}, removedDetails: {} })
await waitFor(() => {
expect(handleModelSourceChange).toHaveBeenCalledWith(false)
})
expect(handleCompletionParamsChange).not.toHaveBeenCalled()
expect(handleModelSelectorChange).not.toHaveBeenCalled()
})
it('does not overwrite parameters edited while a model rules request is in flight', async () => {
const user = userEvent.setup()
let resolveParameters!: (value: {
params: Record<string, number>
removedDetails: Record<string, never>
}) => void
mockFetchAndMergeValidCompletionParams.mockReturnValueOnce(
new Promise((resolve) => {
resolveParameters = resolve
}),
)
const handleModelChanged = vi.fn()
const handleCompletionParamsChange = vi.fn()
mockUseConfig.mockReturnValue(
buildUseConfigResult({
inputs: {
...baseNodeData,
model: { ...baseNodeData.model, completion_params: { temperature: 0.7 } },
},
handleModelChanged,
handleCompletionParamsChange,
}),
)
const rendered = renderPanel()
await user.click(screen.getByRole('button', { name: 'Select direct model' }))
mockUseConfig.mockReturnValue(
buildUseConfigResult({
inputs: {
...baseNodeData,
model: {
...baseNodeData.model,
completion_params: { temperature: 0.9 },
},
},
model: {
...baseNodeData.model,
completion_params: { temperature: 0.9 },
},
handleModelChanged,
handleCompletionParamsChange,
}),
)
rendered.rerender(renderPanelElement())
resolveParameters({ params: { temperature: 0.7 }, removedDetails: {} })
await waitFor(() => expect(mockFetchAndMergeValidCompletionParams).toHaveBeenCalledTimes(1))
expect(handleModelChanged).not.toHaveBeenCalled()
expect(handleCompletionParamsChange).not.toHaveBeenCalled()
})
it('commits a direct model and its filtered parameters together', async () => {
const user = userEvent.setup()
let resolveParameters!: (value: {
params: Record<string, number>
removedDetails: Record<string, never>
}) => void
mockFetchAndMergeValidCompletionParams.mockReturnValueOnce(
new Promise((resolve) => {
resolveParameters = resolve
}),
)
const handleModelChanged = vi.fn()
const handleCompletionParamsChange = vi.fn()
mockUseConfig.mockReturnValue(
buildUseConfigResult({ handleModelChanged, handleCompletionParamsChange }),
)
const rendered = renderPanel()
await user.click(screen.getByRole('button', { name: 'Select direct model' }))
expect(handleModelChanged).not.toHaveBeenCalled()
rendered.rerender(renderPanelElement())
resolveParameters({ params: { temperature: 0.7 }, removedDetails: {} })
await waitFor(() => {
expect(handleModelChanged).toHaveBeenCalledWith(
{ provider: 'anthropic', modelId: 'claude-3-5-sonnet' },
{ temperature: 0.7 },
)
})
expect(handleCompletionParamsChange).not.toHaveBeenCalled()
})
it('preserves completion parameters when model rules cannot be fetched', async () => {
const user = userEvent.setup()
const handleModelChanged = vi.fn()
const handleCompletionParamsChange = vi.fn()
mockFetchAndMergeValidCompletionParams.mockRejectedValueOnce(new Error('network error'))
mockUseConfig.mockReturnValue(
buildUseConfigResult({ handleCompletionParamsChange, handleModelChanged }),
)
renderPanel()
await user.click(screen.getByRole('button', { name: 'Select direct model' }))
await waitFor(() => {
expect(mockFetchAndMergeValidCompletionParams).toHaveBeenCalledTimes(1)
})
expect(handleModelChanged).not.toHaveBeenCalled()
expect(handleCompletionParamsChange).not.toHaveBeenCalled()
})
})
@@ -170,6 +170,7 @@ describe('llm/use-config', () => {
} as ReturnType<typeof useModelListAndDefaultModelAndCurrentProviderAndModel>)
mockUseStore.mockImplementation((selector) => {
return selector({
environmentVariables: [],
nodesDefaultConfigs: {
[BlockEnum.LLM]: {
prompt_templates: {
@@ -246,11 +247,14 @@ describe('llm/use-config', () => {
},
})
result.current.handleCompletionParamsChange({ top_p: 0.5 })
result.current.handleModelChanged({
provider: 'openai',
modelId: 'gpt-4.1',
mode: AppModeEnum.CHAT,
})
result.current.handleModelChanged(
{
provider: 'openai',
modelId: 'gpt-4.1',
mode: AppModeEnum.CHAT,
},
{ temperature: 0.3 },
)
})
expect(setInputs).toHaveBeenNthCalledWith(
@@ -280,6 +284,7 @@ describe('llm/use-config', () => {
provider: 'openai',
name: 'gpt-4.1',
mode: AppModeEnum.CHAT,
completion_params: { temperature: 0.3 },
}),
}),
)
@@ -333,4 +338,171 @@ describe('llm/use-config', () => {
expect(handleVisionConfigAfterModelChanged).toHaveBeenCalled()
})
})
it('resolves a referenced environment model with its shared completion params', () => {
inputRef.current = createPayload({
model_selector: ['env', 'for_summarize'],
model: {
provider: 'cached-provider',
name: 'cached-model',
mode: AppModeEnum.CHAT,
completion_params: { temperature: 0.2 },
},
})
mockUseNodeCrud.mockImplementation(() => ({
inputs: inputRef.current,
setInputs: vi.fn(),
}))
mockUseStore.mockImplementation((selector) =>
selector({
environmentVariables: [
{
id: 'env-1',
name: 'for_summarize',
value_type: 'llm',
value: {
provider: 'shared-provider',
name: 'shared-model',
mode: AppModeEnum.CHAT,
completion_params: { temperature: 0.8 },
},
description: '',
},
],
nodesDefaultConfigs: {},
} as never),
)
const { result } = renderHook(() => useConfig('llm-node', inputRef.current))
expect(result.current.model).toEqual({
provider: 'shared-provider',
name: 'shared-model',
mode: AppModeEnum.CHAT,
completion_params: { temperature: 0.8 },
})
expect(result.current.isEnvironmentModelSource).toBe(true)
expect(mockUseConfigVision).toHaveBeenCalledWith(
expect.objectContaining({ provider: 'shared-provider', name: 'shared-model' }),
expect.any(Object),
)
expect(mockUseLLMStructuredOutputConfig).toHaveBeenCalledWith(
expect.objectContaining({
model: expect.objectContaining({ provider: 'shared-provider', name: 'shared-model' }),
}),
)
})
it('copies a newly bound environment identity and applies mode-specific prompt defaults', () => {
mockUseStore.mockImplementation((selector) =>
selector({
environmentVariables: [
{
id: 'env-1',
name: 'for_research',
value_type: 'llm',
value: {
provider: 'shared-provider',
name: 'shared-completion-model',
mode: AppModeEnum.COMPLETION,
completion_params: { top_p: 0.9 },
},
description: '',
},
],
nodesDefaultConfigs: {
[BlockEnum.LLM]: {
prompt_templates: {
chat_model: { prompts: [] },
completion_model: {
prompt: { text: 'default completion prompt' },
conversation_histories_role: {
user_prefix: 'User',
assistant_prefix: 'Assistant',
},
},
},
},
},
} as never),
)
const { result } = renderHook(() => useConfig('llm-node', inputRef.current))
act(() => {
result.current.handleModelSelectorChange(['env', 'for_research'], { max_tokens: 42 })
})
expect(setInputs).toHaveBeenCalledWith(
expect.objectContaining({
model_selector: ['env', 'for_research'],
model: expect.objectContaining({
provider: 'shared-provider',
name: 'shared-completion-model',
mode: AppModeEnum.COMPLETION,
completion_params: { top_p: 0.9 },
}),
}),
)
expect(appendDefaultPromptConfig).toHaveBeenCalled()
expect(appendDefaultPromptConfig.mock.calls[0]![1]).toEqual(
expect.objectContaining({ prompt_templates: expect.any(Object) }),
)
expect(appendDefaultPromptConfig.mock.calls[0]![2]).toBe(false)
})
it('switches back to direct input with the latest resolved environment identity', () => {
inputRef.current = createPayload({
model_selector: ['env', 'for_summarize'],
model: {
provider: 'cached-provider',
name: 'cached-model',
mode: AppModeEnum.CHAT,
completion_params: { temperature: 0.4 },
},
})
mockUseNodeCrud.mockImplementation(() => ({
inputs: inputRef.current,
setInputs: vi.fn(),
}))
mockUseStore.mockImplementation((selector) =>
selector({
environmentVariables: [
{
id: 'env-1',
name: 'for_summarize',
value_type: 'llm',
value: {
provider: 'latest-provider',
name: 'latest-model',
mode: AppModeEnum.CHAT,
completion_params: { temperature: 0.8 },
},
description: '',
},
],
nodesDefaultConfigs: {},
} as never),
)
const { result } = renderHook(() => useConfig('llm-node', inputRef.current))
act(() => {
result.current.handleModelSourceChange(false)
})
expect(setInputs).toHaveBeenCalledWith(
expect.objectContaining({
model: {
provider: 'latest-provider',
name: 'latest-model',
mode: AppModeEnum.CHAT,
completion_params: { temperature: 0.8 },
},
model_selector: undefined,
}),
)
const updatedInputs = setInputs.mock.calls[0]![0]
expect(updatedInputs).toHaveProperty('model_selector', undefined)
})
})
@@ -1,4 +1,13 @@
import { getLLMModelIssue, isLLMModelProviderInstalled, LLMModelIssueCode } from '../utils'
import type { EnvironmentVariable, ModelConfig } from '@/app/components/workflow/types'
import { AppModeEnum } from '@/types/app'
import {
getLLMEnvironmentModel,
getLLMModelIssue,
isEnvironmentModelSource,
isLLMModelProviderInstalled,
LLMModelIssueCode,
resolveLLMNodeModel,
} from '../utils'
describe('llm utils', () => {
describe('getLLMModelIssue', () => {
@@ -44,4 +53,112 @@ describe('llm utils', () => {
).toBe(false)
})
})
describe('environment model resolution', () => {
const model: ModelConfig = {
provider: 'direct-provider',
name: 'direct-model',
mode: AppModeEnum.CHAT,
completion_params: { temperature: 0.3 },
}
const environmentVariables: EnvironmentVariable[] = [
{
id: 'env-1',
name: 'for_summarize',
value_type: 'llm',
value: {
provider: 'shared-provider',
name: 'shared-model',
mode: AppModeEnum.CHAT,
completion_params: { temperature: 0.8 },
},
description: '',
},
]
it('uses shared completion params while resolving the shared model', () => {
expect(resolveLLMNodeModel(model, ['env', 'for_summarize'], environmentVariables)).toEqual({
provider: 'shared-provider',
name: 'shared-model',
mode: AppModeEnum.CHAT,
completion_params: { temperature: 0.8 },
})
})
it('keeps node completion params for a legacy shared model without params', () => {
const legacyEnvironmentVariables = [
{
...environmentVariables[0],
value: {
provider: 'shared-provider',
name: 'shared-model',
mode: AppModeEnum.CHAT,
},
},
] as EnvironmentVariable[]
expect(
resolveLLMNodeModel(model, ['env', 'for_summarize'], legacyEnvironmentVariables),
).toEqual({
provider: 'shared-provider',
name: 'shared-model',
mode: AppModeEnum.CHAT,
completion_params: { temperature: 0.3 },
})
})
it('returns the environment identity for initial binding before the node mode is updated', () => {
const completionModelVariables = [
{
...environmentVariables[0],
value: {
provider: 'shared-provider',
name: 'shared-completion-model',
mode: AppModeEnum.COMPLETION,
completion_params: { temperature: 0.8 },
},
},
] as EnvironmentVariable[]
expect(getLLMEnvironmentModel(['env', 'for_summarize'], completionModelVariables)).toEqual({
provider: 'shared-provider',
name: 'shared-completion-model',
mode: AppModeEnum.COMPLETION,
completion_params: { temperature: 0.8 },
})
})
it('rejects a live reference when its mode no longer matches the stored node mode', () => {
const changedModeVariables = [
{
...environmentVariables[0],
value: {
provider: 'shared-provider',
name: 'shared-model',
mode: AppModeEnum.COMPLETION,
},
},
] as EnvironmentVariable[]
expect(
resolveLLMNodeModel(model, ['env', 'for_summarize'], changedModeVariables),
).toBeUndefined()
})
it('returns undefined for a missing or non-LLM environment variable', () => {
expect(resolveLLMNodeModel(model, ['env', 'missing'], environmentVariables)).toBeUndefined()
expect(
resolveLLMNodeModel(model, ['env', 'for_summarize'], [
{ ...environmentVariables[0], value_type: 'string' },
] as EnvironmentVariable[]),
).toBeUndefined()
})
it('keeps the static model for a legacy non-environment selector', () => {
const selector = ['start', 'MODEL_NAME']
expect(isEnvironmentModelSource(selector)).toBe(false)
expect(resolveLLMNodeModel(model, selector, environmentVariables)).toBe(model)
})
})
})
@@ -1,12 +1,17 @@
import type { TFunction } from 'i18next'
import type { NodeDefault, PromptItem } from '../../types'
import type { EnvironmentVariable, NodeDefault, PromptItem } from '../../types'
import type { LLMNodeType } from './types'
import { genNodeMetaData } from '@/app/components/workflow/utils'
// import { RETRIEVAL_OUTPUT_STRUCT } from '../../constants'
import { AppModeEnum } from '@/types/app'
import { FlowType } from '@/types/common'
import { BlockEnum, EditionType, PromptRole } from '../../types'
import { getLLMModelIssue, LLMModelIssueCode } from './utils'
import {
getLLMModelIssue,
isEnvironmentModelSource,
LLMModelIssueCode,
resolveLLMNodeModel,
} from './utils'
const RETRIEVAL_OUTPUT_STRUCT = `{
"content": "",
@@ -66,11 +71,28 @@ const nodeDefault: NodeDefault<LLMNodeType> = {
checkValid(
payload: LLMNodeType,
t: TFunction<'workflow'>,
moreDataForCheckValid?: { flowType?: FlowType },
moreDataForCheckValid?: {
flowType?: FlowType
environmentVariables?: EnvironmentVariable[]
},
) {
let errorMessages = ''
const isSnippetFlow = moreDataForCheckValid?.flowType === FlowType.snippet
const modelIssue = getLLMModelIssue({ modelProvider: payload.model.provider })
const hasValidModelSelector =
payload.model_selector === undefined ||
!isEnvironmentModelSource(payload.model_selector) ||
(payload.model_selector.length === 2 && payload.model_selector[0] === 'env')
const model =
payload.model_selector !== undefined && moreDataForCheckValid?.environmentVariables
? resolveLLMNodeModel(
payload.model,
payload.model_selector,
moreDataForCheckValid.environmentVariables,
)
: payload.model
const modelIssue = getLLMModelIssue({
modelProvider: hasValidModelSelector ? model?.provider : undefined,
})
if (!errorMessages && modelIssue === LLMModelIssueCode.providerRequired)
errorMessages = t(($) => $[`${i18nPrefix}.fieldRequired`], {
ns: 'workflow',
@@ -78,7 +100,7 @@ const nodeDefault: NodeDefault<LLMNodeType> = {
})
if (!errorMessages && !payload.memory) {
const isChatModel = payload.model.mode === AppModeEnum.CHAT
const isChatModel = model?.mode === AppModeEnum.CHAT
const isPromptEmpty = isChatModel
? !(payload.prompt_template as PromptItem[]).some((t) => {
if (t.edition_type === EditionType.jinja2) return t.jinja2_text !== ''
@@ -96,7 +118,7 @@ const nodeDefault: NodeDefault<LLMNodeType> = {
}
if (!errorMessages && !!payload.memory) {
const isChatModel = payload.model.mode === AppModeEnum.CHAT
const isChatModel = model?.mode === AppModeEnum.CHAT
// payload.memory.query_prompt_template not pass is default: {{#sys.query#}}
if (
!isSnippetFlow &&
@@ -108,7 +130,7 @@ const nodeDefault: NodeDefault<LLMNodeType> = {
}
if (!errorMessages) {
const isChatModel = payload.model.mode === AppModeEnum.CHAT
const isChatModel = model?.mode === AppModeEnum.CHAT
const isShowVars = (() => {
if (isChatModel)
return (payload.prompt_template as PromptItem[]).some(
+158 -23
View File
@@ -1,41 +1,66 @@
import type { FC } from 'react'
import type { LLMNodeType } from './types'
import type { NodePanelProps } from '@/app/components/workflow/types'
import {
Select,
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectTrigger,
} from '@langgenius/dify-ui/select'
import { toast } from '@langgenius/dify-ui/toast'
import * as React from 'react'
import { useCallback } from 'react'
import { useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
import { useHooksStore } from '@/app/components/workflow/hooks-store/store'
import Field from '@/app/components/workflow/nodes/_base/components/field'
import FormInputTypeSwitch from '@/app/components/workflow/nodes/_base/components/form-input-type-switch'
import Split from '@/app/components/workflow/nodes/_base/components/split'
import VarList from '@/app/components/workflow/nodes/_base/components/variable/var-list'
import { useProviderContextSelector } from '@/context/provider-context'
import { FlowType } from '@/types/common'
import { fetchAndMergeValidCompletionParams } from '@/utils/completion-params'
import { extractPluginId } from '../../utils/plugin'
import ConfigVision from '../_base/components/config-vision'
import VarReferencePicker from '../_base/components/variable/var-reference-picker'
import { VarType } from '../tool/types'
import ConfigPrompt from './components/config-prompt'
import PanelMemorySection from './components/panel-memory-section'
import PanelOutputSection from './components/panel-output-section'
import ReasoningFormatConfig from './components/reasoning-format-config'
import useConfig from './use-config'
import { getLLMModelIssue, LLMModelIssueCode } from './utils'
import { getLLMEnvironmentModel, getLLMModelIssue, LLMModelIssueCode } from './utils'
const i18nPrefix = 'nodes.llm'
const getModelSelectionKey = (
source: 'direct' | 'env',
provider: string,
modelName: string,
completionParams: LLMNodeType['model']['completion_params'],
environmentVariableName = '',
) =>
`${source}:${environmentVariableName}:${provider}:${modelName}:${JSON.stringify(completionParams)}`
const Panel: FC<NodePanelProps<LLMNodeType>> = ({ id, data }) => {
const { t } = useTranslation()
const flowType = useHooksStore((s) => s.configsMap?.flowType)
const {
readOnly,
inputs,
model,
environmentVariables,
isEnvironmentModelSource,
isChatModel,
isChatMode,
isCompletionModel,
shouldShowContextTip,
isVisionModel,
handleModelChanged,
handleModelSourceChange,
handleModelSelectorChange,
hasSetBlockStatus,
handleCompletionParamsChange,
handleContextVarChange,
@@ -62,7 +87,6 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({ id, data }) => {
handleReasoningFormatChange,
} = useConfig(id, data)
const model = inputs.model
const isModelProviderInstalled = useProviderContextSelector((state) => {
const modelIssue = getLLMModelIssue({ modelProvider: model?.provider })
if (modelIssue === LLMModelIssueCode.providerRequired) return true
@@ -77,9 +101,22 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({ id, data }) => {
modelProvider: model?.provider,
isModelProviderInstalled,
}) !== null
const selectedEnvironmentVariableName = inputs.model_selector?.[1]
const modelSelectionKey = getModelSelectionKey(
isEnvironmentModelSource ? 'env' : 'direct',
model.provider,
model.name,
model.completion_params,
selectedEnvironmentVariableName,
)
const modelSelectionKeyRef = useRef(modelSelectionKey)
const modelSelectionRequestGenerationRef = useRef(0)
modelSelectionKeyRef.current = modelSelectionKey
const handleModelChange = useCallback(
(model: { provider: string; modelId: string; mode?: string }) => {
const baselineSelectionKey = modelSelectionKeyRef.current
const requestGeneration = ++modelSelectionRequestGenerationRef.current
;(async () => {
try {
const { params: filtered, removedDetails } = await fetchAndMergeValidCompletionParams(
@@ -88,21 +125,79 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({ id, data }) => {
inputs.model.completion_params,
true,
)
if (
modelSelectionRequestGenerationRef.current !== requestGeneration ||
modelSelectionKeyRef.current !== baselineSelectionKey
)
return
const keys = Object.keys(removedDetails)
if (keys.length)
toast.warning(
`${t(($) => $['modelProvider.parametersInvalidRemoved'], { ns: 'common' })}: ${keys.map((k) => `${k} (${removedDetails[k]})`).join(', ')}`,
)
handleCompletionParamsChange(filtered)
handleModelChanged(model, filtered)
} catch {
if (
modelSelectionRequestGenerationRef.current !== requestGeneration ||
modelSelectionKeyRef.current !== baselineSelectionKey
)
return
toast.error(t(($) => $.error, { ns: 'common' }))
handleCompletionParamsChange({})
} finally {
handleModelChanged(model)
}
})()
},
[handleCompletionParamsChange, handleModelChanged, inputs.model.completion_params, t],
[handleModelChanged, inputs.model.completion_params, t],
)
const llmEnvironmentVariables = environmentVariables.filter(
(variable) => variable.value_type === 'llm',
)
const handleEnvironmentModelChange = useCallback(
(environmentVariableName: string) => {
const baselineSelectionKey = modelSelectionKeyRef.current
const requestGeneration = ++modelSelectionRequestGenerationRef.current
const modelSelector = ['env', environmentVariableName]
const selectedModel = getLLMEnvironmentModel(modelSelector, environmentVariables)
if (!selectedModel) {
handleModelSelectorChange(modelSelector)
return
}
if (selectedModel.completion_params !== undefined) {
handleModelSelectorChange(modelSelector, selectedModel.completion_params)
return
}
;(async () => {
try {
const { params: filtered, removedDetails } = await fetchAndMergeValidCompletionParams(
selectedModel.provider,
selectedModel.name,
inputs.model.completion_params,
true,
)
if (
modelSelectionRequestGenerationRef.current !== requestGeneration ||
modelSelectionKeyRef.current !== baselineSelectionKey
)
return
const keys = Object.keys(removedDetails)
if (keys.length)
toast.warning(
`${t(($) => $['modelProvider.parametersInvalidRemoved'], { ns: 'common' })}: ${keys.map((key) => `${key} (${removedDetails[key]})`).join(', ')}`,
)
handleModelSelectorChange(modelSelector, filtered)
} catch {
if (
modelSelectionRequestGenerationRef.current !== requestGeneration ||
modelSelectionKeyRef.current !== baselineSelectionKey
)
return
toast.error(t(($) => $.error, { ns: 'common' }))
}
})()
},
[environmentVariables, handleModelSelectorChange, inputs.model, t],
)
return (
@@ -112,22 +207,62 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({ id, data }) => {
title={t(($) => $[`${i18nPrefix}.model`], { ns: 'workflow' })}
required
warningDot={hasModelWarning}
operations={
flowType === FlowType.snippet && !isEnvironmentModelSource ? undefined : (
<FormInputTypeSwitch
value={isEnvironmentModelSource ? VarType.variable : VarType.constant}
readonly={readOnly}
onChange={(value) => {
const useEnvironmentVariable = value === VarType.variable
if (useEnvironmentVariable === isEnvironmentModelSource) return
modelSelectionRequestGenerationRef.current++
handleModelSourceChange(useEnvironmentVariable)
}}
/>
)
}
>
<ModelParameterModal
popupClassName="w-[387px]!"
isInWorkflow
isAdvancedMode={true}
provider={model?.provider}
completionParams={model?.completion_params}
modelId={model?.name}
setModel={handleModelChange}
onCompletionParamsChange={handleCompletionParamsChange}
hideDebugWithMultipleModel
debugWithMultipleModel={false}
readonly={readOnly}
nodesOutputVars={availableVars}
availableNodes={availableNodesWithParent}
/>
<div className="space-y-2">
{isEnvironmentModelSource && (
<Select
value={selectedEnvironmentVariableName ?? null}
disabled={readOnly}
onValueChange={(nextValue) => nextValue && handleEnvironmentModelChange(nextValue)}
>
<SelectTrigger
aria-label={t(($) => $[`${i18nPrefix}.model`], { ns: 'workflow' })}
className="w-full"
>
{selectedEnvironmentVariableName ??
t(($) => $['nodes.common.typeSwitch.variable'], { ns: 'workflow' })}
</SelectTrigger>
<SelectContent>
{llmEnvironmentVariables.map((variable) => (
<SelectItem key={variable.id} value={variable.name}>
<SelectItemText>{variable.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)}
<ModelParameterModal
popupClassName="w-[387px]!"
isInWorkflow
isAdvancedMode={true}
provider={model?.provider}
completionParams={model?.completion_params}
modelId={model?.name}
setModel={handleModelChange}
onCompletionParamsChange={handleCompletionParamsChange}
hideDebugWithMultipleModel
debugWithMultipleModel={false}
readonly={readOnly || isEnvironmentModelSource}
modelSelectorReadonly={isEnvironmentModelSource}
nodesOutputVars={availableVars}
availableNodes={availableNodesWithParent}
/>
</div>
</Field>
{/* knowledge */}
@@ -10,6 +10,7 @@ import type {
export type LLMNodeType = CommonNodeType & {
model: ModelConfig
model_selector?: ValueSelector
prompt_template: PromptItem[] | PromptItem
prompt_config?: {
jinja2_variables?: Variable[]
@@ -1,5 +1,6 @@
import type { LLMDefaultConfig } from './hooks/use-llm-input-manager'
import type { LLMNodeType } from './types'
import type { EnvironmentVariable, ValueSelector } from '@/app/components/workflow/types'
import { produce } from 'immer'
import { useCallback, useEffect, useState } from 'react'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
@@ -14,6 +15,13 @@ import useAvailableVarList from '../_base/hooks/use-available-var-list'
import useLLMInputManager from './hooks/use-llm-input-manager'
import useLLMPromptConfig from './hooks/use-llm-prompt-config'
import useLLMStructuredOutputConfig from './hooks/use-llm-structured-output-config'
import {
isEnvironmentModelSource as getIsEnvironmentModelSource,
getLLMEnvironmentModel,
resolveLLMNodeModel,
} from './utils'
const EMPTY_ENVIRONMENT_VARIABLES: EnvironmentVariable[] = []
const useConfig = (id: string, payload: LLMNodeType) => {
const { nodesReadOnly: readOnly } = useNodesReadOnly()
@@ -22,9 +30,21 @@ const useConfig = (id: string, payload: LLMNodeType) => {
const defaultConfig = useStore((s) => s.nodesDefaultConfigs)?.[payload.type] as
| LLMDefaultConfig
| undefined
const environmentVariables =
useStore((s) => s.environmentVariables) ?? EMPTY_ENVIRONMENT_VARIABLES
const { inputs, setInputs: doSetInputs } = useNodeCrud<LLMNodeType>(id, payload)
const model = inputs.model
const modelMode = inputs.model?.mode
const isEnvironmentModelSource = getIsEnvironmentModelSource(inputs.model_selector)
const resolvedModel = resolveLLMNodeModel(
inputs.model,
inputs.model_selector,
environmentVariables,
)
const model = resolvedModel ?? {
...inputs.model,
provider: '',
name: '',
}
const modelMode = model.mode
const isChatModel = modelMode === AppModeEnum.CHAT
const isCompletionModel = !isChatModel
@@ -57,12 +77,18 @@ const useConfig = (id: string, payload: LLMNodeType) => {
},
})
const handleModelChanged = useCallback(
(model: { provider: string; modelId: string; mode?: string }) => {
const applyModelIdentity = useCallback(
(
model: { provider: string; modelId: string; mode?: string },
modelSelector?: ValueSelector,
completionParams?: LLMNodeType['model']['completion_params'],
) => {
const nextInputs = produce(inputRef.current, (draft) => {
draft.model.provider = model.provider
draft.model.name = model.modelId
draft.model.mode = model.mode!
if (completionParams !== undefined) draft.model.completion_params = completionParams
draft.model_selector = modelSelector
const isModeChange = model.mode !== inputRef.current.model.mode
if (isModeChange && defaultConfig && Object.keys(defaultConfig).length > 0)
appendDefaultPromptConfig(draft, defaultConfig, model.mode === AppModeEnum.CHAT)
@@ -70,27 +96,93 @@ const useConfig = (id: string, payload: LLMNodeType) => {
setInputs(nextInputs)
setModelChanged(true)
},
[setInputs, defaultConfig, appendDefaultPromptConfig],
[setInputs, defaultConfig, appendDefaultPromptConfig, inputRef],
)
const handleModelChanged = useCallback(
(
model: { provider: string; modelId: string; mode?: string },
completionParams?: LLMNodeType['model']['completion_params'],
) => {
applyModelIdentity(model, undefined, completionParams)
},
[applyModelIdentity],
)
const handleModelSourceChange = useCallback(
(useEnvironmentVariable: boolean) => {
const nextInputs = produce(inputRef.current, (draft) => {
if (useEnvironmentVariable) draft.model_selector = []
else {
const currentModel = resolveLLMNodeModel(
inputRef.current.model,
inputRef.current.model_selector,
environmentVariables,
)
if (currentModel) {
draft.model.provider = currentModel.provider
draft.model.name = currentModel.name
draft.model.mode = currentModel.mode
draft.model.completion_params = currentModel.completion_params
}
draft.model_selector = undefined
}
})
setInputs(nextInputs)
},
[environmentVariables, inputRef, setInputs],
)
const handleModelSelectorChange = useCallback(
(
modelSelector: ValueSelector,
completionParams?: LLMNodeType['model']['completion_params'],
) => {
const selectedModel = getLLMEnvironmentModel(modelSelector, environmentVariables)
if (!selectedModel) {
const nextInputs = produce(inputRef.current, (draft) => {
draft.model_selector = modelSelector
})
setInputs(nextInputs)
return
}
applyModelIdentity(
{
provider: selectedModel.provider,
modelId: selectedModel.name,
mode: selectedModel.mode,
},
modelSelector,
selectedModel.completion_params ?? completionParams,
)
},
[applyModelIdentity, environmentVariables, inputRef, setInputs],
)
useEffect(() => {
if (currentProvider?.provider && currentModel?.model && !model.provider) {
if (
!isEnvironmentModelSource &&
currentProvider?.provider &&
currentModel?.model &&
!model.provider
) {
handleModelChanged({
provider: currentProvider?.provider,
modelId: currentModel?.model,
mode: currentModel?.model_properties?.mode as string,
})
}
}, [model.provider, currentProvider, currentModel, handleModelChanged])
}, [model.provider, currentProvider, currentModel, handleModelChanged, isEnvironmentModelSource])
const handleCompletionParamsChange = useCallback(
(newParams: Record<string, any>) => {
(newParams: LLMNodeType['model']['completion_params']) => {
const newInputs = produce(inputRef.current, (draft) => {
draft.model.completion_params = newParams
})
setInputs(newInputs)
},
[setInputs],
[inputRef, setInputs],
)
// change to vision model to set vision enabled, else disabled
@@ -98,7 +190,7 @@ const useConfig = (id: string, payload: LLMNodeType) => {
if (!modelChanged) return
setModelChanged(false)
handleVisionConfigAfterModelChanged()
}, [isVisionModel, modelChanged])
}, [handleVisionConfigAfterModelChanged, isVisionModel, modelChanged])
const promptConfig = useLLMPromptConfig({
inputs,
inputRef,
@@ -124,12 +216,17 @@ const useConfig = (id: string, payload: LLMNodeType) => {
readOnly,
isChatMode,
inputs,
model,
environmentVariables,
isEnvironmentModelSource,
isChatModel,
isCompletionModel,
hasSetBlockStatus: promptConfig.hasSetBlockStatus,
shouldShowContextTip: promptConfig.shouldShowContextTip,
isVisionModel,
handleModelChanged,
handleModelSourceChange,
handleModelSelectorChange,
handleCompletionParamsChange,
isShowVars: promptConfig.isShowVars,
handleVarListChange: promptConfig.handleVarListChange,
@@ -1,5 +1,11 @@
import type { ValidationError } from 'jsonschema'
import type { ArrayItems, Field } from './types'
import type {
EnvironmentVariable,
LLMEnvironmentVariableValue,
ModelConfig,
ValueSelector,
} from '@/app/components/workflow/types'
import * as z from 'zod'
import { draft07Validator, forbidBooleanProperties } from '@/utils/validators'
import { extractPluginId } from '../../utils/plugin'
@@ -10,6 +16,49 @@ export enum LLMModelIssueCode {
providerPluginUnavailable = 'provider-plugin-unavailable',
}
const isLLMEnvironmentVariableValue = (value: unknown): value is LLMEnvironmentVariableValue => {
if (!value || typeof value !== 'object') return false
const candidate = value as Partial<LLMEnvironmentVariableValue>
return !!candidate.provider && !!candidate.name && !!candidate.mode
}
export const isEnvironmentModelSource = (modelSelector: ValueSelector | undefined) =>
modelSelector !== undefined && (modelSelector.length === 0 || modelSelector[0] === 'env')
export const getLLMEnvironmentModel = (
modelSelector: ValueSelector | undefined,
environmentVariables: readonly EnvironmentVariable[],
): LLMEnvironmentVariableValue | undefined => {
if (!modelSelector || modelSelector.length !== 2 || modelSelector[0] !== 'env') return undefined
const environmentVariable = environmentVariables.find(
(variable) => variable.name === modelSelector[1] && variable.value_type === 'llm',
)
if (!environmentVariable || !isLLMEnvironmentVariableValue(environmentVariable.value))
return undefined
return environmentVariable.value
}
export const resolveLLMNodeModel = (
model: ModelConfig,
modelSelector: ValueSelector | undefined,
environmentVariables: readonly EnvironmentVariable[],
): ModelConfig | undefined => {
if (!isEnvironmentModelSource(modelSelector)) return model
const environmentModel = getLLMEnvironmentModel(modelSelector, environmentVariables)
if (!environmentModel || environmentModel.mode !== model.mode) return undefined
return {
...model,
provider: environmentModel.provider,
name: environmentModel.name,
completion_params: environmentModel.completion_params ?? model.completion_params,
}
}
export const getLLMModelIssue = ({
modelProvider,
isModelProviderInstalled = true,
@@ -100,4 +100,20 @@ describe('EnvItem', () => {
fireEvent.mouseOut(deleteWrapper)
expect(container.firstElementChild).not.toHaveClass('border-state-destructive-border')
})
it('renders an LLM environment variable without exposing its object value', () => {
const env = createEnv({
id: 'env-llm',
name: 'for_summarize',
value: { provider: 'openai', name: 'gpt-4o', mode: 'chat' },
value_type: 'llm',
description: '',
})
renderWithProviders(<EnvItem env={env} onEdit={vi.fn()} onDelete={vi.fn()} />)
expect(screen.getByText('for_summarize')).toBeInTheDocument()
expect(screen.getByText('workflow.blocks.llm')).toBeInTheDocument()
expect(screen.getByText('gpt-4o')).toBeInTheDocument()
})
})
File diff suppressed because it is too large Load Diff
@@ -1,13 +1,83 @@
import type { ReactElement } from 'react'
import type {
Model,
ModelItem,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { Shape } from '@/app/components/workflow/store/workflow'
import type { EnvironmentVariable } from '@/app/components/workflow/types'
import { toast } from '@langgenius/dify-ui/toast'
import { fireEvent, render, screen } from '@testing-library/react'
import { act, fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {
ConfigurationMethodEnum,
ModelStatusEnum,
ModelTypeEnum,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import { WorkflowContext } from '@/app/components/workflow/context'
import { createWorkflowStore } from '@/app/components/workflow/store/workflow'
import VariableModal from '../variable-modal'
type MockModelParameterModalProps = {
provider: string
modelId: string
completionParams: Record<string, unknown>
modelList?: Model[]
setModel: (model: { provider: string; modelId: string; mode?: string }) => void
onCompletionParamsChange: (params: Record<string, unknown>) => void
}
let mockTextGenerationModelList: Model[] = []
let latestModelParameterModalProps: MockModelParameterModalProps | undefined
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useTextGenerationCurrentProviderAndModelAndModelList: () => ({
currentProvider: undefined,
currentModel: undefined,
textGenerationModelList: mockTextGenerationModelList,
activeTextGenerationModelList: mockTextGenerationModelList,
}),
}))
vi.mock(
'@/app/components/header/account-setting/model-provider-page/model-parameter-modal',
() => ({
default: (props: MockModelParameterModalProps) => {
latestModelParameterModalProps = props
return (
<div>
<div>{props.modelId ? `selected:${props.modelId}` : 'selected:none'}</div>
{(props.modelList ?? mockTextGenerationModelList).flatMap((provider) =>
provider.models.map((model) => (
<button
key={`${provider.provider}:${model.model}`}
type="button"
onClick={() =>
props.setModel({
provider: provider.provider,
modelId: model.model,
mode: model.model_properties.mode as string,
})
}
>
{model.model}
</button>
)),
)}
<button
type="button"
onClick={() =>
props.onCompletionParamsChange({ ...props.completionParams, temperature: 0.4 })
}
>
Set temperature
</button>
</div>
)
},
}),
)
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
success: vi.fn(),
@@ -19,6 +89,28 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
const mockToastError = vi.mocked(toast.error)
const createModelItem = (model: string, mode: string): ModelItem => ({
model,
label: { en_US: model, zh_Hans: model },
model_type: ModelTypeEnum.textGeneration,
features: [],
fetch_from: ConfigurationMethodEnum.predefinedModel,
status: ModelStatusEnum.active,
model_properties: { mode },
load_balancing_enabled: false,
})
const createModelProvider = (): Model => ({
provider: 'openai',
icon_small: { en_US: '', zh_Hans: '' },
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
models: [
createModelItem('chat-model', 'chat'),
createModelItem('completion-model', 'completion'),
],
status: ModelStatusEnum.active,
})
const createEnv = (overrides: Partial<EnvironmentVariable> = {}): EnvironmentVariable => ({
id: 'env-1',
name: 'api_key',
@@ -49,6 +141,8 @@ const renderWithProviders = (
describe('VariableModal', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTextGenerationModelList = [createModelProvider()]
latestModelParameterModalProps = undefined
})
it('creates a secret environment variable and normalizes spaces in its name', async () => {
@@ -153,4 +247,75 @@ describe('VariableModal', () => {
description: 'editable',
})
})
it('creates an LLM environment variable from the selected text-generation model', async () => {
const user = userEvent.setup()
const onSave = vi.fn()
renderWithProviders(<VariableModal onClose={vi.fn()} onSave={onSave} />, {
storeState: {
environmentVariables: [],
},
})
await user.click(screen.getByRole('button', { name: 'workflow.blocks.llm' }))
await user.type(
screen.getByPlaceholderText('workflow.env.modal.namePlaceholder'),
'for_summarize',
)
await user.click(screen.getByRole('button', { name: 'chat-model' }))
await user.click(screen.getByRole('button', { name: 'Set temperature' }))
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onSave).toHaveBeenCalledWith({
id: expect.any(String),
name: 'for_summarize',
value: {
provider: 'openai',
name: 'chat-model',
mode: 'chat',
completion_params: { temperature: 0.4 },
},
value_type: 'llm',
description: '',
})
})
it('keeps an edited LLM environment variable within its existing model mode', async () => {
const user = userEvent.setup()
const onSave = vi.fn()
const env = createEnv({
id: 'env-llm',
name: 'for_research',
value: { provider: 'openai', name: 'chat-model', mode: 'chat' },
value_type: 'llm',
description: 'research model',
})
renderWithProviders(<VariableModal env={env} onClose={vi.fn()} onSave={onSave} />, {
storeState: {
environmentVariables: [env],
},
})
expect(screen.getByText('selected:chat-model')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'chat-model' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'completion-model' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'String' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Number' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Secret' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'workflow.blocks.llm' })).toBeEnabled()
act(() => {
latestModelParameterModalProps?.setModel({
provider: 'openai',
modelId: 'completion-model',
mode: 'completion',
})
})
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(mockToastError).toHaveBeenCalledWith('common.modelProvider.selector.incompatibleTip')
expect(onSave).toHaveBeenCalledWith(env)
})
})
@@ -3,6 +3,7 @@ import { cn } from '@langgenius/dify-ui/cn'
import { RiDeleteBinLine, RiEditLine, RiLock2Line } from '@remixicon/react'
import { capitalize } from 'es-toolkit/string'
import { memo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Env } from '@/app/components/base/icons/src/vender/line/others'
import { useStore } from '@/app/components/workflow/store'
@@ -13,8 +14,19 @@ type EnvItemProps = {
}
const EnvItem = ({ env, onEdit, onDelete }: EnvItemProps) => {
const { t } = useTranslation()
const envSecrets = useStore((s) => s.envSecrets)
const [destructive, setDestructive] = useState(false)
const typeLabel =
env.value_type === 'llm'
? t(($) => $['blocks.llm'], { ns: 'workflow' })
: capitalize(env.value_type)
const displayValue =
env.value_type === 'secret'
? envSecrets[env.id]
: typeof env.value === 'object'
? env.value.name
: env.value
return (
<div
@@ -28,7 +40,7 @@ const EnvItem = ({ env, onEdit, onDelete }: EnvItemProps) => {
<div className="flex grow items-center gap-1">
<Env className="size-4 text-util-colors-violet-violet-600" />
<div className="system-sm-medium text-text-primary">{env.name}</div>
<div className="system-xs-medium text-text-tertiary">{capitalize(env.value_type)}</div>
<div className="system-xs-medium text-text-tertiary">{typeLabel}</div>
{env.value_type === 'secret' && <RiLock2Line className="size-3 text-text-tertiary" />}
</div>
<div className="flex shrink-0 items-center gap-1 text-text-tertiary">
@@ -44,9 +56,7 @@ const EnvItem = ({ env, onEdit, onDelete }: EnvItemProps) => {
</div>
</div>
</div>
<div className="truncate system-xs-regular text-text-tertiary">
{env.value_type === 'secret' ? envSecrets[env.id] : env.value}
</div>
<div className="truncate system-xs-regular text-text-tertiary">{displayValue}</div>
</div>
{env.description && (
<>
@@ -1,8 +1,19 @@
import type { EnvironmentVariable } from '@/app/components/workflow/types'
import type { Model } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types'
import type {
EnvironmentVariable,
LLMEnvironmentVariableValue,
} from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
import { RiCloseLine } from '@remixicon/react'
import { memo, useCallback, useState } from 'react'
import { cloneDeep } from 'es-toolkit/object'
import { isEqual } from 'es-toolkit/predicate'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useTextGenerationCurrentProviderAndModelAndModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
import RemoveEffectVarConfirm from '@/app/components/workflow/nodes/_base/components/remove-effect-var-confirm'
import {
findUsedVarNodes,
@@ -10,12 +21,22 @@ import {
} from '@/app/components/workflow/nodes/_base/components/variable/utils'
import EnvItem from '@/app/components/workflow/panel/env-panel/env-item'
import VariableTrigger from '@/app/components/workflow/panel/env-panel/variable-trigger'
import { useStore } from '@/app/components/workflow/store'
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
import { BlockEnum } from '@/app/components/workflow/types'
import { Resolution } from '@/types/app'
import {
fetchModelParameterRulesForModel,
mergeValidCompletionParams,
} from '@/utils/completion-params'
import { useCollaborativeWorkflow } from '../../hooks/use-collaborative-workflow'
import { useNodesSyncDraft } from '../../hooks/use-nodes-sync-draft'
const HIDDEN_SECRET_VALUE = '[__HIDDEN__]'
type DoSyncWorkflowDraft = ReturnType<typeof useNodesSyncDraft>['doSyncWorkflowDraft']
type EnvironmentVariablePatch = {
environmentVariables: EnvironmentVariable[]
deletedEnvironmentVariableIds: string[]
}
const formatSecret = (secret: string) => {
return secret.length > 8
@@ -33,47 +54,148 @@ const removeSecretFromMap = (secretMap: Record<string, string>, envId: string) =
return nextSecretMap
}
const isRecord = (value: unknown): value is Record<string, unknown> => {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
const restoreChangedValue = (current: unknown, previous: unknown, changed: unknown): unknown => {
if (isEqual(previous, changed)) return current
if (isEqual(current, changed)) return cloneDeep(previous)
if (!isRecord(current) || !isRecord(previous) || !isRecord(changed)) return current
const restored = { ...current }
const keys = new Set([...Object.keys(previous), ...Object.keys(changed)])
keys.forEach((key) => {
const hadPrevious = Object.hasOwn(previous, key)
const hadChanged = Object.hasOwn(changed, key)
const hasCurrent = Object.hasOwn(current, key)
if (!hadPrevious && hadChanged) {
if (hasCurrent && isEqual(current[key], changed[key])) delete restored[key]
return
}
if (hadPrevious && !hadChanged) {
if (!hasCurrent) restored[key] = cloneDeep(previous[key])
return
}
restored[key] = restoreChangedValue(current[key], previous[key], changed[key])
})
return restored
}
const mergeUntouchedEnvironmentVariables = (
committedEnvList: EnvironmentVariable[],
latestEnvList: EnvironmentVariable[],
pendingEnvIds: Set<string>,
) => {
const committedById = new Map(committedEnvList.map((env) => [env.id, env]))
const latestIds = new Set(latestEnvList.map((env) => env.id))
const mergedEnvList = latestEnvList.flatMap((env) => {
if (!pendingEnvIds.has(env.id)) return [env]
const committedEnv = committedById.get(env.id)
return committedEnv ? [committedEnv] : []
})
committedEnvList.forEach((env) => {
if (pendingEnvIds.has(env.id) && !latestIds.has(env.id)) mergedEnvList.push(env)
})
return mergedEnvList
}
const environmentVariablePatchMatches = (
environmentVariables: EnvironmentVariable[],
patch: EnvironmentVariablePatch,
) => {
const variablesById = new Map(
environmentVariables.map((variable) => [variable.id, sanitizeSecretValue(variable)]),
)
const upsertsMatch = patch.environmentVariables.every((variable) =>
isEqual(variablesById.get(variable.id), sanitizeSecretValue(variable)),
)
const deletionsMatch = patch.deletedEnvironmentVariableIds.every(
(variableId) => !variablesById.has(variableId),
)
return upsertsMatch && deletionsMatch
}
const environmentVariableGraphPatchMatches = (
environmentVariables: EnvironmentVariable[],
patch: EnvironmentVariablePatch,
) => {
const variablesById = new Map(environmentVariables.map((variable) => [variable.id, variable]))
const upsertsMatch = patch.environmentVariables.every((variable) => {
const persistedVariable = variablesById.get(variable.id)
if (
!persistedVariable ||
persistedVariable.name !== variable.name ||
persistedVariable.value_type !== variable.value_type
)
return false
if (variable.value_type !== 'llm') return true
return isEqual(persistedVariable.value, variable.value)
})
const deletionsMatch = patch.deletedEnvironmentVariableIds.every(
(variableId) => !variablesById.has(variableId),
)
return upsertsMatch && deletionsMatch
}
const getLLMEnvironmentValue = (
variable: EnvironmentVariable,
): LLMEnvironmentVariableValue | undefined => {
if (variable.value_type !== 'llm' || !variable.value || typeof variable.value !== 'object')
return undefined
const value = variable.value as Partial<LLMEnvironmentVariableValue>
if (!value.provider || !value.name || !value.mode) return undefined
return value as LLMEnvironmentVariableValue
}
const useEnvPanelActions = ({
collaborativeWorkflow,
workflowStore,
appId,
envSecrets,
updateEnvList,
setEnvSecrets,
setControlPromptEditorRerenderKey,
doSyncWorkflowDraft,
activeTextGenerationModelList,
}: {
collaborativeWorkflow: ReturnType<typeof useCollaborativeWorkflow>
workflowStore: ReturnType<typeof useWorkflowStore>
appId: string
envSecrets: Record<string, string>
updateEnvList: (envList: EnvironmentVariable[]) => void
setEnvSecrets: (envSecrets: Record<string, string>) => void
setControlPromptEditorRerenderKey: (controlPromptEditorRerenderKey: number) => void
doSyncWorkflowDraft: DoSyncWorkflowDraft
activeTextGenerationModelList: Model[]
}) => {
const emitVarsAndFeaturesUpdate = useCallback(async () => {
try {
const { webSocketClient } =
await import('@/app/components/workflow/collaboration/core/websocket-manager')
const socket = webSocketClient.getSocket(appId)
if (socket) {
socket.emit('collaboration_event', {
type: 'vars_and_features_update',
})
const emitVarsAndFeaturesUpdate = useCallback(
async (syncWorkflowDraft = false) => {
try {
const { webSocketClient } =
await import('@/app/components/workflow/collaboration/core/websocket-manager')
const socket = webSocketClient.getSocket(appId)
if (socket) {
socket.emit('collaboration_event', {
type: 'vars_and_features_update',
...(syncWorkflowDraft ? { data: { syncWorkflowDraft: true } } : {}),
})
}
} catch (error) {
console.error('Failed to emit vars_and_features_update event:', error)
}
} catch (error) {
console.error('Failed to emit vars_and_features_update event:', error)
}
}, [appId])
},
[appId],
)
const persistEnvironmentVariables = useCallback(
async (nextEnvList: EnvironmentVariable[]) => {
async (patch: EnvironmentVariablePatch, syncWorkflowDraft = false) => {
try {
const { updateEnvironmentVariables } = await import('@/service/workflow')
await updateEnvironmentVariables({
appId,
environmentVariables: nextEnvList,
...patch,
})
await emitVarsAndFeaturesUpdate()
await emitVarsAndFeaturesUpdate(syncWorkflowDraft)
return true
} catch (error) {
console.error('Failed to update environment variables:', error)
@@ -83,6 +205,17 @@ const useEnvPanelActions = ({
[appId, emitVarsAndFeaturesUpdate],
)
const fetchPersistedEnvironmentVariables = useCallback(async () => {
try {
const { fetchWorkflowDraft } = await import('@/service/workflow')
const workflow = await fetchWorkflowDraft(`/apps/${appId}/workflows/draft`)
return workflow.environment_variables
} catch (error) {
console.error('Failed to refresh environment variables:', error)
return undefined
}
}, [appId])
const getAffectedNodes = useCallback(
(env: EnvironmentVariable) => {
const { nodes: allNodes } = collaborativeWorkflow.getState()
@@ -95,6 +228,8 @@ const useEnvPanelActions = ({
(currentEnv: EnvironmentVariable, nextSelector: string[]) => {
const { nodes, setNodes } = collaborativeWorkflow.getState()
const affectedNodes = getAffectedNodes(currentEnv)
if (affectedNodes.length === 0) return undefined
const nextNodes = nodes.map((node) => {
if (affectedNodes.find((affectedNode) => affectedNode.id === node.id))
return updateNodeVars(node, ['env', currentEnv.name], nextSelector)
@@ -103,54 +238,173 @@ const useEnvPanelActions = ({
})
setNodes(nextNodes)
setControlPromptEditorRerenderKey(Date.now())
const previousNodesById = new Map(nodes.map((node) => [node.id, node]))
const changedNodesById = new Map(nextNodes.map((node) => [node.id, node]))
return () => {
const { nodes: latestNodes, setNodes: setLatestNodes } = collaborativeWorkflow.getState()
let hasRestoredNode = false
const restoredNodes = latestNodes.map((node) => {
const previousNode = previousNodesById.get(node.id)
const changedNode = changedNodesById.get(node.id)
if (!previousNode || !changedNode || isEqual(previousNode.data, changedNode.data))
return node
const restoredData = restoreChangedValue(
node.data,
previousNode.data,
changedNode.data,
) as typeof node.data
if (isEqual(restoredData, node.data)) return node
hasRestoredNode = true
return { ...node, data: restoredData }
})
if (hasRestoredNode) setLatestNodes(restoredNodes)
}
},
[collaborativeWorkflow, getAffectedNodes, setControlPromptEditorRerenderKey],
)
const prepareAffectedLLMNodeReconciliation = useCallback(
async (currentEnv: EnvironmentVariable, nextEnv: EnvironmentVariable) => {
const currentModel = getLLMEnvironmentValue(currentEnv)
const nextModel = getLLMEnvironmentValue(nextEnv)
if (!currentModel || !nextModel) return undefined
if (
currentModel.provider === nextModel.provider &&
currentModel.name === nextModel.name &&
currentModel.mode === nextModel.mode
)
return undefined
const affectedNodes = getAffectedNodes(currentEnv).filter(
(node) =>
node.data.type === BlockEnum.LLM &&
(node.data as LLMNodeType).model_selector?.join('.') === `env.${currentEnv.name}`,
)
if (affectedNodes.length === 0) return undefined
const targetModel = activeTextGenerationModelList
.find((provider) => provider.provider === nextModel.provider)
?.models.find((model) => model.model === nextModel.name)
const supportsVision = !!targetModel?.features?.includes(ModelFeatureEnum.vision)
const parameterRules = await fetchModelParameterRulesForModel(
nextModel.provider,
nextModel.name,
)
return () => {
const { nodes, setNodes } = collaborativeWorkflow.getState()
let hasReconciledNode = false
const nextNodes = nodes.map((node) => {
if (
node.data.type !== BlockEnum.LLM ||
(node.data as LLMNodeType).model_selector?.join('.') !== `env.${currentEnv.name}`
)
return node
hasReconciledNode = true
const data = node.data as LLMNodeType
const completionParams = mergeValidCompletionParams(
data.model.completion_params,
parameterRules,
true,
).params
const vision = !supportsVision
? { enabled: false }
: data.vision?.enabled
? {
enabled: true,
configs: {
detail: Resolution.high,
variable_selector: [],
},
}
: data.vision
return {
...node,
data: {
...data,
model: {
...data.model,
provider: nextModel.provider,
name: nextModel.name,
completion_params: completionParams,
},
vision,
},
}
})
if (!hasReconciledNode) return undefined
setNodes(nextNodes)
setControlPromptEditorRerenderKey(Date.now())
const previousNodesById = new Map(nodes.map((node) => [node.id, node]))
const changedNodesById = new Map(nextNodes.map((node) => [node.id, node]))
return () => {
const { nodes: latestNodes, setNodes: setLatestNodes } = collaborativeWorkflow.getState()
let hasRestoredNode = false
const restoredNodes = latestNodes.map((node) => {
const previousNode = previousNodesById.get(node.id)
const changedNode = changedNodesById.get(node.id)
if (!previousNode || !changedNode || isEqual(previousNode.data, changedNode.data))
return node
const restoredData = restoreChangedValue(
node.data,
previousNode.data,
changedNode.data,
) as typeof node.data
if (isEqual(restoredData, node.data)) return node
hasRestoredNode = true
return { ...node, data: restoredData }
})
if (hasRestoredNode) setLatestNodes(restoredNodes)
}
}
},
[
activeTextGenerationModelList,
collaborativeWorkflow,
getAffectedNodes,
setControlPromptEditorRerenderKey,
],
)
const syncEnvList = useCallback(
async (
nextEnvList: EnvironmentVariable[],
options?: {
syncDraft?: boolean
},
patch: EnvironmentVariablePatch,
syncWorkflowDraft = false,
) => {
updateEnvList(nextEnvList)
const shouldSyncDraft = options?.syncDraft ?? true
let persisted = false
if (shouldSyncDraft) {
const syncDraftPromise = doSyncWorkflowDraft()
persisted = await persistEnvironmentVariables(nextEnvList)
await syncDraftPromise
} else {
persisted = await persistEnvironmentVariables(nextEnvList)
}
updateEnvList(nextEnvList.map(sanitizeSecretValue))
const persisted = await persistEnvironmentVariables(patch, syncWorkflowDraft)
return persisted
},
[doSyncWorkflowDraft, persistEnvironmentVariables, updateEnvList],
[persistEnvironmentVariables, updateEnvList],
)
const saveSecretValue = useCallback(
(env: EnvironmentVariable) => {
const latestEnvSecrets = workflowStore.getState().envSecrets
setEnvSecrets({
...envSecrets,
...latestEnvSecrets,
[env.id]: formatSecret(String(env.value)),
})
},
[envSecrets, setEnvSecrets],
[setEnvSecrets, workflowStore],
)
const removeEnvSecret = useCallback(
(envId: string) => {
setEnvSecrets(removeSecretFromMap(envSecrets, envId))
setEnvSecrets(removeSecretFromMap(workflowStore.getState().envSecrets, envId))
},
[envSecrets, setEnvSecrets],
[setEnvSecrets, workflowStore],
)
return {
emitVarsAndFeaturesUpdate,
fetchPersistedEnvironmentVariables,
getAffectedNodes,
prepareAffectedLLMNodeReconciliation,
updateAffectedNodes,
syncEnvList,
saveSecretValue,
@@ -161,30 +415,167 @@ const useEnvPanelActions = ({
const EnvPanel = () => {
const { t } = useTranslation()
const collaborativeWorkflow = useCollaborativeWorkflow()
const workflowStore = useWorkflowStore()
const setShowEnvPanel = useStore((s) => s.setShowEnvPanel)
const envList = useStore((s) => s.environmentVariables) as EnvironmentVariable[]
const envSecrets = useStore((s) => s.envSecrets)
const updateEnvList = useStore((s) => s.setEnvironmentVariables)
const setEnvSecrets = useStore((s) => s.setEnvSecrets)
const setControlPromptEditorRerenderKey = useStore((s) => s.setControlPromptEditorRerenderKey)
const appId = useStore((s) => s.appId) as string
const { doSyncWorkflowDraft } = useNodesSyncDraft()
const { getAffectedNodes, updateAffectedNodes, syncEnvList, saveSecretValue, removeEnvSecret } =
useEnvPanelActions({
collaborativeWorkflow,
appId,
envSecrets,
updateEnvList,
setEnvSecrets,
setControlPromptEditorRerenderKey,
doSyncWorkflowDraft,
})
const { activeTextGenerationModelList } = useTextGenerationCurrentProviderAndModelAndModelList()
const {
emitVarsAndFeaturesUpdate,
fetchPersistedEnvironmentVariables,
getAffectedNodes,
prepareAffectedLLMNodeReconciliation,
updateAffectedNodes,
syncEnvList,
saveSecretValue,
removeEnvSecret,
} = useEnvPanelActions({
collaborativeWorkflow,
workflowStore,
appId,
updateEnvList,
setEnvSecrets,
setControlPromptEditorRerenderKey,
activeTextGenerationModelList,
})
const [showVariableModal, setShowVariableModal] = useState(false)
const [currentVar, setCurrentVar] = useState<EnvironmentVariable>()
const [showRemoveVarConfirm, setShowRemoveVarConfirm] = useState(false)
const [cacheForDelete, setCacheForDelete] = useState<EnvironmentVariable>()
const saveOperationQueueRef = useRef(Promise.resolve())
const committedEnvListRef = useRef(envList)
const latestEnvListRef = useRef(envList)
const pendingSaveEnvIdsRef = useRef(new Map<string, number>())
latestEnvListRef.current = envList
useEffect(() => {
if (pendingSaveEnvIdsRef.current.size === 0) committedEnvListRef.current = envList
}, [envList])
const mergeLatestUntouchedEnvList = useCallback((envListToMerge: EnvironmentVariable[]) => {
return mergeUntouchedEnvironmentVariables(
envListToMerge,
latestEnvListRef.current,
new Set(pendingSaveEnvIdsRef.current.keys()),
)
}, [])
const restoreCommittedEnvList = useCallback(() => {
committedEnvListRef.current = mergeLatestUntouchedEnvList(committedEnvListRef.current)
updateEnvList(committedEnvListRef.current)
}, [mergeLatestUntouchedEnvList, updateEnvList])
const reconcileAmbiguousPersistenceFailure = useCallback(
async (patch: EnvironmentVariablePatch) => {
const persistedEnvList = await fetchPersistedEnvironmentVariables()
if (!persistedEnvList) {
if (!environmentVariablePatchMatches(latestEnvListRef.current, patch)) {
committedEnvListRef.current = latestEnvListRef.current.map(sanitizeSecretValue)
updateEnvList(committedEnvListRef.current)
return false
}
restoreCommittedEnvList()
return false
}
committedEnvListRef.current = persistedEnvList.map(sanitizeSecretValue)
updateEnvList(committedEnvListRef.current)
return environmentVariablePatchMatches(persistedEnvList, patch)
},
[fetchPersistedEnvironmentVariables, restoreCommittedEnvList, updateEnvList],
)
const commitPersistedEnvList = useCallback(
async (nextEnvList: EnvironmentVariable[], patch: EnvironmentVariablePatch) => {
const persistedEnvList = collaborationManager.isConnected()
? await fetchPersistedEnvironmentVariables()
: undefined
committedEnvListRef.current = (
persistedEnvList ?? mergeLatestUntouchedEnvList(nextEnvList)
).map(sanitizeSecretValue)
updateEnvList(committedEnvListRef.current)
return !persistedEnvList || environmentVariableGraphPatchMatches(persistedEnvList, patch)
},
[fetchPersistedEnvironmentVariables, mergeLatestUntouchedEnvList, updateEnvList],
)
const enqueueSaveOperation = useCallback(
(envId: string, operation: () => Promise<void>) => {
pendingSaveEnvIdsRef.current.set(envId, (pendingSaveEnvIdsRef.current.get(envId) ?? 0) + 1)
const runOperation = async () => {
committedEnvListRef.current = mergeLatestUntouchedEnvList(committedEnvListRef.current)
try {
await operation()
} finally {
const pendingCount = pendingSaveEnvIdsRef.current.get(envId) ?? 0
if (pendingCount <= 1) pendingSaveEnvIdsRef.current.delete(envId)
else pendingSaveEnvIdsRef.current.set(envId, pendingCount - 1)
}
}
const queuedOperation = saveOperationQueueRef.current.then(runOperation, runOperation)
saveOperationQueueRef.current = queuedOperation.catch(() => undefined)
return queuedOperation
},
[mergeLatestUntouchedEnvList],
)
const syncDraftWithResult = useCallback(
async (environmentVariablePatch?: EnvironmentVariablePatch) => {
let succeeded = false
try {
await doSyncWorkflowDraft(
false,
{
onSuccess: () => {
succeeded = true
},
onError: () => {
succeeded = false
},
},
environmentVariablePatch ? { environmentVariablePatch } : undefined,
)
} catch {
succeeded = false
}
return succeeded
},
[doSyncWorkflowDraft],
)
const persistQueuedEnvList = useCallback(
async (nextEnvList: EnvironmentVariable[], patch: EnvironmentVariablePatch) => {
const persisted = await syncEnvList(nextEnvList, patch)
if (persisted) return { persisted: true, usedDraftFallback: false }
const fallbackPersisted = await syncDraftWithResult(patch)
if (fallbackPersisted) await emitVarsAndFeaturesUpdate()
return {
persisted: fallbackPersisted,
usedDraftFallback: true,
}
},
[emitVarsAndFeaturesUpdate, syncDraftWithResult, syncEnvList],
)
const persistEnvListWithGraphChanges = useCallback(
async (nextEnvList: EnvironmentVariable[], patch: EnvironmentVariablePatch) => {
const isCollaborationFollower =
collaborationManager.isConnected() && !collaborationManager.getIsLeader()
if (isCollaborationFollower) return syncEnvList(nextEnvList, patch, true)
const persisted = await syncDraftWithResult(patch)
if (persisted) await emitVarsAndFeaturesUpdate()
return persisted
},
[emitVarsAndFeaturesUpdate, syncDraftWithResult, syncEnvList],
)
const handleEdit = (env: EnvironmentVariable) => {
setCurrentVar(env)
@@ -193,14 +584,58 @@ const EnvPanel = () => {
const handleDelete = useCallback(
async (env: EnvironmentVariable) => {
const nextEnvList = envList.filter((e) => e.id !== env.id)
setCacheForDelete(undefined)
setShowRemoveVarConfirm(false)
updateAffectedNodes(env, [])
if (env.value_type === 'secret') removeEnvSecret(env.id)
await syncEnvList(nextEnvList)
await enqueueSaveOperation(env.id, async () => {
const committedEnv = committedEnvListRef.current.find((item) => item.id === env.id)
if (!committedEnv) return
const nextEnvList = committedEnvListRef.current.filter((item) => item.id !== env.id)
const patch: EnvironmentVariablePatch = {
environmentVariables: [],
deletedEnvironmentVariableIds: [committedEnv.id],
}
updateEnvList(nextEnvList)
const rollbackAffectedNodes = updateAffectedNodes(committedEnv, [])
const persisted = rollbackAffectedNodes
? await persistEnvListWithGraphChanges(nextEnvList, patch)
: (await persistQueuedEnvList(nextEnvList, patch)).persisted
if (!persisted) {
const recovered = await reconcileAmbiguousPersistenceFailure(patch)
if (!recovered) {
rollbackAffectedNodes?.()
toast.error(t(($) => $.error, { ns: 'common' }))
}
if (recovered)
await emitVarsAndFeaturesUpdate(
!!rollbackAffectedNodes &&
collaborationManager.isConnected() &&
!collaborationManager.getIsLeader(),
)
if (recovered && committedEnv.value_type === 'secret') removeEnvSecret(committedEnv.id)
return
}
const mutationIsCurrent = await commitPersistedEnvList(nextEnvList, patch)
if (!mutationIsCurrent) {
rollbackAffectedNodes?.()
return
}
if (committedEnv.value_type === 'secret') removeEnvSecret(committedEnv.id)
})
},
[envList, removeEnvSecret, syncEnvList, updateAffectedNodes],
[
enqueueSaveOperation,
commitPersistedEnvList,
emitVarsAndFeaturesUpdate,
persistEnvListWithGraphChanges,
persistQueuedEnvList,
removeEnvSecret,
reconcileAmbiguousPersistenceFailure,
t,
updateAffectedNodes,
updateEnvList,
],
)
const deleteCheck = useCallback(
@@ -220,42 +655,129 @@ const EnvPanel = () => {
async (env: EnvironmentVariable) => {
let newEnv = env
if (!currentVar) {
if (env.value_type === 'secret') saveSecretValue(env)
await syncEnvList([env, ...envList])
await enqueueSaveOperation(env.id, async () => {
const nextEnvList = [
env,
...committedEnvListRef.current.filter((item) => item.id !== env.id),
]
const patch: EnvironmentVariablePatch = {
environmentVariables: [env],
deletedEnvironmentVariableIds: [],
}
const { persisted } = await persistQueuedEnvList(nextEnvList, patch)
if (!persisted) {
const recovered = await reconcileAmbiguousPersistenceFailure(patch)
if (!recovered) toast.error(t(($) => $.error, { ns: 'common' }))
if (recovered) {
await emitVarsAndFeaturesUpdate()
if (env.value_type === 'secret') saveSecretValue(env)
}
return
}
const mutationIsCurrent = await commitPersistedEnvList(nextEnvList, patch)
if (!mutationIsCurrent) return
if (env.value_type === 'secret') saveSecretValue(env)
})
return
}
let shouldSaveSecret = false
let shouldRemoveSecret = false
if (currentVar.value_type === 'secret') {
if (env.value_type === 'secret') {
if (envSecrets[currentVar.id] !== env.value) {
if (workflowStore.getState().envSecrets[currentVar.id] !== env.value) {
newEnv = env
saveSecretValue(env)
shouldSaveSecret = true
} else {
newEnv = sanitizeSecretValue(env)
}
} else {
removeEnvSecret(currentVar.id)
shouldRemoveSecret = true
}
} else if (env.value_type === 'secret') {
saveSecretValue(env)
shouldSaveSecret = true
}
const nextEnvList = envList.map((e) => (e.id === currentVar.id ? newEnv : e))
const hasEnvNameChanged = currentVar.name !== env.name
if (hasEnvNameChanged) updateAffectedNodes(currentVar, ['env', env.name])
await enqueueSaveOperation(currentVar.id, async () => {
const committedEnv = committedEnvListRef.current.find((item) => item.id === currentVar.id)
if (!committedEnv) return
const persisted = await syncEnvList(nextEnvList, { syncDraft: hasEnvNameChanged })
if (!persisted && !hasEnvNameChanged) await doSyncWorkflowDraft()
let applyLLMNodeReconciliation: (() => (() => void) | undefined) | undefined
try {
applyLLMNodeReconciliation = await prepareAffectedLLMNodeReconciliation(
committedEnv,
newEnv,
)
} catch {
restoreCommittedEnvList()
toast.error(t(($) => $.error, { ns: 'common' }))
return
}
committedEnvListRef.current = mergeLatestUntouchedEnvList(committedEnvListRef.current)
const nextCommittedEnvList = committedEnvListRef.current.map((item) =>
item.id === currentVar.id ? newEnv : item,
)
updateEnvList(nextCommittedEnvList)
const rollbackReconciledLLMNodes = applyLLMNodeReconciliation?.()
const hasEnvNameChanged = committedEnv.name !== newEnv.name
const rollbackRenamedAffectedNodes = hasEnvNameChanged
? updateAffectedNodes(committedEnv, ['env', newEnv.name])
: undefined
const hasGraphChanges = !!rollbackReconciledLLMNodes || !!rollbackRenamedAffectedNodes
const patch: EnvironmentVariablePatch = {
environmentVariables: [newEnv],
deletedEnvironmentVariableIds: [],
}
const persisted = hasGraphChanges
? await persistEnvListWithGraphChanges(nextCommittedEnvList, patch)
: (await persistQueuedEnvList(nextCommittedEnvList, patch)).persisted
if (!persisted) {
const recovered = await reconcileAmbiguousPersistenceFailure(patch)
if (!recovered) {
rollbackRenamedAffectedNodes?.()
rollbackReconciledLLMNodes?.()
toast.error(t(($) => $.error, { ns: 'common' }))
}
if (recovered)
await emitVarsAndFeaturesUpdate(
hasGraphChanges &&
collaborationManager.isConnected() &&
!collaborationManager.getIsLeader(),
)
if (recovered && shouldSaveSecret) saveSecretValue(newEnv)
else if (recovered && shouldRemoveSecret) removeEnvSecret(currentVar.id)
return
}
const mutationIsCurrent = await commitPersistedEnvList(nextCommittedEnvList, patch)
if (!mutationIsCurrent) {
rollbackRenamedAffectedNodes?.()
rollbackReconciledLLMNodes?.()
return
}
if (shouldSaveSecret) saveSecretValue(newEnv)
else if (shouldRemoveSecret) removeEnvSecret(currentVar.id)
})
},
[
currentVar,
doSyncWorkflowDraft,
envList,
envSecrets,
commitPersistedEnvList,
enqueueSaveOperation,
emitVarsAndFeaturesUpdate,
mergeLatestUntouchedEnvList,
persistEnvListWithGraphChanges,
persistQueuedEnvList,
removeEnvSecret,
prepareAffectedLLMNodeReconciliation,
reconcileAmbiguousPersistenceFailure,
saveSecretValue,
syncEnvList,
restoreCommittedEnvList,
t,
updateAffectedNodes,
updateEnvList,
workflowStore,
],
)
@@ -272,13 +794,15 @@ const EnvPanel = () => {
<div className="flex shrink-0 items-center justify-between p-4 pb-0 system-xl-semibold text-text-primary">
{t(($) => $['env.envPanelTitle'], { ns: 'workflow' })}
<div className="flex items-center">
<div
<button
type="button"
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
className="flex size-6 cursor-pointer items-center justify-center"
onClick={() => setShowEnvPanel(false)}
>
{/* oxlint-disable-next-line hyoban/prefer-tailwind-icons */}
<RiCloseLine className="size-4 text-text-tertiary" />
</div>
</button>
</div>
</div>
<div className="shrink-0 px-4 py-1 system-sm-regular text-text-tertiary">
@@ -1,14 +1,21 @@
import type { EnvironmentVariable } from '@/app/components/workflow/types'
import type {
EnvironmentVariable,
EnvironmentVariableValue,
LLMCompletionParams,
LLMEnvironmentVariableValue,
} from '@/app/components/workflow/types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
import { RiCloseLine } from '@remixicon/react'
import * as React from 'react'
import { useEffect } from 'react'
import { useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { v4 as uuid4 } from 'uuid'
import { Infotip } from '@/app/components/base/infotip'
import Input from '@/app/components/base/input'
import { useTextGenerationCurrentProviderAndModelAndModelList } 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 { useWorkflowStore } from '@/app/components/workflow/store'
import { checkKeys, replaceSpaceWithUnderscoreInVarNameInput } from '@/utils/var'
@@ -17,13 +24,44 @@ type ModalPropsType = {
onClose: () => void
onSave: (env: EnvironmentVariable) => void
}
const isLLMEnvironmentVariableValue = (value: unknown): value is LLMEnvironmentVariableValue => {
if (typeof value !== 'object' || value === null) return false
return (
'provider' in value &&
typeof value.provider === 'string' &&
'name' in value &&
typeof value.name === 'string' &&
'mode' in value &&
typeof value.mode === 'string'
)
}
const VariableModal = ({ env, onClose, onSave }: ModalPropsType) => {
const { t } = useTranslation()
const workflowStore = useWorkflowStore()
const [type, setType] = React.useState<'string' | 'number' | 'secret'>('string')
const [type, setType] = React.useState<EnvironmentVariable['value_type']>('string')
const [name, setName] = React.useState('')
const [value, setValue] = React.useState<any>()
const [value, setValue] = React.useState<EnvironmentVariableValue>()
const [description, setDescription] = React.useState<string>('')
const { activeTextGenerationModelList } = useTextGenerationCurrentProviderAndModelAndModelList()
const originalLLMMode =
env?.value_type === 'llm' && isLLMEnvironmentVariableValue(env.value)
? env.value.mode
: undefined
const selectableModelList = useMemo(() => {
if (!originalLLMMode) return activeTextGenerationModelList
return activeTextGenerationModelList
.map((provider) => ({
...provider,
models: provider.models.filter((model) => model.model_properties.mode === originalLLMMode),
}))
.filter((provider) => provider.models.length > 0)
}, [activeTextGenerationModelList, originalLLMMode])
const isTypeChangeDisabled = (nextType: EnvironmentVariable['value_type']) =>
!!env && (env.value_type === 'llm') !== (nextType === 'llm')
const checkVariableName = (value: string) => {
const { isValid, errorMessageKey } = checkKeys([value], false)
@@ -45,9 +83,61 @@ const VariableModal = ({ env, onClose, onSave }: ModalPropsType) => {
setName(e.target.value || '')
}
const handleTypeChange = (nextType: EnvironmentVariable['value_type']) => {
if (isTypeChangeDisabled(nextType)) return
setType(nextType)
if (nextType === 'llm') {
if (!isLLMEnvironmentVariableValue(value)) setValue(undefined)
return
}
if (isLLMEnvironmentVariableValue(value)) {
setValue('')
return
}
if (nextType === 'number') {
if (value === undefined || value === '' || Number.isNaN(Number(value))) setValue('')
return
}
if (typeof value === 'number') setValue(String(value))
}
const handleModelSelect = ({ provider, modelId }: { provider: string; modelId: string }) => {
const targetProvider = activeTextGenerationModelList.find(
(providerItem) => providerItem.provider === provider,
)
const targetModel = targetProvider?.models.find((modelItem) => modelItem.model === modelId)
const mode = targetModel?.model_properties.mode
if (typeof mode !== 'string') return
if (originalLLMMode && mode !== originalLLMMode) {
toast.error(t(($) => $['modelProvider.selector.incompatibleTip'], { ns: 'common' }))
return
}
const completionParams =
isLLMEnvironmentVariableValue(value) && value.provider === provider && value.name === modelId
? (value.completion_params ?? {})
: {}
setValue({ provider, name: modelId, mode, completion_params: completionParams })
}
const handleCompletionParamsChange = (completionParams: LLMCompletionParams) => {
if (!isLLMEnvironmentVariableValue(value)) return
setValue({ ...value, completion_params: completionParams })
}
const handleSave = () => {
if (!checkVariableName(name)) return
if (!value) return toast.error(t(($) => $['env.modal.valueRequired'], { ns: 'workflow' }))
if (
value === undefined ||
value === '' ||
(type === 'llm' && !isLLMEnvironmentVariableValue(value))
)
return toast.error(t(($) => $['env.modal.valueRequired'], { ns: 'workflow' }))
// Add check for duplicate name when editing
const envList = workflowStore.getState().environmentVariables
@@ -109,47 +199,72 @@ const VariableModal = ({ env, onClose, onSave }: ModalPropsType) => {
<div className="mb-1 flex h-6 items-center system-sm-semibold text-text-secondary">
{t(($) => $['env.modal.type'], { ns: 'workflow' })}
</div>
<div className="flex gap-2">
<div
<div
role="group"
aria-label={t(($) => $['env.modal.type'], { ns: 'workflow' })}
className="grid grid-cols-4 gap-2"
>
<button
type="button"
aria-pressed={type === 'string'}
disabled={isTypeChangeDisabled('string')}
className={cn(
'flex w-[106px] cursor-pointer items-center justify-center rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg p-2 system-sm-regular text-text-secondary hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs',
'flex min-w-0 cursor-pointer items-center justify-center rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg p-2 system-sm-regular text-text-secondary hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs',
type === 'string' &&
'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg system-sm-medium text-text-primary shadow-xs hover:border-components-option-card-option-selected-border',
)}
onClick={() => setType('string')}
onClick={() => handleTypeChange('string')}
>
String
</div>
<div
</button>
<button
type="button"
aria-pressed={type === 'number'}
disabled={isTypeChangeDisabled('number')}
className={cn(
'flex w-[106px] cursor-pointer items-center justify-center rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg p-2 system-sm-regular text-text-secondary hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs',
'flex min-w-0 cursor-pointer items-center justify-center rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg p-2 system-sm-regular text-text-secondary hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs',
type === 'number' &&
'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg font-medium text-text-primary shadow-xs hover:border-components-option-card-option-selected-border',
)}
onClick={() => {
setType('number')
if (!/^\d$/.test(value)) setValue('')
}}
onClick={() => handleTypeChange('number')}
>
Number
</div>
<div
className={cn(
'flex w-[106px] cursor-pointer items-center justify-center rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg p-2 system-sm-regular text-text-secondary hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs',
type === 'secret' &&
'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg font-medium text-text-primary shadow-xs hover:border-components-option-card-option-selected-border',
)}
onClick={() => setType('secret')}
>
<span>Secret</span>
</button>
<div className="relative min-w-0">
<button
type="button"
aria-pressed={type === 'secret'}
disabled={isTypeChangeDisabled('secret')}
className={cn(
'flex w-full min-w-0 cursor-pointer items-center justify-center rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg p-2 pr-5 system-sm-regular text-text-secondary hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs',
type === 'secret' &&
'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg font-medium text-text-primary shadow-xs hover:border-components-option-card-option-selected-border',
)}
onClick={() => handleTypeChange('secret')}
>
Secret
</button>
<Infotip
aria-label={t(($) => $['env.modal.secretTip'], { ns: 'workflow' })}
className="ml-0.5 size-3.5"
className="absolute top-1/2 right-1 size-3.5 -translate-y-1/2"
popupClassName="w-[240px]"
>
{t(($) => $['env.modal.secretTip'], { ns: 'workflow' })}
</Infotip>
</div>
<button
type="button"
aria-pressed={type === 'llm'}
disabled={isTypeChangeDisabled('llm')}
className={cn(
'flex min-w-0 cursor-pointer items-center justify-center rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg p-2 system-sm-regular text-text-secondary hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs',
type === 'llm' &&
'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg font-medium text-text-primary shadow-xs hover:border-components-option-card-option-selected-border',
)}
onClick={() => handleTypeChange('llm')}
>
{t(($) => $['blocks.llm'], { ns: 'workflow' })}
</button>
</div>
</div>
{/* name */}
@@ -170,20 +285,37 @@ const VariableModal = ({ env, onClose, onSave }: ModalPropsType) => {
{/* value */}
<div className="mb-4">
<div className="mb-1 flex h-6 items-center system-sm-semibold text-text-secondary">
{t(($) => $['env.modal.value'], { ns: 'workflow' })}
{type === 'llm'
? t(($) => $['modelProvider.model'], { ns: 'common' })
: t(($) => $['env.modal.value'], { ns: 'workflow' })}
</div>
<div className="flex">
{type !== 'number' ? (
<div className="flex [&>div]:w-full">
{type === 'llm' ? (
<ModelParameterModal
provider={isLLMEnvironmentVariableValue(value) ? value.provider : ''}
modelId={isLLMEnvironmentVariableValue(value) ? value.name : ''}
completionParams={
isLLMEnvironmentVariableValue(value) ? (value.completion_params ?? {}) : {}
}
modelList={selectableModelList}
popupClassName="w-[328px]! max-w-[328px]!"
isAdvancedMode={true}
setModel={handleModelSelect}
onCompletionParamsChange={handleCompletionParamsChange}
hideDebugWithMultipleModel
debugWithMultipleModel={false}
/>
) : type !== 'number' ? (
<textarea
className="block h-20 w-full resize-none appearance-none rounded-lg border border-transparent bg-components-input-bg-normal p-2 system-sm-regular text-components-input-text-filled caret-primary-600 outline-hidden placeholder:system-sm-regular placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
value={value}
value={typeof value === 'string' || typeof value === 'number' ? value : ''}
placeholder={t(($) => $['env.modal.valuePlaceholder'], { ns: 'workflow' }) || ''}
onChange={(e) => setValue(e.target.value)}
/>
) : (
<Input
placeholder={t(($) => $['env.modal.valuePlaceholder'], { ns: 'workflow' }) || ''}
value={value}
value={typeof value === 'string' || typeof value === 'number' ? value : ''}
onChange={(e) => setValue(e.target.value)}
type="number"
/>
@@ -250,6 +250,7 @@ export function SelectionContextmenu({ onClose }: { onClose: () => void }) {
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const { handleNodesCopy, handleNodesDelete, handleNodesDuplicate } = useNodesInteractions()
const isSelectionContextMenu = useStore((s) => s.contextMenuTarget?.type === 'selection')
const environmentVariables = useStore((s) => s.environmentVariables)
// Access React Flow methods
const workflowStore = useWorkflowStore()
@@ -265,6 +266,7 @@ export function SelectionContextmenu({ onClose }: { onClose: () => void }) {
const { createSnippetDialog, handleOpenCreateSnippet, isCreateSnippetDialogOpen } =
useCreateSnippetFromSelection({
edges,
environmentVariables,
selectedNodes,
onClose,
})
+15 -7
View File
@@ -170,11 +170,22 @@ export type Variable = {
isParagraph?: boolean
}
export type LLMCompletionParams = Record<string, unknown>
export type LLMEnvironmentVariableValue = {
provider: string
name: string
mode: string
completion_params?: LLMCompletionParams
}
export type EnvironmentVariableValue = string | number | LLMEnvironmentVariableValue
export type EnvironmentVariable = {
id: string
name: string
value: any
value_type: 'string' | 'number' | 'secret'
value: EnvironmentVariableValue
value_type: 'string' | 'number' | 'secret' | 'llm'
description: string
}
@@ -234,11 +245,8 @@ export type InputVar = {
json_schema?: string | Record<string, any> // for jsonObject type
} & Partial<UploadFileSetting>
export type ModelConfig = {
provider: string
name: string
mode: string
completion_params: Record<string, any>
export type ModelConfig = LLMEnvironmentVariableValue & {
completion_params: LLMCompletionParams
}
export enum PromptRole {
@@ -43,9 +43,18 @@ vi.mock('../value-content-sections', () => ({
BoolArraySection: ({ onChange }: { onChange: (value: boolean[]) => void }) => (
<button onClick={() => onChange([true, true])}>bool-array-editor</button>
),
JsonEditorSection: ({ json, onChange }: { json: string; onChange: (value: string) => void }) => (
JsonEditorSection: ({
json,
readonly,
onChange,
}: {
json: string
readonly: boolean
onChange: (value: string) => void
}) => (
<textarea
data-testid="json-editor"
readOnly={readonly}
value={json}
onChange={(event) => onChange(event.target.value)}
/>
@@ -312,6 +321,36 @@ describe('ValueContent', () => {
})
})
it('should render LLM environment variables as read-only JSON', async () => {
const value = { provider: 'openai', name: 'gpt-4o', mode: 'chat' }
renderWorkflowComponent(
<ValueContent
currentVar={createVar({
id: 'var-llm',
name: 'for_summarize',
type: VarInInspectType.environment,
value_type: 'llm' as VarType,
value,
})}
handleValueChange={vi.fn()}
isTruncated={false}
/>,
{
initialStoreState: {
fileUploadConfig: {
workflow_file_upload_limit: 5,
} as never,
},
},
)
await waitFor(() => {
expect(screen.getByTestId('json-editor')).toHaveValue(JSON.stringify(value, null, 2))
})
expect(screen.getByTestId('json-editor')).toHaveAttribute('readonly')
})
it('should update uploaded single file values and ignore pending uploads', async () => {
const handleValueChange = vi.fn()
@@ -15,6 +15,7 @@ type UploadedFileLike = {
}
export const getValueEditorState = (currentVar: VarInInspect) => {
const valueType = String(currentVar.value_type)
const showTextEditor =
currentVar.value_type === 'secret' ||
currentVar.value_type === 'string' ||
@@ -25,8 +26,8 @@ export const getValueEditorState = (currentVar: VarInInspect) => {
const isSysFiles = currentVar.type === VarInInspectType.system && currentVar.name === 'files'
const showJSONEditor =
!isSysFiles &&
['object', 'array[string]', 'array[number]', 'array[object]', 'array[any]'].includes(
currentVar.value_type,
['object', 'array[string]', 'array[number]', 'array[object]', 'array[any]', 'llm'].includes(
valueType,
)
const showFileEditor =
isSysFiles || currentVar.value_type === 'file' || currentVar.value_type === 'array[file]'
@@ -35,7 +36,7 @@ export const getValueEditorState = (currentVar: VarInInspect) => {
(currentVar.type === VarInInspectType.system &&
currentVar.name !== 'query' &&
currentVar.name !== 'files')
const JSONEditorDisabled = currentVar.value_type === 'array[any]'
const JSONEditorDisabled = valueType === 'array[any]' || valueType === 'llm'
const hasChunks = !!currentVar.schemaType && CHUNK_SCHEMA_TYPES.includes(currentVar.schemaType)
return {
@@ -61,7 +61,6 @@ describe('applyToNewApp', () => {
params: {
graph,
features: {},
environment_variables: [],
conversation_variables: [],
},
})
@@ -218,9 +217,10 @@ describe('applyToCurrentApp', () => {
hash: 'h-existing',
}),
})
// Existing env vars and conversation vars must be preserved verbatim.
// Environment variables are preserved server-side; conversation variables
// must still be forwarded verbatim.
const params = mockSyncWorkflowDraft.mock.calls[0]![0].params
expect(params.environment_variables).toHaveLength(1)
expect(params).not.toHaveProperty('environment_variables')
expect(params.conversation_variables).toHaveLength(1)
})
@@ -236,7 +236,7 @@ describe('applyToCurrentApp', () => {
const params = mockSyncWorkflowDraft.mock.calls[0]![0].params
expect(params).not.toHaveProperty('hash')
expect(params.features).toEqual({})
expect(params.environment_variables).toEqual([])
expect(params).not.toHaveProperty('environment_variables')
expect(params.conversation_variables).toEqual([])
})
@@ -121,7 +121,6 @@ export const applyToNewApp = async ({
params: {
graph,
features: {},
environment_variables: [],
conversation_variables: [],
},
})
@@ -148,9 +147,9 @@ type ApplyToCurrentAppParams = {
* 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.
* existing ``features`` and ``conversation_variables``. Environment variables
* are preserved by omitting them from the draft-sync payload, so 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.
@@ -178,7 +177,6 @@ export const applyToCurrentApp = async ({
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.
+51
View File
@@ -0,0 +1,51 @@
import type { EnvironmentVariable } from '@/app/components/workflow/types'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { updateEnvironmentVariables } from './workflow'
const mockPost = vi.hoisted(() => vi.fn())
vi.mock('./base', () => ({
get: vi.fn(),
post: mockPost,
}))
vi.mock('./client', () => ({
consoleClient: {},
}))
describe('updateEnvironmentVariables', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('sends per-ID upserts and deletions as a patch', async () => {
const environmentVariables = [
{
id: 'env-1',
name: 'for_summarize',
description: '',
value_type: 'llm',
value: {
provider: 'langgenius/openai/openai',
name: 'gpt-4.1',
mode: 'chat',
},
},
] satisfies EnvironmentVariable[]
mockPost.mockResolvedValue({ result: 'success' })
await updateEnvironmentVariables({
appId: 'app-1',
environmentVariables,
deletedEnvironmentVariableIds: ['env-2'],
})
expect(mockPost).toHaveBeenCalledWith('apps/app-1/workflows/draft/environment-variables', {
body: {
environment_variables: environmentVariables,
patch: true,
deleted_environment_variable_ids: ['env-2'],
},
})
})
})
+22 -9
View File
@@ -19,6 +19,16 @@ import { getFlowPrefix } from './utils'
export type WorkflowDraftFeaturesPayload = WorkflowFeaturesConfigPayload
export type EnvironmentVariablePatch = {
environmentVariables: EnvironmentVariable[]
deletedEnvironmentVariableIds: string[]
}
type EnvironmentVariablePatchPayload = {
environment_variables: EnvironmentVariable[]
deleted_environment_variable_ids: string[]
}
export const fetchWorkflowDraft = (url: string) => {
return get(url, {}, { silent: true }) as Promise<FetchWorkflowDraftResponse>
}
@@ -28,10 +38,10 @@ export const syncWorkflowDraft = ({
params,
}: {
url: string
params: Pick<
FetchWorkflowDraftResponse,
'graph' | 'features' | 'environment_variables' | 'conversation_variables'
>
params: Pick<FetchWorkflowDraftResponse, 'graph' | 'features' | 'conversation_variables'> &
Partial<Pick<FetchWorkflowDraftResponse, 'environment_variables'>> & {
environment_variable_patch?: EnvironmentVariablePatchPayload
}
}) => {
return post<CommonResponse & { updated_at: number; hash: string }>(
url,
@@ -146,13 +156,16 @@ export const fetchNodeInspectVars = async (
export const updateEnvironmentVariables = ({
appId,
environmentVariables,
deletedEnvironmentVariableIds,
}: {
appId: string
environmentVariables: EnvironmentVariable[]
}) => {
return consoleClient.apps.byAppId.workflows.draft.environmentVariables.post({
params: { app_id: appId },
body: { environment_variables: environmentVariables },
} & EnvironmentVariablePatch) => {
return post<CommonResponse>(`apps/${appId}/workflows/draft/environment-variables`, {
body: {
environment_variables: environmentVariables,
patch: true,
deleted_environment_variable_ids: deletedEnvironmentVariableIds,
},
})
}
+12 -4
View File
@@ -78,14 +78,22 @@ export const mergeValidCompletionParams = (
return { params: nextParams, removedDetails }
}
export const fetchModelParameterRulesForModel = async (
provider: string,
modelId: string,
): Promise<ModelParameterRule[]> => {
const { fetchModelParameterRules } = await import('@/service/common')
const url = `/workspaces/current/model-providers/${provider}/models/parameter-rules?model=${modelId}`
const { data: parameterRules } = await fetchModelParameterRules(url)
return parameterRules ?? []
}
export const fetchAndMergeValidCompletionParams = async (
provider: string,
modelId: string,
oldParams: FormValue | undefined,
isAdvancedMode: boolean = false,
): Promise<{ params: FormValue; removedDetails: Record<string, string> }> => {
const { fetchModelParameterRules } = await import('@/service/common')
const url = `/workspaces/current/model-providers/${provider}/models/parameter-rules?model=${modelId}`
const { data: parameterRules } = await fetchModelParameterRules(url)
return mergeValidCompletionParams(oldParams, parameterRules ?? [], isAdvancedMode)
const parameterRules = await fetchModelParameterRulesForModel(provider, modelId)
return mergeValidCompletionParams(oldParams, parameterRules, isAdvancedMode)
}