mirror of https://github.com/langgenius/dify.git
Merge branch 'main' into feat/rag-pipeline
This commit is contained in:
commit
ac68d62d1c
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
from collections.abc import Sequence
|
||||
from enum import Enum, StrEnum
|
||||
from typing import Optional
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from pydantic import BaseModel, Field, field_serializer, field_validator
|
||||
|
||||
|
||||
class PromptMessageRole(Enum):
|
||||
|
|
@ -135,6 +135,16 @@ class PromptMessage(BaseModel):
|
|||
"""
|
||||
return not self.content
|
||||
|
||||
@field_serializer("content")
|
||||
def serialize_content(
|
||||
self, content: Optional[Union[str, Sequence[PromptMessageContent]]]
|
||||
) -> Optional[str | list[dict[str, Any] | PromptMessageContent] | Sequence[PromptMessageContent]]:
|
||||
if content is None or isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
return [item.model_dump() if hasattr(item, "model_dump") else item for item in content]
|
||||
return content
|
||||
|
||||
|
||||
class UserPromptMessage(PromptMessage):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@ import array
|
|||
import json
|
||||
import re
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from typing import Any
|
||||
|
||||
import jieba.posseg as pseg # type: ignore
|
||||
import numpy
|
||||
import oracledb
|
||||
from oracledb.connection import Connection
|
||||
from pydantic import BaseModel, model_validator
|
||||
|
||||
from configs import dify_config
|
||||
|
|
@ -70,6 +70,7 @@ class OracleVector(BaseVector):
|
|||
super().__init__(collection_name)
|
||||
self.pool = self._create_connection_pool(config)
|
||||
self.table_name = f"embedding_{collection_name}"
|
||||
self.config = config
|
||||
|
||||
def get_type(self) -> str:
|
||||
return VectorType.ORACLE
|
||||
|
|
@ -107,16 +108,19 @@ class OracleVector(BaseVector):
|
|||
outconverter=self.numpy_converter_out,
|
||||
)
|
||||
|
||||
def _get_connection(self) -> Connection:
|
||||
connection = oracledb.connect(user=self.config.user, password=self.config.password, dsn=self.config.dsn)
|
||||
return connection
|
||||
|
||||
def _create_connection_pool(self, config: OracleVectorConfig):
|
||||
pool_params = {
|
||||
"user": config.user,
|
||||
"password": config.password,
|
||||
"dsn": config.dsn,
|
||||
"min": 1,
|
||||
"max": 50,
|
||||
"max": 5,
|
||||
"increment": 1,
|
||||
}
|
||||
|
||||
if config.is_autonomous:
|
||||
pool_params.update(
|
||||
{
|
||||
|
|
@ -125,22 +129,8 @@ class OracleVector(BaseVector):
|
|||
"wallet_password": config.wallet_password,
|
||||
}
|
||||
)
|
||||
|
||||
return oracledb.create_pool(**pool_params)
|
||||
|
||||
@contextmanager
|
||||
def _get_cursor(self):
|
||||
conn = self.pool.acquire()
|
||||
conn.inputtypehandler = self.input_type_handler
|
||||
conn.outputtypehandler = self.output_type_handler
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
yield cur
|
||||
finally:
|
||||
cur.close()
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def create(self, texts: list[Document], embeddings: list[list[float]], **kwargs):
|
||||
dimension = len(embeddings[0])
|
||||
self._create_collection(dimension)
|
||||
|
|
@ -162,41 +152,68 @@ class OracleVector(BaseVector):
|
|||
numpy.array(embeddings[i]),
|
||||
)
|
||||
)
|
||||
# print(f"INSERT INTO {self.table_name} (id, text, meta, embedding) VALUES (:1, :2, :3, :4)")
|
||||
with self._get_cursor() as cur:
|
||||
cur.executemany(
|
||||
f"INSERT INTO {self.table_name} (id, text, meta, embedding) VALUES (:1, :2, :3, :4)", values
|
||||
)
|
||||
with self._get_connection() as conn:
|
||||
conn.inputtypehandler = self.input_type_handler
|
||||
conn.outputtypehandler = self.output_type_handler
|
||||
# with conn.cursor() as cur:
|
||||
# cur.executemany(
|
||||
# f"INSERT INTO {self.table_name} (id, text, meta, embedding) VALUES (:1, :2, :3, :4)", values
|
||||
# )
|
||||
# conn.commit()
|
||||
for value in values:
|
||||
with conn.cursor() as cur:
|
||||
try:
|
||||
cur.execute(
|
||||
f"""INSERT INTO {self.table_name} (id, text, meta, embedding)
|
||||
VALUES (:1, :2, :3, :4)""",
|
||||
value,
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
print(e)
|
||||
conn.close()
|
||||
return pks
|
||||
|
||||
def text_exists(self, id: str) -> bool:
|
||||
with self._get_cursor() as cur:
|
||||
cur.execute(f"SELECT id FROM {self.table_name} WHERE id = '%s'" % (id,))
|
||||
return cur.fetchone() is not None
|
||||
with self._get_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f"SELECT id FROM {self.table_name} WHERE id = '%s'" % (id,))
|
||||
return cur.fetchone() is not None
|
||||
conn.close()
|
||||
|
||||
def get_by_ids(self, ids: list[str]) -> list[Document]:
|
||||
with self._get_cursor() as cur:
|
||||
cur.execute(f"SELECT meta, text FROM {self.table_name} WHERE id IN %s", (tuple(ids),))
|
||||
docs = []
|
||||
for record in cur:
|
||||
docs.append(Document(page_content=record[1], metadata=record[0]))
|
||||
with self._get_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f"SELECT meta, text FROM {self.table_name} WHERE id IN %s", (tuple(ids),))
|
||||
docs = []
|
||||
for record in cur:
|
||||
docs.append(Document(page_content=record[1], metadata=record[0]))
|
||||
self.pool.release(connection=conn)
|
||||
conn.close()
|
||||
return docs
|
||||
|
||||
def delete_by_ids(self, ids: list[str]) -> None:
|
||||
if not ids:
|
||||
return
|
||||
with self._get_cursor() as cur:
|
||||
cur.execute(f"DELETE FROM {self.table_name} WHERE id IN %s" % (tuple(ids),))
|
||||
with self._get_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f"DELETE FROM {self.table_name} WHERE id IN %s" % (tuple(ids),))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def delete_by_metadata_field(self, key: str, value: str) -> None:
|
||||
with self._get_cursor() as cur:
|
||||
cur.execute(f"DELETE FROM {self.table_name} WHERE meta->>%s = %s", (key, value))
|
||||
with self._get_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f"DELETE FROM {self.table_name} WHERE meta->>%s = %s", (key, value))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def search_by_vector(self, query_vector: list[float], **kwargs: Any) -> list[Document]:
|
||||
"""
|
||||
Search the nearest neighbors to a vector.
|
||||
|
||||
:param query_vector: The input vector to search for similar items.
|
||||
:param top_k: The number of nearest neighbors to return, default is 5.
|
||||
:return: List of Documents that are nearest to the query vector.
|
||||
"""
|
||||
top_k = kwargs.get("top_k", 4)
|
||||
|
|
@ -205,20 +222,25 @@ class OracleVector(BaseVector):
|
|||
if document_ids_filter:
|
||||
document_ids = ", ".join(f"'{id}'" for id in document_ids_filter)
|
||||
where_clause = f"WHERE metadata->>'document_id' in ({document_ids})"
|
||||
with self._get_cursor() as cur:
|
||||
cur.execute(
|
||||
f"SELECT meta, text, vector_distance(embedding,:1) AS distance FROM {self.table_name}"
|
||||
f" {where_clause} ORDER BY distance fetch first {top_k} rows only",
|
||||
[numpy.array(query_vector)],
|
||||
)
|
||||
docs = []
|
||||
score_threshold = float(kwargs.get("score_threshold") or 0.0)
|
||||
for record in cur:
|
||||
metadata, text, distance = record
|
||||
score = 1 - distance
|
||||
metadata["score"] = score
|
||||
if score > score_threshold:
|
||||
docs.append(Document(page_content=text, metadata=metadata))
|
||||
with self._get_connection() as conn:
|
||||
conn.inputtypehandler = self.input_type_handler
|
||||
conn.outputtypehandler = self.output_type_handler
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"""SELECT meta, text, vector_distance(embedding,(select to_vector(:1) from dual),cosine)
|
||||
AS distance FROM {self.table_name}
|
||||
{where_clause} ORDER BY distance fetch first {top_k} rows only""",
|
||||
[numpy.array(query_vector)],
|
||||
)
|
||||
docs = []
|
||||
score_threshold = float(kwargs.get("score_threshold") or 0.0)
|
||||
for record in cur:
|
||||
metadata, text, distance = record
|
||||
score = 1 - distance
|
||||
metadata["score"] = score
|
||||
if score > score_threshold:
|
||||
docs.append(Document(page_content=text, metadata=metadata))
|
||||
conn.close()
|
||||
return docs
|
||||
|
||||
def search_by_full_text(self, query: str, **kwargs: Any) -> list[Document]:
|
||||
|
|
@ -228,7 +250,7 @@ class OracleVector(BaseVector):
|
|||
|
||||
top_k = kwargs.get("top_k", 5)
|
||||
# just not implement fetch by score_threshold now, may be later
|
||||
# score_threshold = float(kwargs.get("score_threshold") or 0.0)
|
||||
score_threshold = float(kwargs.get("score_threshold") or 0.0)
|
||||
if len(query) > 0:
|
||||
# Check which language the query is in
|
||||
zh_pattern = re.compile("[\u4e00-\u9fa5]+")
|
||||
|
|
@ -239,7 +261,7 @@ class OracleVector(BaseVector):
|
|||
words = pseg.cut(query)
|
||||
current_entity = ""
|
||||
for word, pos in words:
|
||||
if pos in {"nr", "Ng", "eng", "nz", "n", "ORG", "v"}: # nr: 人名,ns: 地名,nt: 机构名
|
||||
if pos in {"nr", "Ng", "eng", "nz", "n", "ORG", "v"}: # nr: 人名, ns: 地名, nt: 机构名
|
||||
current_entity += word
|
||||
else:
|
||||
if current_entity:
|
||||
|
|
@ -260,30 +282,35 @@ class OracleVector(BaseVector):
|
|||
for token in all_tokens:
|
||||
if token not in stop_words:
|
||||
entities.append(token)
|
||||
with self._get_cursor() as cur:
|
||||
document_ids_filter = kwargs.get("document_ids_filter")
|
||||
where_clause = ""
|
||||
if document_ids_filter:
|
||||
document_ids = ", ".join(f"'{id}'" for id in document_ids_filter)
|
||||
where_clause = f" AND metadata->>'document_id' in ({document_ids}) "
|
||||
cur.execute(
|
||||
f"select meta, text, embedding FROM {self.table_name}"
|
||||
f"WHERE CONTAINS(text, :1, 1) > 0 {where_clause} "
|
||||
f"order by score(1) desc fetch first {top_k} rows only",
|
||||
[" ACCUM ".join(entities)],
|
||||
)
|
||||
docs = []
|
||||
for record in cur:
|
||||
metadata, text, embedding = record
|
||||
docs.append(Document(page_content=text, vector=embedding, metadata=metadata))
|
||||
with self._get_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
document_ids_filter = kwargs.get("document_ids_filter")
|
||||
where_clause = ""
|
||||
if document_ids_filter:
|
||||
document_ids = ", ".join(f"'{id}'" for id in document_ids_filter)
|
||||
where_clause = f" AND metadata->>'document_id' in ({document_ids}) "
|
||||
cur.execute(
|
||||
f"""select meta, text, embedding FROM {self.table_name}
|
||||
WHERE CONTAINS(text, :kk, 1) > 0 {where_clause}
|
||||
order by score(1) desc fetch first {top_k} rows only""",
|
||||
kk=" ACCUM ".join(entities),
|
||||
)
|
||||
docs = []
|
||||
for record in cur:
|
||||
metadata, text, embedding = record
|
||||
docs.append(Document(page_content=text, vector=embedding, metadata=metadata))
|
||||
conn.close()
|
||||
return docs
|
||||
else:
|
||||
return [Document(page_content="", metadata={})]
|
||||
return []
|
||||
|
||||
def delete(self) -> None:
|
||||
with self._get_cursor() as cur:
|
||||
cur.execute(f"DROP TABLE IF EXISTS {self.table_name} cascade constraints")
|
||||
with self._get_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f"DROP TABLE IF EXISTS {self.table_name} cascade constraints")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def _create_collection(self, dimension: int):
|
||||
cache_key = f"vector_indexing_{self._collection_name}"
|
||||
|
|
@ -293,11 +320,14 @@ class OracleVector(BaseVector):
|
|||
if redis_client.get(collection_exist_cache_key):
|
||||
return
|
||||
|
||||
with self._get_cursor() as cur:
|
||||
cur.execute(SQL_CREATE_TABLE.format(table_name=self.table_name))
|
||||
redis_client.set(collection_exist_cache_key, 1, ex=3600)
|
||||
with self._get_cursor() as cur:
|
||||
cur.execute(SQL_CREATE_INDEX.format(table_name=self.table_name))
|
||||
with self._get_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(SQL_CREATE_TABLE.format(table_name=self.table_name))
|
||||
redis_client.set(collection_exist_cache_key, 1, ex=3600)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(SQL_CREATE_INDEX.format(table_name=self.table_name))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
class OracleVectorFactory(AbstractVectorFactory):
|
||||
|
|
|
|||
|
|
@ -126,9 +126,7 @@ class WordExtractor(BaseExtractor):
|
|||
|
||||
db.session.add(upload_file)
|
||||
db.session.commit()
|
||||
image_map[rel.target_part] = (
|
||||
f""
|
||||
)
|
||||
image_map[rel.target_part] = f""
|
||||
|
||||
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.
|
||||
"""
|
||||
...
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ class DatasetRetrieverTool(DatasetRetrieverBaseTool):
|
|||
"title": item.metadata.get("title"),
|
||||
"content": item.page_content,
|
||||
}
|
||||
context_list.append(source)
|
||||
context_list.append(source)
|
||||
for hit_callback in self.hit_callbacks:
|
||||
hit_callback.return_retriever_resource_info(context_list)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = """You’re 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
|
||||
|
|
@ -180,7 +178,7 @@ vdb = [
|
|||
"couchbase~=4.3.0",
|
||||
"elasticsearch==8.14.0",
|
||||
"opensearch-py==2.4.0",
|
||||
"oracledb~=2.2.1",
|
||||
"oracledb==3.0.0",
|
||||
"pgvecto-rs[sqlalchemy]~=0.2.1",
|
||||
"pgvector==0.2.5",
|
||||
"pymilvus~=2.5.0",
|
||||
|
|
|
|||
|
|
@ -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,18 +188,20 @@ def _delete_app_workflow_runs(tenant_id: str, app_id: str):
|
|||
|
||||
|
||||
def _delete_app_workflow_node_executions(tenant_id: str, app_id: str):
|
||||
def del_workflow_node_execution(workflow_node_execution_id: str):
|
||||
db.session.query(WorkflowNodeExecution).filter(WorkflowNodeExecution.id == workflow_node_execution_id).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
|
||||
_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",
|
||||
# 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,
|
||||
}
|
||||
)
|
||||
|
||||
# Use the clear method to delete all records for this tenant_id and app_id
|
||||
repository.clear()
|
||||
|
||||
logging.info(click.style(f"Deleted workflow node executions for tenant {tenant_id} and app {app_id}", fg="green"))
|
||||
|
||||
|
||||
def _delete_app_workflow_app_logs(tenant_id: str, app_id: str):
|
||||
def del_workflow_app_log(workflow_app_log_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()
|
||||
|
|
|
|||
40
api/uv.lock
40
api/uv.lock
|
|
@ -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" },
|
||||
|
|
@ -1470,7 +1471,7 @@ vdb = [
|
|||
{ name = "couchbase", specifier = "~=4.3.0" },
|
||||
{ name = "elasticsearch", specifier = "==8.14.0" },
|
||||
{ name = "opensearch-py", specifier = "==2.4.0" },
|
||||
{ name = "oracledb", specifier = "~=2.2.1" },
|
||||
{ name = "oracledb", specifier = "==3.0.0" },
|
||||
{ name = "pgvecto-rs", extras = ["sqlalchemy"], specifier = "~=0.2.1" },
|
||||
{ name = "pgvector", specifier = "==0.2.5" },
|
||||
{ name = "pymilvus", specifier = "~=2.5.0" },
|
||||
|
|
@ -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"
|
||||
|
|
@ -3590,23 +3600,23 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "oracledb"
|
||||
version = "2.2.1"
|
||||
version = "3.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/36/fb/3fbacb351833dd794abb184303a5761c4bb33df9d770fd15d01ead2ff738/oracledb-2.2.1.tar.gz", hash = "sha256:8464c6f0295f3318daf6c2c72c83c2dcbc37e13f8fd44e3e39ff8665f442d6b6", size = 580818 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bf/39/712f797b75705c21148fa1d98651f63c2e5cc6876e509a0a9e2f5b406572/oracledb-3.0.0.tar.gz", hash = "sha256:64dc86ee5c032febc556798b06e7b000ef6828bb0252084f6addacad3363db85", size = 840431 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/74/b7/a4238295944670fb8cc50a8cc082e0af5a0440bfb1c2bac2b18429c0a579/oracledb-2.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:fb6d9a4d7400398b22edb9431334f9add884dec9877fd9c4ae531e1ccc6ee1fd", size = 3551303 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/5f/98481d44976cd2b3086361f2d50026066b24090b0e6cd1f2a12c824e9717/oracledb-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:07757c240afbb4f28112a6affc2c5e4e34b8a92e5bb9af81a40fba398da2b028", size = 12258455 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/54/06b2540286e2b63f60877d6f3c6c40747e216b6eeda0756260e194897076/oracledb-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63daec72f853c47179e98493e9b732909d96d495bdceb521c5973a3940d28142", size = 12317476 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/1a/67814439a4e24df83281a72cb0ba433d6b74e1bff52a9975b87a725bcba5/oracledb-2.2.1-cp311-cp311-win32.whl", hash = "sha256:fec5318d1e0ada7e4674574cb6c8d1665398e8b9c02982279107212f05df1660", size = 1369368 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/b8/b2a8f0607be17f58ec6689ad5fd15c2956f4996c64547325e96439570edf/oracledb-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:5134dccb5a11bc755abf02fd49be6dc8141dfcae4b650b55d40509323d00b5c2", size = 1655035 },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/5b/2fff762243030f31a6b1561fc8eeb142e69ba6ebd3e7fbe4a2c82f0eb6f0/oracledb-2.2.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:ac5716bc9a48247fdf563f5f4ec097f5c9f074a60fd130cdfe16699208ca29b5", size = 3583960 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/88/34117ae830e7338af7c0481f1c0fc6eda44d558e12f9203b45b491e53071/oracledb-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c150bddb882b7c73fb462aa2d698744da76c363e404570ed11d05b65811d96c3", size = 11749006 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/58/bac788f18c21f727955652fe238de2d24a12c2b455ed4db18a6d23ff781e/oracledb-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:193e1888411bc21187ade4b16b76820bd1e8f216e25602f6cd0a97d45723c1dc", size = 11950663 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/e2/005f66ae919c6f7c73e06863256cf43aa844330e2dc61a5f9779ae44a801/oracledb-2.2.1-cp312-cp312-win32.whl", hash = "sha256:44a960f8bbb0711af222e0a9690e037b6a2a382e0559ae8eeb9cfafe26c7a3bc", size = 1324255 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/25/759eb2143134513382e66d874c4aacfd691dec3fef7141170cfa6c1b154f/oracledb-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:470136add32f0d0084225c793f12a52b61b52c3dc00c9cd388ec6a3db3a7643e", size = 1613047 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/bf/d872c4b3fc15cd3261fe0ea72b21d181700c92dbc050160e161654987062/oracledb-3.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:52daa9141c63dfa75c07d445e9bb7f69f43bfb3c5a173ecc48c798fe50288d26", size = 4312963 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/ea/01ee29e76a610a53bb34fdc1030f04b7669c3f80b25f661e07850fc6160e/oracledb-3.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af98941789df4c6aaaf4338f5b5f6b7f2c8c3fe6f8d6a9382f177f350868747a", size = 2661536 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/8e/ad380e34a46819224423b4773e58c350bc6269643c8969604097ced8c3bc/oracledb-3.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9812bb48865aaec35d73af54cd1746679f2a8a13cbd1412ab371aba2e39b3943", size = 2867461 },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/09/ecc4384a27fd6e1e4de824ae9c160e4ad3aaebdaade5b4bdcf56a4d1ff63/oracledb-3.0.0-cp311-cp311-win32.whl", hash = "sha256:6c27fe0de64f2652e949eb05b3baa94df9b981a4a45fa7f8a991e1afb450c8e2", size = 1752046 },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/e8/f34bde24050c6e55eeba46b23b2291f2dd7fd272fa8b322dcbe71be55778/oracledb-3.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:f922709672002f0b40997456f03a95f03e5712a86c61159951c5ce09334325e0", size = 2101210 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/fc/24590c3a3d41e58494bd3c3b447a62835138e5f9b243d9f8da0cfb5da8dc/oracledb-3.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:acd0e747227dea01bebe627b07e958bf36588a337539f24db629dc3431d3f7eb", size = 4351993 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/b6/1f3b0b7bb94d53e8857d77b2e8dbdf6da091dd7e377523e24b79dac4fd71/oracledb-3.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8b402f77c22af031cd0051aea2472ecd0635c1b452998f511aa08b7350c90a4", size = 2532640 },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/1a/1815f6c086ab49c00921cf155ff5eede5267fb29fcec37cb246339a5ce4d/oracledb-3.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:378a27782e9a37918bd07a5a1427a77cb6f777d0a5a8eac9c070d786f50120ef", size = 2765949 },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/8d/208900f8d372909792ee70b2daad3f7361181e55f2217c45ed9dff658b54/oracledb-3.0.0-cp312-cp312-win32.whl", hash = "sha256:54a28c2cb08316a527cd1467740a63771cc1c1164697c932aa834c0967dc4efc", size = 1709373 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/5e/c21754f19c896102793c3afec2277e2180aa7d505e4d7fcca24b52d14e4f/oracledb-3.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:8289bad6d103ce42b140e40576cf0c81633e344d56e2d738b539341eacf65624", size = 2056452 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next
|
|||
### Run by source code
|
||||
|
||||
Before starting the web frontend service, please make sure the following environment is ready.
|
||||
- [Node.js](https://nodejs.org) >= v18.x
|
||||
- [Node.js](https://nodejs.org) >= v22.11.x
|
||||
- [pnpm](https://pnpm.io) v10.x
|
||||
|
||||
First, install the dependencies:
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -162,11 +162,22 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
|||
return check
|
||||
}
|
||||
|
||||
const validatePrivacyPolicy = (privacyPolicy: string | null) => {
|
||||
if (privacyPolicy === null || privacyPolicy?.length === 0)
|
||||
return true
|
||||
|
||||
return privacyPolicy.startsWith('http://') || privacyPolicy.startsWith('https://')
|
||||
}
|
||||
|
||||
if (inputInfo !== null) {
|
||||
if (!validateColorHex(inputInfo.chatColorTheme)) {
|
||||
notify({ type: 'error', message: t(`${prefixSettings}.invalidHexMessage`) })
|
||||
return
|
||||
}
|
||||
if (!validatePrivacyPolicy(inputInfo.privacyPolicy)) {
|
||||
notify({ type: 'error', message: t(`${prefixSettings}.invalidPrivacyPolicy`) })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setSaveLoading(true)
|
||||
|
|
@ -410,7 +421,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
|||
<p className={cn('body-xs-regular pb-0.5 text-text-tertiary')}>
|
||||
<Trans
|
||||
i18nKey={`${prefixSettings}.more.privacyPolicyTip`}
|
||||
components={{ privacyPolicyLink: <Link href={'https://docs.dify.ai/user-agreement/privacy-policy'} target='_blank' rel='noopener noreferrer' className='text-text-accent' /> }}
|
||||
components={{ privacyPolicyLink: <Link href={'https://dify.ai/privacy'} target='_blank' rel='noopener noreferrer' className='text-text-accent' /> }}
|
||||
/>
|
||||
</p>
|
||||
<Input
|
||||
|
|
|
|||
|
|
@ -234,4 +234,6 @@ const Answer: FC<AnswerProps> = ({
|
|||
)
|
||||
}
|
||||
|
||||
export default memo(Answer)
|
||||
export default memo(Answer, (prevProps, nextProps) =>
|
||||
prevProps.responding === false && nextProps.responding === false,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
const IndeterminateIcon = () => {
|
||||
return (
|
||||
<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 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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -40,9 +40,10 @@ const Checkbox = ({
|
|||
return
|
||||
onCheck?.()
|
||||
}}
|
||||
data-testid={`checkbox-${id}`}
|
||||
>
|
||||
{!checked && indeterminate && <IndeterminateIcon />}
|
||||
{checked && <RiCheckLine className='h-3 w-3' />}
|
||||
{checked && <RiCheckLine className='h-3 w-3' data-testid={`check-icon-${id}`} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -24,12 +24,13 @@ const Label = ({
|
|||
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>
|
||||
{showOptional && <div className='system-xs-regular ml-1 text-text-tertiary'>{t('common.label.optional')}</div>}
|
||||
{!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
|
||||
|
|
@ -37,6 +38,7 @@ const Label = ({
|
|||
<div className='w-[200px]'>{tooltip}</div>
|
||||
}
|
||||
triggerClassName='ml-0.5 w-4 h-4'
|
||||
triggerTestId={`${htmlFor}-tooltip`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -22,11 +22,9 @@ export const InputNumber: FC<InputNumberProps> = (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 = () => {
|
||||
|
|
@ -87,6 +85,7 @@ export const InputNumber: FC<InputNumberProps> = (props) => {
|
|||
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',
|
||||
|
|
@ -100,6 +99,7 @@ export const InputNumber: FC<InputNumberProps> = (props) => {
|
|||
type='button'
|
||||
onClick={dec}
|
||||
disabled={disabled}
|
||||
aria-label='decrement'
|
||||
className={classNames(
|
||||
size === 'regular' ? 'pb-1' : 'pb-1.5',
|
||||
'px-1.5 hover:bg-components-input-bg-hover',
|
||||
|
|
|
|||
|
|
@ -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,45 +139,54 @@ const CodeBlock: any = memo(({ inline, className, children, ...props }: any) =>
|
|||
|
||||
const renderCodeContent = useMemo(() => {
|
||||
const content = String(children).replace(/\n$/, '')
|
||||
if (language === 'mermaid' && isSVG) {
|
||||
return <Flowchart PrimitiveCode={content} />
|
||||
}
|
||||
else if (language === 'echarts') {
|
||||
return (
|
||||
<div style={{ minHeight: '350px', minWidth: '100%', overflowX: 'scroll' }}>
|
||||
switch (language) {
|
||||
case 'mermaid':
|
||||
if (isSVG)
|
||||
return <Flowchart PrimitiveCode={content} />
|
||||
break
|
||||
case 'echarts':
|
||||
return (
|
||||
<div style={{ minHeight: '350px', minWidth: '100%', overflowX: 'scroll' }}>
|
||||
<ErrorBoundary>
|
||||
<ReactEcharts option={chartData} style={{ minWidth: '700px' }} />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
)
|
||||
case 'svg':
|
||||
if (isSVG) {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<SVGRenderer content={content} />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
break
|
||||
case 'abc':
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<ReactEcharts option={chartData} style={{ minWidth: '700px' }} />
|
||||
<MarkdownMusic children={content} />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<SyntaxHighlighter
|
||||
{...props}
|
||||
style={theme === Theme.light ? atelierHeathLight : atelierHeathDark}
|
||||
customStyle={{
|
||||
paddingLeft: 12,
|
||||
borderBottomLeftRadius: '10px',
|
||||
borderBottomRightRadius: '10px',
|
||||
backgroundColor: 'var(--color-components-input-bg-normal)',
|
||||
}}
|
||||
language={match?.[1]}
|
||||
showLineNumbers
|
||||
PreTag="div"
|
||||
>
|
||||
{content}
|
||||
</SyntaxHighlighter>
|
||||
)
|
||||
}
|
||||
else if (language === 'svg' && isSVG) {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<SVGRenderer content={content} />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
else {
|
||||
return (
|
||||
<SyntaxHighlighter
|
||||
{...props}
|
||||
style={theme === Theme.light ? atelierHeathLight : atelierHeathDark}
|
||||
customStyle={{
|
||||
paddingLeft: 12,
|
||||
borderBottomLeftRadius: '10px',
|
||||
borderBottomRightRadius: '10px',
|
||||
backgroundColor: 'var(--color-components-input-bg-normal)',
|
||||
}}
|
||||
language={match?.[1]}
|
||||
showLineNumbers
|
||||
PreTag="div"
|
||||
>
|
||||
{content}
|
||||
</SyntaxHighlighter>
|
||||
)
|
||||
}
|
||||
}, [language, match, props, children, chartData, isSVG])
|
||||
}, [children, language, isSVG, chartData, props, theme, match])
|
||||
|
||||
if (inline || !match)
|
||||
return <code {...props} className={className}>{children}</code>
|
||||
|
|
|
|||
|
|
@ -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]"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
className={cn(
|
||||
'h-4 w-4 text-text-tertiary',
|
||||
collapse && '-rotate-90',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<ArrowDownRoundFill
|
||||
className={cn(
|
||||
'h-4 w-4 cursor-pointer text-text-quaternary group-hover/collapse:text-text-secondary',
|
||||
collapse && 'rotate-[270deg]',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,34 +20,44 @@ 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='flex items-center'
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
setCollapsedLocal(!collapsedMerged)
|
||||
onCollapse?.(!collapsedMerged)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className='h-4 w-4 shrink-0'>
|
||||
{
|
||||
!disabled && (
|
||||
<RiArrowDropRightLine
|
||||
className={cn(
|
||||
'h-4 w-4 text-text-tertiary',
|
||||
!collapsedMerged && 'rotate-90',
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<div className='group/collapse flex items-center'>
|
||||
<div
|
||||
className='ml-4 flex grow items-center'
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
setCollapsedLocal(!collapsedMerged)
|
||||
onCollapse?.(!collapsedMerged)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{typeof trigger === 'function' ? trigger(collapseIcon) : trigger}
|
||||
{!hideCollapseIcon && (
|
||||
<div className='h-4 w-4 shrink-0'>
|
||||
{collapseIcon}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{trigger}
|
||||
{operations}
|
||||
</div>
|
||||
{
|
||||
!collapsedMerged && children
|
||||
|
|
|
|||
|
|
@ -49,20 +49,23 @@ const ErrorHandle = ({
|
|||
disabled={!error_strategy}
|
||||
collapsed={collapsed}
|
||||
onCollapse={setCollapsed}
|
||||
hideCollapseIcon
|
||||
trigger={
|
||||
<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')}
|
||||
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>
|
||||
<Tooltip popupContent={t('workflow.nodes.common.errorHandle.tip')} />
|
||||
<ErrorHandleTypeSelector
|
||||
value={error_strategy || ErrorHandleTypeEnum.none}
|
||||
onSelected={getHandleErrorHandleTypeChange(data)}
|
||||
/>
|
||||
</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,27 +51,33 @@ export const VarItem: FC<VarItemProps> = ({
|
|||
type,
|
||||
description,
|
||||
subItems,
|
||||
isIndent,
|
||||
}) => {
|
||||
return (
|
||||
<div className='py-1'>
|
||||
<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>
|
||||
<div className='system-xs-regular mt-0.5 text-text-tertiary'>
|
||||
{description}
|
||||
{subItems && (
|
||||
<div className='ml-2 border-l border-divider-regular pl-2'>
|
||||
{subItems.map((item, index) => (
|
||||
<VarItem
|
||||
key={index}
|
||||
name={item.name}
|
||||
type={item.type}
|
||||
description={item.description}
|
||||
/>
|
||||
))}
|
||||
<div 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 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-gray-200 pl-2'>
|
||||
{subItems.map((item, index) => (
|
||||
<VarItem
|
||||
key={index}
|
||||
name={item.name}
|
||||
type={item.type}
|
||||
description={item.description}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/
|
|||
import Switch from '@/app/components/base/switch'
|
||||
import { 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)
|
||||
|
|
@ -57,7 +57,14 @@ const inputVarTypeToVarType = (type: InputVarType): VarType => {
|
|||
} as any)[type] || VarType.string
|
||||
}
|
||||
|
||||
const structTypeToVarType = (type: Type): VarType => {
|
||||
const structTypeToVarType = (type: Type, isArray?: boolean): VarType => {
|
||||
if (isArray) {
|
||||
return ({
|
||||
[Type.string]: VarType.arrayString,
|
||||
[Type.number]: VarType.arrayNumber,
|
||||
[Type.object]: VarType.arrayObject,
|
||||
} as any)[type] || VarType.string
|
||||
}
|
||||
return ({
|
||||
[Type.string]: VarType.string,
|
||||
[Type.number]: VarType.number,
|
||||
|
|
@ -82,9 +89,12 @@ const findExceptVarInStructuredProperties = (properties: Record<string, StructFi
|
|||
Object.keys(properties).forEach((key) => {
|
||||
const item = properties[key]
|
||||
const isObj = item.type === Type.object
|
||||
const isArray = item.type === Type.array
|
||||
const arrayType = item.items?.type
|
||||
|
||||
if (!isObj && !filterVar({
|
||||
variable: key,
|
||||
type: structTypeToVarType(item.type),
|
||||
type: structTypeToVarType(isArray ? arrayType! : item.type, isArray),
|
||||
}, [key])) {
|
||||
delete properties[key]
|
||||
return
|
||||
|
|
@ -103,9 +113,11 @@ const findExceptVarInStructuredOutput = (structuredOutput: StructuredOutput, fil
|
|||
Object.keys(properties).forEach((key) => {
|
||||
const item = properties[key]
|
||||
const isObj = item.type === Type.object
|
||||
const isArray = item.type === Type.array
|
||||
const arrayType = item.items?.type
|
||||
if (!isObj && !filterVar({
|
||||
variable: key,
|
||||
type: structTypeToVarType(item.type),
|
||||
type: structTypeToVarType(isArray ? arrayType! : item.type, isArray),
|
||||
}, [key])) {
|
||||
delete properties[key]
|
||||
return
|
||||
|
|
@ -319,12 +331,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 = [
|
||||
|
|
@ -1307,9 +1326,12 @@ const varToValueSelectorList = (v: Var, parentValueSelector: ValueSelector, res:
|
|||
}
|
||||
if (isStructuredOutput) {
|
||||
Object.keys((v.children as StructuredOutput)?.schema?.properties || {}).forEach((key) => {
|
||||
const type = (v.children as StructuredOutput)?.schema?.properties[key].type
|
||||
const isArray = type === Type.array
|
||||
const arrayType = (v.children as StructuredOutput)?.schema?.properties[key].items?.type
|
||||
varToValueSelectorList({
|
||||
variable: key,
|
||||
type: structTypeToVarType((v.children as StructuredOutput)?.schema?.properties[key].type),
|
||||
type: structTypeToVarType(isArray ? arrayType! : type, isArray),
|
||||
}, [...parentValueSelector, v.variable], res)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 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]
|
||||
const isShowAPart = (value as ValueSelector).length > 2
|
||||
|
||||
return `${isSystem ? 'sys.' : ''}${varName}`
|
||||
}
|
||||
return ''
|
||||
const varName = useMemo(() => {
|
||||
if (!hasValue)
|
||||
return ''
|
||||
|
||||
const isSystem = isSystemVar(value as ValueSelector)
|
||||
const varName = Array.isArray(value) ? value[(value as ValueSelector).length - 1] : ''
|
||||
return `${isSystem ? 'sys.' : ''}${varName}`
|
||||
}, [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 && (
|
||||
<ChevronRight className={cn('ml-0.5 h-3 w-3 text-text-quaternary', isHovering && 'text-text-tertiary')} />
|
||||
)}
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
{
|
||||
(isObj || isStructureOutput) && (
|
||||
<ChevronRight className={cn('ml-0.5 h-3 w-3 text-text-quaternary', isHovering && 'text-text-tertiary')} />
|
||||
)
|
||||
}
|
||||
</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}
|
||||
{(isStructureOutput || isObj) && (
|
||||
<PickerStructurePanel
|
||||
root={{ nodeId, nodeName: title, attrName: itemData.variable }}
|
||||
payload={structuredOutput!}
|
||||
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}
|
||||
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>
|
||||
}
|
||||
)}
|
||||
>
|
||||
<>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,140 @@
|
|||
import React, { type FC, useCallback, useEffect, useRef } from 'react'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { Theme } from '@/types/app'
|
||||
import classNames from '@/utils/classnames'
|
||||
import { Editor } from '@monaco-editor/react'
|
||||
import { RiClipboardLine, RiIndentIncrease } from '@remixicon/react'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type CodeEditorProps = {
|
||||
value: string
|
||||
onUpdate?: (value: string) => void
|
||||
showFormatButton?: boolean
|
||||
editorWrapperClassName?: string
|
||||
readOnly?: boolean
|
||||
} & React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
const CodeEditor: FC<CodeEditorProps> = ({
|
||||
value,
|
||||
onUpdate,
|
||||
showFormatButton = true,
|
||||
editorWrapperClassName,
|
||||
readOnly = false,
|
||||
className,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
const monacoRef = useRef<any>(null)
|
||||
const editorRef = useRef<any>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (monacoRef.current) {
|
||||
if (theme === Theme.light)
|
||||
monacoRef.current.editor.setTheme('light-theme')
|
||||
else
|
||||
monacoRef.current.editor.setTheme('dark-theme')
|
||||
}
|
||||
}, [theme])
|
||||
|
||||
const handleEditorDidMount = useCallback((editor: any, monaco: any) => {
|
||||
editorRef.current = editor
|
||||
monacoRef.current = monaco
|
||||
monaco.editor.defineTheme('light-theme', {
|
||||
base: 'vs',
|
||||
inherit: true,
|
||||
rules: [],
|
||||
colors: {
|
||||
'editor.background': '#00000000',
|
||||
'editor.lineHighlightBackground': '#00000000',
|
||||
'focusBorder': '#00000000',
|
||||
},
|
||||
})
|
||||
monaco.editor.defineTheme('dark-theme', {
|
||||
base: 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [],
|
||||
colors: {
|
||||
'editor.background': '#00000000',
|
||||
'editor.lineHighlightBackground': '#00000000',
|
||||
'focusBorder': '#00000000',
|
||||
},
|
||||
})
|
||||
monaco.editor.setTheme('light-theme')
|
||||
}, [])
|
||||
|
||||
const formatJsonContent = useCallback(() => {
|
||||
if (editorRef.current)
|
||||
editorRef.current.getAction('editor.action.formatDocument')?.run()
|
||||
}, [])
|
||||
|
||||
const handleEditorChange = useCallback((value: string | undefined) => {
|
||||
if (value !== undefined)
|
||||
onUpdate?.(value)
|
||||
}, [onUpdate])
|
||||
|
||||
return (
|
||||
<div className={classNames('flex flex-col h-full bg-components-input-bg-normal overflow-hidden', className)}>
|
||||
<div className='flex items-center justify-between pl-2 pr-1 pt-1'>
|
||||
<div className='system-xs-semibold-uppercase py-0.5 text-text-secondary'>
|
||||
<span className='px-1 py-0.5'>JSON</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-x-0.5'>
|
||||
{showFormatButton && (
|
||||
<Tooltip popupContent={t('common.operation.format')}>
|
||||
<button
|
||||
type='button'
|
||||
className='flex h-6 w-6 items-center justify-center'
|
||||
onClick={formatJsonContent}
|
||||
>
|
||||
<RiIndentIncrease className='h-4 w-4 text-text-tertiary' />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip popupContent={t('common.operation.copy')}>
|
||||
<button
|
||||
type='button'
|
||||
className='flex h-6 w-6 items-center justify-center'
|
||||
onClick={() => copy(value)}>
|
||||
<RiClipboardLine className='h-4 w-4 text-text-tertiary' />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className={classNames('relative', editorWrapperClassName)}>
|
||||
<Editor
|
||||
height='100%'
|
||||
defaultLanguage='json'
|
||||
value={value}
|
||||
onChange={handleEditorChange}
|
||||
onMount={handleEditorDidMount}
|
||||
options={{
|
||||
readOnly,
|
||||
domReadOnly: true,
|
||||
minimap: { enabled: false },
|
||||
tabSize: 2,
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'on',
|
||||
wrappingIndent: 'same',
|
||||
// Add these options
|
||||
overviewRulerBorder: false,
|
||||
hideCursorInOverviewRuler: true,
|
||||
renderLineHighlightOnlyWhenFocus: false,
|
||||
renderLineHighlight: 'none',
|
||||
// Hide scrollbar borders
|
||||
scrollbar: {
|
||||
vertical: 'hidden',
|
||||
horizontal: 'hidden',
|
||||
verticalScrollbarSize: 0,
|
||||
horizontalScrollbarSize: 0,
|
||||
alwaysConsumeMouseWheel: false,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(CodeEditor)
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import React from 'react'
|
||||
import type { FC } from 'react'
|
||||
import { RiErrorWarningFill } from '@remixicon/react'
|
||||
import classNames from '@/utils/classnames'
|
||||
|
||||
type ErrorMessageProps = {
|
||||
message: string
|
||||
} & React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
const ErrorMessage: FC<ErrorMessageProps> = ({
|
||||
message,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<div className={classNames(
|
||||
'flex gap-x-1 mt-1 p-2 rounded-lg border-[0.5px] border-components-panel-border bg-toast-error-bg',
|
||||
className,
|
||||
)}>
|
||||
<RiErrorWarningFill className='h-4 w-4 shrink-0 text-text-destructive' />
|
||||
<div className='system-xs-medium max-h-12 grow overflow-y-auto break-words text-text-primary'>
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(ErrorMessage)
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import React, { type FC } from 'react'
|
||||
import Modal from '../../../../../base/modal'
|
||||
import type { SchemaRoot } from '../../types'
|
||||
import JsonSchemaConfig from './json-schema-config'
|
||||
|
||||
type JsonSchemaConfigModalProps = {
|
||||
isShow: boolean
|
||||
defaultSchema?: SchemaRoot
|
||||
onSave: (schema: SchemaRoot) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const JsonSchemaConfigModal: FC<JsonSchemaConfigModalProps> = ({
|
||||
isShow,
|
||||
defaultSchema,
|
||||
onSave,
|
||||
onClose,
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
isShow={isShow}
|
||||
onClose={onClose}
|
||||
className='h-[800px] max-w-[960px] p-0'
|
||||
>
|
||||
<JsonSchemaConfig
|
||||
defaultSchema={defaultSchema}
|
||||
onSave={onSave}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default JsonSchemaConfigModal
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
import React, { type FC, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
|
||||
import cn from '@/utils/classnames'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { checkJsonDepth } from '../../utils'
|
||||
import { JSON_SCHEMA_MAX_DEPTH } from '@/config'
|
||||
import CodeEditor from './code-editor'
|
||||
import ErrorMessage from './error-message'
|
||||
import { useVisualEditorStore } from './visual-editor/store'
|
||||
import { useMittContext } from './visual-editor/context'
|
||||
|
||||
type JsonImporterProps = {
|
||||
onSubmit: (schema: any) => void
|
||||
updateBtnWidth: (width: number) => void
|
||||
}
|
||||
|
||||
const JsonImporter: FC<JsonImporterProps> = ({
|
||||
onSubmit,
|
||||
updateBtnWidth,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [json, setJson] = useState('')
|
||||
const [parseError, setParseError] = useState<any>(null)
|
||||
const importBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const advancedEditing = useVisualEditorStore(state => state.advancedEditing)
|
||||
const isAddingNewField = useVisualEditorStore(state => state.isAddingNewField)
|
||||
const { emit } = useMittContext()
|
||||
|
||||
useEffect(() => {
|
||||
if (importBtnRef.current) {
|
||||
const rect = importBtnRef.current.getBoundingClientRect()
|
||||
updateBtnWidth(rect.width)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const handleTrigger = useCallback((e: React.MouseEvent<HTMLElement, MouseEvent>) => {
|
||||
e.stopPropagation()
|
||||
if (advancedEditing || isAddingNewField)
|
||||
emit('quitEditing', {})
|
||||
setOpen(!open)
|
||||
}, [open, advancedEditing, isAddingNewField, emit])
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
setOpen(false)
|
||||
}, [])
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
try {
|
||||
const parsedJSON = JSON.parse(json)
|
||||
if (typeof parsedJSON !== 'object' || Array.isArray(parsedJSON)) {
|
||||
setParseError(new Error('Root must be an object, not an array or primitive value.'))
|
||||
return
|
||||
}
|
||||
const maxDepth = checkJsonDepth(parsedJSON)
|
||||
if (maxDepth > JSON_SCHEMA_MAX_DEPTH) {
|
||||
setParseError({
|
||||
type: 'error',
|
||||
message: `Schema exceeds maximum depth of ${JSON_SCHEMA_MAX_DEPTH}.`,
|
||||
})
|
||||
return
|
||||
}
|
||||
onSubmit(parsedJSON)
|
||||
setParseError(null)
|
||||
setOpen(false)
|
||||
}
|
||||
catch (e: any) {
|
||||
if (e instanceof Error)
|
||||
setParseError(e)
|
||||
else
|
||||
setParseError(new Error('Invalid JSON'))
|
||||
}
|
||||
}, [onSubmit, json])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement='bottom-end'
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: 16,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger ref={importBtnRef} onClick={handleTrigger}>
|
||||
<button
|
||||
type='button'
|
||||
className={cn(
|
||||
'system-xs-medium flex shrink-0 rounded-md px-1.5 py-1 text-text-tertiary hover:bg-components-button-ghost-bg-hover',
|
||||
open && 'bg-components-button-ghost-bg-hover',
|
||||
)}
|
||||
>
|
||||
<span className='px-0.5'>{t('workflow.nodes.llm.jsonSchema.import')}</span>
|
||||
</button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-[100]'>
|
||||
<div className='flex w-[400px] flex-col rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-2xl shadow-shadow-shadow-9'>
|
||||
{/* Title */}
|
||||
<div className='relative px-3 pb-1 pt-3.5'>
|
||||
<div className='absolute bottom-0 right-2.5 flex h-8 w-8 items-center justify-center' onClick={onClose}>
|
||||
<RiCloseLine className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
<div className='system-xl-semibold flex pl-1 pr-8 text-text-primary'>
|
||||
{t('workflow.nodes.llm.jsonSchema.import')}
|
||||
</div>
|
||||
</div>
|
||||
{/* Content */}
|
||||
<div className='px-4 py-2'>
|
||||
<CodeEditor
|
||||
className='rounded-lg'
|
||||
editorWrapperClassName='h-[340px]'
|
||||
value={json}
|
||||
onUpdate={setJson}
|
||||
showFormatButton={false}
|
||||
/>
|
||||
{parseError && <ErrorMessage message={parseError.message} />}
|
||||
</div>
|
||||
{/* Footer */}
|
||||
<div className='flex items-center justify-end gap-x-2 p-4 pt-2'>
|
||||
<Button variant='secondary' onClick={onClose}>
|
||||
{t('common.operation.cancel')}
|
||||
</Button>
|
||||
<Button variant='primary' onClick={handleSubmit}>
|
||||
{t('common.operation.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default JsonImporter
|
||||
|
|
@ -0,0 +1,301 @@
|
|||
import React, { type FC, useCallback, useState } from 'react'
|
||||
import { type SchemaRoot, Type } from '../../types'
|
||||
import { RiBracesLine, RiCloseLine, RiExternalLinkLine, RiTimelineView } from '@remixicon/react'
|
||||
import { SegmentedControl } from '../../../../../base/segmented-control'
|
||||
import JsonSchemaGenerator from './json-schema-generator'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import JsonImporter from './json-importer'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import VisualEditor from './visual-editor'
|
||||
import SchemaEditor from './schema-editor'
|
||||
import {
|
||||
checkJsonSchemaDepth,
|
||||
convertBooleanToString,
|
||||
getValidationErrorMessage,
|
||||
jsonToSchema,
|
||||
preValidateSchema,
|
||||
validateSchemaAgainstDraft7,
|
||||
} from '../../utils'
|
||||
import { MittProvider, VisualEditorContextProvider, useMittContext } from './visual-editor/context'
|
||||
import ErrorMessage from './error-message'
|
||||
import { useVisualEditorStore } from './visual-editor/store'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import { JSON_SCHEMA_MAX_DEPTH } from '@/config'
|
||||
|
||||
type JsonSchemaConfigProps = {
|
||||
defaultSchema?: SchemaRoot
|
||||
onSave: (schema: SchemaRoot) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
enum SchemaView {
|
||||
VisualEditor = 'visualEditor',
|
||||
JsonSchema = 'jsonSchema',
|
||||
}
|
||||
|
||||
const VIEW_TABS = [
|
||||
{ Icon: RiTimelineView, text: 'Visual Editor', value: SchemaView.VisualEditor },
|
||||
{ Icon: RiBracesLine, text: 'JSON Schema', value: SchemaView.JsonSchema },
|
||||
]
|
||||
|
||||
const DEFAULT_SCHEMA: SchemaRoot = {
|
||||
type: Type.object,
|
||||
properties: {},
|
||||
required: [],
|
||||
additionalProperties: false,
|
||||
}
|
||||
|
||||
const HELP_DOC_URL = {
|
||||
zh_Hans: 'https://docs.dify.ai/zh-hans/guides/workflow/structured-outputs',
|
||||
en_US: 'https://docs.dify.ai/guides/workflow/structured-outputs',
|
||||
ja_JP: 'https://docs.dify.ai/ja-jp/guides/workflow/structured-outputs',
|
||||
}
|
||||
|
||||
type LocaleKey = keyof typeof HELP_DOC_URL
|
||||
|
||||
const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({
|
||||
defaultSchema,
|
||||
onSave,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const locale = useGetLanguage() as LocaleKey
|
||||
const [currentTab, setCurrentTab] = useState(SchemaView.VisualEditor)
|
||||
const [jsonSchema, setJsonSchema] = useState(defaultSchema || DEFAULT_SCHEMA)
|
||||
const [json, setJson] = useState(JSON.stringify(jsonSchema, null, 2))
|
||||
const [btnWidth, setBtnWidth] = useState(0)
|
||||
const [parseError, setParseError] = useState<Error | null>(null)
|
||||
const [validationError, setValidationError] = useState<string>('')
|
||||
const advancedEditing = useVisualEditorStore(state => state.advancedEditing)
|
||||
const setAdvancedEditing = useVisualEditorStore(state => state.setAdvancedEditing)
|
||||
const isAddingNewField = useVisualEditorStore(state => state.isAddingNewField)
|
||||
const setIsAddingNewField = useVisualEditorStore(state => state.setIsAddingNewField)
|
||||
const setHoveringProperty = useVisualEditorStore(state => state.setHoveringProperty)
|
||||
const { emit } = useMittContext()
|
||||
|
||||
const updateBtnWidth = useCallback((width: number) => {
|
||||
setBtnWidth(width + 32)
|
||||
}, [])
|
||||
|
||||
const handleTabChange = useCallback((value: SchemaView) => {
|
||||
if (currentTab === value) return
|
||||
if (currentTab === SchemaView.JsonSchema) {
|
||||
try {
|
||||
const schema = JSON.parse(json)
|
||||
setParseError(null)
|
||||
const result = preValidateSchema(schema)
|
||||
if (!result.success) {
|
||||
setValidationError(result.error.message)
|
||||
return
|
||||
}
|
||||
const schemaDepth = checkJsonSchemaDepth(schema)
|
||||
if (schemaDepth > JSON_SCHEMA_MAX_DEPTH) {
|
||||
setValidationError(`Schema exceeds maximum depth of ${JSON_SCHEMA_MAX_DEPTH}.`)
|
||||
return
|
||||
}
|
||||
convertBooleanToString(schema)
|
||||
const validationErrors = validateSchemaAgainstDraft7(schema)
|
||||
if (validationErrors.length > 0) {
|
||||
setValidationError(getValidationErrorMessage(validationErrors))
|
||||
return
|
||||
}
|
||||
setJsonSchema(schema)
|
||||
setValidationError('')
|
||||
}
|
||||
catch (error) {
|
||||
setValidationError('')
|
||||
if (error instanceof Error)
|
||||
setParseError(error)
|
||||
else
|
||||
setParseError(new Error('Invalid JSON'))
|
||||
return
|
||||
}
|
||||
}
|
||||
else if (currentTab === SchemaView.VisualEditor) {
|
||||
if (advancedEditing || isAddingNewField)
|
||||
emit('quitEditing', { callback: (backup: SchemaRoot) => setJson(JSON.stringify(backup || jsonSchema, null, 2)) })
|
||||
else
|
||||
setJson(JSON.stringify(jsonSchema, null, 2))
|
||||
}
|
||||
|
||||
setCurrentTab(value)
|
||||
}, [currentTab, jsonSchema, json, advancedEditing, isAddingNewField, emit])
|
||||
|
||||
const handleApplySchema = useCallback((schema: SchemaRoot) => {
|
||||
if (currentTab === SchemaView.VisualEditor)
|
||||
setJsonSchema(schema)
|
||||
else if (currentTab === SchemaView.JsonSchema)
|
||||
setJson(JSON.stringify(schema, null, 2))
|
||||
}, [currentTab])
|
||||
|
||||
const handleSubmit = useCallback((schema: any) => {
|
||||
const jsonSchema = jsonToSchema(schema) as SchemaRoot
|
||||
if (currentTab === SchemaView.VisualEditor)
|
||||
setJsonSchema(jsonSchema)
|
||||
else if (currentTab === SchemaView.JsonSchema)
|
||||
setJson(JSON.stringify(jsonSchema, null, 2))
|
||||
}, [currentTab])
|
||||
|
||||
const handleVisualEditorUpdate = useCallback((schema: SchemaRoot) => {
|
||||
setJsonSchema(schema)
|
||||
}, [])
|
||||
|
||||
const handleSchemaEditorUpdate = useCallback((schema: string) => {
|
||||
setJson(schema)
|
||||
}, [])
|
||||
|
||||
const handleResetDefaults = useCallback(() => {
|
||||
if (currentTab === SchemaView.VisualEditor) {
|
||||
setHoveringProperty(null)
|
||||
advancedEditing && setAdvancedEditing(false)
|
||||
isAddingNewField && setIsAddingNewField(false)
|
||||
}
|
||||
setJsonSchema(DEFAULT_SCHEMA)
|
||||
setJson(JSON.stringify(DEFAULT_SCHEMA, null, 2))
|
||||
}, [currentTab, advancedEditing, isAddingNewField, setAdvancedEditing, setIsAddingNewField, setHoveringProperty])
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
onClose()
|
||||
}, [onClose])
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
let schema = jsonSchema
|
||||
if (currentTab === SchemaView.JsonSchema) {
|
||||
try {
|
||||
schema = JSON.parse(json)
|
||||
setParseError(null)
|
||||
const result = preValidateSchema(schema)
|
||||
if (!result.success) {
|
||||
setValidationError(result.error.message)
|
||||
return
|
||||
}
|
||||
const schemaDepth = checkJsonSchemaDepth(schema)
|
||||
if (schemaDepth > JSON_SCHEMA_MAX_DEPTH) {
|
||||
setValidationError(`Schema exceeds maximum depth of ${JSON_SCHEMA_MAX_DEPTH}.`)
|
||||
return
|
||||
}
|
||||
convertBooleanToString(schema)
|
||||
const validationErrors = validateSchemaAgainstDraft7(schema)
|
||||
if (validationErrors.length > 0) {
|
||||
setValidationError(getValidationErrorMessage(validationErrors))
|
||||
return
|
||||
}
|
||||
setJsonSchema(schema)
|
||||
setValidationError('')
|
||||
}
|
||||
catch (error) {
|
||||
setValidationError('')
|
||||
if (error instanceof Error)
|
||||
setParseError(error)
|
||||
else
|
||||
setParseError(new Error('Invalid JSON'))
|
||||
return
|
||||
}
|
||||
}
|
||||
else if (currentTab === SchemaView.VisualEditor) {
|
||||
if (advancedEditing || isAddingNewField) {
|
||||
Toast.notify({
|
||||
type: 'warning',
|
||||
message: t('workflow.nodes.llm.jsonSchema.warningTips.saveSchema'),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
onSave(schema)
|
||||
onClose()
|
||||
}, [currentTab, jsonSchema, json, onSave, onClose, advancedEditing, isAddingNewField, t])
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-col'>
|
||||
{/* Header */}
|
||||
<div className='relative flex p-6 pb-3 pr-14'>
|
||||
<div className='title-2xl-semi-bold grow truncate text-text-primary'>
|
||||
{t('workflow.nodes.llm.jsonSchema.title')}
|
||||
</div>
|
||||
<div className='absolute right-5 top-5 flex h-8 w-8 items-center justify-center p-1.5' onClick={onClose}>
|
||||
<RiCloseLine className='h-[18px] w-[18px] text-text-tertiary' />
|
||||
</div>
|
||||
</div>
|
||||
{/* Content */}
|
||||
<div className='flex items-center justify-between px-6 py-2'>
|
||||
{/* Tab */}
|
||||
<SegmentedControl<SchemaView>
|
||||
options={VIEW_TABS}
|
||||
value={currentTab}
|
||||
onChange={handleTabChange}
|
||||
/>
|
||||
<div className='flex items-center gap-x-0.5'>
|
||||
{/* JSON Schema Generator */}
|
||||
<JsonSchemaGenerator
|
||||
crossAxisOffset={btnWidth}
|
||||
onApply={handleApplySchema}
|
||||
/>
|
||||
<Divider type='vertical' className='h-3' />
|
||||
{/* JSON Schema Importer */}
|
||||
<JsonImporter
|
||||
updateBtnWidth={updateBtnWidth}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex grow flex-col gap-y-1 overflow-hidden px-6'>
|
||||
{currentTab === SchemaView.VisualEditor && (
|
||||
<VisualEditor
|
||||
schema={jsonSchema}
|
||||
onChange={handleVisualEditorUpdate}
|
||||
/>
|
||||
)}
|
||||
{currentTab === SchemaView.JsonSchema && (
|
||||
<SchemaEditor
|
||||
schema={json}
|
||||
onUpdate={handleSchemaEditorUpdate}
|
||||
/>
|
||||
)}
|
||||
{parseError && <ErrorMessage message={parseError.message} />}
|
||||
{validationError && <ErrorMessage message={validationError} />}
|
||||
</div>
|
||||
{/* Footer */}
|
||||
<div className='flex items-center gap-x-2 p-6 pt-5'>
|
||||
<a
|
||||
className='flex grow items-center gap-x-1 text-text-accent'
|
||||
href={HELP_DOC_URL[locale]}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
<span className='system-xs-regular'>{t('workflow.nodes.llm.jsonSchema.doc')}</span>
|
||||
<RiExternalLinkLine className='h-3 w-3' />
|
||||
</a>
|
||||
<div className='flex items-center gap-x-3'>
|
||||
<div className='flex items-center gap-x-2'>
|
||||
<Button variant='secondary' onClick={handleResetDefaults}>
|
||||
{t('workflow.nodes.llm.jsonSchema.resetDefaults')}
|
||||
</Button>
|
||||
<Divider type='vertical' className='ml-1 mr-0 h-4' />
|
||||
</div>
|
||||
<div className='flex items-center gap-x-2'>
|
||||
<Button variant='secondary' onClick={handleCancel}>
|
||||
{t('common.operation.cancel')}
|
||||
</Button>
|
||||
<Button variant='primary' onClick={handleSave}>
|
||||
{t('common.operation.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const JsonSchemaConfigWrapper: FC<JsonSchemaConfigProps> = (props) => {
|
||||
return (
|
||||
<MittProvider>
|
||||
<VisualEditorContextProvider>
|
||||
<JsonSchemaConfig {...props} />
|
||||
</VisualEditorContextProvider>
|
||||
</MittProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default JsonSchemaConfigWrapper
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import SchemaGeneratorLight from './schema-generator-light'
|
||||
import SchemaGeneratorDark from './schema-generator-dark'
|
||||
|
||||
export {
|
||||
SchemaGeneratorLight,
|
||||
SchemaGeneratorDark,
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
const SchemaGeneratorDark = () => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M9.33329 2.95825C10.2308 2.95825 10.9583 2.23071 10.9583 1.33325H11.7083C11.7083 2.23071 12.4358 2.95825 13.3333 2.95825V3.70825C12.4358 3.70825 11.7083 4.43579 11.7083 5.33325H10.9583C10.9583 4.43579 10.2308 3.70825 9.33329 3.70825V2.95825ZM0.666626 7.33325C2.87577 7.33325 4.66663 5.54239 4.66663 3.33325H5.99996C5.99996 5.54239 7.79083 7.33325 9.99996 7.33325V8.66659C7.79083 8.66659 5.99996 10.4575 5.99996 12.6666H4.66663C4.66663 10.4575 2.87577 8.66659 0.666626 8.66659V7.33325ZM11.5 9.33325C11.5 10.5299 10.5299 11.4999 9.33329 11.4999V12.4999C10.5299 12.4999 11.5 13.47 11.5 14.6666H12.5C12.5 13.47 13.47 12.4999 14.6666 12.4999V11.4999C13.47 11.4999 12.5 10.5299 12.5 9.33325H11.5Z" fill="url(#paint0_linear_13059_32065)" fillOpacity="0.95" />
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_13059_32065" x1="14.9996" y1="15" x2="-2.55847" y2="16.6207" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#36BFFA" />
|
||||
<stop offset="1" stopColor="#296DFF" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default SchemaGeneratorDark
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
const SchemaGeneratorLight = () => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M9.33329 2.95837C10.2308 2.95837 10.9583 2.23083 10.9583 1.33337H11.7083C11.7083 2.23083 12.4358 2.95837 13.3333 2.95837V3.70837C12.4358 3.70837 11.7083 4.43591 11.7083 5.33337H10.9583C10.9583 4.43591 10.2308 3.70837 9.33329 3.70837V2.95837ZM0.666626 7.33337C2.87577 7.33337 4.66663 5.54251 4.66663 3.33337H5.99996C5.99996 5.54251 7.79083 7.33337 9.99996 7.33337V8.66671C7.79083 8.66671 5.99996 10.4576 5.99996 12.6667H4.66663C4.66663 10.4576 2.87577 8.66671 0.666626 8.66671V7.33337ZM11.5 9.33337C11.5 10.53 10.5299 11.5 9.33329 11.5V12.5C10.5299 12.5 11.5 13.4701 11.5 14.6667H12.5C12.5 13.4701 13.47 12.5 14.6666 12.5V11.5C13.47 11.5 12.5 10.53 12.5 9.33337H11.5Z" fill="url(#paint0_linear_13059_18704)" fillOpacity="0.95" />
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_13059_18704" x1="14.9996" y1="15.0001" x2="-2.55847" y2="16.6209" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#0BA5EC" />
|
||||
<stop offset="1" stopColor="#155AEF" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default SchemaGeneratorLight
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
import React, { type FC, useCallback, useMemo, useState } from 'react'
|
||||
import type { SchemaRoot } from '../../../types'
|
||||
import { RiArrowLeftLine, RiCloseLine, RiSparklingLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import CodeEditor from '../code-editor'
|
||||
import ErrorMessage from '../error-message'
|
||||
import { getValidationErrorMessage, validateSchemaAgainstDraft7 } from '../../../utils'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
|
||||
type GeneratedResultProps = {
|
||||
schema: SchemaRoot
|
||||
isGenerating: boolean
|
||||
onBack: () => void
|
||||
onRegenerate: () => void
|
||||
onClose: () => void
|
||||
onApply: () => void
|
||||
}
|
||||
|
||||
const GeneratedResult: FC<GeneratedResultProps> = ({
|
||||
schema,
|
||||
isGenerating,
|
||||
onBack,
|
||||
onRegenerate,
|
||||
onClose,
|
||||
onApply,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [parseError, setParseError] = useState<Error | null>(null)
|
||||
const [validationError, setValidationError] = useState<string>('')
|
||||
|
||||
const formatJSON = (json: SchemaRoot) => {
|
||||
try {
|
||||
const schema = JSON.stringify(json, null, 2)
|
||||
setParseError(null)
|
||||
return schema
|
||||
}
|
||||
catch (e) {
|
||||
if (e instanceof Error)
|
||||
setParseError(e)
|
||||
else
|
||||
setParseError(new Error('Invalid JSON'))
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const jsonSchema = useMemo(() => formatJSON(schema), [schema])
|
||||
|
||||
const handleApply = useCallback(() => {
|
||||
const validationErrors = validateSchemaAgainstDraft7(schema)
|
||||
if (validationErrors.length > 0) {
|
||||
setValidationError(getValidationErrorMessage(validationErrors))
|
||||
return
|
||||
}
|
||||
onApply()
|
||||
setValidationError('')
|
||||
}, [schema, onApply])
|
||||
|
||||
return (
|
||||
<div className='flex w-[480px] flex-col rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-2xl shadow-shadow-shadow-9'>
|
||||
{
|
||||
isGenerating ? (
|
||||
<div className='flex h-[600px] flex-col items-center justify-center gap-y-3'>
|
||||
<Loading type='area' />
|
||||
<div className='system-xs-regular text-text-tertiary'>{t('workflow.nodes.llm.jsonSchema.generating')}</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className='absolute right-2.5 top-2.5 flex h-8 w-8 items-center justify-center' onClick={onClose}>
|
||||
<RiCloseLine className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
{/* Title */}
|
||||
<div className='flex flex-col gap-y-[0.5px] px-3 pb-1 pt-3.5'>
|
||||
<div className='system-xl-semibold flex pl-1 pr-8 text-text-primary'>
|
||||
{t('workflow.nodes.llm.jsonSchema.generatedResult')}
|
||||
</div>
|
||||
<div className='system-xs-regular flex px-1 text-text-tertiary'>
|
||||
{t('workflow.nodes.llm.jsonSchema.resultTip')}
|
||||
</div>
|
||||
</div>
|
||||
{/* Content */}
|
||||
<div className='px-4 py-2'>
|
||||
<CodeEditor
|
||||
className='rounded-lg'
|
||||
editorWrapperClassName='h-[424px]'
|
||||
value={jsonSchema}
|
||||
readOnly
|
||||
showFormatButton={false}
|
||||
/>
|
||||
{parseError && <ErrorMessage message={parseError.message} />}
|
||||
{validationError && <ErrorMessage message={validationError} />}
|
||||
</div>
|
||||
{/* Footer */}
|
||||
<div className='flex items-center justify-between p-4 pt-2'>
|
||||
<Button variant='secondary' className='flex items-center gap-x-0.5' onClick={onBack}>
|
||||
<RiArrowLeftLine className='h-4 w-4' />
|
||||
<span>{t('workflow.nodes.llm.jsonSchema.back')}</span>
|
||||
</Button>
|
||||
<div className='flex items-center gap-x-2'>
|
||||
<Button
|
||||
variant='secondary'
|
||||
className='flex items-center gap-x-0.5'
|
||||
onClick={onRegenerate}
|
||||
>
|
||||
<RiSparklingLine className='h-4 w-4' />
|
||||
<span>{t('workflow.nodes.llm.jsonSchema.regenerate')}</span>
|
||||
</Button>
|
||||
<Button variant='primary' onClick={handleApply}>
|
||||
{t('workflow.nodes.llm.jsonSchema.apply')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(GeneratedResult)
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
import React, { type FC, useCallback, useEffect, useState } from 'react'
|
||||
import type { SchemaRoot } from '../../../types'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import type { CompletionParams, Model } from '@/types/app'
|
||||
import { ModelModeType } from '@/types/app'
|
||||
import { Theme } from '@/types/app'
|
||||
import { SchemaGeneratorDark, SchemaGeneratorLight } from './assets'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { ModelInfo } from './prompt-editor'
|
||||
import PromptEditor from './prompt-editor'
|
||||
import GeneratedResult from './generated-result'
|
||||
import { useGenerateStructuredOutputRules } from '@/service/use-common'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { type FormValue, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import { useVisualEditorStore } from '../visual-editor/store'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMittContext } from '../visual-editor/context'
|
||||
|
||||
type JsonSchemaGeneratorProps = {
|
||||
onApply: (schema: SchemaRoot) => void
|
||||
crossAxisOffset?: number
|
||||
}
|
||||
|
||||
enum GeneratorView {
|
||||
promptEditor = 'promptEditor',
|
||||
result = 'result',
|
||||
}
|
||||
|
||||
export const JsonSchemaGenerator: FC<JsonSchemaGeneratorProps> = ({
|
||||
onApply,
|
||||
crossAxisOffset,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [view, setView] = useState(GeneratorView.promptEditor)
|
||||
const [model, setModel] = useState<Model>({
|
||||
name: '',
|
||||
provider: '',
|
||||
mode: ModelModeType.completion,
|
||||
completion_params: {} as CompletionParams,
|
||||
})
|
||||
const [instruction, setInstruction] = useState('')
|
||||
const [schema, setSchema] = useState<SchemaRoot | null>(null)
|
||||
const { theme } = useTheme()
|
||||
const {
|
||||
defaultModel,
|
||||
} = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textGeneration)
|
||||
const advancedEditing = useVisualEditorStore(state => state.advancedEditing)
|
||||
const isAddingNewField = useVisualEditorStore(state => state.isAddingNewField)
|
||||
const { emit } = useMittContext()
|
||||
const SchemaGenerator = theme === Theme.light ? SchemaGeneratorLight : SchemaGeneratorDark
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultModel) {
|
||||
setModel(prev => ({
|
||||
...prev,
|
||||
name: defaultModel.model,
|
||||
provider: defaultModel.provider.provider,
|
||||
}))
|
||||
}
|
||||
}, [defaultModel])
|
||||
|
||||
const handleTrigger = useCallback((e: React.MouseEvent<HTMLElement, MouseEvent>) => {
|
||||
e.stopPropagation()
|
||||
if (advancedEditing || isAddingNewField)
|
||||
emit('quitEditing', {})
|
||||
setOpen(!open)
|
||||
}, [open, advancedEditing, isAddingNewField, emit])
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
setOpen(false)
|
||||
}, [])
|
||||
|
||||
const handleModelChange = useCallback((model: ModelInfo) => {
|
||||
setModel(prev => ({
|
||||
...prev,
|
||||
provider: model.provider,
|
||||
name: model.modelId,
|
||||
mode: model.mode as ModelModeType,
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const handleCompletionParamsChange = useCallback((newParams: FormValue) => {
|
||||
setModel(prev => ({
|
||||
...prev,
|
||||
completion_params: newParams as CompletionParams,
|
||||
}),
|
||||
)
|
||||
}, [])
|
||||
|
||||
const { mutateAsync: generateStructuredOutputRules, isPending: isGenerating } = useGenerateStructuredOutputRules()
|
||||
|
||||
const generateSchema = useCallback(async () => {
|
||||
const { output, error } = await generateStructuredOutputRules({ instruction, model_config: model! })
|
||||
if (error) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: error,
|
||||
})
|
||||
setSchema(null)
|
||||
setView(GeneratorView.promptEditor)
|
||||
return
|
||||
}
|
||||
return output
|
||||
}, [instruction, model, generateStructuredOutputRules])
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
setView(GeneratorView.result)
|
||||
const output = await generateSchema()
|
||||
if (output === undefined) return
|
||||
setSchema(JSON.parse(output))
|
||||
}, [generateSchema])
|
||||
|
||||
const goBackToPromptEditor = () => {
|
||||
setView(GeneratorView.promptEditor)
|
||||
}
|
||||
|
||||
const handleRegenerate = useCallback(async () => {
|
||||
const output = await generateSchema()
|
||||
if (output === undefined) return
|
||||
setSchema(JSON.parse(output))
|
||||
}, [generateSchema])
|
||||
|
||||
const handleApply = () => {
|
||||
onApply(schema!)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement='bottom-end'
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: crossAxisOffset ?? 0,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={handleTrigger}>
|
||||
<button
|
||||
type='button'
|
||||
className={cn(
|
||||
'flex h-6 w-6 items-center justify-center rounded-md p-0.5 hover:bg-state-accent-hover',
|
||||
open && 'bg-state-accent-active',
|
||||
)}
|
||||
>
|
||||
<SchemaGenerator />
|
||||
</button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-[100]'>
|
||||
{view === GeneratorView.promptEditor && (
|
||||
<PromptEditor
|
||||
instruction={instruction}
|
||||
model={model}
|
||||
onInstructionChange={setInstruction}
|
||||
onCompletionParamsChange={handleCompletionParamsChange}
|
||||
onGenerate={handleGenerate}
|
||||
onClose={onClose}
|
||||
onModelChange={handleModelChange}
|
||||
/>
|
||||
)}
|
||||
{view === GeneratorView.result && (
|
||||
<GeneratedResult
|
||||
schema={schema!}
|
||||
isGenerating={isGenerating}
|
||||
onBack={goBackToPromptEditor}
|
||||
onRegenerate={handleRegenerate}
|
||||
onApply={handleApply}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default JsonSchemaGenerator
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
import React, { useCallback } from 'react'
|
||||
import type { FC } from 'react'
|
||||
import { RiCloseLine, RiSparklingFill } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import Button from '@/app/components/base/button'
|
||||
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
|
||||
import type { Model } from '@/types/app'
|
||||
|
||||
export type ModelInfo = {
|
||||
modelId: string
|
||||
provider: string
|
||||
mode?: string
|
||||
features?: string[]
|
||||
}
|
||||
|
||||
type PromptEditorProps = {
|
||||
instruction: string
|
||||
model: Model
|
||||
onInstructionChange: (instruction: string) => void
|
||||
onCompletionParamsChange: (newParams: FormValue) => void
|
||||
onModelChange: (model: ModelInfo) => void
|
||||
onClose: () => void
|
||||
onGenerate: () => void
|
||||
}
|
||||
|
||||
const PromptEditor: FC<PromptEditorProps> = ({
|
||||
instruction,
|
||||
model,
|
||||
onInstructionChange,
|
||||
onCompletionParamsChange,
|
||||
onClose,
|
||||
onGenerate,
|
||||
onModelChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleInstructionChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
onInstructionChange(e.target.value)
|
||||
}, [onInstructionChange])
|
||||
|
||||
return (
|
||||
<div className='relative flex w-[480px] flex-col rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-2xl shadow-shadow-shadow-9'>
|
||||
<div className='absolute right-2.5 top-2.5 flex h-8 w-8 items-center justify-center' onClick={onClose}>
|
||||
<RiCloseLine className='h-4 w-4 text-text-tertiary'/>
|
||||
</div>
|
||||
{/* Title */}
|
||||
<div className='flex flex-col gap-y-[0.5px] px-3 pb-1 pt-3.5'>
|
||||
<div className='system-xl-semibold flex pl-1 pr-8 text-text-primary'>
|
||||
{t('workflow.nodes.llm.jsonSchema.generateJsonSchema')}
|
||||
</div>
|
||||
<div className='system-xs-regular flex px-1 text-text-tertiary'>
|
||||
{t('workflow.nodes.llm.jsonSchema.generationTip')}
|
||||
</div>
|
||||
</div>
|
||||
{/* Content */}
|
||||
<div className='flex flex-col gap-y-1 px-4 py-2'>
|
||||
<div className='system-sm-semibold-uppercase flex h-6 items-center text-text-secondary'>
|
||||
{t('common.modelProvider.model')}
|
||||
</div>
|
||||
<ModelParameterModal
|
||||
popupClassName='!w-[448px]'
|
||||
portalToFollowElemContentClassName='z-[1000]'
|
||||
isAdvancedMode={true}
|
||||
provider={model.provider}
|
||||
mode={model.mode}
|
||||
completionParams={model.completion_params}
|
||||
modelId={model.name}
|
||||
setModel={onModelChange}
|
||||
onCompletionParamsChange={onCompletionParamsChange}
|
||||
hideDebugWithMultipleModel
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-y-1 px-4 py-2'>
|
||||
<div className='system-sm-semibold-uppercase flex h-6 items-center text-text-secondary'>
|
||||
<span>{t('workflow.nodes.llm.jsonSchema.instruction')}</span>
|
||||
<Tooltip popupContent={t('workflow.nodes.llm.jsonSchema.promptTooltip')} />
|
||||
</div>
|
||||
<div className='flex items-center'>
|
||||
<Textarea
|
||||
className='h-[364px] resize-none px-2 py-1'
|
||||
value={instruction}
|
||||
placeholder={t('workflow.nodes.llm.jsonSchema.promptPlaceholder')}
|
||||
onChange={handleInstructionChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Footer */}
|
||||
<div className='flex justify-end gap-x-2 p-4 pt-2'>
|
||||
<Button variant='secondary' onClick={onClose}>
|
||||
{t('common.operation.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
className='flex items-center gap-x-0.5'
|
||||
onClick={onGenerate}
|
||||
>
|
||||
<RiSparklingFill className='h-4 w-4' />
|
||||
<span>{t('workflow.nodes.llm.jsonSchema.generate')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(PromptEditor)
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import React, { type FC } from 'react'
|
||||
import CodeEditor from './code-editor'
|
||||
|
||||
type SchemaEditorProps = {
|
||||
schema: string
|
||||
onUpdate: (schema: string) => void
|
||||
}
|
||||
|
||||
const SchemaEditor: FC<SchemaEditorProps> = ({
|
||||
schema,
|
||||
onUpdate,
|
||||
}) => {
|
||||
return (
|
||||
<CodeEditor
|
||||
className='rounded-xl'
|
||||
editorWrapperClassName='grow'
|
||||
value={schema}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default SchemaEditor
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import React, { useCallback } from 'react'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { RiAddCircleFill } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useVisualEditorStore } from './store'
|
||||
import { useMittContext } from './context'
|
||||
|
||||
const AddField = () => {
|
||||
const { t } = useTranslation()
|
||||
const setIsAddingNewField = useVisualEditorStore(state => state.setIsAddingNewField)
|
||||
const { emit } = useMittContext()
|
||||
|
||||
const handleAddField = useCallback(() => {
|
||||
setIsAddingNewField(true)
|
||||
emit('addField', { path: [] })
|
||||
}, [setIsAddingNewField, emit])
|
||||
|
||||
return (
|
||||
<div className='py-2 pl-5'>
|
||||
<Button
|
||||
size='small'
|
||||
variant='secondary-accent'
|
||||
className='flex items-center gap-x-[1px]'
|
||||
onClick={handleAddField}
|
||||
>
|
||||
<RiAddCircleFill className='h-3.5 w-3.5'/>
|
||||
<span className='px-[3px]'>{t('workflow.nodes.llm.jsonSchema.addField')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(AddField)
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import React, { type FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type CardProps = {
|
||||
name: string
|
||||
type: string
|
||||
required: boolean
|
||||
description?: string
|
||||
}
|
||||
|
||||
const Card: FC<CardProps> = ({
|
||||
name,
|
||||
type,
|
||||
required,
|
||||
description,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className='flex flex-col py-0.5'>
|
||||
<div className='flex h-6 items-center gap-x-1 pl-1 pr-0.5'>
|
||||
<div className='system-sm-semibold truncate border border-transparent px-1 py-px text-text-primary'>
|
||||
{name}
|
||||
</div>
|
||||
<div className='system-xs-medium px-1 py-0.5 text-text-tertiary'>
|
||||
{type}
|
||||
</div>
|
||||
{
|
||||
required && (
|
||||
<div className='system-2xs-medium-uppercase px-1 py-0.5 text-text-warning'>
|
||||
{t('workflow.nodes.llm.jsonSchema.required')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
{description && (
|
||||
<div className='system-xs-regular truncate px-2 pb-1 text-text-tertiary'>
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Card)
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import { createVisualEditorStore } from './store'
|
||||
import { useMitt } from '@/hooks/use-mitt'
|
||||
import { noop } from 'lodash-es'
|
||||
|
||||
type VisualEditorStore = ReturnType<typeof createVisualEditorStore>
|
||||
|
||||
type VisualEditorContextType = VisualEditorStore | null
|
||||
|
||||
type VisualEditorProviderProps = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export const VisualEditorContext = createContext<VisualEditorContextType>(null)
|
||||
|
||||
export const VisualEditorContextProvider = ({ children }: VisualEditorProviderProps) => {
|
||||
const storeRef = useRef<VisualEditorStore>()
|
||||
|
||||
if (!storeRef.current)
|
||||
storeRef.current = createVisualEditorStore()
|
||||
|
||||
return (
|
||||
<VisualEditorContext.Provider value={storeRef.current}>
|
||||
{children}
|
||||
</VisualEditorContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const MittContext = createContext<ReturnType<typeof useMitt>>({
|
||||
emit: noop,
|
||||
useSubscribe: noop,
|
||||
})
|
||||
|
||||
export const MittProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const mitt = useMitt()
|
||||
|
||||
return (
|
||||
<MittContext.Provider value={mitt}>
|
||||
{children}
|
||||
</MittContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useMittContext = () => {
|
||||
return useContext(MittContext)
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { RiAddCircleLine, RiDeleteBinLine, RiEditLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type ActionsProps = {
|
||||
disableAddBtn: boolean
|
||||
onAddChildField: () => void
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
const Actions: FC<ActionsProps> = ({
|
||||
disableAddBtn,
|
||||
onAddChildField,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-x-0.5'>
|
||||
<Tooltip popupContent={t('workflow.nodes.llm.jsonSchema.addChildField')}>
|
||||
<button
|
||||
type='button'
|
||||
className='flex h-6 w-6 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary disabled:cursor-not-allowed disabled:text-text-disabled'
|
||||
onClick={onAddChildField}
|
||||
disabled={disableAddBtn}
|
||||
>
|
||||
<RiAddCircleLine className='h-4 w-4'/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip popupContent={t('common.operation.edit')}>
|
||||
<button
|
||||
type='button'
|
||||
className='flex h-6 w-6 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary'
|
||||
onClick={onEdit}
|
||||
>
|
||||
<RiEditLine className='h-4 w-4' />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip popupContent={t('common.operation.remove')}>
|
||||
<button
|
||||
type='button'
|
||||
className='flex h-6 w-6 items-center justify-center rounded-md text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive'
|
||||
onClick={onDelete}
|
||||
>
|
||||
<RiDeleteBinLine className='h-4 w-4' />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Actions)
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import React, { type FC } from 'react'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getKeyboardKeyCodeBySystem, getKeyboardKeyNameBySystem } from '@/app/components/workflow/utils'
|
||||
import { useKeyPress } from 'ahooks'
|
||||
|
||||
type AdvancedActionsProps = {
|
||||
isConfirmDisabled: boolean
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
const Key = (props: { keyName: string }) => {
|
||||
const { keyName } = props
|
||||
return (
|
||||
<kbd className='system-kbd flex h-4 min-w-4 items-center justify-center rounded-[4px] bg-components-kbd-bg-white px-px text-text-primary-on-surface'>
|
||||
{keyName}
|
||||
</kbd>
|
||||
)
|
||||
}
|
||||
|
||||
const AdvancedActions: FC<AdvancedActionsProps> = ({
|
||||
isConfirmDisabled,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
useKeyPress([`${getKeyboardKeyCodeBySystem('ctrl')}.enter`], (e) => {
|
||||
e.preventDefault()
|
||||
onConfirm()
|
||||
}, {
|
||||
exactMatch: true,
|
||||
useCapture: true,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-x-1'>
|
||||
<Button size='small' variant='secondary' onClick={onCancel}>
|
||||
{t('common.operation.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
className='flex items-center gap-x-1'
|
||||
disabled={isConfirmDisabled}
|
||||
size='small'
|
||||
variant='primary'
|
||||
onClick={onConfirm}
|
||||
>
|
||||
<span>{t('common.operation.confirm')}</span>
|
||||
<div className='flex items-center gap-x-0.5'>
|
||||
<Key keyName={getKeyboardKeyNameBySystem('ctrl')} />
|
||||
<Key keyName='⏎' />
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(AdvancedActions)
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import React, { type FC, useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
|
||||
export type AdvancedOptionsType = {
|
||||
enum: string
|
||||
}
|
||||
|
||||
type AdvancedOptionsProps = {
|
||||
options: AdvancedOptionsType
|
||||
onChange: (options: AdvancedOptionsType) => void
|
||||
}
|
||||
|
||||
const AdvancedOptions: FC<AdvancedOptionsProps> = ({
|
||||
onChange,
|
||||
options,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
// const [showAdvancedOptions, setShowAdvancedOptions] = useState(false)
|
||||
const [enumValue, setEnumValue] = useState(options.enum)
|
||||
|
||||
const handleEnumChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setEnumValue(e.target.value)
|
||||
}, [])
|
||||
|
||||
const handleEnumBlur = useCallback((e: React.FocusEvent<HTMLTextAreaElement>) => {
|
||||
onChange({ enum: e.target.value })
|
||||
}, [onChange])
|
||||
|
||||
// const handleToggleAdvancedOptions = useCallback(() => {
|
||||
// setShowAdvancedOptions(prev => !prev)
|
||||
// }, [])
|
||||
|
||||
return (
|
||||
<div className='border-t border-divider-subtle'>
|
||||
{/* {showAdvancedOptions ? ( */}
|
||||
<div className='flex flex-col gap-y-1 px-2 py-1.5'>
|
||||
<div className='flex w-full items-center gap-x-2'>
|
||||
<span className='system-2xs-medium-uppercase text-text-tertiary'>
|
||||
{t('workflow.nodes.llm.jsonSchema.stringValidations')}
|
||||
</span>
|
||||
<div className='grow'>
|
||||
<Divider type='horizontal' className='my-0 h-px bg-line-divider-bg' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col'>
|
||||
<div className='system-xs-medium flex h-6 items-center text-text-secondary'>
|
||||
Enum
|
||||
</div>
|
||||
<Textarea
|
||||
size='small'
|
||||
className='min-h-6'
|
||||
value={enumValue}
|
||||
onChange={handleEnumChange}
|
||||
onBlur={handleEnumBlur}
|
||||
placeholder={'abcd, 1, 1.5, etc.'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* ) : (
|
||||
<button
|
||||
type='button'
|
||||
className='flex items-center gap-x-0.5 pb-1 pl-1.5 pr-2 pt-2'
|
||||
onClick={handleToggleAdvancedOptions}
|
||||
>
|
||||
<RiArrowDownDoubleLine className='h-3 w-3 text-text-tertiary' />
|
||||
<span className='system-xs-regular text-text-tertiary'>
|
||||
{t('workflow.nodes.llm.jsonSchema.showAdvancedOptions')}
|
||||
</span>
|
||||
</button>
|
||||
)} */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(AdvancedOptions)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue