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
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(RuleCodeGenerateApi, "/rule-code-generate")
api.add_resource(RuleStructuredOutputGenerateApi, "/rule-structured-output-generate")

@ -16,7 +16,7 @@ from controllers.console.auth.error import (
PasswordMismatchError,
)
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 extensions.ext_database import db
from libs.helper import email, extract_remote_ip
@ -30,6 +30,7 @@ from services.feature_service import FeatureService
class ForgotPasswordSendEmailApi(Resource):
@setup_required
@email_password_login_enabled
def post(self):
parser = reqparse.RequestParser()
parser.add_argument("email", type=email, required=True, location="json")
@ -62,6 +63,7 @@ class ForgotPasswordSendEmailApi(Resource):
class ForgotPasswordCheckApi(Resource):
@setup_required
@email_password_login_enabled
def post(self):
parser = reqparse.RequestParser()
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"])
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"])
return {"is_valid": True, "email": token_data.get("email")}
return {"is_valid": True, "email": token_data.get("email"), "token": new_token}
class ForgotPasswordResetApi(Resource):
@setup_required
@email_password_login_enabled
def post(self):
parser = reqparse.RequestParser()
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"])
if not reset_data:
raise InvalidTokenError()
# Must use token in reset phase
if reset_data.get("phase", "") != "reset":
raise InvalidTokenError()
# Revoke token to prevent reuse
AccountService.revoke_reset_password_token(args["token"])

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

@ -210,3 +210,16 @@ def enterprise_license_required(view):
return view(*args, **kwargs)
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,
JAVASCRIPT_CODE_GENERATOR_PROMPT_TEMPLATE,
PYTHON_CODE_GENERATOR_PROMPT_TEMPLATE,
SYSTEM_STRUCTURED_OUTPUT_GENERATE,
WORKFLOW_RULE_CONFIG_PROMPT_GENERATE_TEMPLATE,
)
from core.model_manager import ModelManager
@ -340,3 +341,37 @@ class LLMGenerator:
answer = cast(str, response.message.content)
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
""" # 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 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
@ -85,6 +85,7 @@ class ModelFeature(Enum):
DOCUMENT = "document"
VIDEO = "video"
AUDIO = "audio"
STRUCTURED_OUTPUT = "structured-output"
class DefaultParameterName(StrEnum):
@ -197,6 +198,19 @@ class AIModelEntity(ProviderModel):
parameter_rules: list[ParameterRule] = []
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):
pass

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

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

@ -86,3 +86,12 @@ class WorkflowNodeExecutionRepository(Protocol):
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.variable_pool import VariablePool
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.enums import NodeType
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
]
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
return result
@ -348,3 +353,10 @@ class AgentNode(ToolNode):
)
model_schema = model_type_instance.get_model_schema(model_name, model_credentials)
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):
CLOSE = 0
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
context: ContextConfig
vision: VisionConfig = Field(default_factory=VisionConfig)
structured_output: dict | None = None
structured_output_enabled: bool = False
@field_validator("prompt_config", mode="before")
@classmethod

@ -4,6 +4,8 @@ from collections.abc import Generator, Mapping, Sequence
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, Optional, cast
import json_repair
from configs import dify_config
from core.app.entities.app_invoke_entities import ModelConfigWithCredentialsEntity
from core.entities.model_entities import ModelStatus
@ -27,7 +29,13 @@ from core.model_runtime.entities.message_entities import (
SystemPromptMessage,
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.utils.encoders import jsonable_encoder
from core.plugin.entities.plugin import ModelProviderID
@ -57,6 +65,12 @@ from core.workflow.nodes.event import (
RunRetrieverResourceEvent,
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 extensions.ext_database import db
from models.model import Conversation
@ -92,6 +106,12 @@ class LLMNode(BaseNode[LLMNodeData]):
_node_type = NodeType.LLM
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
process_data = None
result_text = ""
@ -130,7 +150,6 @@ class LLMNode(BaseNode[LLMNodeData]):
if isinstance(event, RunRetrieverResourceEvent):
context = event.context
yield event
if 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)
break
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(
run_result=NodeRunResult(
status=WorkflowNodeExecutionStatus.SUCCEEDED,
@ -513,7 +534,12 @@ class LLMNode(BaseNode[LLMNodeData]):
if not model_schema:
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(
provider=provider_name,
model=model_name,
@ -724,10 +750,29 @@ class LLMNode(BaseNode[LLMNodeData]):
"No prompt found in the LLM configuration. "
"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
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
def deduct_llm_quota(cls, tenant_id: str, model_instance: ModelInstance, usage: LLMUsage) -> None:
provider_model_bundle = model_instance.provider_model_bundle
@ -926,6 +971,166 @@ class LLMNode(BaseNode[LLMNodeData]):
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):
match role:
@ -1064,3 +1269,49 @@ def _handle_completion_template(
)
prompt_messages.append(prompt_message)
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
def created_by_account(self):
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
@property
@ -637,6 +638,7 @@ class WorkflowNodeExecution(Base):
from models.model import EndUser
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
@property

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

@ -6,7 +6,7 @@ import logging
from collections.abc import Sequence
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.orm import sessionmaker
@ -168,3 +168,25 @@ class SQLAlchemyWorkflowNodeExecutionRepository:
session.merge(execution)
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()
code = "".join([str(random.randint(0, 9)) for _ in range(6)])
token = TokenManager.generate_token(
account=account, email=email, token_type="reset_password", additional_data={"code": code}
)
code, token = cls.generate_reset_password_token(account_email, account)
send_reset_password_mail_task.delay(
language=language,
to=account_email,
@ -419,6 +417,22 @@ class AccountService:
cls.reset_password_rate_limiter.increment_rate_limit(account_email)
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
def revoke_reset_password_token(cls, token: str):
TokenManager.revoke_token(token, "reset_password")

@ -2,13 +2,14 @@ import threading
from typing import Optional
import contexts
from core.repository import RepositoryFactory
from core.repository.workflow_node_execution_repository import OrderConfig
from extensions.ext_database import db
from libs.infinite_scroll_pagination import InfiniteScrollPagination
from models.enums import WorkflowRunTriggeredFrom
from models.model import App
from models.workflow import (
WorkflowNodeExecution,
WorkflowNodeExecutionTriggeredFrom,
WorkflowRun,
)
@ -127,17 +128,17 @@ class WorkflowRunService:
if not workflow_run:
return []
node_executions = (
db.session.query(WorkflowNodeExecution)
.filter(
WorkflowNodeExecution.tenant_id == app_model.tenant_id,
WorkflowNodeExecution.app_id == app_model.id,
WorkflowNodeExecution.workflow_id == workflow_run.workflow_id,
WorkflowNodeExecution.triggered_from == WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN.value,
WorkflowNodeExecution.workflow_run_id == run_id,
)
.order_by(WorkflowNodeExecution.index.desc())
.all()
# Use the repository to get the node executions
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,
}
)
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.workflow.app_config_manager import WorkflowAppConfigManager
from core.model_runtime.utils.encoders import jsonable_encoder
from core.repository import RepositoryFactory
from core.variables import Variable
from core.workflow.entities.node_entities import NodeRunResult
from core.workflow.errors import WorkflowNodeRunFailedError
@ -282,8 +283,15 @@ class WorkflowService:
workflow_node_execution.created_by = account.id
workflow_node_execution.workflow_id = draft_workflow.id
db.session.add(workflow_node_execution)
db.session.commit()
# Use the repository to save the workflow node execution
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

@ -7,6 +7,7 @@ from celery import shared_task # type: ignore
from sqlalchemy import delete
from sqlalchemy.exc import SQLAlchemyError
from core.repository import RepositoryFactory
from extensions.ext_database import db
from models.dataset import AppDatasetJoin
from models.model import (
@ -30,7 +31,7 @@ from models.model import (
)
from models.tools import WorkflowToolProvider
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)
@ -187,17 +188,19 @@ def _delete_app_workflow_runs(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):
db.session.query(WorkflowNodeExecution).filter(WorkflowNodeExecution.id == workflow_node_execution_id).delete(
synchronize_session=False
# Create a repository instance for WorkflowNodeExecution
repository = RepositoryFactory.create_workflow_node_execution_repository(
params={
"tenant_id": tenant_id,
"app_id": app_id,
"session_factory": db.session.get_bind,
}
)
_delete_records(
"""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):

@ -152,3 +152,27 @@ def test_update(repository, session):
# Assert session.merge was called
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
revision = 1
requires-python = ">=3.11, <3.13"
resolution-markers = [
"python_full_version >= '3.12.4' and platform_python_implementation != 'PyPy'",
@ -1178,6 +1177,7 @@ dependencies = [
{ name = "gunicorn" },
{ name = "httpx", extra = ["socks"] },
{ name = "jieba" },
{ name = "json-repair" },
{ name = "langfuse" },
{ name = "langsmith" },
{ name = "mailchimp-transactional" },
@ -1346,6 +1346,7 @@ requires-dist = [
{ name = "gunicorn", specifier = "~=23.0.0" },
{ name = "httpx", extras = ["socks"], specifier = "~=0.27.0" },
{ name = "jieba", specifier = "==0.42.1" },
{ name = "json-repair", specifier = ">=0.41.1" },
{ name = "langfuse", specifier = "~=2.51.3" },
{ name = "langsmith", specifier = "~=0.1.77" },
{ 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 },
]
[[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]]
name = "jsonpath-python"
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/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/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]]

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

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

@ -603,6 +603,7 @@ services:
HTTP_PROXY: ${SANDBOX_HTTP_PROXY:-http://ssrf_proxy:3128}
HTTPS_PROXY: ${SANDBOX_HTTPS_PROXY:-http://ssrf_proxy:3128}
SANDBOX_PORT: ${SANDBOX_PORT:-8194}
PIP_MIRROR_URL: ${PIP_MIRROR_URL:-}
volumes:
- ./volumes/sandbox/dependencies:/dependencies
- ./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' />
<input
key={index}
type="input"
type='input'
value={o || ''}
onChange={(e) => {
const value = e.target.value
@ -67,6 +67,7 @@ const ConfigSelect: FC<IConfigSelectProps> = ({
onBlur={() => setFocusID(null)}
/>
<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'
onClick={() => {
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 s from './index.module.css'
import cn from '@/utils/classnames'
import IndeterminateIcon from './assets/indeterminate-icon'
type CheckboxProps = {
id?: string
checked?: boolean
onCheck?: () => void
className?: string
disabled?: boolean
mixed?: boolean
indeterminate?: boolean
}
const Checkbox = ({ checked, onCheck, className, disabled, mixed }: CheckboxProps) => {
if (!checked) {
return (
<div
className={cn(
'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',
mixed ? s.mixed : 'hover:bg-components-checkbox-bg-unchecked-hover',
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',
const Checkbox = ({
id,
checked,
onCheck,
className,
)}
onClick={() => {
if (disabled)
return
onCheck?.()
}}
></div>
)
}
disabled,
indeterminate,
}: CheckboxProps) => {
const checkClassName = (checked || indeterminate)
? 'bg-components-checkbox-bg text-components-checkbox-icon hover:bg-components-checkbox-bg-hover'
: 'border border-components-checkbox-border bg-components-checkbox-bg-unchecked hover:bg-components-checkbox-bg-unchecked-hover hover:border-components-checkbox-border-hover'
const disabledClassName = (checked || indeterminate)
? 'cursor-not-allowed bg-components-checkbox-bg-disabled-checked text-components-checkbox-icon-disabled hover:bg-components-checkbox-bg-disabled-checked'
: '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'
return (
<div
id={id}
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',
disabled && 'cursor-not-allowed bg-components-checkbox-bg-disabled-checked text-components-checkbox-icon-disabled hover:bg-components-checkbox-bg-disabled-checked',
'flex h-4 w-4 cursor-pointer items-center justify-center rounded-[4px] shadow-xs shadow-shadow-shadow-3',
checkClassName,
disabled && disabledClassName,
className,
)}
onClick={() => {
if (disabled)
return
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>
)
}

@ -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 ArrowDownRoundFill } from './ArrowDownRoundFill'
export { default as CheckCircle } from './CheckCircle'
export { default as CheckDone01 } from './CheckDone01'
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
onChange: (value?: number) => void
amount?: number
size?: 'sm' | 'md'
size?: 'regular' | 'large'
max?: number
min?: number
defaultValue?: number
@ -19,14 +19,12 @@ export type InputNumberProps = {
} & Omit<InputProps, 'value' | 'onChange' | 'size' | 'min' | 'max' | 'defaultValue'>
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) => {
if (max && v > max)
if (typeof max === 'number' && v > max)
return false
if (min && v < min)
return false
return true
return !(typeof min === 'number' && v < min)
}
const inc = () => {
@ -76,29 +74,39 @@ export const InputNumber: FC<InputNumberProps> = (props) => {
onChange(parsed)
}}
unit={unit}
size={size}
/>
<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',
disabled && 'opacity-50 cursor-not-allowed',
controlWrapClassName)}
>
<button onClick={inc} disabled={disabled} className={classNames(
size === 'sm' ? 'pt-1' : 'pt-1.5',
<button
type='button'
onClick={inc}
disabled={disabled}
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' />
</button>
<button
type='button'
onClick={dec}
disabled={disabled}
aria-label='decrement'
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',
disabled && 'cursor-not-allowed hover:bg-transparent',
controlClassName,
)}>
)}
>
<RiArrowDownSLine className='size-3' />
</button>
</div>

@ -30,7 +30,7 @@ export type InputProps = {
wrapperClassName?: string
styleCss?: CSSProperties
unit?: string
} & React.InputHTMLAttributes<HTMLInputElement> & VariantProps<typeof inputVariants>
} & Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> & VariantProps<typeof inputVariants>
const Input = ({
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 MarkdownButton from '@/app/components/base/markdown-blocks/button'
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 { Theme } from '@/types/app'
import useTheme from '@/hooks/use-theme'
@ -51,6 +52,7 @@ const capitalizationLanguageNameMap: Record<string, string> = {
json: 'JSON',
latex: 'Latex',
svg: 'SVG',
abc: 'ABC',
}
const getCorrectCapitalizationLanguageName = (language: string) => {
if (!language)
@ -137,10 +139,12 @@ const CodeBlock: any = memo(({ inline, className, children, ...props }: any) =>
const renderCodeContent = useMemo(() => {
const content = String(children).replace(/\n$/, '')
if (language === 'mermaid' && isSVG) {
switch (language) {
case 'mermaid':
if (isSVG)
return <Flowchart PrimitiveCode={content} />
}
else if (language === 'echarts') {
break
case 'echarts':
return (
<div style={{ minHeight: '350px', minWidth: '100%', overflowX: 'scroll' }}>
<ErrorBoundary>
@ -148,15 +152,22 @@ const CodeBlock: any = memo(({ inline, className, children, ...props }: any) =>
</ErrorBoundary>
</div>
)
}
else if (language === 'svg' && isSVG) {
case 'svg':
if (isSVG) {
return (
<ErrorBoundary>
<SVGRenderer content={content} />
</ErrorBoundary>
)
}
else {
break
case 'abc':
return (
<ErrorBoundary>
<MarkdownMusic children={content} />
</ErrorBoundary>
)
default:
return (
<SyntaxHighlighter
{...props}
@ -175,7 +186,7 @@ const CodeBlock: any = memo(({ inline, className, children, ...props }: any) =>
</SyntaxHighlighter>
)
}
}, [language, match, props, children, chartData, isSVG])
}, [children, language, isSVG, chartData, props, theme, match])
if (inline || !match)
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}
step={step}
amount={step}
size='sm'
size='regular'
value={value}
onChange={(value) => {
onChange(id, value)

@ -14,7 +14,7 @@ export class HistoryBlockNode extends DecoratorNode<React.JSX.Element> {
}
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) {

@ -11,6 +11,7 @@ import { mergeRegister } from '@lexical/utils'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import {
RiErrorWarningFill,
RiMoreLine,
} from '@remixicon/react'
import { useSelectOrDelete } from '../../hooks'
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 Tooltip from '@/app/components/base/tooltip'
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 = {
nodeKey: string
variables: string[]
workflowNodesMap: WorkflowNodesMap
getVarType?: (payload: {
nodeId: string,
valueSelector: ValueSelector,
}) => Type
}
const WorkflowVariableBlockComponent = ({
nodeKey,
variables,
workflowNodesMap = {},
getVarType,
}: WorkflowVariableBlockComponentProps) => {
const { t } = useTranslation()
const [editor] = useLexicalComposerContext()
const [ref, isSelected] = useSelectOrDelete(nodeKey, DELETE_WORKFLOW_VARIABLE_BLOCK_COMMAND)
const variablesLength = variables.length
const isShowAPart = variablesLength > 2
const varName = (
() => {
const isSystem = isSystemVar(variables)
const varName = variablesLength >= 3 ? (variables).slice(-2).join('.') : variables[variablesLength - 1]
const varName = variables[variablesLength - 1]
return `${isSystem ? 'sys.' : ''}${varName}`
}
)()
@ -76,7 +86,7 @@ const WorkflowVariableBlockComponent = ({
const Item = (
<div
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',
!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>
</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'>
{!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' />}
@ -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)

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

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

@ -16,6 +16,7 @@ import { VAR_REGEX as REGEX, resetReg } from '@/config'
const WorkflowVariableBlockReplacementBlock = ({
workflowNodesMap,
getVarType,
onInsert,
}: WorkflowVariableBlockType) => {
const [editor] = useLexicalComposerContext()
@ -30,8 +31,8 @@ const WorkflowVariableBlockReplacementBlock = ({
onInsert()
const nodePathString = textNode.getTextContent().slice(3, -3)
return $applyNodeReplacement($createWorkflowVariableBlockNode(nodePathString.split('.'), workflowNodesMap))
}, [onInsert, workflowNodesMap])
return $applyNodeReplacement($createWorkflowVariableBlockNode(nodePathString.split('.'), workflowNodesMap, getVarType))
}, [onInsert, workflowNodesMap, getVarType])
const getMatch = useCallback((text: string) => {
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 { RoleName } from './plugins/history-block'
import type {
Node,
NodeOutPutVar,
ValueSelector,
} from '@/app/components/workflow/types'
export type Option = {
@ -54,12 +56,18 @@ export type ExternalToolBlockType = {
onAddExternalTool?: () => void
}
export type GetVarType = (payload: {
nodeId: string,
valueSelector: ValueSelector,
}) => Type
export type WorkflowVariableBlockType = {
show?: boolean
variables?: NodeOutPutVar[]
workflowNodesMap?: Record<string, Pick<Node['data'], 'title' | 'type'>>
onInsert?: () => void
onDelete?: () => void
getVarType?: GetVarType
}
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: {
size: {
regular: 'px-3 radius-md system-sm-regular',
large: 'px-4 radius-lg system-md-regular',
small: 'py-1 rounded-md system-xs-regular',
regular: 'px-3 rounded-md system-sm-regular',
large: 'px-4 rounded-lg system-md-regular',
},
},
defaultVariants: {

@ -10,6 +10,7 @@ export type TooltipProps = {
position?: Placement
triggerMethod?: 'hover' | 'click'
triggerClassName?: string
triggerTestId?: string
disabled?: boolean
popupContent?: React.ReactNode
children?: React.ReactNode
@ -24,6 +25,7 @@ const Tooltip: FC<TooltipProps> = ({
position = 'top',
triggerMethod = 'hover',
triggerClassName,
triggerTestId,
disabled = false,
popupContent,
children,
@ -91,7 +93,7 @@ const Tooltip: FC<TooltipProps> = ({
onMouseLeave={() => triggerMethod === 'hover' && handleLeave(true)}
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>
<PortalToFollowElemContent
className="z-[9999]"

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

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

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

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

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

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

@ -40,7 +40,7 @@ const InputCombined: FC<Props> = ({
className={cn(className, 'rounded-l-md')}
value={value}
onChange={onChange}
size='sm'
size='regular'
controlWrapClassName='overflow-hidden'
controlClassName='pt-0 pb-0'
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]'>
<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 className='flex flex-row'>
<div className={'system-sm-medium max-w-[160px] truncate text-text-secondary'}>{currentWorkspace?.name}</div>
@ -73,7 +73,7 @@ const WorkplaceSelector = () => {
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 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 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} />

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

@ -23,9 +23,9 @@ const ModelIcon: FC<ModelIconProps> = ({
isDeprecated = false,
}) => {
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>
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>
if (provider?.icon_small) {

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

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

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

@ -8,6 +8,8 @@ import type {
ValueSelector,
Var,
} from '@/app/components/workflow/types'
import { useIsChatMode } from './use-workflow'
import { useStoreApi } from 'reactflow'
export const useWorkflowVariables = () => {
const { t } = useTranslation()
@ -75,3 +77,37 @@ export const useWorkflowVariables = () => {
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
onChange={onChange as any}
defaultValue={defaultValue}
size='sm'
size='regular'
min={def.min}
max={def.max}
className='w-12'

@ -4,10 +4,16 @@ import Collapse from '.'
type FieldCollapseProps = {
title: string
children: ReactNode
collapsed?: boolean
onCollapse?: (collapsed: boolean) => void
operations?: ReactNode
}
const FieldCollapse = ({
title,
children,
collapsed,
onCollapse,
operations,
}: FieldCollapseProps) => {
return (
<div className='py-4'>
@ -15,6 +21,9 @@ const FieldCollapse = ({
trigger={
<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'>
{children}

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

@ -49,20 +49,23 @@ const ErrorHandle = ({
disabled={!error_strategy}
collapsed={collapsed}
onCollapse={setCollapsed}
hideCollapseIcon
trigger={
collapseIcon => (
<div className='flex grow items-center justify-between pr-4'>
<div className='flex items-center'>
<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>
<ErrorHandleTypeSelector
value={error_strategy || ErrorHandleTypeEnum.none}
onSelected={getHandleErrorHandleTypeChange(data)}
/>
</div>
}
)}
>
<>
{

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

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

@ -35,6 +35,7 @@ import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/
import Switch from '@/app/components/base/switch'
import { Jinja } from '@/app/components/base/icons/src/vender/workflow'
import { useStore } from '@/app/components/workflow/store'
import { useWorkflowVariableType } from '@/app/components/workflow/hooks'
type Props = {
className?: string
@ -144,6 +145,8 @@ const Editor: FC<Props> = ({
eventEmitter?.emit({ type: PROMPT_EDITOR_INSERT_QUICKLY, instanceId } as any)
}
const getVarType = useWorkflowVariableType()
return (
<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)}>
@ -251,6 +254,7 @@ const Editor: FC<Props> = ({
workflowVariableBlock={{
show: true,
variables: nodesOutputVars || [],
getVarType,
workflowNodesMap: availableNodes.reduce((acc, node) => {
acc[node.id] = {
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 { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
import { BubbleX, Env } from '@/app/components/base/icons/src/vender/line/others'
import { RiMoreLine } from '@remixicon/react'
type Props = {
nodeId: string
value: string
@ -45,6 +46,7 @@ const ReadonlyInputWithSelectVar: FC<Props> = ({
const isChatVar = isConversationVar(value)
const node = (isSystem ? startNode : getNodeInfoById(availableNodes, value[0]))?.data
const varName = `${isSystem ? 'sys.' : ''}${value[value.length - 1]}`
const isShowAPart = value.length > 2
return (<span key={index}>
<span className='relative top-[-3px] leading-[16px]'>{str}</span>
@ -61,6 +63,12 @@ const ReadonlyInputWithSelectVar: FC<Props> = ({
<Line3 className='mr-0.5'></Line3>
</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'>
{!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' />}

@ -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[] = []
Object.keys(output_schema.properties).forEach((outputKey) => {
const output = output_schema.properties[outputKey]
const dataType = output.type
outputSchema.push({
variable: outputKey,
type: output.type === 'array'
type: dataType === 'array'
? `array[${output.items?.type.slice(0, 1).toLocaleLowerCase()}${output.items?.type.slice(1)}]`
: `${output.type.slice(0, 1).toLocaleLowerCase()}${output.type.slice(1)}`,
description: output.description,
children: output.type === 'object' ? {
schema: {
type: 'object',
properties: output.properties,
},
} : undefined,
})
})
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,
RiCloseLine,
RiErrorWarningFill,
RiMoreLine,
} from '@remixicon/react'
import produce from 'immer'
import { useStoreApi } from 'reactflow'
import RemoveButton from '../remove-button'
import useAvailableVarList from '../../hooks/use-available-var-list'
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 cn from '@/utils/classnames'
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 Tooltip from '@/app/components/base/tooltip'
import { isExceptionVariable } from '@/app/components/workflow/utils'
import VarFullPathPanel from './var-full-path-panel'
import { noop } from 'lodash-es'
const TRIGGER_DEFAULT_WIDTH = 227
@ -173,16 +175,15 @@ const VarReferencePicker: FC<Props> = ({
return getNodeInfoById(availableNodes, outputVarNodeId)?.data
}, [value, hasValue, isConstant, isIterationVar, iterationNode, availableNodes, outputVarNodeId, startNode, isLoopVar, loopNode])
const isShowAPart = (value as ValueSelector).length > 2
const varName = useMemo(() => {
if (hasValue) {
const isSystem = isSystemVar(value as ValueSelector)
let varName = ''
if (Array.isArray(value))
varName = value.length >= 3 ? (value as ValueSelector).slice(-2).join('.') : value[value.length - 1]
if (!hasValue)
return ''
const isSystem = isSystemVar(value as ValueSelector)
const varName = Array.isArray(value) ? value[(value as ValueSelector).length - 1] : ''
return `${isSystem ? 'sys.' : ''}${varName}`
}
return ''
}, [hasValue, value])
const varKindTypes = [
@ -270,6 +271,22 @@ const VarReferencePicker: FC<Props> = ({
const WrapElem = 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 (
<div className={cn(className, !readonly && 'cursor-pointer')}>
<PortalToFollowElem
@ -334,7 +351,7 @@ const VarReferencePicker: FC<Props> = ({
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')}>
<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')}>
{hasValue
? (
@ -353,6 +370,12 @@ const VarReferencePicker: FC<Props> = ({
<Line3 className='mr-0.5'></Line3>
</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'>
{!hasValue && <Variable02 className='h-3.5 w-3.5' />}
{isEnv && <Env className='h-3.5 w-3.5 text-util-colors-violet-violet-600' />}

@ -1,6 +1,6 @@
'use client'
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 { useTranslation } from 'react-i18next'
import cn from '@/utils/classnames'
@ -15,6 +15,11 @@ import {
import Input from '@/app/components/base/input'
import { BubbleX, Env } from '@/app/components/base/icons/src/vender/line/others'
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 { Loop } from '@/app/components/base/icons/src/vender/workflow'
import { noop } from 'lodash-es'
@ -52,16 +57,41 @@ const Item: FC<ItemProps> = ({
itemData,
onChange,
onHovering,
itemWidth,
isSupportFileVar,
isException,
isLoopVar,
}) => {
const isFile = itemData.type === VarType.file
const isObj = (objVarTypes.includes(itemData.type) && itemData.children && itemData.children.length > 0)
const isStructureOutput = itemData.type === VarType.object && (itemData.children as StructuredOutput)?.schema?.properties
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 isEnv = itemData.variable.startsWith('env.')
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 [isItemHovering, setIsItemHovering] = useState(false)
useHover(itemRef, {
@ -70,7 +100,7 @@ const Item: FC<ItemProps> = ({
setIsItemHovering(true)
}
else {
if (isObj) {
if (isObj || isStructureOutput) {
setTimeout(() => {
setIsItemHovering(false)
}, 100)
@ -83,7 +113,7 @@ const Item: FC<ItemProps> = ({
})
const [isChildrenHovering, setIsChildrenHovering] = useState(false)
const isHovering = isItemHovering || isChildrenHovering
const open = isObj && isHovering
const open = (isObj || isStructureOutput) && isHovering
useEffect(() => {
onHovering && onHovering(isHovering)
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -110,8 +140,8 @@ const Item: FC<ItemProps> = ({
<div
ref={itemRef}
className={cn(
isObj ? ' pr-1' : 'pr-[18px]',
isHovering && (isObj ? 'bg-primary-50' : 'bg-state-base-hover'),
(isObj || isStructureOutput) ? ' pr-1' : 'pr-[18px]',
isHovering && ((isObj || isStructureOutput) ? 'bg-primary-50' : 'bg-state-base-hover'),
'relative flex h-6 w-full cursor-pointer items-center rounded-md pl-3')
}
onClick={handleChosen}
@ -133,42 +163,28 @@ const Item: FC<ItemProps> = ({
)}
</div>
<div className='ml-1 shrink-0 text-xs font-normal capitalize text-text-tertiary'>{itemData.type}</div>
{isObj && (
{
(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={{
zIndex: 100,
}}>
{(isObj && !isFile) && (
// eslint-disable-next-line ts/no-use-before-define
<ObjectChildren
nodeId={nodeId}
title={title}
objPath={[...objPath, itemData.variable]}
data={itemData.children as Var[]}
onChange={onChange}
onHovering={setIsChildrenHovering}
itemWidth={itemWidth}
isSupportFileVar={isSupportFileVar}
/>
)}
{isFile && (
// eslint-disable-next-line ts/no-use-before-define
<ObjectChildren
nodeId={nodeId}
title={title}
objPath={[...objPath, itemData.variable]}
data={FILE_STRUCT}
onChange={onChange}
{(isStructureOutput || isObj) && (
<PickerStructurePanel
root={{ nodeId, nodeName: title, attrName: itemData.variable }}
payload={structuredOutput!}
onHovering={setIsChildrenHovering}
itemWidth={itemWidth}
isSupportFileVar={isSupportFileVar}
onSelect={(valueSelector) => {
onChange(valueSelector, itemData)
}}
/>
)}
</PortalToFollowElemContent>
</PortalToFollowElem>
</PortalToFollowElem >
)
}
@ -331,7 +347,7 @@ const VarReferenceVars: FC<Props> = ({
}
</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)

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

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

Loading…
Cancel
Save