Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88d694d51c | ||
|
|
82b2d2f76f | ||
|
|
8811db1191 | ||
|
|
385d9a50fd | ||
|
|
6226d7634c | ||
|
|
f873fbad2a | ||
|
|
fdc938e94d | ||
|
|
3d4423ceb3 |
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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"]])
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"}])
|
||||
|
||||
|
||||
+33
@@ -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",
|
||||
|
||||
+2
-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 } })
|
||||
|
||||
@@ -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 } })
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
+11
-7
@@ -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>,
|
||||
|
||||
+93
-8
@@ -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" />
|
||||
|
||||
+52
-1
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user