Merge branch 'langgenius:main' into add-document-status-update

pull/18235/head
GuanMu 1 year ago committed by GitHub
commit fcbbc2d42d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -85,5 +85,35 @@ class RuleCodeGenerateApi(Resource):
return code_result return code_result
class RuleStructuredOutputGenerateApi(Resource):
@setup_required
@login_required
@account_initialization_required
def post(self):
parser = reqparse.RequestParser()
parser.add_argument("instruction", type=str, required=True, nullable=False, location="json")
parser.add_argument("model_config", type=dict, required=True, nullable=False, location="json")
args = parser.parse_args()
account = current_user
try:
structured_output = LLMGenerator.generate_structured_output(
tenant_id=account.current_tenant_id,
instruction=args["instruction"],
model_config=args["model_config"],
)
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
raise ProviderQuotaExceededError()
except ModelCurrentlyNotSupportError:
raise ProviderModelCurrentlyNotSupportError()
except InvokeError as e:
raise CompletionRequestError(e.description)
return structured_output
api.add_resource(RuleGenerateApi, "/rule-generate") api.add_resource(RuleGenerateApi, "/rule-generate")
api.add_resource(RuleCodeGenerateApi, "/rule-code-generate") api.add_resource(RuleCodeGenerateApi, "/rule-code-generate")
api.add_resource(RuleStructuredOutputGenerateApi, "/rule-structured-output-generate")

@ -16,7 +16,7 @@ from controllers.console.auth.error import (
PasswordMismatchError, PasswordMismatchError,
) )
from controllers.console.error import AccountInFreezeError, AccountNotFound, EmailSendIpLimitError from controllers.console.error import AccountInFreezeError, AccountNotFound, EmailSendIpLimitError
from controllers.console.wraps import setup_required from controllers.console.wraps import email_password_login_enabled, setup_required
from events.tenant_event import tenant_was_created from events.tenant_event import tenant_was_created
from extensions.ext_database import db from extensions.ext_database import db
from libs.helper import email, extract_remote_ip from libs.helper import email, extract_remote_ip
@ -30,6 +30,7 @@ from services.feature_service import FeatureService
class ForgotPasswordSendEmailApi(Resource): class ForgotPasswordSendEmailApi(Resource):
@setup_required @setup_required
@email_password_login_enabled
def post(self): def post(self):
parser = reqparse.RequestParser() parser = reqparse.RequestParser()
parser.add_argument("email", type=email, required=True, location="json") parser.add_argument("email", type=email, required=True, location="json")
@ -62,6 +63,7 @@ class ForgotPasswordSendEmailApi(Resource):
class ForgotPasswordCheckApi(Resource): class ForgotPasswordCheckApi(Resource):
@setup_required @setup_required
@email_password_login_enabled
def post(self): def post(self):
parser = reqparse.RequestParser() parser = reqparse.RequestParser()
parser.add_argument("email", type=str, required=True, location="json") parser.add_argument("email", type=str, required=True, location="json")
@ -86,12 +88,21 @@ class ForgotPasswordCheckApi(Resource):
AccountService.add_forgot_password_error_rate_limit(args["email"]) AccountService.add_forgot_password_error_rate_limit(args["email"])
raise EmailCodeError() raise EmailCodeError()
# Verified, revoke the first token
AccountService.revoke_reset_password_token(args["token"])
# Refresh token data by generating a new token
_, new_token = AccountService.generate_reset_password_token(
user_email, code=args["code"], additional_data={"phase": "reset"}
)
AccountService.reset_forgot_password_error_rate_limit(args["email"]) AccountService.reset_forgot_password_error_rate_limit(args["email"])
return {"is_valid": True, "email": token_data.get("email")} return {"is_valid": True, "email": token_data.get("email"), "token": new_token}
class ForgotPasswordResetApi(Resource): class ForgotPasswordResetApi(Resource):
@setup_required @setup_required
@email_password_login_enabled
def post(self): def post(self):
parser = reqparse.RequestParser() parser = reqparse.RequestParser()
parser.add_argument("token", type=str, required=True, nullable=False, location="json") parser.add_argument("token", type=str, required=True, nullable=False, location="json")
@ -107,6 +118,9 @@ class ForgotPasswordResetApi(Resource):
reset_data = AccountService.get_reset_password_data(args["token"]) reset_data = AccountService.get_reset_password_data(args["token"])
if not reset_data: if not reset_data:
raise InvalidTokenError() raise InvalidTokenError()
# Must use token in reset phase
if reset_data.get("phase", "") != "reset":
raise InvalidTokenError()
# Revoke token to prevent reuse # Revoke token to prevent reuse
AccountService.revoke_reset_password_token(args["token"]) AccountService.revoke_reset_password_token(args["token"])

@ -22,7 +22,7 @@ from controllers.console.error import (
EmailSendIpLimitError, EmailSendIpLimitError,
NotAllowedCreateWorkspace, NotAllowedCreateWorkspace,
) )
from controllers.console.wraps import setup_required from controllers.console.wraps import email_password_login_enabled, setup_required
from events.tenant_event import tenant_was_created from events.tenant_event import tenant_was_created
from libs.helper import email, extract_remote_ip from libs.helper import email, extract_remote_ip
from libs.password import valid_password from libs.password import valid_password
@ -38,6 +38,7 @@ class LoginApi(Resource):
"""Resource for user login.""" """Resource for user login."""
@setup_required @setup_required
@email_password_login_enabled
def post(self): def post(self):
"""Authenticate user and login.""" """Authenticate user and login."""
parser = reqparse.RequestParser() parser = reqparse.RequestParser()
@ -110,6 +111,7 @@ class LogoutApi(Resource):
class ResetPasswordSendEmailApi(Resource): class ResetPasswordSendEmailApi(Resource):
@setup_required @setup_required
@email_password_login_enabled
def post(self): def post(self):
parser = reqparse.RequestParser() parser = reqparse.RequestParser()
parser.add_argument("email", type=email, required=True, location="json") parser.add_argument("email", type=email, required=True, location="json")

@ -210,3 +210,16 @@ def enterprise_license_required(view):
return view(*args, **kwargs) return view(*args, **kwargs)
return decorated return decorated
def email_password_login_enabled(view):
@wraps(view)
def decorated(*args, **kwargs):
features = FeatureService.get_system_features()
if features.enable_email_password_login:
return view(*args, **kwargs)
# otherwise, return 403
abort(403)
return decorated

@ -10,6 +10,7 @@ from core.llm_generator.prompts import (
GENERATOR_QA_PROMPT, GENERATOR_QA_PROMPT,
JAVASCRIPT_CODE_GENERATOR_PROMPT_TEMPLATE, JAVASCRIPT_CODE_GENERATOR_PROMPT_TEMPLATE,
PYTHON_CODE_GENERATOR_PROMPT_TEMPLATE, PYTHON_CODE_GENERATOR_PROMPT_TEMPLATE,
SYSTEM_STRUCTURED_OUTPUT_GENERATE,
WORKFLOW_RULE_CONFIG_PROMPT_GENERATE_TEMPLATE, WORKFLOW_RULE_CONFIG_PROMPT_GENERATE_TEMPLATE,
) )
from core.model_manager import ModelManager from core.model_manager import ModelManager
@ -340,3 +341,37 @@ class LLMGenerator:
answer = cast(str, response.message.content) answer = cast(str, response.message.content)
return answer.strip() return answer.strip()
@classmethod
def generate_structured_output(cls, tenant_id: str, instruction: str, model_config: dict):
model_manager = ModelManager()
model_instance = model_manager.get_model_instance(
tenant_id=tenant_id,
model_type=ModelType.LLM,
provider=model_config.get("provider", ""),
model=model_config.get("name", ""),
)
prompt_messages = [
SystemPromptMessage(content=SYSTEM_STRUCTURED_OUTPUT_GENERATE),
UserPromptMessage(content=instruction),
]
model_parameters = model_config.get("model_parameters", {})
try:
response = cast(
LLMResult,
model_instance.invoke_llm(
prompt_messages=list(prompt_messages), model_parameters=model_parameters, stream=False
),
)
generated_json_schema = cast(str, response.message.content)
return {"output": generated_json_schema, "error": ""}
except InvokeError as e:
error = str(e)
return {"output": "", "error": f"Failed to generate JSON Schema. Error: {error}"}
except Exception as e:
logging.exception(f"Failed to invoke LLM model, model: {model_config.get('name')}")
return {"output": "", "error": f"An unexpected error occurred: {str(e)}"}

@ -220,3 +220,110 @@ Here is the task description: {{INPUT_TEXT}}
You just need to generate the output You just need to generate the output
""" # noqa: E501 """ # noqa: E501
SYSTEM_STRUCTURED_OUTPUT_GENERATE = """
Your task is to convert simple user descriptions into properly formatted JSON Schema definitions. When a user describes data fields they need, generate a complete, valid JSON Schema that accurately represents those fields with appropriate types and requirements.
## Instructions:
1. Analyze the user's description of their data needs
2. Identify each property that should be included in the schema
3. Determine the appropriate data type for each property
4. Decide which properties should be required
5. Generate a complete JSON Schema with proper syntax
6. Include appropriate constraints when specified (min/max values, patterns, formats)
7. Provide ONLY the JSON Schema without any additional explanations, comments, or markdown formatting.
8. DO NOT use markdown code blocks (``` or ``` json). Return the raw JSON Schema directly.
## Examples:
### Example 1:
**User Input:** I need name and age
**JSON Schema Output:**
{
"type": "object",
"properties": {
"name": { "type": "string" },
"age": { "type": "number" }
},
"required": ["name", "age"]
}
### Example 2:
**User Input:** I want to store information about books including title, author, publication year and optional page count
**JSON Schema Output:**
{
"type": "object",
"properties": {
"title": { "type": "string" },
"author": { "type": "string" },
"publicationYear": { "type": "integer" },
"pageCount": { "type": "integer" }
},
"required": ["title", "author", "publicationYear"]
}
### Example 3:
**User Input:** Create a schema for user profiles with email, password, and age (must be at least 18)
**JSON Schema Output:**
{
"type": "object",
"properties": {
"email": {
"type": "string",
"format": "email"
},
"password": {
"type": "string",
"minLength": 8
},
"age": {
"type": "integer",
"minimum": 18
}
},
"required": ["email", "password", "age"]
}
### Example 4:
**User Input:** I need album schema, the ablum has songs, and each song has name, duration, and artist.
**JSON Schema Output:**
{
"type": "object",
"properties": {
"properties": {
"songs": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"id": {
"type": "string"
},
"duration": {
"type": "string"
},
"aritst": {
"type": "string"
}
},
"required": [
"name",
"id",
"duration",
"aritst"
]
}
}
}
},
"required": [
"songs"
]
}
Now, generate a JSON Schema based on my description
""" # noqa: E501

@ -2,7 +2,7 @@ from decimal import Decimal
from enum import Enum, StrEnum from enum import Enum, StrEnum
from typing import Any, Optional from typing import Any, Optional
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict, model_validator
from core.model_runtime.entities.common_entities import I18nObject from core.model_runtime.entities.common_entities import I18nObject
@ -85,6 +85,7 @@ class ModelFeature(Enum):
DOCUMENT = "document" DOCUMENT = "document"
VIDEO = "video" VIDEO = "video"
AUDIO = "audio" AUDIO = "audio"
STRUCTURED_OUTPUT = "structured-output"
class DefaultParameterName(StrEnum): class DefaultParameterName(StrEnum):
@ -197,6 +198,19 @@ class AIModelEntity(ProviderModel):
parameter_rules: list[ParameterRule] = [] parameter_rules: list[ParameterRule] = []
pricing: Optional[PriceConfig] = None pricing: Optional[PriceConfig] = None
@model_validator(mode="after")
def validate_model(self):
supported_schema_keys = ["json_schema"]
schema_key = next((rule.name for rule in self.parameter_rules if rule.name in supported_schema_keys), None)
if not schema_key:
return self
if self.features is None:
self.features = [ModelFeature.STRUCTURED_OUTPUT]
else:
if ModelFeature.STRUCTURED_OUTPUT not in self.features:
self.features.append(ModelFeature.STRUCTURED_OUTPUT)
return self
class ModelUsage(BaseModel): class ModelUsage(BaseModel):
pass pass

@ -39,6 +39,7 @@ class PluginNodeBackwardsInvocation(BaseBackwardsInvocation):
:param query: str :param query: str
:return: dict :return: dict
""" """
# FIXME(-LAN-): Avoid import service into core
workflow_service = WorkflowService() workflow_service = WorkflowService()
node_id = "1919810" node_id = "1919810"
node_data = ParameterExtractorNodeData( node_data = ParameterExtractorNodeData(
@ -89,6 +90,7 @@ class PluginNodeBackwardsInvocation(BaseBackwardsInvocation):
:param query: str :param query: str
:return: dict :return: dict
""" """
# FIXME(-LAN-): Avoid import service into core
workflow_service = WorkflowService() workflow_service = WorkflowService()
node_id = "1919810" node_id = "1919810"
node_data = QuestionClassifierNodeData( node_data = QuestionClassifierNodeData(

@ -126,9 +126,7 @@ class WordExtractor(BaseExtractor):
db.session.add(upload_file) db.session.add(upload_file)
db.session.commit() db.session.commit()
image_map[rel.target_part] = ( image_map[rel.target_part] = f"![image]({dify_config.FILES_URL}/files/{upload_file.id}/file-preview)"
f"![image]({dify_config.CONSOLE_API_URL}/files/{upload_file.id}/file-preview)"
)
return image_map return image_map

@ -86,3 +86,12 @@ class WorkflowNodeExecutionRepository(Protocol):
execution: The WorkflowNodeExecution instance to update execution: The WorkflowNodeExecution instance to update
""" """
... ...
def clear(self) -> None:
"""
Clear all WorkflowNodeExecution records based on implementation-specific criteria.
This method is intended to be used for bulk deletion operations, such as removing
all records associated with a specific app_id and tenant_id in multi-tenant implementations.
"""
...

@ -16,7 +16,7 @@ from core.variables.segments import StringSegment
from core.workflow.entities.node_entities import NodeRunResult from core.workflow.entities.node_entities import NodeRunResult
from core.workflow.entities.variable_pool import VariablePool from core.workflow.entities.variable_pool import VariablePool
from core.workflow.enums import SystemVariableKey from core.workflow.enums import SystemVariableKey
from core.workflow.nodes.agent.entities import AgentNodeData, ParamsAutoGenerated from core.workflow.nodes.agent.entities import AgentNodeData, AgentOldVersionModelFeatures, ParamsAutoGenerated
from core.workflow.nodes.base.entities import BaseNodeData from core.workflow.nodes.base.entities import BaseNodeData
from core.workflow.nodes.enums import NodeType from core.workflow.nodes.enums import NodeType
from core.workflow.nodes.event.event import RunCompletedEvent from core.workflow.nodes.event.event import RunCompletedEvent
@ -251,7 +251,12 @@ class AgentNode(ToolNode):
prompt_message.model_dump(mode="json") for prompt_message in prompt_messages prompt_message.model_dump(mode="json") for prompt_message in prompt_messages
] ]
value["history_prompt_messages"] = history_prompt_messages value["history_prompt_messages"] = history_prompt_messages
value["entity"] = model_schema.model_dump(mode="json") if model_schema else None if model_schema:
# remove structured output feature to support old version agent plugin
model_schema = self._remove_unsupported_model_features_for_old_version(model_schema)
value["entity"] = model_schema.model_dump(mode="json")
else:
value["entity"] = None
result[parameter_name] = value result[parameter_name] = value
return result return result
@ -348,3 +353,10 @@ class AgentNode(ToolNode):
) )
model_schema = model_type_instance.get_model_schema(model_name, model_credentials) model_schema = model_type_instance.get_model_schema(model_name, model_credentials)
return model_instance, model_schema return model_instance, model_schema
def _remove_unsupported_model_features_for_old_version(self, model_schema: AIModelEntity) -> AIModelEntity:
if model_schema.features:
for feature in model_schema.features:
if feature.value not in AgentOldVersionModelFeatures:
model_schema.features.remove(feature)
return model_schema

@ -24,3 +24,18 @@ class AgentNodeData(BaseNodeData):
class ParamsAutoGenerated(Enum): class ParamsAutoGenerated(Enum):
CLOSE = 0 CLOSE = 0
OPEN = 1 OPEN = 1
class AgentOldVersionModelFeatures(Enum):
"""
Enum class for old SDK version llm feature.
"""
TOOL_CALL = "tool-call"
MULTI_TOOL_CALL = "multi-tool-call"
AGENT_THOUGHT = "agent-thought"
VISION = "vision"
STREAM_TOOL_CALL = "stream-tool-call"
DOCUMENT = "document"
VIDEO = "video"
AUDIO = "audio"

@ -65,6 +65,8 @@ class LLMNodeData(BaseNodeData):
memory: Optional[MemoryConfig] = None memory: Optional[MemoryConfig] = None
context: ContextConfig context: ContextConfig
vision: VisionConfig = Field(default_factory=VisionConfig) vision: VisionConfig = Field(default_factory=VisionConfig)
structured_output: dict | None = None
structured_output_enabled: bool = False
@field_validator("prompt_config", mode="before") @field_validator("prompt_config", mode="before")
@classmethod @classmethod

@ -4,6 +4,8 @@ from collections.abc import Generator, Mapping, Sequence
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, Optional, cast from typing import TYPE_CHECKING, Any, Optional, cast
import json_repair
from configs import dify_config from configs import dify_config
from core.app.entities.app_invoke_entities import ModelConfigWithCredentialsEntity from core.app.entities.app_invoke_entities import ModelConfigWithCredentialsEntity
from core.entities.model_entities import ModelStatus from core.entities.model_entities import ModelStatus
@ -27,7 +29,13 @@ from core.model_runtime.entities.message_entities import (
SystemPromptMessage, SystemPromptMessage,
UserPromptMessage, UserPromptMessage,
) )
from core.model_runtime.entities.model_entities import ModelFeature, ModelPropertyKey, ModelType from core.model_runtime.entities.model_entities import (
AIModelEntity,
ModelFeature,
ModelPropertyKey,
ModelType,
ParameterRule,
)
from core.model_runtime.model_providers.__base.large_language_model import LargeLanguageModel from core.model_runtime.model_providers.__base.large_language_model import LargeLanguageModel
from core.model_runtime.utils.encoders import jsonable_encoder from core.model_runtime.utils.encoders import jsonable_encoder
from core.plugin.entities.plugin import ModelProviderID from core.plugin.entities.plugin import ModelProviderID
@ -57,6 +65,12 @@ from core.workflow.nodes.event import (
RunRetrieverResourceEvent, RunRetrieverResourceEvent,
RunStreamChunkEvent, RunStreamChunkEvent,
) )
from core.workflow.utils.structured_output.entities import (
ResponseFormat,
SpecialModelType,
SupportStructuredOutputStatus,
)
from core.workflow.utils.structured_output.prompt import STRUCTURED_OUTPUT_PROMPT
from core.workflow.utils.variable_template_parser import VariableTemplateParser from core.workflow.utils.variable_template_parser import VariableTemplateParser
from extensions.ext_database import db from extensions.ext_database import db
from models.model import Conversation from models.model import Conversation
@ -92,6 +106,12 @@ class LLMNode(BaseNode[LLMNodeData]):
_node_type = NodeType.LLM _node_type = NodeType.LLM
def _run(self) -> Generator[NodeEvent | InNodeEvent, None, None]: def _run(self) -> Generator[NodeEvent | InNodeEvent, None, None]:
def process_structured_output(text: str) -> Optional[dict[str, Any] | list[Any]]:
"""Process structured output if enabled"""
if not self.node_data.structured_output_enabled or not self.node_data.structured_output:
return None
return self._parse_structured_output(text)
node_inputs: Optional[dict[str, Any]] = None node_inputs: Optional[dict[str, Any]] = None
process_data = None process_data = None
result_text = "" result_text = ""
@ -130,7 +150,6 @@ class LLMNode(BaseNode[LLMNodeData]):
if isinstance(event, RunRetrieverResourceEvent): if isinstance(event, RunRetrieverResourceEvent):
context = event.context context = event.context
yield event yield event
if context: if context:
node_inputs["#context#"] = context node_inputs["#context#"] = context
@ -192,7 +211,9 @@ class LLMNode(BaseNode[LLMNodeData]):
self.deduct_llm_quota(tenant_id=self.tenant_id, model_instance=model_instance, usage=usage) self.deduct_llm_quota(tenant_id=self.tenant_id, model_instance=model_instance, usage=usage)
break break
outputs = {"text": result_text, "usage": jsonable_encoder(usage), "finish_reason": finish_reason} outputs = {"text": result_text, "usage": jsonable_encoder(usage), "finish_reason": finish_reason}
structured_output = process_structured_output(result_text)
if structured_output:
outputs["structured_output"] = structured_output
yield RunCompletedEvent( yield RunCompletedEvent(
run_result=NodeRunResult( run_result=NodeRunResult(
status=WorkflowNodeExecutionStatus.SUCCEEDED, status=WorkflowNodeExecutionStatus.SUCCEEDED,
@ -513,7 +534,12 @@ class LLMNode(BaseNode[LLMNodeData]):
if not model_schema: if not model_schema:
raise ModelNotExistError(f"Model {model_name} not exist.") raise ModelNotExistError(f"Model {model_name} not exist.")
support_structured_output = self._check_model_structured_output_support()
if support_structured_output == SupportStructuredOutputStatus.SUPPORTED:
completion_params = self._handle_native_json_schema(completion_params, model_schema.parameter_rules)
elif support_structured_output == SupportStructuredOutputStatus.UNSUPPORTED:
# Set appropriate response format based on model capabilities
self._set_response_format(completion_params, model_schema.parameter_rules)
return model_instance, ModelConfigWithCredentialsEntity( return model_instance, ModelConfigWithCredentialsEntity(
provider=provider_name, provider=provider_name,
model=model_name, model=model_name,
@ -724,10 +750,29 @@ class LLMNode(BaseNode[LLMNodeData]):
"No prompt found in the LLM configuration. " "No prompt found in the LLM configuration. "
"Please ensure a prompt is properly configured before proceeding." "Please ensure a prompt is properly configured before proceeding."
) )
support_structured_output = self._check_model_structured_output_support()
if support_structured_output == SupportStructuredOutputStatus.UNSUPPORTED:
filtered_prompt_messages = self._handle_prompt_based_schema(
prompt_messages=filtered_prompt_messages,
)
stop = model_config.stop stop = model_config.stop
return filtered_prompt_messages, stop return filtered_prompt_messages, stop
def _parse_structured_output(self, result_text: str) -> dict[str, Any] | list[Any]:
structured_output: dict[str, Any] | list[Any] = {}
try:
parsed = json.loads(result_text)
if not isinstance(parsed, (dict | list)):
raise LLMNodeError(f"Failed to parse structured output: {result_text}")
structured_output = parsed
except json.JSONDecodeError as e:
# if the result_text is not a valid json, try to repair it
parsed = json_repair.loads(result_text)
if not isinstance(parsed, (dict | list)):
raise LLMNodeError(f"Failed to parse structured output: {result_text}")
structured_output = parsed
return structured_output
@classmethod @classmethod
def deduct_llm_quota(cls, tenant_id: str, model_instance: ModelInstance, usage: LLMUsage) -> None: def deduct_llm_quota(cls, tenant_id: str, model_instance: ModelInstance, usage: LLMUsage) -> None:
provider_model_bundle = model_instance.provider_model_bundle provider_model_bundle = model_instance.provider_model_bundle
@ -926,6 +971,166 @@ class LLMNode(BaseNode[LLMNodeData]):
return prompt_messages return prompt_messages
def _handle_native_json_schema(self, model_parameters: dict, rules: list[ParameterRule]) -> dict:
"""
Handle structured output for models with native JSON schema support.
:param model_parameters: Model parameters to update
:param rules: Model parameter rules
:return: Updated model parameters with JSON schema configuration
"""
# Process schema according to model requirements
schema = self._fetch_structured_output_schema()
schema_json = self._prepare_schema_for_model(schema)
# Set JSON schema in parameters
model_parameters["json_schema"] = json.dumps(schema_json, ensure_ascii=False)
# Set appropriate response format if required by the model
for rule in rules:
if rule.name == "response_format" and ResponseFormat.JSON_SCHEMA.value in rule.options:
model_parameters["response_format"] = ResponseFormat.JSON_SCHEMA.value
return model_parameters
def _handle_prompt_based_schema(self, prompt_messages: Sequence[PromptMessage]) -> list[PromptMessage]:
"""
Handle structured output for models without native JSON schema support.
This function modifies the prompt messages to include schema-based output requirements.
Args:
prompt_messages: Original sequence of prompt messages
Returns:
list[PromptMessage]: Updated prompt messages with structured output requirements
"""
# Convert schema to string format
schema_str = json.dumps(self._fetch_structured_output_schema(), ensure_ascii=False)
# Find existing system prompt with schema placeholder
system_prompt = next(
(prompt for prompt in prompt_messages if isinstance(prompt, SystemPromptMessage)),
None,
)
structured_output_prompt = STRUCTURED_OUTPUT_PROMPT.replace("{{schema}}", schema_str)
# Prepare system prompt content
system_prompt_content = (
structured_output_prompt + "\n\n" + system_prompt.content
if system_prompt and isinstance(system_prompt.content, str)
else structured_output_prompt
)
system_prompt = SystemPromptMessage(content=system_prompt_content)
# Extract content from the last user message
filtered_prompts = [prompt for prompt in prompt_messages if not isinstance(prompt, SystemPromptMessage)]
updated_prompt = [system_prompt] + filtered_prompts
return updated_prompt
def _set_response_format(self, model_parameters: dict, rules: list) -> None:
"""
Set the appropriate response format parameter based on model rules.
:param model_parameters: Model parameters to update
:param rules: Model parameter rules
"""
for rule in rules:
if rule.name == "response_format":
if ResponseFormat.JSON.value in rule.options:
model_parameters["response_format"] = ResponseFormat.JSON.value
elif ResponseFormat.JSON_OBJECT.value in rule.options:
model_parameters["response_format"] = ResponseFormat.JSON_OBJECT.value
def _prepare_schema_for_model(self, schema: dict) -> dict:
"""
Prepare JSON schema based on model requirements.
Different models have different requirements for JSON schema formatting.
This function handles these differences.
:param schema: The original JSON schema
:return: Processed schema compatible with the current model
"""
# Deep copy to avoid modifying the original schema
processed_schema = schema.copy()
# Convert boolean types to string types (common requirement)
convert_boolean_to_string(processed_schema)
# Apply model-specific transformations
if SpecialModelType.GEMINI in self.node_data.model.name:
remove_additional_properties(processed_schema)
return processed_schema
elif SpecialModelType.OLLAMA in self.node_data.model.provider:
return processed_schema
else:
# Default format with name field
return {"schema": processed_schema, "name": "llm_response"}
def _fetch_model_schema(self, provider: str) -> AIModelEntity | None:
"""
Fetch model schema
"""
model_name = self.node_data.model.name
model_manager = ModelManager()
model_instance = model_manager.get_model_instance(
tenant_id=self.tenant_id, model_type=ModelType.LLM, provider=provider, model=model_name
)
model_type_instance = model_instance.model_type_instance
model_type_instance = cast(LargeLanguageModel, model_type_instance)
model_credentials = model_instance.credentials
model_schema = model_type_instance.get_model_schema(model_name, model_credentials)
return model_schema
def _fetch_structured_output_schema(self) -> dict[str, Any]:
"""
Fetch the structured output schema from the node data.
Returns:
dict[str, Any]: The structured output schema
"""
if not self.node_data.structured_output:
raise LLMNodeError("Please provide a valid structured output schema")
structured_output_schema = json.dumps(self.node_data.structured_output.get("schema", {}), ensure_ascii=False)
if not structured_output_schema:
raise LLMNodeError("Please provide a valid structured output schema")
try:
schema = json.loads(structured_output_schema)
if not isinstance(schema, dict):
raise LLMNodeError("structured_output_schema must be a JSON object")
return schema
except json.JSONDecodeError:
raise LLMNodeError("structured_output_schema is not valid JSON format")
def _check_model_structured_output_support(self) -> SupportStructuredOutputStatus:
"""
Check if the current model supports structured output.
Returns:
SupportStructuredOutput: The support status of structured output
"""
# Early return if structured output is disabled
if (
not isinstance(self.node_data, LLMNodeData)
or not self.node_data.structured_output_enabled
or not self.node_data.structured_output
):
return SupportStructuredOutputStatus.DISABLED
# Get model schema and check if it exists
model_schema = self._fetch_model_schema(self.node_data.model.provider)
if not model_schema:
return SupportStructuredOutputStatus.DISABLED
# Check if model supports structured output feature
return (
SupportStructuredOutputStatus.SUPPORTED
if bool(model_schema.features and ModelFeature.STRUCTURED_OUTPUT in model_schema.features)
else SupportStructuredOutputStatus.UNSUPPORTED
)
def _combine_message_content_with_role(*, contents: Sequence[PromptMessageContent], role: PromptMessageRole): def _combine_message_content_with_role(*, contents: Sequence[PromptMessageContent], role: PromptMessageRole):
match role: match role:
@ -1064,3 +1269,49 @@ def _handle_completion_template(
) )
prompt_messages.append(prompt_message) prompt_messages.append(prompt_message)
return prompt_messages return prompt_messages
def remove_additional_properties(schema: dict) -> None:
"""
Remove additionalProperties fields from JSON schema.
Used for models like Gemini that don't support this property.
:param schema: JSON schema to modify in-place
"""
if not isinstance(schema, dict):
return
# Remove additionalProperties at current level
schema.pop("additionalProperties", None)
# Process nested structures recursively
for value in schema.values():
if isinstance(value, dict):
remove_additional_properties(value)
elif isinstance(value, list):
for item in value:
if isinstance(item, dict):
remove_additional_properties(item)
def convert_boolean_to_string(schema: dict) -> None:
"""
Convert boolean type specifications to string in JSON schema.
:param schema: JSON schema to modify in-place
"""
if not isinstance(schema, dict):
return
# Check for boolean type at current level
if schema.get("type") == "boolean":
schema["type"] = "string"
# Process nested dictionaries and lists recursively
for value in schema.values():
if isinstance(value, dict):
convert_boolean_to_string(value)
elif isinstance(value, list):
for item in value:
if isinstance(item, dict):
convert_boolean_to_string(item)

@ -0,0 +1,24 @@
from enum import StrEnum
class ResponseFormat(StrEnum):
"""Constants for model response formats"""
JSON_SCHEMA = "json_schema" # model's structured output mode. some model like gemini, gpt-4o, support this mode.
JSON = "JSON" # model's json mode. some model like claude support this mode.
JSON_OBJECT = "json_object" # json mode's another alias. some model like deepseek-chat, qwen use this alias.
class SpecialModelType(StrEnum):
"""Constants for identifying model types"""
GEMINI = "gemini"
OLLAMA = "ollama"
class SupportStructuredOutputStatus(StrEnum):
"""Constants for structured output support status"""
SUPPORTED = "supported"
UNSUPPORTED = "unsupported"
DISABLED = "disabled"

@ -0,0 +1,17 @@
STRUCTURED_OUTPUT_PROMPT = """Youre a helpful AI assistant. You could answer questions and output in JSON format.
constraints:
- You must output in JSON format.
- Do not output boolean value, use string type instead.
- Do not output integer or float value, use number type instead.
eg:
Here is the JSON schema:
{"additionalProperties": false, "properties": {"age": {"type": "number"}, "name": {"type": "string"}}, "required": ["name", "age"], "type": "object"}
Here is the user's question:
My name is John Doe and I am 30 years old.
output:
{"name": "John Doe", "age": 30}
Here is the JSON schema:
{{schema}}
""" # noqa: E501

@ -630,6 +630,7 @@ class WorkflowNodeExecution(Base):
@property @property
def created_by_account(self): def created_by_account(self):
created_by_role = CreatedByRole(self.created_by_role) created_by_role = CreatedByRole(self.created_by_role)
# TODO(-LAN-): Avoid using db.session.get() here.
return db.session.get(Account, self.created_by) if created_by_role == CreatedByRole.ACCOUNT else None return db.session.get(Account, self.created_by) if created_by_role == CreatedByRole.ACCOUNT else None
@property @property
@ -637,6 +638,7 @@ class WorkflowNodeExecution(Base):
from models.model import EndUser from models.model import EndUser
created_by_role = CreatedByRole(self.created_by_role) created_by_role = CreatedByRole(self.created_by_role)
# TODO(-LAN-): Avoid using db.session.get() here.
return db.session.get(EndUser, self.created_by) if created_by_role == CreatedByRole.END_USER else None return db.session.get(EndUser, self.created_by) if created_by_role == CreatedByRole.END_USER else None
@property @property

@ -30,6 +30,7 @@ dependencies = [
"gunicorn~=23.0.0", "gunicorn~=23.0.0",
"httpx[socks]~=0.27.0", "httpx[socks]~=0.27.0",
"jieba==0.42.1", "jieba==0.42.1",
"json-repair>=0.41.1",
"langfuse~=2.51.3", "langfuse~=2.51.3",
"langsmith~=0.1.77", "langsmith~=0.1.77",
"mailchimp-transactional~=1.0.50", "mailchimp-transactional~=1.0.50",
@ -163,10 +164,7 @@ storage = [
############################################################ ############################################################
# [ Tools ] dependency group # [ Tools ] dependency group
############################################################ ############################################################
tools = [ tools = ["cloudscraper~=1.2.71", "nltk~=3.9.1"]
"cloudscraper~=1.2.71",
"nltk~=3.9.1",
]
############################################################ ############################################################
# [ VDB ] dependency group # [ VDB ] dependency group

@ -6,7 +6,7 @@ import logging
from collections.abc import Sequence from collections.abc import Sequence
from typing import Optional from typing import Optional
from sqlalchemy import UnaryExpression, asc, desc, select from sqlalchemy import UnaryExpression, asc, delete, desc, select
from sqlalchemy.engine import Engine from sqlalchemy.engine import Engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
@ -168,3 +168,25 @@ class SQLAlchemyWorkflowNodeExecutionRepository:
session.merge(execution) session.merge(execution)
session.commit() session.commit()
def clear(self) -> None:
"""
Clear all WorkflowNodeExecution records for the current tenant_id and app_id.
This method deletes all WorkflowNodeExecution records that match the tenant_id
and app_id (if provided) associated with this repository instance.
"""
with self._session_factory() as session:
stmt = delete(WorkflowNodeExecution).where(WorkflowNodeExecution.tenant_id == self._tenant_id)
if self._app_id:
stmt = stmt.where(WorkflowNodeExecution.app_id == self._app_id)
result = session.execute(stmt)
session.commit()
deleted_count = result.rowcount
logger.info(
f"Cleared {deleted_count} workflow node execution records for tenant {self._tenant_id}"
+ (f" and app {self._app_id}" if self._app_id else "")
)

@ -407,10 +407,8 @@ class AccountService:
raise PasswordResetRateLimitExceededError() raise PasswordResetRateLimitExceededError()
code = "".join([str(random.randint(0, 9)) for _ in range(6)]) code, token = cls.generate_reset_password_token(account_email, account)
token = TokenManager.generate_token(
account=account, email=email, token_type="reset_password", additional_data={"code": code}
)
send_reset_password_mail_task.delay( send_reset_password_mail_task.delay(
language=language, language=language,
to=account_email, to=account_email,
@ -419,6 +417,22 @@ class AccountService:
cls.reset_password_rate_limiter.increment_rate_limit(account_email) cls.reset_password_rate_limiter.increment_rate_limit(account_email)
return token return token
@classmethod
def generate_reset_password_token(
cls,
email: str,
account: Optional[Account] = None,
code: Optional[str] = None,
additional_data: dict[str, Any] = {},
):
if not code:
code = "".join([str(random.randint(0, 9)) for _ in range(6)])
additional_data["code"] = code
token = TokenManager.generate_token(
account=account, email=email, token_type="reset_password", additional_data=additional_data
)
return code, token
@classmethod @classmethod
def revoke_reset_password_token(cls, token: str): def revoke_reset_password_token(cls, token: str):
TokenManager.revoke_token(token, "reset_password") TokenManager.revoke_token(token, "reset_password")

@ -2,13 +2,14 @@ import threading
from typing import Optional from typing import Optional
import contexts import contexts
from core.repository import RepositoryFactory
from core.repository.workflow_node_execution_repository import OrderConfig
from extensions.ext_database import db from extensions.ext_database import db
from libs.infinite_scroll_pagination import InfiniteScrollPagination from libs.infinite_scroll_pagination import InfiniteScrollPagination
from models.enums import WorkflowRunTriggeredFrom from models.enums import WorkflowRunTriggeredFrom
from models.model import App from models.model import App
from models.workflow import ( from models.workflow import (
WorkflowNodeExecution, WorkflowNodeExecution,
WorkflowNodeExecutionTriggeredFrom,
WorkflowRun, WorkflowRun,
) )
@ -127,17 +128,17 @@ class WorkflowRunService:
if not workflow_run: if not workflow_run:
return [] return []
node_executions = ( # Use the repository to get the node executions
db.session.query(WorkflowNodeExecution) repository = RepositoryFactory.create_workflow_node_execution_repository(
.filter( params={
WorkflowNodeExecution.tenant_id == app_model.tenant_id, "tenant_id": app_model.tenant_id,
WorkflowNodeExecution.app_id == app_model.id, "app_id": app_model.id,
WorkflowNodeExecution.workflow_id == workflow_run.workflow_id, "session_factory": db.session.get_bind,
WorkflowNodeExecution.triggered_from == WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN.value, }
WorkflowNodeExecution.workflow_run_id == run_id,
)
.order_by(WorkflowNodeExecution.index.desc())
.all()
) )
return node_executions # Use the repository to get the node executions with ordering
order_config = OrderConfig(order_by=["index"], order_direction="desc")
node_executions = repository.get_by_workflow_run(workflow_run_id=run_id, order_config=order_config)
return list(node_executions)

@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
from core.app.apps.advanced_chat.app_config_manager import AdvancedChatAppConfigManager from core.app.apps.advanced_chat.app_config_manager import AdvancedChatAppConfigManager
from core.app.apps.workflow.app_config_manager import WorkflowAppConfigManager from core.app.apps.workflow.app_config_manager import WorkflowAppConfigManager
from core.model_runtime.utils.encoders import jsonable_encoder from core.model_runtime.utils.encoders import jsonable_encoder
from core.repository import RepositoryFactory
from core.variables import Variable from core.variables import Variable
from core.workflow.entities.node_entities import NodeRunResult from core.workflow.entities.node_entities import NodeRunResult
from core.workflow.errors import WorkflowNodeRunFailedError from core.workflow.errors import WorkflowNodeRunFailedError
@ -282,8 +283,15 @@ class WorkflowService:
workflow_node_execution.created_by = account.id workflow_node_execution.created_by = account.id
workflow_node_execution.workflow_id = draft_workflow.id workflow_node_execution.workflow_id = draft_workflow.id
db.session.add(workflow_node_execution) # Use the repository to save the workflow node execution
db.session.commit() repository = RepositoryFactory.create_workflow_node_execution_repository(
params={
"tenant_id": app_model.tenant_id,
"app_id": app_model.id,
"session_factory": db.session.get_bind,
}
)
repository.save(workflow_node_execution)
return workflow_node_execution return workflow_node_execution

@ -7,6 +7,7 @@ from celery import shared_task # type: ignore
from sqlalchemy import delete from sqlalchemy import delete
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from core.repository import RepositoryFactory
from extensions.ext_database import db from extensions.ext_database import db
from models.dataset import AppDatasetJoin from models.dataset import AppDatasetJoin
from models.model import ( from models.model import (
@ -30,7 +31,7 @@ from models.model import (
) )
from models.tools import WorkflowToolProvider from models.tools import WorkflowToolProvider
from models.web import PinnedConversation, SavedMessage from models.web import PinnedConversation, SavedMessage
from models.workflow import ConversationVariable, Workflow, WorkflowAppLog, WorkflowNodeExecution, WorkflowRun from models.workflow import ConversationVariable, Workflow, WorkflowAppLog, WorkflowRun
@shared_task(queue="app_deletion", bind=True, max_retries=3) @shared_task(queue="app_deletion", bind=True, max_retries=3)
@ -187,18 +188,20 @@ def _delete_app_workflow_runs(tenant_id: str, app_id: str):
def _delete_app_workflow_node_executions(tenant_id: str, app_id: str): def _delete_app_workflow_node_executions(tenant_id: str, app_id: str):
def del_workflow_node_execution(workflow_node_execution_id: str): # Create a repository instance for WorkflowNodeExecution
db.session.query(WorkflowNodeExecution).filter(WorkflowNodeExecution.id == workflow_node_execution_id).delete( repository = RepositoryFactory.create_workflow_node_execution_repository(
synchronize_session=False params={
) "tenant_id": tenant_id,
"app_id": app_id,
_delete_records( "session_factory": db.session.get_bind,
"""select id from workflow_node_executions where tenant_id=:tenant_id and app_id=:app_id limit 1000""", }
{"tenant_id": tenant_id, "app_id": app_id},
del_workflow_node_execution,
"workflow node execution",
) )
# Use the clear method to delete all records for this tenant_id and app_id
repository.clear()
logging.info(click.style(f"Deleted workflow node executions for tenant {tenant_id} and app {app_id}", fg="green"))
def _delete_app_workflow_app_logs(tenant_id: str, app_id: str): def _delete_app_workflow_app_logs(tenant_id: str, app_id: str):
def del_workflow_app_log(workflow_app_log_id: str): def del_workflow_app_log(workflow_app_log_id: str):

@ -152,3 +152,27 @@ def test_update(repository, session):
# Assert session.merge was called # Assert session.merge was called
session_obj.merge.assert_called_once_with(execution) session_obj.merge.assert_called_once_with(execution)
def test_clear(repository, session, mocker: MockerFixture):
"""Test clear method."""
session_obj, _ = session
# Set up mock
mock_delete = mocker.patch("repositories.workflow_node_execution.sqlalchemy_repository.delete")
mock_stmt = mocker.MagicMock()
mock_delete.return_value = mock_stmt
mock_stmt.where.return_value = mock_stmt
# Mock the execute result with rowcount
mock_result = mocker.MagicMock()
mock_result.rowcount = 5 # Simulate 5 records deleted
session_obj.execute.return_value = mock_result
# Call method
repository.clear()
# Assert delete was called with correct parameters
mock_delete.assert_called_once_with(WorkflowNodeExecution)
mock_stmt.where.assert_called()
session_obj.execute.assert_called_once_with(mock_stmt)
session_obj.commit.assert_called_once()

@ -1,5 +1,4 @@
version = 1 version = 1
revision = 1
requires-python = ">=3.11, <3.13" requires-python = ">=3.11, <3.13"
resolution-markers = [ resolution-markers = [
"python_full_version >= '3.12.4' and platform_python_implementation != 'PyPy'", "python_full_version >= '3.12.4' and platform_python_implementation != 'PyPy'",
@ -1178,6 +1177,7 @@ dependencies = [
{ name = "gunicorn" }, { name = "gunicorn" },
{ name = "httpx", extra = ["socks"] }, { name = "httpx", extra = ["socks"] },
{ name = "jieba" }, { name = "jieba" },
{ name = "json-repair" },
{ name = "langfuse" }, { name = "langfuse" },
{ name = "langsmith" }, { name = "langsmith" },
{ name = "mailchimp-transactional" }, { name = "mailchimp-transactional" },
@ -1346,6 +1346,7 @@ requires-dist = [
{ name = "gunicorn", specifier = "~=23.0.0" }, { name = "gunicorn", specifier = "~=23.0.0" },
{ name = "httpx", extras = ["socks"], specifier = "~=0.27.0" }, { name = "httpx", extras = ["socks"], specifier = "~=0.27.0" },
{ name = "jieba", specifier = "==0.42.1" }, { name = "jieba", specifier = "==0.42.1" },
{ name = "json-repair", specifier = ">=0.41.1" },
{ name = "langfuse", specifier = "~=2.51.3" }, { name = "langfuse", specifier = "~=2.51.3" },
{ name = "langsmith", specifier = "~=0.1.77" }, { name = "langsmith", specifier = "~=0.1.77" },
{ name = "mailchimp-transactional", specifier = "~=1.0.50" }, { name = "mailchimp-transactional", specifier = "~=1.0.50" },
@ -2524,6 +2525,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/91/29/df4b9b42f2be0b623cbd5e2140cafcaa2bef0759a00b7b70104dcfe2fb51/joblib-1.4.2-py3-none-any.whl", hash = "sha256:06d478d5674cbc267e7496a410ee875abd68e4340feff4490bcb7afb88060ae6", size = 301817 }, { url = "https://files.pythonhosted.org/packages/91/29/df4b9b42f2be0b623cbd5e2140cafcaa2bef0759a00b7b70104dcfe2fb51/joblib-1.4.2-py3-none-any.whl", hash = "sha256:06d478d5674cbc267e7496a410ee875abd68e4340feff4490bcb7afb88060ae6", size = 301817 },
] ]
[[package]]
name = "json-repair"
version = "0.41.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6d/6a/6c7a75a10da6dc807b582f2449034da1ed74415e8899746bdfff97109012/json_repair-0.41.1.tar.gz", hash = "sha256:bba404b0888c84a6b86ecc02ec43b71b673cfee463baf6da94e079c55b136565", size = 31208 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/5c/abd7495c934d9af5c263c2245ae30cfaa716c3c0cf027b2b8fa686ee7bd4/json_repair-0.41.1-py3-none-any.whl", hash = "sha256:0e181fd43a696887881fe19fed23422a54b3e4c558b6ff27a86a8c3ddde9ae79", size = 21578 },
]
[[package]] [[package]]
name = "jsonpath-python" name = "jsonpath-python"
version = "1.0.6" version = "1.0.6"
@ -4074,6 +4084,8 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/af/cd/ed6e429fb0792ce368f66e83246264dd3a7a045b0b1e63043ed22a063ce5/pycryptodome-3.19.1-cp35-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:7c9e222d0976f68d0cf6409cfea896676ddc1d98485d601e9508f90f60e2b0a2", size = 2144914 }, { url = "https://files.pythonhosted.org/packages/af/cd/ed6e429fb0792ce368f66e83246264dd3a7a045b0b1e63043ed22a063ce5/pycryptodome-3.19.1-cp35-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:7c9e222d0976f68d0cf6409cfea896676ddc1d98485d601e9508f90f60e2b0a2", size = 2144914 },
{ url = "https://files.pythonhosted.org/packages/f6/23/b064bd4cfbf2cc5f25afcde0e7c880df5b20798172793137ba4b62d82e72/pycryptodome-3.19.1-cp35-abi3-win32.whl", hash = "sha256:4805e053571140cb37cf153b5c72cd324bb1e3e837cbe590a19f69b6cf85fd03", size = 1713105 }, { url = "https://files.pythonhosted.org/packages/f6/23/b064bd4cfbf2cc5f25afcde0e7c880df5b20798172793137ba4b62d82e72/pycryptodome-3.19.1-cp35-abi3-win32.whl", hash = "sha256:4805e053571140cb37cf153b5c72cd324bb1e3e837cbe590a19f69b6cf85fd03", size = 1713105 },
{ url = "https://files.pythonhosted.org/packages/7d/e0/ded1968a5257ab34216a0f8db7433897a2337d59e6d03be113713b346ea2/pycryptodome-3.19.1-cp35-abi3-win_amd64.whl", hash = "sha256:a470237ee71a1efd63f9becebc0ad84b88ec28e6784a2047684b693f458f41b7", size = 1749222 }, { url = "https://files.pythonhosted.org/packages/7d/e0/ded1968a5257ab34216a0f8db7433897a2337d59e6d03be113713b346ea2/pycryptodome-3.19.1-cp35-abi3-win_amd64.whl", hash = "sha256:a470237ee71a1efd63f9becebc0ad84b88ec28e6784a2047684b693f458f41b7", size = 1749222 },
{ url = "https://files.pythonhosted.org/packages/1d/e3/0c9679cd66cf5604b1f070bdf4525a0c01a15187be287d8348b2eafb718e/pycryptodome-3.19.1-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:ed932eb6c2b1c4391e166e1a562c9d2f020bfff44a0e1b108f67af38b390ea89", size = 1629005 },
{ url = "https://files.pythonhosted.org/packages/13/75/0d63bf0daafd0580b17202d8a9dd57f28c8487f26146b3e2799b0c5a059c/pycryptodome-3.19.1-pp27-pypy_73-win32.whl", hash = "sha256:81e9d23c0316fc1b45d984a44881b220062336bbdc340aa9218e8d0656587934", size = 1697997 },
] ]
[[package]] [[package]]

@ -130,6 +130,7 @@ services:
HTTP_PROXY: ${SANDBOX_HTTP_PROXY:-http://ssrf_proxy:3128} HTTP_PROXY: ${SANDBOX_HTTP_PROXY:-http://ssrf_proxy:3128}
HTTPS_PROXY: ${SANDBOX_HTTPS_PROXY:-http://ssrf_proxy:3128} HTTPS_PROXY: ${SANDBOX_HTTPS_PROXY:-http://ssrf_proxy:3128}
SANDBOX_PORT: ${SANDBOX_PORT:-8194} SANDBOX_PORT: ${SANDBOX_PORT:-8194}
PIP_MIRROR_URL: ${PIP_MIRROR_URL:-}
volumes: volumes:
- ./volumes/sandbox/dependencies:/dependencies - ./volumes/sandbox/dependencies:/dependencies
- ./volumes/sandbox/conf:/conf - ./volumes/sandbox/conf:/conf

@ -60,6 +60,7 @@ services:
HTTP_PROXY: ${SANDBOX_HTTP_PROXY:-http://ssrf_proxy:3128} HTTP_PROXY: ${SANDBOX_HTTP_PROXY:-http://ssrf_proxy:3128}
HTTPS_PROXY: ${SANDBOX_HTTPS_PROXY:-http://ssrf_proxy:3128} HTTPS_PROXY: ${SANDBOX_HTTPS_PROXY:-http://ssrf_proxy:3128}
SANDBOX_PORT: ${SANDBOX_PORT:-8194} SANDBOX_PORT: ${SANDBOX_PORT:-8194}
PIP_MIRROR_URL: ${PIP_MIRROR_URL:-}
volumes: volumes:
- ./volumes/sandbox/dependencies:/dependencies - ./volumes/sandbox/dependencies:/dependencies
- ./volumes/sandbox/conf:/conf - ./volumes/sandbox/conf:/conf

@ -603,6 +603,7 @@ services:
HTTP_PROXY: ${SANDBOX_HTTP_PROXY:-http://ssrf_proxy:3128} HTTP_PROXY: ${SANDBOX_HTTP_PROXY:-http://ssrf_proxy:3128}
HTTPS_PROXY: ${SANDBOX_HTTPS_PROXY:-http://ssrf_proxy:3128} HTTPS_PROXY: ${SANDBOX_HTTPS_PROXY:-http://ssrf_proxy:3128}
SANDBOX_PORT: ${SANDBOX_PORT:-8194} SANDBOX_PORT: ${SANDBOX_PORT:-8194}
PIP_MIRROR_URL: ${PIP_MIRROR_URL:-}
volumes: volumes:
- ./volumes/sandbox/dependencies:/dependencies - ./volumes/sandbox/dependencies:/dependencies
- ./volumes/sandbox/conf:/conf - ./volumes/sandbox/conf:/conf

@ -0,0 +1,82 @@
import { fireEvent, render, screen } from '@testing-library/react'
import ConfigSelect from './index'
jest.mock('react-sortablejs', () => ({
ReactSortable: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
describe('ConfigSelect Component', () => {
const defaultProps = {
options: ['Option 1', 'Option 2'],
onChange: jest.fn(),
}
afterEach(() => {
jest.clearAllMocks()
})
it('renders all options', () => {
render(<ConfigSelect {...defaultProps} />)
defaultProps.options.forEach((option) => {
expect(screen.getByDisplayValue(option)).toBeInTheDocument()
})
})
it('renders add button', () => {
render(<ConfigSelect {...defaultProps} />)
expect(screen.getByText('appDebug.variableConfig.addOption')).toBeInTheDocument()
})
it('handles option deletion', () => {
render(<ConfigSelect {...defaultProps} />)
const optionContainer = screen.getByDisplayValue('Option 1').closest('div')
const deleteButton = optionContainer?.querySelector('div[role="button"]')
if (!deleteButton) return
fireEvent.click(deleteButton)
expect(defaultProps.onChange).toHaveBeenCalledWith(['Option 2'])
})
it('handles adding new option', () => {
render(<ConfigSelect {...defaultProps} />)
const addButton = screen.getByText('appDebug.variableConfig.addOption')
fireEvent.click(addButton)
expect(defaultProps.onChange).toHaveBeenCalledWith([...defaultProps.options, ''])
})
it('applies focus styles on input focus', () => {
render(<ConfigSelect {...defaultProps} />)
const firstInput = screen.getByDisplayValue('Option 1')
fireEvent.focus(firstInput)
expect(firstInput.closest('div')).toHaveClass('border-components-input-border-active')
})
it('applies delete hover styles', () => {
render(<ConfigSelect {...defaultProps} />)
const optionContainer = screen.getByDisplayValue('Option 1').closest('div')
const deleteButton = optionContainer?.querySelector('div[role="button"]')
if (!deleteButton) return
fireEvent.mouseEnter(deleteButton)
expect(optionContainer).toHaveClass('border-components-input-border-destructive')
})
it('renders empty state correctly', () => {
render(<ConfigSelect options={[]} onChange={defaultProps.onChange} />)
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
expect(screen.getByText('appDebug.variableConfig.addOption')).toBeInTheDocument()
})
})

@ -51,7 +51,7 @@ const ConfigSelect: FC<IConfigSelectProps> = ({
<RiDraggable className='handle h-4 w-4 cursor-grab text-text-quaternary' /> <RiDraggable className='handle h-4 w-4 cursor-grab text-text-quaternary' />
<input <input
key={index} key={index}
type="input" type='input'
value={o || ''} value={o || ''}
onChange={(e) => { onChange={(e) => {
const value = e.target.value const value = e.target.value
@ -67,6 +67,7 @@ const ConfigSelect: FC<IConfigSelectProps> = ({
onBlur={() => setFocusID(null)} onBlur={() => setFocusID(null)}
/> />
<div <div
role='button'
className='absolute right-1.5 top-1/2 block translate-y-[-50%] cursor-pointer rounded-md p-1 text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive' className='absolute right-1.5 top-1/2 block translate-y-[-50%] cursor-pointer rounded-md p-1 text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive'
onClick={() => { onClick={() => {
onChange(options.filter((_, i) => index !== i)) onChange(options.filter((_, i) => index !== i))

@ -234,4 +234,6 @@ const Answer: FC<AnswerProps> = ({
) )
} }
export default memo(Answer) export default memo(Answer, (prevProps, nextProps) =>
prevProps.responding === false && nextProps.responding === false,
)

@ -0,0 +1,11 @@
const IndeterminateIcon = () => {
return (
<div data-testid='indeterminate-icon'>
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M2.5 6H9.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
</div>
)
}
export default IndeterminateIcon

@ -1,5 +0,0 @@
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="check">
<path id="Vector 1" d="M2.5 6H9.5" stroke="white" stroke-width="1.5" stroke-linecap="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 217 B

@ -1,10 +0,0 @@
.mixed {
background: var(--color-components-checkbox-bg) url(./assets/mixed.svg) center center no-repeat;
background-size: 12px 12px;
border: none;
}
.checked.disabled {
background-color: #d0d5dd;
border-color: #d0d5dd;
}

@ -0,0 +1,67 @@
import { fireEvent, render, screen } from '@testing-library/react'
import Checkbox from './index'
describe('Checkbox Component', () => {
const mockProps = {
id: 'test',
}
it('renders unchecked checkbox by default', () => {
render(<Checkbox {...mockProps} />)
const checkbox = screen.getByTestId('checkbox-test')
expect(checkbox).toBeInTheDocument()
expect(checkbox).not.toHaveClass('bg-components-checkbox-bg')
})
it('renders checked checkbox when checked prop is true', () => {
render(<Checkbox {...mockProps} checked />)
const checkbox = screen.getByTestId('checkbox-test')
expect(checkbox).toHaveClass('bg-components-checkbox-bg')
expect(screen.getByTestId('check-icon-test')).toBeInTheDocument()
})
it('renders indeterminate state correctly', () => {
render(<Checkbox {...mockProps} indeterminate />)
expect(screen.getByTestId('indeterminate-icon')).toBeInTheDocument()
})
it('handles click events when not disabled', () => {
const onCheck = jest.fn()
render(<Checkbox {...mockProps} onCheck={onCheck} />)
const checkbox = screen.getByTestId('checkbox-test')
fireEvent.click(checkbox)
expect(onCheck).toHaveBeenCalledTimes(1)
})
it('does not handle click events when disabled', () => {
const onCheck = jest.fn()
render(<Checkbox {...mockProps} disabled onCheck={onCheck} />)
const checkbox = screen.getByTestId('checkbox-test')
fireEvent.click(checkbox)
expect(onCheck).not.toHaveBeenCalled()
expect(checkbox).toHaveClass('cursor-not-allowed')
})
it('applies custom className when provided', () => {
const customClass = 'custom-class'
render(<Checkbox {...mockProps} className={customClass} />)
const checkbox = screen.getByTestId('checkbox-test')
expect(checkbox).toHaveClass(customClass)
})
it('applies correct styles for disabled checked state', () => {
render(<Checkbox {...mockProps} checked disabled />)
const checkbox = screen.getByTestId('checkbox-test')
expect(checkbox).toHaveClass('bg-components-checkbox-bg-disabled-checked')
expect(checkbox).toHaveClass('cursor-not-allowed')
})
it('applies correct styles for disabled unchecked state', () => {
render(<Checkbox {...mockProps} disabled />)
const checkbox = screen.getByTestId('checkbox-test')
expect(checkbox).toHaveClass('bg-components-checkbox-bg-disabled')
expect(checkbox).toHaveClass('cursor-not-allowed')
})
})

@ -1,48 +1,49 @@
import { RiCheckLine } from '@remixicon/react' import { RiCheckLine } from '@remixicon/react'
import s from './index.module.css'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
import IndeterminateIcon from './assets/indeterminate-icon'
type CheckboxProps = { type CheckboxProps = {
id?: string
checked?: boolean checked?: boolean
onCheck?: () => void onCheck?: () => void
className?: string className?: string
disabled?: boolean disabled?: boolean
mixed?: boolean indeterminate?: boolean
} }
const Checkbox = ({ checked, onCheck, className, disabled, mixed }: CheckboxProps) => { const Checkbox = ({
if (!checked) { id,
return ( checked,
<div onCheck,
className={cn( className,
'h-4 w-4 cursor-pointer rounded-[4px] border border-components-checkbox-border bg-components-checkbox-bg-unchecked shadow-xs hover:border-components-checkbox-border-hover', disabled,
mixed ? s.mixed : 'hover:bg-components-checkbox-bg-unchecked-hover', indeterminate,
disabled && 'cursor-not-allowed border-components-checkbox-border-disabled bg-components-checkbox-bg-disabled hover:border-components-checkbox-border-disabled hover:bg-components-checkbox-bg-disabled', }: CheckboxProps) => {
className, const checkClassName = (checked || indeterminate)
)} ? 'bg-components-checkbox-bg text-components-checkbox-icon hover:bg-components-checkbox-bg-hover'
onClick={() => { : 'border border-components-checkbox-border bg-components-checkbox-bg-unchecked hover:bg-components-checkbox-bg-unchecked-hover hover:border-components-checkbox-border-hover'
if (disabled) const disabledClassName = (checked || indeterminate)
return ? 'cursor-not-allowed bg-components-checkbox-bg-disabled-checked text-components-checkbox-icon-disabled hover:bg-components-checkbox-bg-disabled-checked'
onCheck?.() : 'cursor-not-allowed border-components-checkbox-border-disabled bg-components-checkbox-bg-disabled hover:border-components-checkbox-border-disabled hover:bg-components-checkbox-bg-disabled'
}}
></div>
)
}
return ( return (
<div <div
id={id}
className={cn( className={cn(
'flex h-4 w-4 cursor-pointer items-center justify-center rounded-[4px] bg-components-checkbox-bg text-components-checkbox-icon shadow-xs hover:bg-components-checkbox-bg-hover', 'flex h-4 w-4 cursor-pointer items-center justify-center rounded-[4px] shadow-xs shadow-shadow-shadow-3',
disabled && 'cursor-not-allowed bg-components-checkbox-bg-disabled-checked text-components-checkbox-icon-disabled hover:bg-components-checkbox-bg-disabled-checked', checkClassName,
disabled && disabledClassName,
className, className,
)} )}
onClick={() => { onClick={() => {
if (disabled) if (disabled)
return return
onCheck?.() onCheck?.()
}} }}
data-testid={`checkbox-${id}`}
> >
<RiCheckLine className={cn('h-3 w-3')} /> {!checked && indeterminate && <IndeterminateIcon />}
{checked && <RiCheckLine className='h-3 w-3' data-testid={`check-icon-${id}`} />}
</div> </div>
) )
} }

@ -0,0 +1,43 @@
import cn from '@/utils/classnames'
import { useFieldContext } from '../..'
import Checkbox from '../../../checkbox'
type CheckboxFieldProps = {
label: string;
labelClassName?: string;
}
const CheckboxField = ({
label,
labelClassName,
}: CheckboxFieldProps) => {
const field = useFieldContext<boolean>()
return (
<div className='flex gap-2'>
<div className='flex h-6 shrink-0 items-center'>
<Checkbox
id={field.name}
checked={field.state.value}
onCheck={() => {
field.handleChange(!field.state.value)
}}
/>
</div>
<label
htmlFor={field.name}
className={cn(
'system-sm-medium grow cursor-pointer pt-1 text-text-secondary',
labelClassName,
)}
onClick={() => {
field.handleChange(!field.state.value)
}}
>
{label}
</label>
</div>
)
}
export default CheckboxField

@ -0,0 +1,49 @@
import React from 'react'
import { useFieldContext } from '../..'
import Label from '../label'
import cn from '@/utils/classnames'
import type { InputNumberProps } from '../../../input-number'
import { InputNumber } from '../../../input-number'
type TextFieldProps = {
label: string
isRequired?: boolean
showOptional?: boolean
tooltip?: string
className?: string
labelClassName?: string
} & Omit<InputNumberProps, 'id' | 'value' | 'onChange' | 'onBlur'>
const NumberInputField = ({
label,
isRequired,
showOptional,
tooltip,
className,
labelClassName,
...inputProps
}: TextFieldProps) => {
const field = useFieldContext<number | undefined>()
return (
<div className={cn('flex flex-col gap-y-0.5', className)}>
<Label
htmlFor={field.name}
label={label}
isRequired={isRequired}
showOptional={showOptional}
tooltip={tooltip}
className={labelClassName}
/>
<InputNumber
id={field.name}
value={field.state.value}
onChange={value => field.handleChange(value)}
onBlur={field.handleBlur}
{...inputProps}
/>
</div>
)
}
export default NumberInputField

@ -0,0 +1,34 @@
import cn from '@/utils/classnames'
import { useFieldContext } from '../..'
import Label from '../label'
import ConfigSelect from '@/app/components/app/configuration/config-var/config-select'
type OptionsFieldProps = {
label: string;
className?: string;
labelClassName?: string;
}
const OptionsField = ({
label,
className,
labelClassName,
}: OptionsFieldProps) => {
const field = useFieldContext<string[]>()
return (
<div className={cn('flex flex-col gap-y-0.5', className)}>
<Label
htmlFor={field.name}
label={label}
className={labelClassName}
/>
<ConfigSelect
options={field.state.value}
onChange={value => field.handleChange(value)}
/>
</div>
)
}
export default OptionsField

@ -0,0 +1,51 @@
import cn from '@/utils/classnames'
import { useFieldContext } from '../..'
import PureSelect from '../../../select/pure'
import Label from '../label'
type SelectOption = {
value: string
label: string
}
type SelectFieldProps = {
label: string
options: SelectOption[]
isRequired?: boolean
showOptional?: boolean
tooltip?: string
className?: string
labelClassName?: string
}
const SelectField = ({
label,
options,
isRequired,
showOptional,
tooltip,
className,
labelClassName,
}: SelectFieldProps) => {
const field = useFieldContext<string>()
return (
<div className={cn('flex flex-col gap-y-0.5', className)}>
<Label
htmlFor={field.name}
label={label}
isRequired={isRequired}
showOptional={showOptional}
tooltip={tooltip}
className={labelClassName}
/>
<PureSelect
value={field.state.value}
options={options}
onChange={value => field.handleChange(value)}
/>
</div>
)
}
export default SelectField

@ -0,0 +1,48 @@
import React from 'react'
import { useFieldContext } from '../..'
import Input, { type InputProps } from '../../../input'
import Label from '../label'
import cn from '@/utils/classnames'
type TextFieldProps = {
label: string
isRequired?: boolean
showOptional?: boolean
tooltip?: string
className?: string
labelClassName?: string
} & Omit<InputProps, 'className' | 'onChange' | 'onBlur' | 'value' | 'id'>
const TextField = ({
label,
isRequired,
showOptional,
tooltip,
className,
labelClassName,
...inputProps
}: TextFieldProps) => {
const field = useFieldContext<string>()
return (
<div className={cn('flex flex-col gap-y-0.5', className)}>
<Label
htmlFor={field.name}
label={label}
isRequired={isRequired}
showOptional={showOptional}
tooltip={tooltip}
className={labelClassName}
/>
<Input
id={field.name}
value={field.state.value}
onChange={e => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
{...inputProps}
/>
</div>
)
}
export default TextField

@ -0,0 +1,25 @@
import { useStore } from '@tanstack/react-form'
import { useFormContext } from '../..'
import Button, { type ButtonProps } from '../../../button'
type SubmitButtonProps = Omit<ButtonProps, 'disabled' | 'loading' | 'onClick'>
const SubmitButton = ({ ...buttonProps }: SubmitButtonProps) => {
const form = useFormContext()
const [isSubmitting, canSubmit] = useStore(form.store, state => [
state.isSubmitting,
state.canSubmit,
])
return (
<Button
disabled={isSubmitting || !canSubmit}
loading={isSubmitting}
onClick={() => form.handleSubmit()}
{...buttonProps}
/>
)
}
export default SubmitButton

@ -0,0 +1,53 @@
import { fireEvent, render, screen } from '@testing-library/react'
import Label from './label'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
describe('Label Component', () => {
const defaultProps = {
htmlFor: 'test-input',
label: 'Test Label',
}
it('renders basic label correctly', () => {
render(<Label {...defaultProps} />)
const label = screen.getByTestId('label')
expect(label).toBeInTheDocument()
expect(label).toHaveAttribute('for', 'test-input')
})
it('shows optional text when showOptional is true', () => {
render(<Label {...defaultProps} showOptional />)
expect(screen.getByText('common.label.optional')).toBeInTheDocument()
})
it('shows required asterisk when isRequired is true', () => {
render(<Label {...defaultProps} isRequired />)
expect(screen.getByText('*')).toBeInTheDocument()
})
it('renders tooltip when tooltip prop is provided', () => {
const tooltipText = 'Test Tooltip'
render(<Label {...defaultProps} tooltip={tooltipText} />)
const trigger = screen.getByTestId('test-input-tooltip')
fireEvent.mouseEnter(trigger)
expect(screen.getByText(tooltipText)).toBeInTheDocument()
})
it('applies custom className when provided', () => {
const customClass = 'custom-label'
render(<Label {...defaultProps} className={customClass} />)
const label = screen.getByTestId('label')
expect(label).toHaveClass(customClass)
})
it('does not show optional text and required asterisk simultaneously', () => {
render(<Label {...defaultProps} isRequired showOptional />)
expect(screen.queryByText('common.label.optional')).not.toBeInTheDocument()
expect(screen.getByText('*')).toBeInTheDocument()
})
})

@ -0,0 +1,48 @@
import cn from '@/utils/classnames'
import Tooltip from '../../tooltip'
import { useTranslation } from 'react-i18next'
export type LabelProps = {
htmlFor: string
label: string
isRequired?: boolean
showOptional?: boolean
tooltip?: string
className?: string
}
const Label = ({
htmlFor,
label,
isRequired,
showOptional,
tooltip,
className,
}: LabelProps) => {
const { t } = useTranslation()
return (
<div className='flex h-6 items-center'>
<label
data-testid='label'
htmlFor={htmlFor}
className={cn('system-sm-medium text-text-secondary', className)}
>
{label}
</label>
{!isRequired && showOptional && <div className='system-xs-regular ml-1 text-text-tertiary'>{t('common.label.optional')}</div>}
{isRequired && <div className='system-xs-regular ml-1 text-text-destructive-secondary'>*</div>}
{tooltip && (
<Tooltip
popupContent={
<div className='w-[200px]'>{tooltip}</div>
}
triggerClassName='ml-0.5 w-4 h-4'
triggerTestId={`${htmlFor}-tooltip`}
/>
)}
</div>
)
}
export default Label

@ -0,0 +1,35 @@
import { withForm } from '../..'
import { demoFormOpts } from './shared-options'
import { ContactMethods } from './types'
const ContactFields = withForm({
...demoFormOpts,
render: ({ form }) => {
return (
<div className='my-2'>
<h3 className='title-lg-bold text-text-primary'>Contacts</h3>
<div className='flex flex-col gap-4'>
<form.AppField
name='contact.email'
children={field => <field.TextField label='Email' />}
/>
<form.AppField
name='contact.phone'
children={field => <field.TextField label='Phone' />}
/>
<form.AppField
name='contact.preferredContactMethod'
children={field => (
<field.SelectField
label='Preferred Contact Method'
options={ContactMethods}
/>
)}
/>
</div>
</div>
)
},
})
export default ContactFields

@ -0,0 +1,68 @@
import { useStore } from '@tanstack/react-form'
import { useAppForm } from '../..'
import ContactFields from './contact-fields'
import { demoFormOpts } from './shared-options'
import { UserSchema } from './types'
const DemoForm = () => {
const form = useAppForm({
...demoFormOpts,
validators: {
onSubmit: ({ value }) => {
// Validate the entire form
const result = UserSchema.safeParse(value)
if (!result.success) {
const issues = result.error.issues
console.log('Validation errors:', issues)
return issues[0].message
}
return undefined
},
},
onSubmit: ({ value }) => {
console.log('Form submitted:', value)
},
})
const name = useStore(form.store, state => state.values.name)
return (
<form
className='flex w-[400px] flex-col gap-4'
onSubmit={(e) => {
e.preventDefault()
e.stopPropagation()
form.handleSubmit()
}}
>
<form.AppField
name='name'
children={field => (
<field.TextField label='Name' />
)}
/>
<form.AppField
name='surname'
children={field => (
<field.TextField label='Surname' />
)}
/>
<form.AppField
name='isAcceptingTerms'
children={field => (
<field.CheckboxField label='I accept the terms and conditions.' />
)}
/>
{
!!name && (
<ContactFields form={form} />
)
}
<form.AppForm>
<form.SubmitButton>Submit</form.SubmitButton>
</form.AppForm>
</form>
)
}
export default DemoForm

@ -0,0 +1,14 @@
import { formOptions } from '@tanstack/react-form'
export const demoFormOpts = formOptions({
defaultValues: {
name: '',
surname: '',
isAcceptingTerms: false,
contact: {
email: '',
phone: '',
preferredContactMethod: 'email',
},
},
})

@ -0,0 +1,34 @@
import { z } from 'zod'
const ContactMethod = z.union([
z.literal('email'),
z.literal('phone'),
z.literal('whatsapp'),
z.literal('sms'),
])
export const ContactMethods = ContactMethod.options.map(({ value }) => ({
value,
label: value.charAt(0).toUpperCase() + value.slice(1),
}))
export const UserSchema = z.object({
name: z
.string()
.regex(/^[A-Z]/, 'Name must start with a capital letter')
.min(3, 'Name must be at least 3 characters long'),
surname: z
.string()
.min(3, 'Surname must be at least 3 characters long')
.regex(/^[A-Z]/, 'Surname must start with a capital letter'),
isAcceptingTerms: z.boolean().refine(val => val, {
message: 'You must accept the terms and conditions',
}),
contact: z.object({
email: z.string().email('Invalid email address'),
phone: z.string().optional(),
preferredContactMethod: ContactMethod,
}),
})
export type User = z.infer<typeof UserSchema>

@ -0,0 +1,25 @@
import { createFormHook, createFormHookContexts } from '@tanstack/react-form'
import TextField from './components/field/text'
import NumberInputField from './components/field/number-input'
import CheckboxField from './components/field/checkbox'
import SelectField from './components/field/select'
import OptionsField from './components/field/options'
import SubmitButton from './components/form/submit-button'
export const { fieldContext, useFieldContext, formContext, useFormContext }
= createFormHookContexts()
export const { useAppForm, withForm } = createFormHook({
fieldComponents: {
TextField,
NumberInputField,
CheckboxField,
SelectField,
OptionsField,
},
formComponents: {
SubmitButton,
},
fieldContext,
formContext,
})

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="arrow-down-round-fill">
<path id="Vector" d="M6.02913 6.23572C5.08582 6.23572 4.56482 7.33027 5.15967 8.06239L7.13093 10.4885C7.57922 11.0403 8.42149 11.0403 8.86986 10.4885L10.8411 8.06239C11.4359 7.33027 10.9149 6.23572 9.97158 6.23572H6.02913Z" fill="#101828"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 380 B

@ -0,0 +1,36 @@
{
"icon": {
"type": "element",
"isRootNode": true,
"name": "svg",
"attributes": {
"width": "16",
"height": "16",
"viewBox": "0 0 16 16",
"fill": "none",
"xmlns": "http://www.w3.org/2000/svg"
},
"children": [
{
"type": "element",
"name": "g",
"attributes": {
"id": "arrow-down-round-fill"
},
"children": [
{
"type": "element",
"name": "path",
"attributes": {
"id": "Vector",
"d": "M6.02913 6.23572C5.08582 6.23572 4.56482 7.33027 5.15967 8.06239L7.13093 10.4885C7.57922 11.0403 8.42149 11.0403 8.86986 10.4885L10.8411 8.06239C11.4359 7.33027 10.9149 6.23572 9.97158 6.23572H6.02913Z",
"fill": "currentColor"
},
"children": []
}
]
}
]
},
"name": "ArrowDownRoundFill"
}

@ -0,0 +1,20 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import * as React from 'react'
import data from './ArrowDownRoundFill.json'
import IconBase from '@/app/components/base/icons/IconBase'
import type { IconData } from '@/app/components/base/icons/IconBase'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'ArrowDownRoundFill'
export default Icon

@ -1,4 +1,5 @@
export { default as AnswerTriangle } from './AnswerTriangle' export { default as AnswerTriangle } from './AnswerTriangle'
export { default as ArrowDownRoundFill } from './ArrowDownRoundFill'
export { default as CheckCircle } from './CheckCircle' export { default as CheckCircle } from './CheckCircle'
export { default as CheckDone01 } from './CheckDone01' export { default as CheckDone01 } from './CheckDone01'
export { default as Download02 } from './Download02' export { default as Download02 } from './Download02'

@ -0,0 +1,97 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { InputNumber } from './index'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
describe('InputNumber Component', () => {
const defaultProps = {
onChange: jest.fn(),
}
afterEach(() => {
jest.clearAllMocks()
})
it('renders input with default values', () => {
render(<InputNumber {...defaultProps} />)
const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
})
it('handles increment button click', () => {
render(<InputNumber {...defaultProps} value={5} />)
const incrementBtn = screen.getByRole('button', { name: /increment/i })
fireEvent.click(incrementBtn)
expect(defaultProps.onChange).toHaveBeenCalledWith(6)
})
it('handles decrement button click', () => {
render(<InputNumber {...defaultProps} value={5} />)
const decrementBtn = screen.getByRole('button', { name: /decrement/i })
fireEvent.click(decrementBtn)
expect(defaultProps.onChange).toHaveBeenCalledWith(4)
})
it('respects max value constraint', () => {
render(<InputNumber {...defaultProps} value={10} max={10} />)
const incrementBtn = screen.getByRole('button', { name: /increment/i })
fireEvent.click(incrementBtn)
expect(defaultProps.onChange).not.toHaveBeenCalled()
})
it('respects min value constraint', () => {
render(<InputNumber {...defaultProps} value={0} min={0} />)
const decrementBtn = screen.getByRole('button', { name: /decrement/i })
fireEvent.click(decrementBtn)
expect(defaultProps.onChange).not.toHaveBeenCalled()
})
it('handles direct input changes', () => {
render(<InputNumber {...defaultProps} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '42' } })
expect(defaultProps.onChange).toHaveBeenCalledWith(42)
})
it('handles empty input', () => {
render(<InputNumber {...defaultProps} value={0} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '' } })
expect(defaultProps.onChange).toHaveBeenCalledWith(undefined)
})
it('handles invalid input', () => {
render(<InputNumber {...defaultProps} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'abc' } })
expect(defaultProps.onChange).not.toHaveBeenCalled()
})
it('displays unit when provided', () => {
const unit = 'px'
render(<InputNumber {...defaultProps} unit={unit} />)
expect(screen.getByText(unit)).toBeInTheDocument()
})
it('disables controls when disabled prop is true', () => {
render(<InputNumber {...defaultProps} disabled />)
const input = screen.getByRole('textbox')
const incrementBtn = screen.getByRole('button', { name: /increment/i })
const decrementBtn = screen.getByRole('button', { name: /decrement/i })
expect(input).toBeDisabled()
expect(incrementBtn).toBeDisabled()
expect(decrementBtn).toBeDisabled()
})
})

@ -8,7 +8,7 @@ export type InputNumberProps = {
value?: number value?: number
onChange: (value?: number) => void onChange: (value?: number) => void
amount?: number amount?: number
size?: 'sm' | 'md' size?: 'regular' | 'large'
max?: number max?: number
min?: number min?: number
defaultValue?: number defaultValue?: number
@ -19,14 +19,12 @@ export type InputNumberProps = {
} & Omit<InputProps, 'value' | 'onChange' | 'size' | 'min' | 'max' | 'defaultValue'> } & Omit<InputProps, 'value' | 'onChange' | 'size' | 'min' | 'max' | 'defaultValue'>
export const InputNumber: FC<InputNumberProps> = (props) => { export const InputNumber: FC<InputNumberProps> = (props) => {
const { unit, className, onChange, amount = 1, value, size = 'md', max, min, defaultValue, wrapClassName, controlWrapClassName, controlClassName, disabled, ...rest } = props const { unit, className, onChange, amount = 1, value, size = 'regular', max, min, defaultValue, wrapClassName, controlWrapClassName, controlClassName, disabled, ...rest } = props
const isValidValue = (v: number) => { const isValidValue = (v: number) => {
if (max && v > max) if (typeof max === 'number' && v > max)
return false return false
if (min && v < min) return !(typeof min === 'number' && v < min)
return false
return true
} }
const inc = () => { const inc = () => {
@ -76,29 +74,39 @@ export const InputNumber: FC<InputNumberProps> = (props) => {
onChange(parsed) onChange(parsed)
}} }}
unit={unit} unit={unit}
size={size}
/> />
<div className={classNames( <div className={classNames(
'flex flex-col bg-components-input-bg-normal rounded-r-md border-l border-divider-subtle text-text-tertiary focus:shadow-xs', 'flex flex-col bg-components-input-bg-normal rounded-r-md border-l border-divider-subtle text-text-tertiary focus:shadow-xs',
disabled && 'opacity-50 cursor-not-allowed', disabled && 'opacity-50 cursor-not-allowed',
controlWrapClassName)} controlWrapClassName)}
> >
<button onClick={inc} disabled={disabled} className={classNames( <button
size === 'sm' ? 'pt-1' : 'pt-1.5', type='button'
'px-1.5 hover:bg-components-input-bg-hover', onClick={inc}
disabled && 'cursor-not-allowed hover:bg-transparent', disabled={disabled}
controlClassName, aria-label='increment'
)}> className={classNames(
size === 'regular' ? 'pt-1' : 'pt-1.5',
'px-1.5 hover:bg-components-input-bg-hover',
disabled && 'cursor-not-allowed hover:bg-transparent',
controlClassName,
)}
>
<RiArrowUpSLine className='size-3' /> <RiArrowUpSLine className='size-3' />
</button> </button>
<button <button
type='button'
onClick={dec} onClick={dec}
disabled={disabled} disabled={disabled}
aria-label='decrement'
className={classNames( className={classNames(
size === 'sm' ? 'pb-1' : 'pb-1.5', size === 'regular' ? 'pb-1' : 'pb-1.5',
'px-1.5 hover:bg-components-input-bg-hover', 'px-1.5 hover:bg-components-input-bg-hover',
disabled && 'cursor-not-allowed hover:bg-transparent', disabled && 'cursor-not-allowed hover:bg-transparent',
controlClassName, controlClassName,
)}> )}
>
<RiArrowDownSLine className='size-3' /> <RiArrowDownSLine className='size-3' />
</button> </button>
</div> </div>

@ -30,7 +30,7 @@ export type InputProps = {
wrapperClassName?: string wrapperClassName?: string
styleCss?: CSSProperties styleCss?: CSSProperties
unit?: string unit?: string
} & React.InputHTMLAttributes<HTMLInputElement> & VariantProps<typeof inputVariants> } & Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> & VariantProps<typeof inputVariants>
const Input = ({ const Input = ({
size, size,

@ -0,0 +1,37 @@
import abcjs from 'abcjs'
import { useEffect, useRef } from 'react'
import 'abcjs/abcjs-audio.css'
const MarkdownMusic = ({ children }: { children: React.ReactNode }) => {
const containerRef = useRef<HTMLDivElement>(null)
const controlsRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (containerRef.current && controlsRef.current) {
if (typeof children === 'string') {
const visualObjs = abcjs.renderAbc(containerRef.current, children, {
add_classes: true, // Add classes to SVG elements for cursor tracking
responsive: 'resize', // Make notation responsive
})
const synthControl = new abcjs.synth.SynthController()
synthControl.load(controlsRef.current, {}, { displayPlay: true })
const synth = new abcjs.synth.CreateSynth()
const visualObj = visualObjs[0]
synth.init({ visualObj }).then(() => {
synthControl.setTune(visualObj, false)
})
containerRef.current.style.overflow = 'auto'
}
}
}, [children])
return (
<div style={{ minWidth: '100%', overflow: 'auto' }}>
<div ref={containerRef} />
<div ref={controlsRef} />
</div>
)
}
MarkdownMusic.displayName = 'MarkdownMusic'
export default MarkdownMusic

@ -23,6 +23,7 @@ import VideoGallery from '@/app/components/base/video-gallery'
import AudioGallery from '@/app/components/base/audio-gallery' import AudioGallery from '@/app/components/base/audio-gallery'
import MarkdownButton from '@/app/components/base/markdown-blocks/button' import MarkdownButton from '@/app/components/base/markdown-blocks/button'
import MarkdownForm from '@/app/components/base/markdown-blocks/form' import MarkdownForm from '@/app/components/base/markdown-blocks/form'
import MarkdownMusic from '@/app/components/base/markdown-blocks/music'
import ThinkBlock from '@/app/components/base/markdown-blocks/think-block' import ThinkBlock from '@/app/components/base/markdown-blocks/think-block'
import { Theme } from '@/types/app' import { Theme } from '@/types/app'
import useTheme from '@/hooks/use-theme' import useTheme from '@/hooks/use-theme'
@ -51,6 +52,7 @@ const capitalizationLanguageNameMap: Record<string, string> = {
json: 'JSON', json: 'JSON',
latex: 'Latex', latex: 'Latex',
svg: 'SVG', svg: 'SVG',
abc: 'ABC',
} }
const getCorrectCapitalizationLanguageName = (language: string) => { const getCorrectCapitalizationLanguageName = (language: string) => {
if (!language) if (!language)
@ -137,45 +139,54 @@ const CodeBlock: any = memo(({ inline, className, children, ...props }: any) =>
const renderCodeContent = useMemo(() => { const renderCodeContent = useMemo(() => {
const content = String(children).replace(/\n$/, '') const content = String(children).replace(/\n$/, '')
if (language === 'mermaid' && isSVG) { switch (language) {
return <Flowchart PrimitiveCode={content} /> case 'mermaid':
} if (isSVG)
else if (language === 'echarts') { return <Flowchart PrimitiveCode={content} />
return ( break
<div style={{ minHeight: '350px', minWidth: '100%', overflowX: 'scroll' }}> case 'echarts':
return (
<div style={{ minHeight: '350px', minWidth: '100%', overflowX: 'scroll' }}>
<ErrorBoundary>
<ReactEcharts option={chartData} style={{ minWidth: '700px' }} />
</ErrorBoundary>
</div>
)
case 'svg':
if (isSVG) {
return (
<ErrorBoundary>
<SVGRenderer content={content} />
</ErrorBoundary>
)
}
break
case 'abc':
return (
<ErrorBoundary> <ErrorBoundary>
<ReactEcharts option={chartData} style={{ minWidth: '700px' }} /> <MarkdownMusic children={content} />
</ErrorBoundary> </ErrorBoundary>
</div> )
) default:
} return (
else if (language === 'svg' && isSVG) { <SyntaxHighlighter
return ( {...props}
<ErrorBoundary> style={theme === Theme.light ? atelierHeathLight : atelierHeathDark}
<SVGRenderer content={content} /> customStyle={{
</ErrorBoundary> paddingLeft: 12,
) borderBottomLeftRadius: '10px',
} borderBottomRightRadius: '10px',
else { backgroundColor: 'var(--color-components-input-bg-normal)',
return ( }}
<SyntaxHighlighter language={match?.[1]}
{...props} showLineNumbers
style={theme === Theme.light ? atelierHeathLight : atelierHeathDark} PreTag="div"
customStyle={{ >
paddingLeft: 12, {content}
borderBottomLeftRadius: '10px', </SyntaxHighlighter>
borderBottomRightRadius: '10px', )
backgroundColor: 'var(--color-components-input-bg-normal)',
}}
language={match?.[1]}
showLineNumbers
PreTag="div"
>
{content}
</SyntaxHighlighter>
)
} }
}, [language, match, props, children, chartData, isSVG]) }, [children, language, isSVG, chartData, props, theme, match])
if (inline || !match) if (inline || !match)
return <code {...props} className={className}>{children}</code> return <code {...props} className={className}>{children}</code>

@ -54,7 +54,7 @@ const ParamItem: FC<Props> = ({ className, id, name, noTooltip, tip, step = 0.1,
max={max} max={max}
step={step} step={step}
amount={step} amount={step}
size='sm' size='regular'
value={value} value={value}
onChange={(value) => { onChange={(value) => {
onChange(id, value) onChange(id, value)

@ -14,7 +14,7 @@ export class HistoryBlockNode extends DecoratorNode<React.JSX.Element> {
} }
static clone(node: HistoryBlockNode): HistoryBlockNode { static clone(node: HistoryBlockNode): HistoryBlockNode {
return new HistoryBlockNode(node.__roleName, node.__onEditRole) return new HistoryBlockNode(node.__roleName, node.__onEditRole, node.__key)
} }
constructor(roleName: RoleName, onEditRole: () => void, key?: NodeKey) { constructor(roleName: RoleName, onEditRole: () => void, key?: NodeKey) {

@ -11,6 +11,7 @@ import { mergeRegister } from '@lexical/utils'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { import {
RiErrorWarningFill, RiErrorWarningFill,
RiMoreLine,
} from '@remixicon/react' } from '@remixicon/react'
import { useSelectOrDelete } from '../../hooks' import { useSelectOrDelete } from '../../hooks'
import type { WorkflowNodesMap } from './node' import type { WorkflowNodesMap } from './node'
@ -27,26 +28,35 @@ import { Line3 } from '@/app/components/base/icons/src/public/common'
import { isConversationVar, isENV, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils' import { isConversationVar, isENV, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils'
import Tooltip from '@/app/components/base/tooltip' import Tooltip from '@/app/components/base/tooltip'
import { isExceptionVariable } from '@/app/components/workflow/utils' import { isExceptionVariable } from '@/app/components/workflow/utils'
import VarFullPathPanel from '@/app/components/workflow/nodes/_base/components/variable/var-full-path-panel'
import { Type } from '@/app/components/workflow/nodes/llm/types'
import type { ValueSelector } from '@/app/components/workflow/types'
type WorkflowVariableBlockComponentProps = { type WorkflowVariableBlockComponentProps = {
nodeKey: string nodeKey: string
variables: string[] variables: string[]
workflowNodesMap: WorkflowNodesMap workflowNodesMap: WorkflowNodesMap
getVarType?: (payload: {
nodeId: string,
valueSelector: ValueSelector,
}) => Type
} }
const WorkflowVariableBlockComponent = ({ const WorkflowVariableBlockComponent = ({
nodeKey, nodeKey,
variables, variables,
workflowNodesMap = {}, workflowNodesMap = {},
getVarType,
}: WorkflowVariableBlockComponentProps) => { }: WorkflowVariableBlockComponentProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const [editor] = useLexicalComposerContext() const [editor] = useLexicalComposerContext()
const [ref, isSelected] = useSelectOrDelete(nodeKey, DELETE_WORKFLOW_VARIABLE_BLOCK_COMMAND) const [ref, isSelected] = useSelectOrDelete(nodeKey, DELETE_WORKFLOW_VARIABLE_BLOCK_COMMAND)
const variablesLength = variables.length const variablesLength = variables.length
const isShowAPart = variablesLength > 2
const varName = ( const varName = (
() => { () => {
const isSystem = isSystemVar(variables) const isSystem = isSystemVar(variables)
const varName = variablesLength >= 3 ? (variables).slice(-2).join('.') : variables[variablesLength - 1] const varName = variables[variablesLength - 1]
return `${isSystem ? 'sys.' : ''}${varName}` return `${isSystem ? 'sys.' : ''}${varName}`
} }
)() )()
@ -76,7 +86,7 @@ const WorkflowVariableBlockComponent = ({
const Item = ( const Item = (
<div <div
className={cn( className={cn(
'group/wrap relative mx-0.5 flex h-[18px] select-none items-center rounded-[5px] border pl-0.5 pr-[3px]', 'group/wrap relative mx-0.5 flex h-[18px] select-none items-center rounded-[5px] border pl-0.5 pr-[3px] hover:border-state-accent-solid hover:bg-state-accent-hover',
isSelected ? ' border-state-accent-solid bg-state-accent-hover' : ' border-components-panel-border-subtle bg-components-badge-white-to-dark', isSelected ? ' border-state-accent-solid bg-state-accent-hover' : ' border-components-panel-border-subtle bg-components-badge-white-to-dark',
!node && !isEnv && !isChatVar && '!border-state-destructive-solid !bg-state-destructive-hover', !node && !isEnv && !isChatVar && '!border-state-destructive-solid !bg-state-destructive-hover',
)} )}
@ -99,6 +109,13 @@ const WorkflowVariableBlockComponent = ({
<Line3 className='mr-0.5 text-divider-deep'></Line3> <Line3 className='mr-0.5 text-divider-deep'></Line3>
</div> </div>
)} )}
{isShowAPart && (
<div className='flex items-center'>
<RiMoreLine className='h-3 w-3 text-text-secondary' />
<Line3 className='mr-0.5 text-divider-deep'></Line3>
</div>
)}
<div className='flex items-center text-text-accent'> <div className='flex items-center text-text-accent'>
{!isEnv && !isChatVar && <Variable02 className={cn('h-3.5 w-3.5 shrink-0', isException && 'text-text-warning')} />} {!isEnv && !isChatVar && <Variable02 className={cn('h-3.5 w-3.5 shrink-0', isException && 'text-text-warning')} />}
{isEnv && <Env className='h-3.5 w-3.5 shrink-0 text-util-colors-violet-violet-600' />} {isEnv && <Env className='h-3.5 w-3.5 shrink-0 text-util-colors-violet-violet-600' />}
@ -126,7 +143,27 @@ const WorkflowVariableBlockComponent = ({
) )
} }
return Item if (!node)
return null
return (
<Tooltip
noDecoration
popupContent={
<VarFullPathPanel
nodeName={node.title}
path={variables.slice(1)}
varType={getVarType ? getVarType({
nodeId: variables[0],
valueSelector: variables,
}) : Type.string}
nodeType={node?.type}
/>}
disabled={!isShowAPart}
>
<div>{Item}</div>
</Tooltip>
)
} }
export default memo(WorkflowVariableBlockComponent) export default memo(WorkflowVariableBlockComponent)

@ -9,7 +9,7 @@ import {
} from 'lexical' } from 'lexical'
import { mergeRegister } from '@lexical/utils' import { mergeRegister } from '@lexical/utils'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import type { WorkflowVariableBlockType } from '../../types' import type { GetVarType, WorkflowVariableBlockType } from '../../types'
import { import {
$createWorkflowVariableBlockNode, $createWorkflowVariableBlockNode,
WorkflowVariableBlockNode, WorkflowVariableBlockNode,
@ -25,11 +25,13 @@ export type WorkflowVariableBlockProps = {
getWorkflowNode: (nodeId: string) => Node getWorkflowNode: (nodeId: string) => Node
onInsert?: () => void onInsert?: () => void
onDelete?: () => void onDelete?: () => void
getVarType: GetVarType
} }
const WorkflowVariableBlock = memo(({ const WorkflowVariableBlock = memo(({
workflowNodesMap, workflowNodesMap,
onInsert, onInsert,
onDelete, onDelete,
getVarType,
}: WorkflowVariableBlockType) => { }: WorkflowVariableBlockType) => {
const [editor] = useLexicalComposerContext() const [editor] = useLexicalComposerContext()
@ -48,7 +50,7 @@ const WorkflowVariableBlock = memo(({
INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND,
(variables: string[]) => { (variables: string[]) => {
editor.dispatchCommand(CLEAR_HIDE_MENU_TIMEOUT, undefined) editor.dispatchCommand(CLEAR_HIDE_MENU_TIMEOUT, undefined)
const workflowVariableBlockNode = $createWorkflowVariableBlockNode(variables, workflowNodesMap) const workflowVariableBlockNode = $createWorkflowVariableBlockNode(variables, workflowNodesMap, getVarType)
$insertNodes([workflowVariableBlockNode]) $insertNodes([workflowVariableBlockNode])
if (onInsert) if (onInsert)
@ -69,7 +71,7 @@ const WorkflowVariableBlock = memo(({
COMMAND_PRIORITY_EDITOR, COMMAND_PRIORITY_EDITOR,
), ),
) )
}, [editor, onInsert, onDelete, workflowNodesMap]) }, [editor, onInsert, onDelete, workflowNodesMap, getVarType])
return null return null
}) })

@ -2,34 +2,39 @@ import type { LexicalNode, NodeKey, SerializedLexicalNode } from 'lexical'
import { DecoratorNode } from 'lexical' import { DecoratorNode } from 'lexical'
import type { WorkflowVariableBlockType } from '../../types' import type { WorkflowVariableBlockType } from '../../types'
import WorkflowVariableBlockComponent from './component' import WorkflowVariableBlockComponent from './component'
import type { GetVarType } from '../../types'
export type WorkflowNodesMap = WorkflowVariableBlockType['workflowNodesMap'] export type WorkflowNodesMap = WorkflowVariableBlockType['workflowNodesMap']
export type SerializedNode = SerializedLexicalNode & { export type SerializedNode = SerializedLexicalNode & {
variables: string[] variables: string[]
workflowNodesMap: WorkflowNodesMap workflowNodesMap: WorkflowNodesMap
getVarType?: GetVarType
} }
export class WorkflowVariableBlockNode extends DecoratorNode<React.JSX.Element> { export class WorkflowVariableBlockNode extends DecoratorNode<React.JSX.Element> {
__variables: string[] __variables: string[]
__workflowNodesMap: WorkflowNodesMap __workflowNodesMap: WorkflowNodesMap
__getVarType?: GetVarType
static getType(): string { static getType(): string {
return 'workflow-variable-block' return 'workflow-variable-block'
} }
static clone(node: WorkflowVariableBlockNode): WorkflowVariableBlockNode { static clone(node: WorkflowVariableBlockNode): WorkflowVariableBlockNode {
return new WorkflowVariableBlockNode(node.__variables, node.__workflowNodesMap, node.__key) return new WorkflowVariableBlockNode(node.__variables, node.__workflowNodesMap, node.__getVarType, node.__key)
} }
isInline(): boolean { isInline(): boolean {
return true return true
} }
constructor(variables: string[], workflowNodesMap: WorkflowNodesMap, key?: NodeKey) { constructor(variables: string[], workflowNodesMap: WorkflowNodesMap, getVarType: any, key?: NodeKey) {
super(key) super(key)
this.__variables = variables this.__variables = variables
this.__workflowNodesMap = workflowNodesMap this.__workflowNodesMap = workflowNodesMap
this.__getVarType = getVarType
} }
createDOM(): HTMLElement { createDOM(): HTMLElement {
@ -48,12 +53,13 @@ export class WorkflowVariableBlockNode extends DecoratorNode<React.JSX.Element>
nodeKey={this.getKey()} nodeKey={this.getKey()}
variables={this.__variables} variables={this.__variables}
workflowNodesMap={this.__workflowNodesMap} workflowNodesMap={this.__workflowNodesMap}
getVarType={this.__getVarType!}
/> />
) )
} }
static importJSON(serializedNode: SerializedNode): WorkflowVariableBlockNode { static importJSON(serializedNode: SerializedNode): WorkflowVariableBlockNode {
const node = $createWorkflowVariableBlockNode(serializedNode.variables, serializedNode.workflowNodesMap) const node = $createWorkflowVariableBlockNode(serializedNode.variables, serializedNode.workflowNodesMap, serializedNode.getVarType)
return node return node
} }
@ -64,6 +70,7 @@ export class WorkflowVariableBlockNode extends DecoratorNode<React.JSX.Element>
version: 1, version: 1,
variables: this.getVariables(), variables: this.getVariables(),
workflowNodesMap: this.getWorkflowNodesMap(), workflowNodesMap: this.getWorkflowNodesMap(),
getVarType: this.getVarType(),
} }
} }
@ -77,12 +84,17 @@ export class WorkflowVariableBlockNode extends DecoratorNode<React.JSX.Element>
return self.__workflowNodesMap return self.__workflowNodesMap
} }
getVarType(): any {
const self = this.getLatest()
return self.__getVarType
}
getTextContent(): string { getTextContent(): string {
return `{{#${this.getVariables().join('.')}#}}` return `{{#${this.getVariables().join('.')}#}}`
} }
} }
export function $createWorkflowVariableBlockNode(variables: string[], workflowNodesMap: WorkflowNodesMap): WorkflowVariableBlockNode { export function $createWorkflowVariableBlockNode(variables: string[], workflowNodesMap: WorkflowNodesMap, getVarType?: GetVarType): WorkflowVariableBlockNode {
return new WorkflowVariableBlockNode(variables, workflowNodesMap) return new WorkflowVariableBlockNode(variables, workflowNodesMap, getVarType)
} }
export function $isWorkflowVariableBlockNode( export function $isWorkflowVariableBlockNode(

@ -16,6 +16,7 @@ import { VAR_REGEX as REGEX, resetReg } from '@/config'
const WorkflowVariableBlockReplacementBlock = ({ const WorkflowVariableBlockReplacementBlock = ({
workflowNodesMap, workflowNodesMap,
getVarType,
onInsert, onInsert,
}: WorkflowVariableBlockType) => { }: WorkflowVariableBlockType) => {
const [editor] = useLexicalComposerContext() const [editor] = useLexicalComposerContext()
@ -30,8 +31,8 @@ const WorkflowVariableBlockReplacementBlock = ({
onInsert() onInsert()
const nodePathString = textNode.getTextContent().slice(3, -3) const nodePathString = textNode.getTextContent().slice(3, -3)
return $applyNodeReplacement($createWorkflowVariableBlockNode(nodePathString.split('.'), workflowNodesMap)) return $applyNodeReplacement($createWorkflowVariableBlockNode(nodePathString.split('.'), workflowNodesMap, getVarType))
}, [onInsert, workflowNodesMap]) }, [onInsert, workflowNodesMap, getVarType])
const getMatch = useCallback((text: string) => { const getMatch = useCallback((text: string) => {
const matchArr = REGEX.exec(text) const matchArr = REGEX.exec(text)

@ -1,8 +1,10 @@
import type { Type } from '../../workflow/nodes/llm/types'
import type { Dataset } from './plugins/context-block' import type { Dataset } from './plugins/context-block'
import type { RoleName } from './plugins/history-block' import type { RoleName } from './plugins/history-block'
import type { import type {
Node, Node,
NodeOutPutVar, NodeOutPutVar,
ValueSelector,
} from '@/app/components/workflow/types' } from '@/app/components/workflow/types'
export type Option = { export type Option = {
@ -54,12 +56,18 @@ export type ExternalToolBlockType = {
onAddExternalTool?: () => void onAddExternalTool?: () => void
} }
export type GetVarType = (payload: {
nodeId: string,
valueSelector: ValueSelector,
}) => Type
export type WorkflowVariableBlockType = { export type WorkflowVariableBlockType = {
show?: boolean show?: boolean
variables?: NodeOutPutVar[] variables?: NodeOutPutVar[]
workflowNodesMap?: Record<string, Pick<Node['data'], 'title' | 'type'>> workflowNodesMap?: Record<string, Pick<Node['data'], 'title' | 'type'>>
onInsert?: () => void onInsert?: () => void
onDelete?: () => void onDelete?: () => void
getVarType?: GetVarType
} }
export type MenuTextMatch = { export type MenuTextMatch = {

@ -0,0 +1,68 @@
import React from 'react'
import classNames from '@/utils/classnames'
import type { RemixiconComponentType } from '@remixicon/react'
import Divider from '../divider'
// Updated generic type to allow enum values
type SegmentedControlProps<T extends string | number | symbol> = {
options: { Icon: RemixiconComponentType, text: string, value: T }[]
value: T
onChange: (value: T) => void
className?: string
}
export const SegmentedControl = <T extends string | number | symbol>({
options,
value,
onChange,
className,
}: SegmentedControlProps<T>): JSX.Element => {
const selectedOptionIndex = options.findIndex(option => option.value === value)
return (
<div className={classNames(
'flex items-center rounded-lg bg-components-segmented-control-bg-normal gap-x-[1px] p-0.5',
className,
)}>
{options.map((option, index) => {
const { Icon } = option
const isSelected = index === selectedOptionIndex
const isNextSelected = index === selectedOptionIndex - 1
const isLast = index === options.length - 1
return (
<button
type='button'
key={String(option.value)}
className={classNames(
'flex items-center justify-center relative px-2 py-1 rounded-lg gap-x-0.5 group border-0.5 border-transparent',
isSelected
? 'border-components-segmented-control-item-active-border bg-components-segmented-control-item-active-bg shadow-xs shadow-shadow-shadow-3'
: 'hover:bg-state-base-hover',
)}
onClick={() => onChange(option.value)}
>
<span className='flex h-5 w-5 items-center justify-center'>
<Icon className={classNames(
'w-4 h-4 text-text-tertiary',
isSelected ? 'text-text-accent-light-mode-only' : 'group-hover:text-text-secondary',
)} />
</span>
<span className={classNames(
'p-0.5 text-text-tertiary system-sm-medium',
isSelected ? 'text-text-accent-light-mode-only' : 'group-hover:text-text-secondary',
)}>
{option.text}
</span>
{!isLast && !isSelected && !isNextSelected && (
<div className='absolute right-[-1px] top-0 flex h-full items-center'>
<Divider type='vertical' className='mx-0 h-3.5' />
</div>
)}
</button>
)
})}
</div>
)
}
export default React.memo(SegmentedControl) as typeof SegmentedControl

@ -8,8 +8,9 @@ const textareaVariants = cva(
{ {
variants: { variants: {
size: { size: {
regular: 'px-3 radius-md system-sm-regular', small: 'py-1 rounded-md system-xs-regular',
large: 'px-4 radius-lg system-md-regular', regular: 'px-3 rounded-md system-sm-regular',
large: 'px-4 rounded-lg system-md-regular',
}, },
}, },
defaultVariants: { defaultVariants: {

@ -10,6 +10,7 @@ export type TooltipProps = {
position?: Placement position?: Placement
triggerMethod?: 'hover' | 'click' triggerMethod?: 'hover' | 'click'
triggerClassName?: string triggerClassName?: string
triggerTestId?: string
disabled?: boolean disabled?: boolean
popupContent?: React.ReactNode popupContent?: React.ReactNode
children?: React.ReactNode children?: React.ReactNode
@ -24,6 +25,7 @@ const Tooltip: FC<TooltipProps> = ({
position = 'top', position = 'top',
triggerMethod = 'hover', triggerMethod = 'hover',
triggerClassName, triggerClassName,
triggerTestId,
disabled = false, disabled = false,
popupContent, popupContent,
children, children,
@ -91,7 +93,7 @@ const Tooltip: FC<TooltipProps> = ({
onMouseLeave={() => triggerMethod === 'hover' && handleLeave(true)} onMouseLeave={() => triggerMethod === 'hover' && handleLeave(true)}
asChild={asChild} asChild={asChild}
> >
{children || <div className={triggerClassName || 'h-3.5 w-3.5 shrink-0 p-[1px]'}><RiQuestionLine className='h-full w-full text-text-quaternary hover:text-text-tertiary' /></div>} {children || <div data-testid={triggerTestId} className={triggerClassName || 'h-3.5 w-3.5 shrink-0 p-[1px]'}><RiQuestionLine className='h-full w-full text-text-quaternary hover:text-text-tertiary' /></div>}
</PortalToFollowElemTrigger> </PortalToFollowElemTrigger>
<PortalToFollowElemContent <PortalToFollowElemContent
className="z-[9999]" className="z-[9999]"

@ -47,7 +47,7 @@ export const MaxLengthInput: FC<InputNumberProps> = (props) => {
</div>}> </div>}>
<InputNumber <InputNumber
type="number" type="number"
className='h-9' size='large'
placeholder={`${maxValue}`} placeholder={`${maxValue}`}
max={maxValue} max={maxValue}
min={1} min={1}
@ -70,7 +70,7 @@ export const OverlapInput: FC<InputNumberProps> = (props) => {
</div>}> </div>}>
<InputNumber <InputNumber
type="number" type="number"
className='h-9' size='large'
placeholder={t('datasetCreation.stepTwo.overlap') || ''} placeholder={t('datasetCreation.stepTwo.overlap') || ''}
min={1} min={1}
{...props} {...props}

@ -220,13 +220,11 @@ const Completed: FC<ICompletedProps> = ({
const resetList = useCallback(() => { const resetList = useCallback(() => {
setSelectedSegmentIds([]) setSelectedSegmentIds([])
invalidSegmentList() invalidSegmentList()
// eslint-disable-next-line react-hooks/exhaustive-deps }, [invalidSegmentList])
}, [])
const resetChildList = useCallback(() => { const resetChildList = useCallback(() => {
invalidChildSegmentList() invalidChildSegmentList()
// eslint-disable-next-line react-hooks/exhaustive-deps }, [invalidChildSegmentList])
}, [])
const onClickCard = (detail: SegmentDetailModel, isEditMode = false) => { const onClickCard = (detail: SegmentDetailModel, isEditMode = false) => {
setCurrSegment({ segInfo: detail, showModal: true, isEditMode }) setCurrSegment({ segInfo: detail, showModal: true, isEditMode })
@ -253,7 +251,7 @@ const Completed: FC<ICompletedProps> = ({
const invalidChunkListEnabled = useInvalid(useChunkListEnabledKey) const invalidChunkListEnabled = useInvalid(useChunkListEnabledKey)
const invalidChunkListDisabled = useInvalid(useChunkListDisabledKey) const invalidChunkListDisabled = useInvalid(useChunkListDisabledKey)
const refreshChunkListWithStatusChanged = () => { const refreshChunkListWithStatusChanged = useCallback(() => {
switch (selectedStatus) { switch (selectedStatus) {
case 'all': case 'all':
invalidChunkListDisabled() invalidChunkListDisabled()
@ -262,7 +260,7 @@ const Completed: FC<ICompletedProps> = ({
default: default:
invalidSegmentList() invalidSegmentList()
} }
} }, [selectedStatus, invalidChunkListDisabled, invalidChunkListEnabled, invalidSegmentList])
const onChangeSwitch = useCallback(async (enable: boolean, segId?: string) => { const onChangeSwitch = useCallback(async (enable: boolean, segId?: string) => {
const operationApi = enable ? enableSegment : disableSegment const operationApi = enable ? enableSegment : disableSegment
@ -280,8 +278,7 @@ const Completed: FC<ICompletedProps> = ({
notify({ type: 'error', message: t('common.actionMsg.modifiedUnsuccessfully') }) notify({ type: 'error', message: t('common.actionMsg.modifiedUnsuccessfully') })
}, },
}) })
// eslint-disable-next-line react-hooks/exhaustive-deps }, [datasetId, documentId, selectedSegmentIds, segments, disableSegment, enableSegment, t, notify, refreshChunkListWithStatusChanged])
}, [datasetId, documentId, selectedSegmentIds, segments])
const { mutateAsync: deleteSegment } = useDeleteSegment() const { mutateAsync: deleteSegment } = useDeleteSegment()
@ -296,12 +293,11 @@ const Completed: FC<ICompletedProps> = ({
notify({ type: 'error', message: t('common.actionMsg.modifiedUnsuccessfully') }) notify({ type: 'error', message: t('common.actionMsg.modifiedUnsuccessfully') })
}, },
}) })
// eslint-disable-next-line react-hooks/exhaustive-deps }, [datasetId, documentId, selectedSegmentIds, deleteSegment, resetList, t, notify])
}, [datasetId, documentId, selectedSegmentIds])
const { mutateAsync: updateSegment } = useUpdateSegment() const { mutateAsync: updateSegment } = useUpdateSegment()
const refreshChunkListDataWithDetailChanged = () => { const refreshChunkListDataWithDetailChanged = useCallback(() => {
switch (selectedStatus) { switch (selectedStatus) {
case 'all': case 'all':
invalidChunkListDisabled() invalidChunkListDisabled()
@ -316,7 +312,7 @@ const Completed: FC<ICompletedProps> = ({
invalidChunkListEnabled() invalidChunkListEnabled()
break break
} }
} }, [selectedStatus, invalidChunkListDisabled, invalidChunkListEnabled, invalidChunkListAll])
const handleUpdateSegment = useCallback(async ( const handleUpdateSegment = useCallback(async (
segmentId: string, segmentId: string,
@ -375,17 +371,18 @@ const Completed: FC<ICompletedProps> = ({
eventEmitter?.emit('update-segment-done') eventEmitter?.emit('update-segment-done')
}, },
}) })
// eslint-disable-next-line react-hooks/exhaustive-deps }, [segments, datasetId, documentId, updateSegment, docForm, notify, eventEmitter, onCloseSegmentDetail, refreshChunkListDataWithDetailChanged, t])
}, [segments, datasetId, documentId])
useEffect(() => { useEffect(() => {
resetList() resetList()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pathname]) }, [pathname])
useEffect(() => { useEffect(() => {
if (importStatus === ProcessStatus.COMPLETED) if (importStatus === ProcessStatus.COMPLETED)
resetList() resetList()
}, [importStatus, resetList]) // eslint-disable-next-line react-hooks/exhaustive-deps
}, [importStatus])
const onCancelBatchOperation = useCallback(() => { const onCancelBatchOperation = useCallback(() => {
setSelectedSegmentIds([]) setSelectedSegmentIds([])
@ -430,8 +427,7 @@ const Completed: FC<ICompletedProps> = ({
const count = segmentListData?.total || 0 const count = segmentListData?.total || 0
return `${total} ${t('datasetDocuments.segment.searchResults', { count })}` return `${total} ${t('datasetDocuments.segment.searchResults', { count })}`
} }
// eslint-disable-next-line react-hooks/exhaustive-deps }, [segmentListData, mode, parentMode, searchValue, selectedStatus, t])
}, [segmentListData?.total, mode, parentMode, searchValue, selectedStatus])
const toggleFullScreen = useCallback(() => { const toggleFullScreen = useCallback(() => {
setFullScreen(!fullScreen) setFullScreen(!fullScreen)
@ -449,8 +445,7 @@ const Completed: FC<ICompletedProps> = ({
resetList() resetList()
currentPage !== totalPages && setCurrentPage(totalPages) currentPage !== totalPages && setCurrentPage(totalPages)
} }
// eslint-disable-next-line react-hooks/exhaustive-deps }, [segmentListData, limit, currentPage, resetList])
}, [segmentListData, limit, currentPage])
const { mutateAsync: deleteChildSegment } = useDeleteChildSegment() const { mutateAsync: deleteChildSegment } = useDeleteChildSegment()
@ -470,8 +465,7 @@ const Completed: FC<ICompletedProps> = ({
}, },
}, },
) )
// eslint-disable-next-line react-hooks/exhaustive-deps }, [datasetId, documentId, parentMode, deleteChildSegment, resetList, resetChildList, t, notify])
}, [datasetId, documentId, parentMode])
const handleAddNewChildChunk = useCallback((parentChunkId: string) => { const handleAddNewChildChunk = useCallback((parentChunkId: string) => {
setShowNewChildSegmentModal(true) setShowNewChildSegmentModal(true)
@ -490,8 +484,7 @@ const Completed: FC<ICompletedProps> = ({
else { else {
resetChildList() resetChildList()
} }
// eslint-disable-next-line react-hooks/exhaustive-deps }, [parentMode, currChunkId, segments, refreshChunkListDataWithDetailChanged, resetChildList])
}, [parentMode, currChunkId, segments])
const viewNewlyAddedChildChunk = useCallback(() => { const viewNewlyAddedChildChunk = useCallback(() => {
const totalPages = childChunkListData?.total_pages || 0 const totalPages = childChunkListData?.total_pages || 0
@ -505,8 +498,7 @@ const Completed: FC<ICompletedProps> = ({
resetChildList() resetChildList()
currentPage !== totalPages && setCurrentPage(totalPages) currentPage !== totalPages && setCurrentPage(totalPages)
} }
// eslint-disable-next-line react-hooks/exhaustive-deps }, [childChunkListData, limit, currentPage, resetChildList])
}, [childChunkListData, limit, currentPage])
const onClickSlice = useCallback((detail: ChildChunkDetail) => { const onClickSlice = useCallback((detail: ChildChunkDetail) => {
setCurrChildChunk({ childChunkInfo: detail, showModal: true }) setCurrChildChunk({ childChunkInfo: detail, showModal: true })
@ -560,8 +552,7 @@ const Completed: FC<ICompletedProps> = ({
eventEmitter?.emit('update-child-segment-done') eventEmitter?.emit('update-child-segment-done')
}, },
}) })
// eslint-disable-next-line react-hooks/exhaustive-deps }, [segments, datasetId, documentId, parentMode, updateChildSegment, notify, eventEmitter, onCloseChildSegmentDetail, refreshChunkListDataWithDetailChanged, resetChildList, t])
}, [segments, childSegments, datasetId, documentId, parentMode])
const onClearFilter = useCallback(() => { const onClearFilter = useCallback(() => {
setInputValue('') setInputValue('')
@ -570,6 +561,12 @@ const Completed: FC<ICompletedProps> = ({
setCurrentPage(1) setCurrentPage(1)
}, []) }, [])
const selectDefaultValue = useMemo(() => {
if (selectedStatus === 'all')
return 'all'
return selectedStatus ? 1 : 0
}, [selectedStatus])
return ( return (
<SegmentListContext.Provider value={{ <SegmentListContext.Provider value={{
isCollapsed, isCollapsed,
@ -583,7 +580,7 @@ const Completed: FC<ICompletedProps> = ({
<Checkbox <Checkbox
className='shrink-0' className='shrink-0'
checked={isAllSelected} checked={isAllSelected}
mixed={!isAllSelected && isSomeSelected} indeterminate={!isAllSelected && isSomeSelected}
onCheck={onSelectedAll} onCheck={onSelectedAll}
disabled={isLoadingSegmentList} disabled={isLoadingSegmentList}
/> />
@ -591,7 +588,7 @@ const Completed: FC<ICompletedProps> = ({
<SimpleSelect <SimpleSelect
onSelect={onChangeStatus} onSelect={onChangeStatus}
items={statusList.current} items={statusList.current}
defaultValue={selectedStatus === 'all' ? 'all' : selectedStatus ? 1 : 0} defaultValue={selectDefaultValue}
className={s.select} className={s.select}
wrapperClassName='h-fit mr-2' wrapperClassName='h-fit mr-2'
optionWrapClassName='w-[160px]' optionWrapClassName='w-[160px]'

@ -106,13 +106,11 @@ const SegmentCard: FC<ISegmentCardProps> = ({
const wordCountText = useMemo(() => { const wordCountText = useMemo(() => {
const total = formatNumber(word_count) const total = formatNumber(word_count)
return `${total} ${t('datasetDocuments.segment.characters', { count: word_count })}` return `${total} ${t('datasetDocuments.segment.characters', { count: word_count })}`
// eslint-disable-next-line react-hooks/exhaustive-deps }, [word_count, t])
}, [word_count])
const labelPrefix = useMemo(() => { const labelPrefix = useMemo(() => {
return isParentChildMode ? t('datasetDocuments.segment.parentChunk') : t('datasetDocuments.segment.chunk') return isParentChildMode ? t('datasetDocuments.segment.parentChunk') : t('datasetDocuments.segment.chunk')
// eslint-disable-next-line react-hooks/exhaustive-deps }, [isParentChildMode, t])
}, [isParentChildMode])
if (loading) if (loading)
return <ParentChunkCardSkeleton /> return <ParentChunkCardSkeleton />

@ -86,8 +86,7 @@ const SegmentDetail: FC<ISegmentDetailProps> = ({
const titleText = useMemo(() => { const titleText = useMemo(() => {
return isEditMode ? t('datasetDocuments.segment.editChunk') : t('datasetDocuments.segment.chunkDetail') return isEditMode ? t('datasetDocuments.segment.editChunk') : t('datasetDocuments.segment.chunkDetail')
// eslint-disable-next-line react-hooks/exhaustive-deps }, [isEditMode, t])
}, [isEditMode])
const isQAModel = useMemo(() => { const isQAModel = useMemo(() => {
return docForm === ChunkingMode.qa return docForm === ChunkingMode.qa
@ -98,13 +97,11 @@ const SegmentDetail: FC<ISegmentDetailProps> = ({
const total = formatNumber(isEditMode ? contentLength : segInfo!.word_count as number) const total = formatNumber(isEditMode ? contentLength : segInfo!.word_count as number)
const count = isEditMode ? contentLength : segInfo!.word_count as number const count = isEditMode ? contentLength : segInfo!.word_count as number
return `${total} ${t('datasetDocuments.segment.characters', { count })}` return `${total} ${t('datasetDocuments.segment.characters', { count })}`
// eslint-disable-next-line react-hooks/exhaustive-deps }, [isEditMode, question.length, answer.length, isQAModel, segInfo, t])
}, [isEditMode, question.length, answer.length, segInfo?.word_count, isQAModel])
const labelPrefix = useMemo(() => { const labelPrefix = useMemo(() => {
return isParentChildMode ? t('datasetDocuments.segment.parentChunk') : t('datasetDocuments.segment.chunk') return isParentChildMode ? t('datasetDocuments.segment.parentChunk') : t('datasetDocuments.segment.chunk')
// eslint-disable-next-line react-hooks/exhaustive-deps }, [isParentChildMode, t])
}, [isParentChildMode])
return ( return (
<div className={'flex h-full flex-col'}> <div className={'flex h-full flex-col'}>

@ -42,7 +42,7 @@ const SegmentList = (
embeddingAvailable, embeddingAvailable,
onClearFilter, onClearFilter,
}: ISegmentListProps & { }: ISegmentListProps & {
ref: React.RefObject<unknown>; ref: React.LegacyRef<HTMLDivElement>
}, },
) => { ) => {
const mode = useDocumentContext(s => s.mode) const mode = useDocumentContext(s => s.mode)

@ -202,7 +202,7 @@ export const OperationAction: FC<{
const isListScene = scene === 'list' const isListScene = scene === 'list'
const onOperate = async (operationName: OperationName) => { const onOperate = async (operationName: OperationName) => {
let opApi = deleteDocument let opApi
switch (operationName) { switch (operationName) {
case 'archive': case 'archive':
opApi = archiveDocument opApi = archiveDocument
@ -490,7 +490,7 @@ const DocumentList: FC<IDocumentListProps> = ({
const handleAction = (actionName: DocumentActionType) => { const handleAction = (actionName: DocumentActionType) => {
return async () => { return async () => {
let opApi = deleteDocument let opApi
switch (actionName) { switch (actionName) {
case DocumentActionType.archive: case DocumentActionType.archive:
opApi = archiveDocument opApi = archiveDocument
@ -527,7 +527,7 @@ const DocumentList: FC<IDocumentListProps> = ({
<Checkbox <Checkbox
className='mr-2 shrink-0' className='mr-2 shrink-0'
checked={isAllSelected} checked={isAllSelected}
mixed={!isAllSelected && isSomeSelected} indeterminate={!isAllSelected && isSomeSelected}
onCheck={onSelectedAll} onCheck={onSelectedAll}
/> />
)} )}

@ -40,7 +40,7 @@ const InputCombined: FC<Props> = ({
className={cn(className, 'rounded-l-md')} className={cn(className, 'rounded-l-md')}
value={value} value={value}
onChange={onChange} onChange={onChange}
size='sm' size='regular'
controlWrapClassName='overflow-hidden' controlWrapClassName='overflow-hidden'
controlClassName='pt-0 pb-0' controlClassName='pt-0 pb-0'
readOnly={readOnly} readOnly={readOnly}

@ -42,7 +42,7 @@ const WorkplaceSelector = () => {
`, `,
)}> )}>
<div className='flex h-6 w-6 items-center justify-center rounded-md bg-components-icon-bg-blue-solid text-[13px]'> <div className='flex h-6 w-6 items-center justify-center rounded-md bg-components-icon-bg-blue-solid text-[13px]'>
<span className='bg-gradient-to-r from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text font-semibold uppercase text-shadow-shadow-1 opacity-90'>{currentWorkspace?.name[0]?.toLocaleUpperCase()}</span> <span className='h-6 bg-gradient-to-r from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text align-middle font-semibold uppercase leading-6 text-shadow-shadow-1 opacity-90'>{currentWorkspace?.name[0]?.toLocaleUpperCase()}</span>
</div> </div>
<div className='flex flex-row'> <div className='flex flex-row'>
<div className={'system-sm-medium max-w-[160px] truncate text-text-secondary'}>{currentWorkspace?.name}</div> <div className={'system-sm-medium max-w-[160px] truncate text-text-secondary'}>{currentWorkspace?.name}</div>
@ -73,7 +73,7 @@ const WorkplaceSelector = () => {
workspaces.map(workspace => ( workspaces.map(workspace => (
<div className='flex items-center gap-2 self-stretch rounded-lg py-1 pl-3 pr-2 hover:bg-state-base-hover' key={workspace.id} onClick={() => handleSwitchWorkspace(workspace.id)}> <div className='flex items-center gap-2 self-stretch rounded-lg py-1 pl-3 pr-2 hover:bg-state-base-hover' key={workspace.id} onClick={() => handleSwitchWorkspace(workspace.id)}>
<div className='flex h-6 w-6 items-center justify-center rounded-md bg-components-icon-bg-blue-solid text-[13px]'> <div className='flex h-6 w-6 items-center justify-center rounded-md bg-components-icon-bg-blue-solid text-[13px]'>
<span className='bg-gradient-to-r from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text font-semibold uppercase text-shadow-shadow-1 opacity-90'>{workspace?.name[0]?.toLocaleUpperCase()}</span> <span className='h-6 bg-gradient-to-r from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text align-middle font-semibold uppercase leading-6 text-shadow-shadow-1 opacity-90'>{workspace?.name[0]?.toLocaleUpperCase()}</span>
</div> </div>
<div className='system-md-regular line-clamp-1 grow cursor-pointer overflow-hidden text-ellipsis text-text-secondary'>{workspace.name}</div> <div className='system-md-regular line-clamp-1 grow cursor-pointer overflow-hidden text-ellipsis text-text-secondary'>{workspace.name}</div>
<PlanBadge plan={workspace.plan as Plan} /> <PlanBadge plan={workspace.plan as Plan} />

@ -60,6 +60,7 @@ export enum ModelFeatureEnum {
video = 'video', video = 'video',
document = 'document', document = 'document',
audio = 'audio', audio = 'audio',
StructuredOutput = 'structured-output',
} }
export enum ModelFeatureTextEnum { export enum ModelFeatureTextEnum {

@ -23,9 +23,9 @@ const ModelIcon: FC<ModelIconProps> = ({
isDeprecated = false, isDeprecated = false,
}) => { }) => {
const language = useLanguage() const language = useLanguage()
if (provider?.provider.includes('openai') && modelName?.includes('gpt-4o')) if (provider?.provider && ['openai', 'langgenius/openai/openai'].includes(provider.provider) && modelName?.includes('gpt-4o'))
return <div className='flex items-center justify-center'><OpenaiBlue className={cn('h-5 w-5', className)} /></div> return <div className='flex items-center justify-center'><OpenaiBlue className={cn('h-5 w-5', className)} /></div>
if (provider?.provider.includes('openai') && modelName?.startsWith('gpt-4')) if (provider?.provider && ['openai', 'langgenius/openai/openai'].includes(provider.provider) && modelName?.startsWith('gpt-4'))
return <div className='flex items-center justify-center'><OpenaiViolet className={cn('h-5 w-5', className)} /></div> return <div className='flex items-center justify-center'><OpenaiViolet className={cn('h-5 w-5', className)} /></div>
if (provider?.icon_small) { if (provider?.icon_small) {

@ -376,6 +376,7 @@ function Form<
tooltip={tooltip?.[language] || tooltip?.en_US} tooltip={tooltip?.[language] || tooltip?.en_US}
value={value[variable] || []} value={value[variable] || []}
onChange={item => handleFormChange(variable, item as any)} onChange={item => handleFormChange(variable, item as any)}
supportCollapse
/> />
{fieldMoreInfo?.(formSchema)} {fieldMoreInfo?.(formSchema)}
{validating && changeKey === variable && <ValidatingTip />} {validating && changeKey === variable && <ValidatingTip />}

@ -10,6 +10,7 @@ import Slider from '@/app/components/base/slider'
import Radio from '@/app/components/base/radio' import Radio from '@/app/components/base/radio'
import { SimpleSelect } from '@/app/components/base/select' import { SimpleSelect } from '@/app/components/base/select'
import TagInput from '@/app/components/base/tag-input' import TagInput from '@/app/components/base/tag-input'
import { useTranslation } from 'react-i18next'
export type ParameterValue = number | string | string[] | boolean | undefined export type ParameterValue = number | string | string[] | boolean | undefined
@ -27,6 +28,7 @@ const ParameterItem: FC<ParameterItemProps> = ({
onSwitch, onSwitch,
isInWorkflow, isInWorkflow,
}) => { }) => {
const { t } = useTranslation()
const language = useLanguage() const language = useLanguage()
const [localValue, setLocalValue] = useState(value) const [localValue, setLocalValue] = useState(value)
const numberInputRef = useRef<HTMLInputElement>(null) const numberInputRef = useRef<HTMLInputElement>(null)

@ -2,7 +2,6 @@ import React from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { import {
RiAddLine, RiAddLine,
RiArrowDropDownLine,
RiQuestionLine, RiQuestionLine,
} from '@remixicon/react' } from '@remixicon/react'
import ToolSelector from '@/app/components/plugins/plugin-detail-panel/tool-selector' import ToolSelector from '@/app/components/plugins/plugin-detail-panel/tool-selector'
@ -13,6 +12,7 @@ import type { ToolValue } from '@/app/components/workflow/block-selector/types'
import type { Node } from 'reactflow' import type { Node } from 'reactflow'
import type { NodeOutPutVar } from '@/app/components/workflow/types' import type { NodeOutPutVar } from '@/app/components/workflow/types'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid/general'
type Props = { type Props = {
disabled?: boolean disabled?: boolean
@ -98,14 +98,12 @@ const MultipleToolSelector = ({
</Tooltip> </Tooltip>
)} )}
{supportCollapse && ( {supportCollapse && (
<div className='absolute -left-4 top-1'> <ArrowDownRoundFill
<RiArrowDropDownLine className={cn(
className={cn( 'h-4 w-4 cursor-pointer text-text-quaternary group-hover/collapse:text-text-secondary',
'h-4 w-4 text-text-tertiary', collapse && 'rotate-[270deg]',
collapse && '-rotate-90', )}
)} />
/>
</div>
)} )}
</div> </div>
{value.length > 0 && ( {value.length > 0 && (

@ -8,6 +8,8 @@ import type {
ValueSelector, ValueSelector,
Var, Var,
} from '@/app/components/workflow/types' } from '@/app/components/workflow/types'
import { useIsChatMode } from './use-workflow'
import { useStoreApi } from 'reactflow'
export const useWorkflowVariables = () => { export const useWorkflowVariables = () => {
const { t } = useTranslation() const { t } = useTranslation()
@ -75,3 +77,37 @@ export const useWorkflowVariables = () => {
getCurrentVariableType, getCurrentVariableType,
} }
} }
export const useWorkflowVariableType = () => {
const store = useStoreApi()
const {
getNodes,
} = store.getState()
const { getCurrentVariableType } = useWorkflowVariables()
const isChatMode = useIsChatMode()
const getVarType = ({
nodeId,
valueSelector,
}: {
nodeId: string,
valueSelector: ValueSelector,
}) => {
const node = getNodes().find(n => n.id === nodeId)
const isInIteration = !!node?.data.isInIteration
const iterationNode = isInIteration ? getNodes().find(n => n.id === node.parentId) : null
const availableNodes = [node]
const type = getCurrentVariableType({
parentNode: iterationNode,
valueSelector,
availableNodes,
isChatMode,
isConstant: false,
})
return type
}
return getVarType
}

@ -133,7 +133,7 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => {
// TODO: maybe empty, handle this // TODO: maybe empty, handle this
onChange={onChange as any} onChange={onChange as any}
defaultValue={defaultValue} defaultValue={defaultValue}
size='sm' size='regular'
min={def.min} min={def.min}
max={def.max} max={def.max}
className='w-12' className='w-12'

@ -4,10 +4,16 @@ import Collapse from '.'
type FieldCollapseProps = { type FieldCollapseProps = {
title: string title: string
children: ReactNode children: ReactNode
collapsed?: boolean
onCollapse?: (collapsed: boolean) => void
operations?: ReactNode
} }
const FieldCollapse = ({ const FieldCollapse = ({
title, title,
children, children,
collapsed,
onCollapse,
operations,
}: FieldCollapseProps) => { }: FieldCollapseProps) => {
return ( return (
<div className='py-4'> <div className='py-4'>
@ -15,6 +21,9 @@ const FieldCollapse = ({
trigger={ trigger={
<div className='system-sm-semibold-uppercase flex h-6 cursor-pointer items-center text-text-secondary'>{title}</div> <div className='system-sm-semibold-uppercase flex h-6 cursor-pointer items-center text-text-secondary'>{title}</div>
} }
operations={operations}
collapsed={collapsed}
onCollapse={onCollapse}
> >
<div className='px-4'> <div className='px-4'>
{children} {children}

@ -1,15 +1,18 @@
import { useState } from 'react' import type { ReactNode } from 'react'
import { RiArrowDropRightLine } from '@remixicon/react' import { useMemo, useState } from 'react'
import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid/general'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
export { default as FieldCollapse } from './field-collapse' export { default as FieldCollapse } from './field-collapse'
type CollapseProps = { type CollapseProps = {
disabled?: boolean disabled?: boolean
trigger: React.JSX.Element trigger: React.JSX.Element | ((collapseIcon: React.JSX.Element | null) => React.JSX.Element)
children: React.JSX.Element children: React.JSX.Element
collapsed?: boolean collapsed?: boolean
onCollapse?: (collapsed: boolean) => void onCollapse?: (collapsed: boolean) => void
operations?: ReactNode
hideCollapseIcon?: boolean
} }
const Collapse = ({ const Collapse = ({
disabled, disabled,
@ -17,34 +20,44 @@ const Collapse = ({
children, children,
collapsed, collapsed,
onCollapse, onCollapse,
operations,
hideCollapseIcon,
}: CollapseProps) => { }: CollapseProps) => {
const [collapsedLocal, setCollapsedLocal] = useState(true) const [collapsedLocal, setCollapsedLocal] = useState(true)
const collapsedMerged = collapsed !== undefined ? collapsed : collapsedLocal const collapsedMerged = collapsed !== undefined ? collapsed : collapsedLocal
const collapseIcon = useMemo(() => {
if (disabled)
return null
return (
<ArrowDownRoundFill
className={cn(
'h-4 w-4 cursor-pointer text-text-quaternary group-hover/collapse:text-text-secondary',
collapsedMerged && 'rotate-[270deg]',
)}
/>
)
}, [collapsedMerged, disabled])
return ( return (
<> <>
<div <div className='group/collapse flex items-center'>
className='flex items-center' <div
onClick={() => { className='ml-4 flex grow items-center'
if (!disabled) { onClick={() => {
setCollapsedLocal(!collapsedMerged) if (!disabled) {
onCollapse?.(!collapsedMerged) setCollapsedLocal(!collapsedMerged)
} onCollapse?.(!collapsedMerged)
}} }
> }}
<div className='h-4 w-4 shrink-0'> >
{ {typeof trigger === 'function' ? trigger(collapseIcon) : trigger}
!disabled && ( {!hideCollapseIcon && (
<RiArrowDropRightLine <div className='h-4 w-4 shrink-0'>
className={cn( {collapseIcon}
'h-4 w-4 text-text-tertiary', </div>
!collapsedMerged && 'rotate-90', )}
)}
/>
)
}
</div> </div>
{trigger} {operations}
</div> </div>
{ {
!collapsedMerged && children !collapsedMerged && children

@ -49,20 +49,23 @@ const ErrorHandle = ({
disabled={!error_strategy} disabled={!error_strategy}
collapsed={collapsed} collapsed={collapsed}
onCollapse={setCollapsed} onCollapse={setCollapsed}
hideCollapseIcon
trigger={ trigger={
<div className='flex grow items-center justify-between pr-4'> collapseIcon => (
<div className='flex items-center'> <div className='flex grow items-center justify-between pr-4'>
<div className='system-sm-semibold-uppercase mr-0.5 text-text-secondary'> <div className='flex items-center'>
{t('workflow.nodes.common.errorHandle.title')} <div className='system-sm-semibold-uppercase mr-0.5 text-text-secondary'>
{t('workflow.nodes.common.errorHandle.title')}
</div>
<Tooltip popupContent={t('workflow.nodes.common.errorHandle.tip')} />
{collapseIcon}
</div> </div>
<Tooltip popupContent={t('workflow.nodes.common.errorHandle.tip')} /> <ErrorHandleTypeSelector
value={error_strategy || ErrorHandleTypeEnum.none}
onSelected={getHandleErrorHandleTypeChange(data)}
/>
</div> </div>
<ErrorHandleTypeSelector )}
value={error_strategy || ErrorHandleTypeEnum.none}
onSelected={getHandleErrorHandleTypeChange(data)}
/>
</div>
}
> >
<> <>
{ {

@ -50,6 +50,7 @@ const ErrorHandleTypeSelector = ({
> >
<PortalToFollowElemTrigger onClick={(e) => { <PortalToFollowElemTrigger onClick={(e) => {
e.stopPropagation() e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
setOpen(v => !v) setOpen(v => !v)
}}> }}>
<Button <Button
@ -68,6 +69,7 @@ const ErrorHandleTypeSelector = ({
className='flex cursor-pointer rounded-lg p-2 pr-3 hover:bg-state-base-hover' className='flex cursor-pointer rounded-lg p-2 pr-3 hover:bg-state-base-hover'
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
onSelected(option.value) onSelected(option.value)
setOpen(false) setOpen(false)
}} }}

@ -3,20 +3,33 @@ import type { FC, ReactNode } from 'react'
import React from 'react' import React from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { FieldCollapse } from '@/app/components/workflow/nodes/_base/components/collapse' import { FieldCollapse } from '@/app/components/workflow/nodes/_base/components/collapse'
import TreeIndentLine from './variable/object-child-tree-panel/tree-indent-line'
import cn from '@/utils/classnames'
type Props = { type Props = {
className?: string className?: string
title?: string title?: string
children: ReactNode children: ReactNode
operations?: ReactNode
collapsed?: boolean
onCollapse?: (collapsed: boolean) => void
} }
const OutputVars: FC<Props> = ({ const OutputVars: FC<Props> = ({
title, title,
children, children,
operations,
collapsed,
onCollapse,
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<FieldCollapse title={title || t('workflow.nodes.common.outputVars')}> <FieldCollapse
title={title || t('workflow.nodes.common.outputVars')}
operations={operations}
collapsed={collapsed}
onCollapse={onCollapse}
>
{children} {children}
</FieldCollapse> </FieldCollapse>
) )
@ -30,6 +43,7 @@ type VarItemProps = {
type: string type: string
description: string description: string
}[] }[]
isIndent?: boolean
} }
export const VarItem: FC<VarItemProps> = ({ export const VarItem: FC<VarItemProps> = ({
@ -37,27 +51,33 @@ export const VarItem: FC<VarItemProps> = ({
type, type,
description, description,
subItems, subItems,
isIndent,
}) => { }) => {
return ( return (
<div className='py-1'> <div className={cn('flex', isIndent && 'relative left-[-7px]')}>
<div className='flex items-center leading-[18px]'> {isIndent && <TreeIndentLine depth={1} />}
<div className='code-sm-semibold text-text-secondary'>{name}</div> <div className='py-1'>
<div className='system-xs-regular ml-2 capitalize text-text-tertiary'>{type}</div> <div className='flex'>
</div> <div className='flex items-center leading-[18px]'>
<div className='system-xs-regular mt-0.5 text-text-tertiary'> <div className='code-sm-semibold text-text-secondary'>{name}</div>
{description} <div className='system-xs-regular ml-2 text-text-tertiary'>{type}</div>
{subItems && (
<div className='ml-2 border-l border-divider-regular pl-2'>
{subItems.map((item, index) => (
<VarItem
key={index}
name={item.name}
type={item.type}
description={item.description}
/>
))}
</div> </div>
)} </div>
<div className='system-xs-regular mt-0.5 text-text-tertiary'>
{description}
{subItems && (
<div className='ml-2 border-l border-gray-200 pl-2'>
{subItems.map((item, index) => (
<VarItem
key={index}
name={item.name}
type={item.type}
description={item.description}
/>
))}
</div>
)}
</div>
</div> </div>
</div> </div>
) )

@ -35,6 +35,7 @@ import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/
import Switch from '@/app/components/base/switch' import Switch from '@/app/components/base/switch'
import { Jinja } from '@/app/components/base/icons/src/vender/workflow' import { Jinja } from '@/app/components/base/icons/src/vender/workflow'
import { useStore } from '@/app/components/workflow/store' import { useStore } from '@/app/components/workflow/store'
import { useWorkflowVariableType } from '@/app/components/workflow/hooks'
type Props = { type Props = {
className?: string className?: string
@ -144,6 +145,8 @@ const Editor: FC<Props> = ({
eventEmitter?.emit({ type: PROMPT_EDITOR_INSERT_QUICKLY, instanceId } as any) eventEmitter?.emit({ type: PROMPT_EDITOR_INSERT_QUICKLY, instanceId } as any)
} }
const getVarType = useWorkflowVariableType()
return ( return (
<Wrap className={cn(className, wrapClassName)} style={wrapStyle} isInNode isExpand={isExpand}> <Wrap className={cn(className, wrapClassName)} style={wrapStyle} isInNode isExpand={isExpand}>
<div ref={ref} className={cn(isFocus ? (gradientBorder && 'bg-gradient-to-r from-components-input-border-active-prompt-1 to-components-input-border-active-prompt-2') : 'bg-transparent', isExpand && 'h-full', '!rounded-[9px] p-0.5', containerClassName)}> <div ref={ref} className={cn(isFocus ? (gradientBorder && 'bg-gradient-to-r from-components-input-border-active-prompt-1 to-components-input-border-active-prompt-2') : 'bg-transparent', isExpand && 'h-full', '!rounded-[9px] p-0.5', containerClassName)}>
@ -251,6 +254,7 @@ const Editor: FC<Props> = ({
workflowVariableBlock={{ workflowVariableBlock={{
show: true, show: true,
variables: nodesOutputVars || [], variables: nodesOutputVars || [],
getVarType,
workflowNodesMap: availableNodes.reduce((acc, node) => { workflowNodesMap: availableNodes.reduce((acc, node) => {
acc[node.id] = { acc[node.id] = {
title: node.data.title, title: node.data.title,

@ -9,6 +9,7 @@ import { getNodeInfoById, isConversationVar, isENV, isSystemVar } from './variab
import { Line3 } from '@/app/components/base/icons/src/public/common' import { Line3 } from '@/app/components/base/icons/src/public/common'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development' import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
import { BubbleX, Env } from '@/app/components/base/icons/src/vender/line/others' import { BubbleX, Env } from '@/app/components/base/icons/src/vender/line/others'
import { RiMoreLine } from '@remixicon/react'
type Props = { type Props = {
nodeId: string nodeId: string
value: string value: string
@ -45,6 +46,7 @@ const ReadonlyInputWithSelectVar: FC<Props> = ({
const isChatVar = isConversationVar(value) const isChatVar = isConversationVar(value)
const node = (isSystem ? startNode : getNodeInfoById(availableNodes, value[0]))?.data const node = (isSystem ? startNode : getNodeInfoById(availableNodes, value[0]))?.data
const varName = `${isSystem ? 'sys.' : ''}${value[value.length - 1]}` const varName = `${isSystem ? 'sys.' : ''}${value[value.length - 1]}`
const isShowAPart = value.length > 2
return (<span key={index}> return (<span key={index}>
<span className='relative top-[-3px] leading-[16px]'>{str}</span> <span className='relative top-[-3px] leading-[16px]'>{str}</span>
@ -61,6 +63,12 @@ const ReadonlyInputWithSelectVar: FC<Props> = ({
<Line3 className='mr-0.5'></Line3> <Line3 className='mr-0.5'></Line3>
</div> </div>
)} )}
{isShowAPart && (
<div className='flex items-center'>
<RiMoreLine className='h-3 w-3 text-text-secondary' />
<Line3 className='mr-0.5 text-divider-deep'></Line3>
</div>
)}
<div className='flex items-center text-text-accent'> <div className='flex items-center text-text-accent'>
{!isEnv && !isChatVar && <Variable02 className='h-3.5 w-3.5 shrink-0' />} {!isEnv && !isChatVar && <Variable02 className='h-3.5 w-3.5 shrink-0' />}
{isEnv && <Env className='h-3.5 w-3.5 shrink-0 text-util-colors-violet-violet-600' />} {isEnv && <Env className='h-3.5 w-3.5 shrink-0 text-util-colors-violet-violet-600' />}

@ -0,0 +1,77 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { Type } from '../../../../../llm/types'
import { getFieldType } from '../../../../../llm/utils'
import type { Field as FieldType } from '../../../../../llm/types'
import cn from '@/utils/classnames'
import TreeIndentLine from '../tree-indent-line'
import { RiMoreFill } from '@remixicon/react'
import Tooltip from '@/app/components/base/tooltip'
import type { ValueSelector } from '@/app/components/workflow/types'
import { useTranslation } from 'react-i18next'
const MAX_DEPTH = 10
type Props = {
valueSelector: ValueSelector
name: string,
payload: FieldType,
depth?: number
readonly?: boolean
onSelect?: (valueSelector: ValueSelector) => void
}
const Field: FC<Props> = ({
valueSelector,
name,
payload,
depth = 1,
readonly,
onSelect,
}) => {
const { t } = useTranslation()
const isLastFieldHighlight = readonly
const hasChildren = payload.type === Type.object && payload.properties
const isHighlight = isLastFieldHighlight && !hasChildren
if (depth > MAX_DEPTH + 1)
return null
return (
<div>
<Tooltip popupContent={t('app.structOutput.moreFillTip')} disabled={depth !== MAX_DEPTH + 1}>
<div
className={cn('flex items-center justify-between rounded-md pr-2', !readonly && 'hover:bg-state-base-hover', depth !== MAX_DEPTH + 1 && 'cursor-pointer')}
onClick={() => !readonly && onSelect?.([...valueSelector, name])}
>
<div className='flex grow items-stretch'>
<TreeIndentLine depth={depth} />
{depth === MAX_DEPTH + 1 ? (
<RiMoreFill className='h-3 w-3 text-text-tertiary' />
) : (<div className={cn('system-sm-medium h-6 w-0 grow truncate leading-6 text-text-secondary', isHighlight && 'text-text-accent')}>{name}</div>)}
</div>
{depth < MAX_DEPTH + 1 && (
<div className='system-xs-regular ml-2 shrink-0 text-text-tertiary'>{getFieldType(payload)}</div>
)}
</div>
</Tooltip>
{depth <= MAX_DEPTH && payload.type === Type.object && payload.properties && (
<div>
{Object.keys(payload.properties).map(propName => (
<Field
key={propName}
name={propName}
payload={payload.properties?.[propName] as FieldType}
depth={depth + 1}
readonly={readonly}
valueSelector={[...valueSelector, name]}
onSelect={onSelect}
/>
))}
</div>
)}
</div>
)
}
export default React.memo(Field)

@ -0,0 +1,82 @@
'use client'
import type { FC } from 'react'
import React, { useRef } from 'react'
import type { StructuredOutput } from '../../../../../llm/types'
import Field from './field'
import cn from '@/utils/classnames'
import { useHover } from 'ahooks'
import type { ValueSelector } from '@/app/components/workflow/types'
type Props = {
className?: string
root: { nodeId?: string, nodeName?: string, attrName: string }
payload: StructuredOutput
readonly?: boolean
onSelect?: (valueSelector: ValueSelector) => void
onHovering?: (value: boolean) => void
}
export const PickerPanelMain: FC<Props> = ({
className,
root,
payload,
readonly,
onHovering,
onSelect,
}) => {
const ref = useRef<HTMLDivElement>(null)
useHover(ref, {
onChange: (hovering) => {
if (hovering) {
onHovering?.(true)
}
else {
setTimeout(() => {
onHovering?.(false)
}, 100)
}
},
})
const schema = payload.schema
const fieldNames = Object.keys(schema.properties)
return (
<div className={cn(className)} ref={ref}>
{/* Root info */}
<div className='flex items-center justify-between px-2 py-1'>
<div className='flex'>
{root.nodeName && (
<>
<div className='system-sm-medium max-w-[100px] truncate text-text-tertiary'>{root.nodeName}</div>
<div className='system-sm-medium text-text-tertiary'>.</div>
</>
)}
<div className='system-sm-medium text-text-secondary'>{root.attrName}</div>
</div>
{/* It must be object */}
<div className='system-xs-regular ml-2 shrink-0 text-text-tertiary'>object</div>
</div>
{fieldNames.map(name => (
<Field
key={name}
name={name}
payload={schema.properties[name]}
readonly={readonly}
valueSelector={[root.nodeId!, root.attrName]}
onSelect={onSelect}
/>
))}
</div>
)
}
const PickerPanel: FC<Props> = ({
className,
...props
}) => {
return (
<div className={cn('w-[296px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 pb-0 shadow-lg backdrop-blur-[5px]', className)}>
<PickerPanelMain {...props} />
</div>
)
}
export default React.memo(PickerPanel)

@ -0,0 +1,74 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { Type } from '../../../../../llm/types'
import { getFieldType } from '../../../../../llm/utils'
import type { Field as FieldType } from '../../../../../llm/types'
import cn from '@/utils/classnames'
import TreeIndentLine from '../tree-indent-line'
import { useTranslation } from 'react-i18next'
import { useBoolean } from 'ahooks'
import { RiArrowDropDownLine } from '@remixicon/react'
type Props = {
name: string,
payload: FieldType,
required: boolean,
depth?: number,
rootClassName?: string
}
const Field: FC<Props> = ({
name,
payload,
depth = 1,
required,
rootClassName,
}) => {
const { t } = useTranslation()
const isRoot = depth === 1
const hasChildren = payload.type === Type.object && payload.properties
const [fold, {
toggle: toggleFold,
}] = useBoolean(false)
return (
<div>
<div className={cn('flex pr-2')}>
<TreeIndentLine depth={depth} />
<div className='w-0 grow'>
<div className='relative flex select-none'>
{hasChildren && (
<RiArrowDropDownLine
className={cn('absolute left-[-18px] top-[50%] h-4 w-4 translate-y-[-50%] cursor-pointer bg-components-panel-bg text-text-tertiary', fold && 'rotate-[270deg] text-text-accent')}
onClick={toggleFold}
/>
)}
<div className={cn('system-sm-medium ml-[7px] h-6 truncate leading-6 text-text-secondary', isRoot && rootClassName)}>{name}</div>
<div className='system-xs-regular ml-3 shrink-0 leading-6 text-text-tertiary'>{getFieldType(payload)}</div>
{required && <div className='system-2xs-medium-uppercase ml-3 leading-6 text-text-warning'>{t('app.structOutput.required')}</div>}
</div>
{payload.description && (
<div className='ml-[7px] flex'>
<div className='system-xs-regular w-0 grow truncate text-text-tertiary'>{payload.description}</div>
</div>
)}
</div>
</div>
{hasChildren && !fold && (
<div>
{Object.keys(payload.properties!).map(name => (
<Field
key={name}
name={name}
payload={payload.properties?.[name] as FieldType}
depth={depth + 1}
required={!!payload.required?.includes(name)}
/>
))}
</div>
)}
</div>
)
}
export default React.memo(Field)

@ -0,0 +1,39 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import type { StructuredOutput } from '../../../../../llm/types'
import Field from './field'
import { useTranslation } from 'react-i18next'
type Props = {
payload: StructuredOutput
rootClassName?: string
}
const ShowPanel: FC<Props> = ({
payload,
rootClassName,
}) => {
const { t } = useTranslation()
const schema = {
...payload,
schema: {
...payload.schema,
description: t('app.structOutput.LLMResponse'),
},
}
return (
<div className='relative left-[-7px]'>
{Object.keys(schema.schema.properties!).map(name => (
<Field
key={name}
name={name}
payload={schema.schema.properties![name]}
required={!!schema.schema.required?.includes(name)}
rootClassName={rootClassName}
/>
))}
</div>
)
}
export default React.memo(ShowPanel)

@ -0,0 +1,24 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import cn from '@/utils/classnames'
type Props = {
depth?: number,
className?: string,
}
const TreeIndentLine: FC<Props> = ({
depth = 1,
className,
}) => {
const depthArray = Array.from({ length: depth }, (_, index) => index)
return (
<div className={cn('flex', className)}>
{depthArray.map(d => (
<div key={d} className={cn('ml-2.5 mr-2.5 w-px bg-divider-regular')}></div>
))}
</div>
)
}
export default React.memo(TreeIndentLine)

@ -319,12 +319,19 @@ const formatItem = (
const outputSchema: any[] = [] const outputSchema: any[] = []
Object.keys(output_schema.properties).forEach((outputKey) => { Object.keys(output_schema.properties).forEach((outputKey) => {
const output = output_schema.properties[outputKey] const output = output_schema.properties[outputKey]
const dataType = output.type
outputSchema.push({ outputSchema.push({
variable: outputKey, variable: outputKey,
type: output.type === 'array' type: dataType === 'array'
? `array[${output.items?.type.slice(0, 1).toLocaleLowerCase()}${output.items?.type.slice(1)}]` ? `array[${output.items?.type.slice(0, 1).toLocaleLowerCase()}${output.items?.type.slice(1)}]`
: `${output.type.slice(0, 1).toLocaleLowerCase()}${output.type.slice(1)}`, : `${output.type.slice(0, 1).toLocaleLowerCase()}${output.type.slice(1)}`,
description: output.description, description: output.description,
children: output.type === 'object' ? {
schema: {
type: 'object',
properties: output.properties,
},
} : undefined,
}) })
}) })
res.vars = [ res.vars = [

@ -0,0 +1,59 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import type { Field, StructuredOutput, TypeWithArray } from '../../../llm/types'
import { Type } from '../../../llm/types'
import { PickerPanelMain as Panel } from '@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/picker'
import BlockIcon from '@/app/components/workflow/block-icon'
import { BlockEnum } from '@/app/components/workflow/types'
type Props = {
nodeName: string
path: string[]
varType: TypeWithArray
nodeType?: BlockEnum
}
const VarFullPathPanel: FC<Props> = ({
nodeName,
path,
varType,
nodeType = BlockEnum.LLM,
}) => {
const schema: StructuredOutput = (() => {
const schema: StructuredOutput['schema'] = {
type: Type.object,
properties: {} as { [key: string]: Field },
required: [],
additionalProperties: false,
}
let current = schema
for (let i = 1; i < path.length; i++) {
const isLast = i === path.length - 1
const name = path[i]
current.properties[name] = {
type: isLast ? varType : Type.object,
properties: {},
} as Field
current = current.properties[name] as { type: Type.object; properties: { [key: string]: Field; }; required: never[]; additionalProperties: false; }
}
return {
schema,
}
})()
return (
<div className='w-[280px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur pb-0 shadow-lg backdrop-blur-[5px]'>
<div className='flex space-x-1 border-b-[0.5px] border-divider-subtle p-3 pb-2 '>
<BlockIcon size='xs' type={nodeType} />
<div className='system-xs-medium w-0 grow truncate text-text-secondary'>{nodeName}</div>
</div>
<Panel
className='px-1 pb-3 pt-2'
root={{ attrName: path[0] }}
payload={schema}
readonly
/>
</div>
)
}
export default React.memo(VarFullPathPanel)

@ -6,13 +6,14 @@ import {
RiArrowDownSLine, RiArrowDownSLine,
RiCloseLine, RiCloseLine,
RiErrorWarningFill, RiErrorWarningFill,
RiMoreLine,
} from '@remixicon/react' } from '@remixicon/react'
import produce from 'immer' import produce from 'immer'
import { useStoreApi } from 'reactflow' import { useStoreApi } from 'reactflow'
import RemoveButton from '../remove-button' import RemoveButton from '../remove-button'
import useAvailableVarList from '../../hooks/use-available-var-list' import useAvailableVarList from '../../hooks/use-available-var-list'
import VarReferencePopup from './var-reference-popup' import VarReferencePopup from './var-reference-popup'
import { getNodeInfoById, isConversationVar, isENV, isSystemVar } from './utils' import { getNodeInfoById, isConversationVar, isENV, isSystemVar, varTypeToStructType } from './utils'
import ConstantField from './constant-field' import ConstantField from './constant-field'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
import type { Node, NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types' import type { Node, NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types'
@ -37,6 +38,7 @@ import AddButton from '@/app/components/base/button/add-button'
import Badge from '@/app/components/base/badge' import Badge from '@/app/components/base/badge'
import Tooltip from '@/app/components/base/tooltip' import Tooltip from '@/app/components/base/tooltip'
import { isExceptionVariable } from '@/app/components/workflow/utils' import { isExceptionVariable } from '@/app/components/workflow/utils'
import VarFullPathPanel from './var-full-path-panel'
import { noop } from 'lodash-es' import { noop } from 'lodash-es'
const TRIGGER_DEFAULT_WIDTH = 227 const TRIGGER_DEFAULT_WIDTH = 227
@ -173,16 +175,15 @@ const VarReferencePicker: FC<Props> = ({
return getNodeInfoById(availableNodes, outputVarNodeId)?.data return getNodeInfoById(availableNodes, outputVarNodeId)?.data
}, [value, hasValue, isConstant, isIterationVar, iterationNode, availableNodes, outputVarNodeId, startNode, isLoopVar, loopNode]) }, [value, hasValue, isConstant, isIterationVar, iterationNode, availableNodes, outputVarNodeId, startNode, isLoopVar, loopNode])
const isShowAPart = (value as ValueSelector).length > 2
const varName = useMemo(() => { const varName = useMemo(() => {
if (hasValue) { if (!hasValue)
const isSystem = isSystemVar(value as ValueSelector) return ''
let varName = ''
if (Array.isArray(value))
varName = value.length >= 3 ? (value as ValueSelector).slice(-2).join('.') : value[value.length - 1]
return `${isSystem ? 'sys.' : ''}${varName}` const isSystem = isSystemVar(value as ValueSelector)
} const varName = Array.isArray(value) ? value[(value as ValueSelector).length - 1] : ''
return '' return `${isSystem ? 'sys.' : ''}${varName}`
}, [hasValue, value]) }, [hasValue, value])
const varKindTypes = [ const varKindTypes = [
@ -270,6 +271,22 @@ const VarReferencePicker: FC<Props> = ({
const WrapElem = isSupportConstantValue ? 'div' : PortalToFollowElemTrigger const WrapElem = isSupportConstantValue ? 'div' : PortalToFollowElemTrigger
const VarPickerWrap = !isSupportConstantValue ? 'div' : PortalToFollowElemTrigger const VarPickerWrap = !isSupportConstantValue ? 'div' : PortalToFollowElemTrigger
const tooltipPopup = useMemo(() => {
if (isValidVar && isShowAPart) {
return (
<VarFullPathPanel
nodeName={outputVarNode?.title}
path={(value as ValueSelector).slice(1)}
varType={varTypeToStructType(type)}
nodeType={outputVarNode?.type}
/>)
}
if (!isValidVar && hasValue)
return t('workflow.errorMsg.invalidVariable')
return null
}, [isValidVar, isShowAPart, hasValue, t, outputVarNode?.title, outputVarNode?.type, value, type])
return ( return (
<div className={cn(className, !readonly && 'cursor-pointer')}> <div className={cn(className, !readonly && 'cursor-pointer')}>
<PortalToFollowElem <PortalToFollowElem
@ -334,7 +351,7 @@ const VarReferencePicker: FC<Props> = ({
className='h-full grow' className='h-full grow'
> >
<div ref={isSupportConstantValue ? triggerRef : null} className={cn('h-full', isSupportConstantValue && 'flex items-center rounded-lg bg-components-panel-bg py-1 pl-1')}> <div ref={isSupportConstantValue ? triggerRef : null} className={cn('h-full', isSupportConstantValue && 'flex items-center rounded-lg bg-components-panel-bg py-1 pl-1')}>
<Tooltip popupContent={!isValidVar && hasValue && t('workflow.errorMsg.invalidVariable')}> <Tooltip noDecoration={isShowAPart} popupContent={tooltipPopup}>
<div className={cn('h-full items-center rounded-[5px] px-1.5', hasValue ? 'inline-flex bg-components-badge-white-to-dark' : 'flex')}> <div className={cn('h-full items-center rounded-[5px] px-1.5', hasValue ? 'inline-flex bg-components-badge-white-to-dark' : 'flex')}>
{hasValue {hasValue
? ( ? (
@ -353,6 +370,12 @@ const VarReferencePicker: FC<Props> = ({
<Line3 className='mr-0.5'></Line3> <Line3 className='mr-0.5'></Line3>
</div> </div>
)} )}
{isShowAPart && (
<div className='flex items-center'>
<RiMoreLine className='h-3 w-3 text-text-secondary' />
<Line3 className='mr-0.5 text-divider-deep'></Line3>
</div>
)}
<div className='flex items-center text-text-accent'> <div className='flex items-center text-text-accent'>
{!hasValue && <Variable02 className='h-3.5 w-3.5' />} {!hasValue && <Variable02 className='h-3.5 w-3.5' />}
{isEnv && <Env className='h-3.5 w-3.5 text-util-colors-violet-violet-600' />} {isEnv && <Env className='h-3.5 w-3.5 text-util-colors-violet-violet-600' />}

@ -1,6 +1,6 @@
'use client' 'use client'
import type { FC } from 'react' import type { FC } from 'react'
import React, { useEffect, useRef, useState } from 'react' import React, { useEffect, useMemo, useRef, useState } from 'react'
import { useHover } from 'ahooks' import { useHover } from 'ahooks'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
@ -15,6 +15,11 @@ import {
import Input from '@/app/components/base/input' import Input from '@/app/components/base/input'
import { BubbleX, Env } from '@/app/components/base/icons/src/vender/line/others' import { BubbleX, Env } from '@/app/components/base/icons/src/vender/line/others'
import { checkKeys } from '@/utils/var' import { checkKeys } from '@/utils/var'
import type { StructuredOutput } from '../../../llm/types'
import { Type } from '../../../llm/types'
import PickerStructurePanel from '@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/picker'
import { varTypeToStructType } from './utils'
import type { Field } from '@/app/components/workflow/nodes/llm/types'
import { FILE_STRUCT } from '@/app/components/workflow/constants' import { FILE_STRUCT } from '@/app/components/workflow/constants'
import { Loop } from '@/app/components/base/icons/src/vender/workflow' import { Loop } from '@/app/components/base/icons/src/vender/workflow'
import { noop } from 'lodash-es' import { noop } from 'lodash-es'
@ -52,16 +57,41 @@ const Item: FC<ItemProps> = ({
itemData, itemData,
onChange, onChange,
onHovering, onHovering,
itemWidth,
isSupportFileVar, isSupportFileVar,
isException, isException,
isLoopVar, isLoopVar,
}) => { }) => {
const isFile = itemData.type === VarType.file const isStructureOutput = itemData.type === VarType.object && (itemData.children as StructuredOutput)?.schema?.properties
const isObj = (objVarTypes.includes(itemData.type) && itemData.children && itemData.children.length > 0) const isFile = itemData.type === VarType.file && !isStructureOutput
const isObj = ([VarType.object, VarType.file].includes(itemData.type) && itemData.children && (itemData.children as Var[]).length > 0)
const isSys = itemData.variable.startsWith('sys.') const isSys = itemData.variable.startsWith('sys.')
const isEnv = itemData.variable.startsWith('env.') const isEnv = itemData.variable.startsWith('env.')
const isChatVar = itemData.variable.startsWith('conversation.') const isChatVar = itemData.variable.startsWith('conversation.')
const objStructuredOutput: StructuredOutput | null = useMemo(() => {
if (!isObj) return null
const properties: Record<string, Field> = {};
(isFile ? FILE_STRUCT : (itemData.children as Var[])).forEach((c) => {
properties[c.variable] = {
type: varTypeToStructType(c.type),
}
})
return {
schema: {
type: Type.object,
properties,
required: [],
additionalProperties: false,
},
}
}, [isFile, isObj, itemData.children])
const structuredOutput = (() => {
if (isStructureOutput)
return itemData.children as StructuredOutput
return objStructuredOutput
})()
const itemRef = useRef<HTMLDivElement>(null) const itemRef = useRef<HTMLDivElement>(null)
const [isItemHovering, setIsItemHovering] = useState(false) const [isItemHovering, setIsItemHovering] = useState(false)
useHover(itemRef, { useHover(itemRef, {
@ -70,7 +100,7 @@ const Item: FC<ItemProps> = ({
setIsItemHovering(true) setIsItemHovering(true)
} }
else { else {
if (isObj) { if (isObj || isStructureOutput) {
setTimeout(() => { setTimeout(() => {
setIsItemHovering(false) setIsItemHovering(false)
}, 100) }, 100)
@ -83,7 +113,7 @@ const Item: FC<ItemProps> = ({
}) })
const [isChildrenHovering, setIsChildrenHovering] = useState(false) const [isChildrenHovering, setIsChildrenHovering] = useState(false)
const isHovering = isItemHovering || isChildrenHovering const isHovering = isItemHovering || isChildrenHovering
const open = isObj && isHovering const open = (isObj || isStructureOutput) && isHovering
useEffect(() => { useEffect(() => {
onHovering && onHovering(isHovering) onHovering && onHovering(isHovering)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@ -110,8 +140,8 @@ const Item: FC<ItemProps> = ({
<div <div
ref={itemRef} ref={itemRef}
className={cn( className={cn(
isObj ? ' pr-1' : 'pr-[18px]', (isObj || isStructureOutput) ? ' pr-1' : 'pr-[18px]',
isHovering && (isObj ? 'bg-primary-50' : 'bg-state-base-hover'), isHovering && ((isObj || isStructureOutput) ? 'bg-primary-50' : 'bg-state-base-hover'),
'relative flex h-6 w-full cursor-pointer items-center rounded-md pl-3') 'relative flex h-6 w-full cursor-pointer items-center rounded-md pl-3')
} }
onClick={handleChosen} onClick={handleChosen}
@ -133,42 +163,28 @@ const Item: FC<ItemProps> = ({
)} )}
</div> </div>
<div className='ml-1 shrink-0 text-xs font-normal capitalize text-text-tertiary'>{itemData.type}</div> <div className='ml-1 shrink-0 text-xs font-normal capitalize text-text-tertiary'>{itemData.type}</div>
{isObj && ( {
<ChevronRight className={cn('ml-0.5 h-3 w-3 text-text-quaternary', isHovering && 'text-text-tertiary')} /> (isObj || isStructureOutput) && (
)} <ChevronRight className={cn('ml-0.5 h-3 w-3 text-text-quaternary', isHovering && 'text-text-tertiary')} />
</div> )
</PortalToFollowElemTrigger> }
</div >
</PortalToFollowElemTrigger >
<PortalToFollowElemContent style={{ <PortalToFollowElemContent style={{
zIndex: 100, zIndex: 100,
}}> }}>
{(isObj && !isFile) && ( {(isStructureOutput || isObj) && (
// eslint-disable-next-line ts/no-use-before-define <PickerStructurePanel
<ObjectChildren root={{ nodeId, nodeName: title, attrName: itemData.variable }}
nodeId={nodeId} payload={structuredOutput!}
title={title}
objPath={[...objPath, itemData.variable]}
data={itemData.children as Var[]}
onChange={onChange}
onHovering={setIsChildrenHovering} onHovering={setIsChildrenHovering}
itemWidth={itemWidth} onSelect={(valueSelector) => {
isSupportFileVar={isSupportFileVar} onChange(valueSelector, itemData)
/> }}
)}
{isFile && (
// eslint-disable-next-line ts/no-use-before-define
<ObjectChildren
nodeId={nodeId}
title={title}
objPath={[...objPath, itemData.variable]}
data={FILE_STRUCT}
onChange={onChange}
onHovering={setIsChildrenHovering}
itemWidth={itemWidth}
isSupportFileVar={isSupportFileVar}
/> />
)} )}
</PortalToFollowElemContent> </PortalToFollowElemContent>
</PortalToFollowElem> </PortalToFollowElem >
) )
} }
@ -331,7 +347,7 @@ const VarReferenceVars: FC<Props> = ({
} }
</div> </div>
: <div className='pl-3 text-xs font-medium uppercase leading-[18px] text-gray-500'>{t('workflow.common.noVar')}</div>} : <div className='pl-3 text-xs font-medium uppercase leading-[18px] text-gray-500'>{t('workflow.common.noVar')}</div>}
</ > </>
) )
} }
export default React.memo(VarReferenceVars) export default React.memo(VarReferenceVars)

@ -39,7 +39,8 @@ const MetadataFilter = ({
disabled={metadataFilterMode === MetadataFilteringModeEnum.disabled || metadataFilterMode === MetadataFilteringModeEnum.manual} disabled={metadataFilterMode === MetadataFilteringModeEnum.disabled || metadataFilterMode === MetadataFilteringModeEnum.manual}
collapsed={collapsed} collapsed={collapsed}
onCollapse={setCollapsed} onCollapse={setCollapsed}
trigger={ hideCollapseIcon
trigger={collapseIcon => (
<div className='flex grow items-center justify-between pr-4'> <div className='flex grow items-center justify-between pr-4'>
<div className='flex items-center'> <div className='flex items-center'>
<div className='system-sm-semibold-uppercase mr-0.5 text-text-secondary'> <div className='system-sm-semibold-uppercase mr-0.5 text-text-secondary'>
@ -52,6 +53,7 @@ const MetadataFilter = ({
</div> </div>
)} )}
/> />
{collapseIcon}
</div> </div>
<div className='flex items-center'> <div className='flex items-center'>
<MetadataFilterSelector <MetadataFilterSelector
@ -67,7 +69,7 @@ const MetadataFilter = ({
} }
</div> </div>
</div> </div>
} )}
> >
<> <>
{ {

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save