refactor workflow

pull/21398/head
zxhlyh 1 year ago
commit 97cf6b2d65

@ -8,7 +8,7 @@
<a href="https://cloud.dify.ai">Dify Cloud</a> · <a href="https://cloud.dify.ai">Dify Cloud</a> ·
<a href="https://docs.dify.ai/getting-started/install-self-hosted">Self-hosting</a> · <a href="https://docs.dify.ai/getting-started/install-self-hosted">Self-hosting</a> ·
<a href="https://docs.dify.ai">Documentation</a> · <a href="https://docs.dify.ai">Documentation</a> ·
<a href="https://udify.app/chat/22L1zSxg6yW1cWQg">Enterprise inquiry</a> <a href="https://dify.ai/pricing">Dify edition overview</a>
</p> </p>
<p align="center"> <p align="center">

@ -4,7 +4,7 @@
<a href="https://cloud.dify.ai">Dify Cloud</a> · <a href="https://cloud.dify.ai">Dify Cloud</a> ·
<a href="https://docs.dify.ai/getting-started/install-self-hosted">الاستضافة الذاتية</a> · <a href="https://docs.dify.ai/getting-started/install-self-hosted">الاستضافة الذاتية</a> ·
<a href="https://docs.dify.ai">التوثيق</a> · <a href="https://docs.dify.ai">التوثيق</a> ·
<a href="https://udify.app/chat/22L1zSxg6yW1cWQg">استفسار الشركات (للإنجليزية فقط)</a> <a href="https://dify.ai/pricing">نظرة عامة على منتجات Dify</a>
</p> </p>
<p align="center"> <p align="center">

@ -8,7 +8,7 @@
<a href="https://cloud.dify.ai">ডিফাই ক্লাউড</a> · <a href="https://cloud.dify.ai">ডিফাই ক্লাউড</a> ·
<a href="https://docs.dify.ai/getting-started/install-self-hosted">সেল্ফ-হোস্টিং</a> · <a href="https://docs.dify.ai/getting-started/install-self-hosted">সেল্ফ-হোস্টিং</a> ·
<a href="https://docs.dify.ai">ডকুমেন্টেশন</a> · <a href="https://docs.dify.ai">ডকুমেন্টেশন</a> ·
<a href="https://udify.app/chat/22L1zSxg6yW1cWQg">ব্যাবসায়িক অনুসন্ধান</a> <a href="https://dify.ai/pricing">Dify পণ্যের রূপভেদ</a>
</p> </p>
<p align="center"> <p align="center">

@ -4,7 +4,7 @@
<a href="https://cloud.dify.ai">Dify 云服务</a> · <a href="https://cloud.dify.ai">Dify 云服务</a> ·
<a href="https://docs.dify.ai/getting-started/install-self-hosted">自托管</a> · <a href="https://docs.dify.ai/getting-started/install-self-hosted">自托管</a> ·
<a href="https://docs.dify.ai">文档</a> · <a href="https://docs.dify.ai">文档</a> ·
<a href="https://udify.app/chat/22L1zSxg6yW1cWQg">(需用英文)常见问题解答 / 联系团队</a> <a href="https://dify.ai/pricing">Dify 产品形态总览</a>
</div> </div>
<p align="center"> <p align="center">

@ -8,7 +8,7 @@
<a href="https://cloud.dify.ai">Dify Cloud</a> · <a href="https://cloud.dify.ai">Dify Cloud</a> ·
<a href="https://docs.dify.ai/getting-started/install-self-hosted">Selbstgehostetes</a> · <a href="https://docs.dify.ai/getting-started/install-self-hosted">Selbstgehostetes</a> ·
<a href="https://docs.dify.ai">Dokumentation</a> · <a href="https://docs.dify.ai">Dokumentation</a> ·
<a href="https://udify.app/chat/22L1zSxg6yW1cWQg">Anfrage an Unternehmen</a> <a href="https://dify.ai/pricing">Überblick über die Dify-Produkte</a>
</p> </p>
<p align="center"> <p align="center">

@ -4,7 +4,7 @@
<a href="https://cloud.dify.ai">Dify Cloud</a> · <a href="https://cloud.dify.ai">Dify Cloud</a> ·
<a href="https://docs.dify.ai/getting-started/install-self-hosted">Auto-alojamiento</a> · <a href="https://docs.dify.ai/getting-started/install-self-hosted">Auto-alojamiento</a> ·
<a href="https://docs.dify.ai">Documentación</a> · <a href="https://docs.dify.ai">Documentación</a> ·
<a href="https://udify.app/chat/22L1zSxg6yW1cWQg">Consultas empresariales (en inglés)</a> <a href="https://dify.ai/pricing">Resumen de las ediciones de Dify</a>
</p> </p>
<p align="center"> <p align="center">

@ -4,7 +4,7 @@
<a href="https://cloud.dify.ai">Dify Cloud</a> · <a href="https://cloud.dify.ai">Dify Cloud</a> ·
<a href="https://docs.dify.ai/getting-started/install-self-hosted">Auto-hébergement</a> · <a href="https://docs.dify.ai/getting-started/install-self-hosted">Auto-hébergement</a> ·
<a href="https://docs.dify.ai">Documentation</a> · <a href="https://docs.dify.ai">Documentation</a> ·
<a href="https://udify.app/chat/22L1zSxg6yW1cWQg">Demande dentreprise (en anglais seulement)</a> <a href="https://dify.ai/pricing">Présentation des différentes offres Dify</a>
</p> </p>
<p align="center"> <p align="center">

@ -4,7 +4,7 @@
<a href="https://cloud.dify.ai">Dify Cloud</a> · <a href="https://cloud.dify.ai">Dify Cloud</a> ·
<a href="https://docs.dify.ai/getting-started/install-self-hosted">セルフホスティング</a> · <a href="https://docs.dify.ai/getting-started/install-self-hosted">セルフホスティング</a> ·
<a href="https://docs.dify.ai">ドキュメント</a> · <a href="https://docs.dify.ai">ドキュメント</a> ·
<a href="https://udify.app/chat/22L1zSxg6yW1cWQg">企業のお問い合わせ(英語のみ)</a> <a href="https://dify.ai/pricing">Difyの各種エディションについて</a>
</p> </p>
<p align="center"> <p align="center">

@ -4,7 +4,7 @@
<a href="https://cloud.dify.ai">Dify Cloud</a> · <a href="https://cloud.dify.ai">Dify Cloud</a> ·
<a href="https://docs.dify.ai/getting-started/install-self-hosted">Self-hosting</a> · <a href="https://docs.dify.ai/getting-started/install-self-hosted">Self-hosting</a> ·
<a href="https://docs.dify.ai">Documentation</a> · <a href="https://docs.dify.ai">Documentation</a> ·
<a href="https://udify.app/chat/22L1zSxg6yW1cWQg">Commercial enquiries</a> <a href="https://dify.ai/pricing">Dify product editions</a>
</p> </p>
<p align="center"> <p align="center">

@ -4,7 +4,7 @@
<a href="https://cloud.dify.ai">Dify 클라우드</a> · <a href="https://cloud.dify.ai">Dify 클라우드</a> ·
<a href="https://docs.dify.ai/getting-started/install-self-hosted">셀프-호스팅</a> · <a href="https://docs.dify.ai/getting-started/install-self-hosted">셀프-호스팅</a> ·
<a href="https://docs.dify.ai">문서</a> · <a href="https://docs.dify.ai">문서</a> ·
<a href="https://udify.app/chat/22L1zSxg6yW1cWQg">기업 문의 (영어만 가능)</a> <a href="https://dify.ai/pricing">Dify 제품 에디션 안내</a>
</p> </p>
<p align="center"> <p align="center">

@ -8,7 +8,7 @@
<a href="https://cloud.dify.ai">Dify Cloud</a> · <a href="https://cloud.dify.ai">Dify Cloud</a> ·
<a href="https://docs.dify.ai/getting-started/install-self-hosted">Auto-hospedagem</a> · <a href="https://docs.dify.ai/getting-started/install-self-hosted">Auto-hospedagem</a> ·
<a href="https://docs.dify.ai">Documentação</a> · <a href="https://docs.dify.ai">Documentação</a> ·
<a href="https://udify.app/chat/22L1zSxg6yW1cWQg">Consultas empresariais</a> <a href="https://dify.ai/pricing">Visão geral das edições do Dify</a>
</p> </p>
<p align="center"> <p align="center">

@ -8,7 +8,7 @@
<a href="https://cloud.dify.ai">Dify Cloud</a> · <a href="https://cloud.dify.ai">Dify Cloud</a> ·
<a href="https://docs.dify.ai/getting-started/install-self-hosted">Samostojno gostovanje</a> · <a href="https://docs.dify.ai/getting-started/install-self-hosted">Samostojno gostovanje</a> ·
<a href="https://docs.dify.ai">Dokumentacija</a> · <a href="https://docs.dify.ai">Dokumentacija</a> ·
<a href="https://udify.app/chat/22L1zSxg6yW1cWQg">Povpraševanje za podjetja</a> <a href="https://dify.ai/pricing">Pregled ponudb izdelkov Dify</a>
</p> </p>
<p align="center"> <p align="center">

@ -4,7 +4,7 @@
<a href="https://cloud.dify.ai">Dify Bulut</a> · <a href="https://cloud.dify.ai">Dify Bulut</a> ·
<a href="https://docs.dify.ai/getting-started/install-self-hosted">Kendi Sunucunuzda Barındırma</a> · <a href="https://docs.dify.ai/getting-started/install-self-hosted">Kendi Sunucunuzda Barındırma</a> ·
<a href="https://docs.dify.ai">Dokümantasyon</a> · <a href="https://docs.dify.ai">Dokümantasyon</a> ·
<a href="https://udify.app/chat/22L1zSxg6yW1cWQg">Yalnızca İngilizce: Kurumsal Sorgulama</a> <a href="https://dify.ai/pricing">Dify ürün seçeneklerine genel bakış</a>
</p> </p>
<p align="center"> <p align="center">

@ -8,7 +8,7 @@
<a href="https://cloud.dify.ai">Dify 雲端服務</a> · <a href="https://cloud.dify.ai">Dify 雲端服務</a> ·
<a href="https://docs.dify.ai/getting-started/install-self-hosted">自行託管</a> · <a href="https://docs.dify.ai/getting-started/install-self-hosted">自行託管</a> ·
<a href="https://docs.dify.ai">說明文件</a> · <a href="https://docs.dify.ai">說明文件</a> ·
<a href="https://udify.app/chat/22L1zSxg6yW1cWQg">企業諮詢</a> <a href="https://dify.ai/pricing">產品方案概覽</a>
</p> </p>
<p align="center"> <p align="center">

@ -4,7 +4,7 @@
<a href="https://cloud.dify.ai">Dify Cloud</a> · <a href="https://cloud.dify.ai">Dify Cloud</a> ·
<a href="https://docs.dify.ai/getting-started/install-self-hosted">Tự triển khai</a> · <a href="https://docs.dify.ai/getting-started/install-self-hosted">Tự triển khai</a> ·
<a href="https://docs.dify.ai">Tài liệu</a> · <a href="https://docs.dify.ai">Tài liệu</a> ·
<a href="https://udify.app/chat/22L1zSxg6yW1cWQg">Yêu cầu doanh nghiệp</a> <a href="https://dify.ai/pricing">Tổng quan các lựa chọn sản phẩm Dify</a>
</p> </p>
<p align="center"> <p align="center">

@ -482,4 +482,7 @@ OTEL_MAX_QUEUE_SIZE=2048
OTEL_MAX_EXPORT_BATCH_SIZE=512 OTEL_MAX_EXPORT_BATCH_SIZE=512
OTEL_METRIC_EXPORT_INTERVAL=60000 OTEL_METRIC_EXPORT_INTERVAL=60000
OTEL_BATCH_EXPORT_TIMEOUT=10000 OTEL_BATCH_EXPORT_TIMEOUT=10000
OTEL_METRIC_EXPORT_TIMEOUT=30000 OTEL_METRIC_EXPORT_TIMEOUT=30000
# Prevent Clickjacking
ALLOW_EMBED=false

@ -52,6 +52,7 @@ def initialize_extensions(app: DifyApp):
ext_mail, ext_mail,
ext_migrate, ext_migrate,
ext_otel, ext_otel,
ext_otel_patch,
ext_proxy_fix, ext_proxy_fix,
ext_redis, ext_redis,
ext_repositories, ext_repositories,
@ -84,6 +85,7 @@ def initialize_extensions(app: DifyApp):
ext_proxy_fix, ext_proxy_fix,
ext_blueprints, ext_blueprints,
ext_commands, ext_commands,
ext_otel_patch, # Apply patch before initializing OpenTelemetry
ext_otel, ext_otel,
] ]
for ext in extensions: for ext in extensions:

@ -13,6 +13,7 @@ from .observability import ObservabilityConfig
from .packaging import PackagingInfo from .packaging import PackagingInfo
from .remote_settings_sources import RemoteSettingsSource, RemoteSettingsSourceConfig, RemoteSettingsSourceName from .remote_settings_sources import RemoteSettingsSource, RemoteSettingsSourceConfig, RemoteSettingsSourceName
from .remote_settings_sources.apollo import ApolloSettingsSource from .remote_settings_sources.apollo import ApolloSettingsSource
from .remote_settings_sources.nacos import NacosSettingsSource
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -34,6 +35,8 @@ class RemoteSettingsSourceFactory(PydanticBaseSettingsSource):
match remote_source_name: match remote_source_name:
case RemoteSettingsSourceName.APOLLO: case RemoteSettingsSourceName.APOLLO:
remote_source = ApolloSettingsSource(current_state) remote_source = ApolloSettingsSource(current_state)
case RemoteSettingsSourceName.NACOS:
remote_source = NacosSettingsSource(current_state)
case _: case _:
logger.warning(f"Unsupported remote source: {remote_source_name}") logger.warning(f"Unsupported remote source: {remote_source_name}")
return {} return {}

@ -22,6 +22,7 @@ from .vdb.baidu_vector_config import BaiduVectorDBConfig
from .vdb.chroma_config import ChromaConfig from .vdb.chroma_config import ChromaConfig
from .vdb.couchbase_config import CouchbaseConfig from .vdb.couchbase_config import CouchbaseConfig
from .vdb.elasticsearch_config import ElasticsearchConfig from .vdb.elasticsearch_config import ElasticsearchConfig
from .vdb.huawei_cloud_config import HuaweiCloudConfig
from .vdb.lindorm_config import LindormConfig from .vdb.lindorm_config import LindormConfig
from .vdb.milvus_config import MilvusConfig from .vdb.milvus_config import MilvusConfig
from .vdb.myscale_config import MyScaleConfig from .vdb.myscale_config import MyScaleConfig
@ -263,6 +264,7 @@ class MiddlewareConfig(
VectorStoreConfig, VectorStoreConfig,
AnalyticdbConfig, AnalyticdbConfig,
ChromaConfig, ChromaConfig,
HuaweiCloudConfig,
MilvusConfig, MilvusConfig,
MyScaleConfig, MyScaleConfig,
OpenSearchConfig, OpenSearchConfig,

@ -0,0 +1,25 @@
from typing import Optional
from pydantic import Field
from pydantic_settings import BaseSettings
class HuaweiCloudConfig(BaseSettings):
"""
Configuration settings for Huawei cloud search service
"""
HUAWEI_CLOUD_HOSTS: Optional[str] = Field(
description="Hostname or IP address of the Huawei cloud search service instance",
default=None,
)
HUAWEI_CLOUD_USER: Optional[str] = Field(
description="Username for authenticating with Huawei cloud search service",
default=None,
)
HUAWEI_CLOUD_PASSWORD: Optional[str] = Field(
description="Password for authenticating with Huawei cloud search service",
default=None,
)

@ -3,3 +3,4 @@ from enum import StrEnum
class RemoteSettingsSourceName(StrEnum): class RemoteSettingsSourceName(StrEnum):
APOLLO = "apollo" APOLLO = "apollo"
NACOS = "nacos"

@ -0,0 +1,52 @@
import logging
import os
from collections.abc import Mapping
from typing import Any
from pydantic.fields import FieldInfo
from .http_request import NacosHttpClient
logger = logging.getLogger(__name__)
from configs.remote_settings_sources.base import RemoteSettingsSource
from .utils import _parse_config
class NacosSettingsSource(RemoteSettingsSource):
def __init__(self, configs: Mapping[str, Any]):
self.configs = configs
self.remote_configs: dict[str, Any] = {}
self.async_init()
def async_init(self):
data_id = os.getenv("DIFY_ENV_NACOS_DATA_ID", "dify-api-env.properties")
group = os.getenv("DIFY_ENV_NACOS_GROUP", "nacos-dify")
tenant = os.getenv("DIFY_ENV_NACOS_NAMESPACE", "")
params = {"dataId": data_id, "group": group, "tenant": tenant}
try:
content = NacosHttpClient().http_request("/nacos/v1/cs/configs", method="GET", headers={}, params=params)
self.remote_configs = self._parse_config(content)
except Exception as e:
logger.exception("[get-access-token] exception occurred")
raise
def _parse_config(self, content: str) -> dict:
if not content:
return {}
try:
return _parse_config(self, content)
except Exception as e:
raise RuntimeError(f"Failed to parse config: {e}")
def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]:
if not isinstance(self.remote_configs, dict):
raise ValueError(f"remote configs is not dict, but {type(self.remote_configs)}")
field_value = self.remote_configs.get(field_name)
if field_value is None:
return None, field_name, False
return field_value, field_name, False

@ -0,0 +1,83 @@
import base64
import hashlib
import hmac
import logging
import os
import time
import requests
logger = logging.getLogger(__name__)
class NacosHttpClient:
def __init__(self):
self.username = os.getenv("DIFY_ENV_NACOS_USERNAME")
self.password = os.getenv("DIFY_ENV_NACOS_PASSWORD")
self.ak = os.getenv("DIFY_ENV_NACOS_ACCESS_KEY")
self.sk = os.getenv("DIFY_ENV_NACOS_SECRET_KEY")
self.server = os.getenv("DIFY_ENV_NACOS_SERVER_ADDR", "localhost:8848")
self.token = None
self.token_ttl = 18000
self.token_expire_time: float = 0
def http_request(self, url, method="GET", headers=None, params=None):
try:
self._inject_auth_info(headers, params)
response = requests.request(method, url="http://" + self.server + url, headers=headers, params=params)
response.raise_for_status()
return response.text
except requests.exceptions.RequestException as e:
return f"Request to Nacos failed: {e}"
def _inject_auth_info(self, headers, params, module="config"):
headers.update({"User-Agent": "Nacos-Http-Client-In-Dify:v0.0.1"})
if module == "login":
return
ts = str(int(time.time() * 1000))
if self.ak and self.sk:
sign_str = self.get_sign_str(params["group"], params["tenant"], ts)
headers["Spas-AccessKey"] = self.ak
headers["Spas-Signature"] = self.__do_sign(sign_str, self.sk)
headers["timeStamp"] = ts
if self.username and self.password:
self.get_access_token(force_refresh=False)
params["accessToken"] = self.token
def __do_sign(self, sign_str, sk):
return (
base64.encodebytes(hmac.new(sk.encode(), sign_str.encode(), digestmod=hashlib.sha1).digest())
.decode()
.strip()
)
def get_sign_str(self, group, tenant, ts):
sign_str = ""
if tenant:
sign_str = tenant + "+"
if group:
sign_str = sign_str + group + "+"
if sign_str:
sign_str += ts
return sign_str
def get_access_token(self, force_refresh=False):
current_time = time.time()
if self.token and not force_refresh and self.token_expire_time > current_time:
return self.token
params = {"username": self.username, "password": self.password}
url = "http://" + self.server + "/nacos/v1/auth/login"
try:
resp = requests.request("POST", url, headers=None, params=params)
resp.raise_for_status()
response_data = resp.json()
self.token = response_data.get("accessToken")
self.token_ttl = response_data.get("tokenTtl", 18000)
self.token_expire_time = current_time + self.token_ttl - 10
except Exception as e:
logger.exception("[get-access-token] exception occur")
raise

@ -0,0 +1,31 @@
def _parse_config(self, content: str) -> dict[str, str]:
config: dict[str, str] = {}
if not content:
return config
for line in content.splitlines():
cleaned_line = line.strip()
if not cleaned_line or cleaned_line.startswith(("#", "!")):
continue
separator_index = -1
for i, c in enumerate(cleaned_line):
if c in ("=", ":") and (i == 0 or cleaned_line[i - 1] != "\\"):
separator_index = i
break
if separator_index == -1:
continue
key = cleaned_line[:separator_index].strip()
raw_value = cleaned_line[separator_index + 1 :].strip()
try:
decoded_value = bytes(raw_value, "utf-8").decode("unicode_escape")
decoded_value = decoded_value.replace(r"\=", "=").replace(r"\:", ":")
except UnicodeDecodeError:
decoded_value = raw_value
config[key] = decoded_value
return config

@ -664,6 +664,7 @@ class DatasetRetrievalSettingApi(Resource):
| VectorType.OPENGAUSS | VectorType.OPENGAUSS
| VectorType.OCEANBASE | VectorType.OCEANBASE
| VectorType.TABLESTORE | VectorType.TABLESTORE
| VectorType.HUAWEI_CLOUD
| VectorType.TENCENT | VectorType.TENCENT
): ):
return { return {
@ -710,6 +711,7 @@ class DatasetRetrievalSettingMockApi(Resource):
| VectorType.OCEANBASE | VectorType.OCEANBASE
| VectorType.TABLESTORE | VectorType.TABLESTORE
| VectorType.TENCENT | VectorType.TENCENT
| VectorType.HUAWEI_CLOUD
): ):
return { return {
"retrieval_method": [ "retrieval_method": [

@ -5,6 +5,7 @@ from werkzeug.exceptions import Forbidden
from controllers.console import api from controllers.console import api
from controllers.console.wraps import account_initialization_required, setup_required from controllers.console.wraps import account_initialization_required, setup_required
from core.model_runtime.utils.encoders import jsonable_encoder from core.model_runtime.utils.encoders import jsonable_encoder
from core.plugin.manager.exc import PluginPermissionDeniedError
from libs.login import login_required from libs.login import login_required
from services.plugin.endpoint_service import EndpointService from services.plugin.endpoint_service import EndpointService
@ -28,15 +29,18 @@ class EndpointCreateApi(Resource):
settings = args["settings"] settings = args["settings"]
name = args["name"] name = args["name"]
return { try:
"success": EndpointService.create_endpoint( return {
tenant_id=user.current_tenant_id, "success": EndpointService.create_endpoint(
user_id=user.id, tenant_id=user.current_tenant_id,
plugin_unique_identifier=plugin_unique_identifier, user_id=user.id,
name=name, plugin_unique_identifier=plugin_unique_identifier,
settings=settings, name=name,
) settings=settings,
} )
}
except PluginPermissionDeniedError as e:
raise ValueError(e.description) from e
class EndpointListApi(Resource): class EndpointListApi(Resource):

@ -1,3 +1,5 @@
from mimetypes import guess_extension
from flask import request from flask import request
from flask_restful import Resource, marshal_with # type: ignore from flask_restful import Resource, marshal_with # type: ignore
from werkzeug.exceptions import Forbidden from werkzeug.exceptions import Forbidden
@ -9,8 +11,8 @@ from controllers.files.error import UnsupportedFileTypeError
from controllers.inner_api.plugin.wraps import get_user from controllers.inner_api.plugin.wraps import get_user
from controllers.service_api.app.error import FileTooLargeError from controllers.service_api.app.error import FileTooLargeError
from core.file.helpers import verify_plugin_file_signature from core.file.helpers import verify_plugin_file_signature
from core.tools.tool_file_manager import ToolFileManager
from fields.file_fields import file_fields from fields.file_fields import file_fields
from services.file_service import FileService
class PluginUploadFileApi(Resource): class PluginUploadFileApi(Resource):
@ -51,19 +53,26 @@ class PluginUploadFileApi(Resource):
raise Forbidden("Invalid request.") raise Forbidden("Invalid request.")
try: try:
upload_file = FileService.upload_file( tool_file = ToolFileManager.create_file_by_raw(
filename=filename, user_id=user.id,
content=file.read(), tenant_id=tenant_id,
file_binary=file.read(),
mimetype=mimetype, mimetype=mimetype,
user=user, filename=filename,
source=None, conversation_id=None,
) )
extension = guess_extension(tool_file.mimetype) or ".bin"
preview_url = ToolFileManager.sign_file(tool_file_id=tool_file.id, extension=extension)
tool_file.mime_type = mimetype
tool_file.extension = extension
tool_file.preview_url = preview_url
except services.errors.file.FileTooLargeError as file_too_large_error: except services.errors.file.FileTooLargeError as file_too_large_error:
raise FileTooLargeError(file_too_large_error.description) raise FileTooLargeError(file_too_large_error.description)
except services.errors.file.UnsupportedFileTypeError: except services.errors.file.UnsupportedFileTypeError:
raise UnsupportedFileTypeError() raise UnsupportedFileTypeError()
return upload_file, 201 return tool_file, 201
api.add_resource(PluginUploadFileApi, "/files/upload/for-plugin") api.add_resource(PluginUploadFileApi, "/files/upload/for-plugin")

@ -21,14 +21,13 @@ from core.model_runtime.entities import (
AssistantPromptMessage, AssistantPromptMessage,
LLMUsage, LLMUsage,
PromptMessage, PromptMessage,
PromptMessageContent,
PromptMessageTool, PromptMessageTool,
SystemPromptMessage, SystemPromptMessage,
TextPromptMessageContent, TextPromptMessageContent,
ToolPromptMessage, ToolPromptMessage,
UserPromptMessage, UserPromptMessage,
) )
from core.model_runtime.entities.message_entities import ImagePromptMessageContent from core.model_runtime.entities.message_entities import ImagePromptMessageContent, PromptMessageContentUnionTypes
from core.model_runtime.entities.model_entities import ModelFeature from core.model_runtime.entities.model_entities import ModelFeature
from core.model_runtime.model_providers.__base.large_language_model import LargeLanguageModel from core.model_runtime.model_providers.__base.large_language_model import LargeLanguageModel
from core.prompt.utils.extract_thread_messages import extract_thread_messages from core.prompt.utils.extract_thread_messages import extract_thread_messages
@ -501,7 +500,7 @@ class BaseAgentRunner(AppRunner):
) )
if not file_objs: if not file_objs:
return UserPromptMessage(content=message.query) return UserPromptMessage(content=message.query)
prompt_message_contents: list[PromptMessageContent] = [] prompt_message_contents: list[PromptMessageContentUnionTypes] = []
prompt_message_contents.append(TextPromptMessageContent(data=message.query)) prompt_message_contents.append(TextPromptMessageContent(data=message.query))
for file in file_objs: for file in file_objs:
prompt_message_contents.append( prompt_message_contents.append(

@ -5,12 +5,11 @@ from core.file import file_manager
from core.model_runtime.entities import ( from core.model_runtime.entities import (
AssistantPromptMessage, AssistantPromptMessage,
PromptMessage, PromptMessage,
PromptMessageContent,
SystemPromptMessage, SystemPromptMessage,
TextPromptMessageContent, TextPromptMessageContent,
UserPromptMessage, UserPromptMessage,
) )
from core.model_runtime.entities.message_entities import ImagePromptMessageContent from core.model_runtime.entities.message_entities import ImagePromptMessageContent, PromptMessageContentUnionTypes
from core.model_runtime.utils.encoders import jsonable_encoder from core.model_runtime.utils.encoders import jsonable_encoder
@ -40,7 +39,7 @@ class CotChatAgentRunner(CotAgentRunner):
Organize user query Organize user query
""" """
if self.files: if self.files:
prompt_message_contents: list[PromptMessageContent] = [] prompt_message_contents: list[PromptMessageContentUnionTypes] = []
prompt_message_contents.append(TextPromptMessageContent(data=query)) prompt_message_contents.append(TextPromptMessageContent(data=query))
# get image detail config # get image detail config

@ -15,14 +15,13 @@ from core.model_runtime.entities import (
LLMResultChunkDelta, LLMResultChunkDelta,
LLMUsage, LLMUsage,
PromptMessage, PromptMessage,
PromptMessageContent,
PromptMessageContentType, PromptMessageContentType,
SystemPromptMessage, SystemPromptMessage,
TextPromptMessageContent, TextPromptMessageContent,
ToolPromptMessage, ToolPromptMessage,
UserPromptMessage, UserPromptMessage,
) )
from core.model_runtime.entities.message_entities import ImagePromptMessageContent from core.model_runtime.entities.message_entities import ImagePromptMessageContent, PromptMessageContentUnionTypes
from core.prompt.agent_history_prompt_transform import AgentHistoryPromptTransform from core.prompt.agent_history_prompt_transform import AgentHistoryPromptTransform
from core.tools.entities.tool_entities import ToolInvokeMeta from core.tools.entities.tool_entities import ToolInvokeMeta
from core.tools.tool_engine import ToolEngine from core.tools.tool_engine import ToolEngine
@ -395,7 +394,7 @@ class FunctionCallAgentRunner(BaseAgentRunner):
Organize user query Organize user query
""" """
if self.files: if self.files:
prompt_message_contents: list[PromptMessageContent] = [] prompt_message_contents: list[PromptMessageContentUnionTypes] = []
prompt_message_contents.append(TextPromptMessageContent(data=query)) prompt_message_contents.append(TextPromptMessageContent(data=query))
# get image detail config # get image detail config

@ -7,9 +7,9 @@ from core.model_runtime.entities import (
AudioPromptMessageContent, AudioPromptMessageContent,
DocumentPromptMessageContent, DocumentPromptMessageContent,
ImagePromptMessageContent, ImagePromptMessageContent,
MultiModalPromptMessageContent,
VideoPromptMessageContent, VideoPromptMessageContent,
) )
from core.model_runtime.entities.message_entities import PromptMessageContentUnionTypes
from extensions.ext_storage import storage from extensions.ext_storage import storage
from . import helpers from . import helpers
@ -43,7 +43,7 @@ def to_prompt_message_content(
/, /,
*, *,
image_detail_config: ImagePromptMessageContent.DETAIL | None = None, image_detail_config: ImagePromptMessageContent.DETAIL | None = None,
) -> MultiModalPromptMessageContent: ) -> PromptMessageContentUnionTypes:
if f.extension is None: if f.extension is None:
raise ValueError("Missing file extension") raise ValueError("Missing file extension")
if f.mime_type is None: if f.mime_type is None:
@ -58,7 +58,7 @@ def to_prompt_message_content(
if f.type == FileType.IMAGE: if f.type == FileType.IMAGE:
params["detail"] = image_detail_config or ImagePromptMessageContent.DETAIL.LOW params["detail"] = image_detail_config or ImagePromptMessageContent.DETAIL.LOW
prompt_class_map: Mapping[FileType, type[MultiModalPromptMessageContent]] = { prompt_class_map: Mapping[FileType, type[PromptMessageContentUnionTypes]] = {
FileType.IMAGE: ImagePromptMessageContent, FileType.IMAGE: ImagePromptMessageContent,
FileType.AUDIO: AudioPromptMessageContent, FileType.AUDIO: AudioPromptMessageContent,
FileType.VIDEO: VideoPromptMessageContent, FileType.VIDEO: VideoPromptMessageContent,

@ -8,11 +8,11 @@ from core.model_runtime.entities import (
AssistantPromptMessage, AssistantPromptMessage,
ImagePromptMessageContent, ImagePromptMessageContent,
PromptMessage, PromptMessage,
PromptMessageContent,
PromptMessageRole, PromptMessageRole,
TextPromptMessageContent, TextPromptMessageContent,
UserPromptMessage, UserPromptMessage,
) )
from core.model_runtime.entities.message_entities import PromptMessageContentUnionTypes
from core.prompt.utils.extract_thread_messages import extract_thread_messages from core.prompt.utils.extract_thread_messages import extract_thread_messages
from extensions.ext_database import db from extensions.ext_database import db
from factories import file_factory from factories import file_factory
@ -100,7 +100,7 @@ class TokenBufferMemory:
if not file_objs: if not file_objs:
prompt_messages.append(UserPromptMessage(content=message.query)) prompt_messages.append(UserPromptMessage(content=message.query))
else: else:
prompt_message_contents: list[PromptMessageContent] = [] prompt_message_contents: list[PromptMessageContentUnionTypes] = []
prompt_message_contents.append(TextPromptMessageContent(data=message.query)) prompt_message_contents.append(TextPromptMessageContent(data=message.query))
for file in file_objs: for file in file_objs:
prompt_message = file_manager.to_prompt_message_content( prompt_message = file_manager.to_prompt_message_content(

@ -1,6 +1,6 @@
from collections.abc import Sequence from collections.abc import Sequence
from enum import Enum, StrEnum from enum import Enum, StrEnum
from typing import Any, Optional, Union from typing import Annotated, Any, Literal, Optional, Union
from pydantic import BaseModel, Field, field_serializer, field_validator from pydantic import BaseModel, Field, field_serializer, field_validator
@ -61,11 +61,7 @@ class PromptMessageContentType(StrEnum):
class PromptMessageContent(BaseModel): class PromptMessageContent(BaseModel):
""" pass
Model class for prompt message content.
"""
type: PromptMessageContentType
class TextPromptMessageContent(PromptMessageContent): class TextPromptMessageContent(PromptMessageContent):
@ -73,7 +69,7 @@ class TextPromptMessageContent(PromptMessageContent):
Model class for text prompt message content. Model class for text prompt message content.
""" """
type: PromptMessageContentType = PromptMessageContentType.TEXT type: Literal[PromptMessageContentType.TEXT] = PromptMessageContentType.TEXT
data: str data: str
@ -82,7 +78,6 @@ class MultiModalPromptMessageContent(PromptMessageContent):
Model class for multi-modal prompt message content. Model class for multi-modal prompt message content.
""" """
type: PromptMessageContentType
format: str = Field(default=..., description="the format of multi-modal file") format: str = Field(default=..., description="the format of multi-modal file")
base64_data: str = Field(default="", description="the base64 data of multi-modal file") base64_data: str = Field(default="", description="the base64 data of multi-modal file")
url: str = Field(default="", description="the url of multi-modal file") url: str = Field(default="", description="the url of multi-modal file")
@ -94,11 +89,11 @@ class MultiModalPromptMessageContent(PromptMessageContent):
class VideoPromptMessageContent(MultiModalPromptMessageContent): class VideoPromptMessageContent(MultiModalPromptMessageContent):
type: PromptMessageContentType = PromptMessageContentType.VIDEO type: Literal[PromptMessageContentType.VIDEO] = PromptMessageContentType.VIDEO
class AudioPromptMessageContent(MultiModalPromptMessageContent): class AudioPromptMessageContent(MultiModalPromptMessageContent):
type: PromptMessageContentType = PromptMessageContentType.AUDIO type: Literal[PromptMessageContentType.AUDIO] = PromptMessageContentType.AUDIO
class ImagePromptMessageContent(MultiModalPromptMessageContent): class ImagePromptMessageContent(MultiModalPromptMessageContent):
@ -110,12 +105,24 @@ class ImagePromptMessageContent(MultiModalPromptMessageContent):
LOW = "low" LOW = "low"
HIGH = "high" HIGH = "high"
type: PromptMessageContentType = PromptMessageContentType.IMAGE type: Literal[PromptMessageContentType.IMAGE] = PromptMessageContentType.IMAGE
detail: DETAIL = DETAIL.LOW detail: DETAIL = DETAIL.LOW
class DocumentPromptMessageContent(MultiModalPromptMessageContent): class DocumentPromptMessageContent(MultiModalPromptMessageContent):
type: PromptMessageContentType = PromptMessageContentType.DOCUMENT type: Literal[PromptMessageContentType.DOCUMENT] = PromptMessageContentType.DOCUMENT
PromptMessageContentUnionTypes = Annotated[
Union[
TextPromptMessageContent,
ImagePromptMessageContent,
DocumentPromptMessageContent,
AudioPromptMessageContent,
VideoPromptMessageContent,
],
Field(discriminator="type"),
]
class PromptMessage(BaseModel): class PromptMessage(BaseModel):
@ -124,7 +131,7 @@ class PromptMessage(BaseModel):
""" """
role: PromptMessageRole role: PromptMessageRole
content: Optional[str | Sequence[PromptMessageContent]] = None content: Optional[str | list[PromptMessageContentUnionTypes]] = None
name: Optional[str] = None name: Optional[str] = None
def is_empty(self) -> bool: def is_empty(self) -> bool:

@ -9,13 +9,12 @@ from core.memory.token_buffer_memory import TokenBufferMemory
from core.model_runtime.entities import ( from core.model_runtime.entities import (
AssistantPromptMessage, AssistantPromptMessage,
PromptMessage, PromptMessage,
PromptMessageContent,
PromptMessageRole, PromptMessageRole,
SystemPromptMessage, SystemPromptMessage,
TextPromptMessageContent, TextPromptMessageContent,
UserPromptMessage, UserPromptMessage,
) )
from core.model_runtime.entities.message_entities import ImagePromptMessageContent from core.model_runtime.entities.message_entities import ImagePromptMessageContent, PromptMessageContentUnionTypes
from core.prompt.entities.advanced_prompt_entities import ChatModelMessage, CompletionModelPromptTemplate, MemoryConfig from core.prompt.entities.advanced_prompt_entities import ChatModelMessage, CompletionModelPromptTemplate, MemoryConfig
from core.prompt.prompt_transform import PromptTransform from core.prompt.prompt_transform import PromptTransform
from core.prompt.utils.prompt_template_parser import PromptTemplateParser from core.prompt.utils.prompt_template_parser import PromptTemplateParser
@ -125,7 +124,7 @@ class AdvancedPromptTransform(PromptTransform):
prompt = Jinja2Formatter.format(prompt, prompt_inputs) prompt = Jinja2Formatter.format(prompt, prompt_inputs)
if files: if files:
prompt_message_contents: list[PromptMessageContent] = [] prompt_message_contents: list[PromptMessageContentUnionTypes] = []
prompt_message_contents.append(TextPromptMessageContent(data=prompt)) prompt_message_contents.append(TextPromptMessageContent(data=prompt))
for file in files: for file in files:
prompt_message_contents.append( prompt_message_contents.append(
@ -201,7 +200,7 @@ class AdvancedPromptTransform(PromptTransform):
prompt_messages = self._append_chat_histories(memory, memory_config, prompt_messages, model_config) prompt_messages = self._append_chat_histories(memory, memory_config, prompt_messages, model_config)
if files and query is not None: if files and query is not None:
prompt_message_contents: list[PromptMessageContent] = [] prompt_message_contents: list[PromptMessageContentUnionTypes] = []
prompt_message_contents.append(TextPromptMessageContent(data=query)) prompt_message_contents.append(TextPromptMessageContent(data=query))
for file in files: for file in files:
prompt_message_contents.append( prompt_message_contents.append(

@ -11,7 +11,7 @@ from core.memory.token_buffer_memory import TokenBufferMemory
from core.model_runtime.entities.message_entities import ( from core.model_runtime.entities.message_entities import (
ImagePromptMessageContent, ImagePromptMessageContent,
PromptMessage, PromptMessage,
PromptMessageContent, PromptMessageContentUnionTypes,
SystemPromptMessage, SystemPromptMessage,
TextPromptMessageContent, TextPromptMessageContent,
UserPromptMessage, UserPromptMessage,
@ -277,7 +277,7 @@ class SimplePromptTransform(PromptTransform):
image_detail_config: Optional[ImagePromptMessageContent.DETAIL] = None, image_detail_config: Optional[ImagePromptMessageContent.DETAIL] = None,
) -> UserPromptMessage: ) -> UserPromptMessage:
if files: if files:
prompt_message_contents: list[PromptMessageContent] = [] prompt_message_contents: list[PromptMessageContentUnionTypes] = []
prompt_message_contents.append(TextPromptMessageContent(data=prompt)) prompt_message_contents.append(TextPromptMessageContent(data=prompt))
for file in files: for file in files:
prompt_message_contents.append( prompt_message_contents.append(

@ -0,0 +1,215 @@
import json
import logging
import ssl
from typing import Any, Optional
from elasticsearch import Elasticsearch
from pydantic import BaseModel, model_validator
from configs import dify_config
from core.rag.datasource.vdb.field import Field
from core.rag.datasource.vdb.vector_base import BaseVector
from core.rag.datasource.vdb.vector_factory import AbstractVectorFactory
from core.rag.datasource.vdb.vector_type import VectorType
from core.rag.embedding.embedding_base import Embeddings
from core.rag.models.document import Document
from extensions.ext_redis import redis_client
from models.dataset import Dataset
logger = logging.getLogger(__name__)
def create_ssl_context() -> ssl.SSLContext:
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
return ssl_context
class HuaweiCloudVectorConfig(BaseModel):
hosts: str
username: str | None
password: str | None
@model_validator(mode="before")
@classmethod
def validate_config(cls, values: dict) -> dict:
if not values["hosts"]:
raise ValueError("config HOSTS is required")
return values
def to_elasticsearch_params(self) -> dict[str, Any]:
params = {
"hosts": self.hosts.split(","),
"verify_certs": False,
"ssl_show_warn": False,
"request_timeout": 30000,
"retry_on_timeout": True,
"max_retries": 10,
}
if self.username and self.password:
params["basic_auth"] = (self.username, self.password)
return params
class HuaweiCloudVector(BaseVector):
def __init__(self, index_name: str, config: HuaweiCloudVectorConfig):
super().__init__(index_name.lower())
self._client = Elasticsearch(**config.to_elasticsearch_params())
def get_type(self) -> str:
return VectorType.HUAWEI_CLOUD
def add_texts(self, documents: list[Document], embeddings: list[list[float]], **kwargs):
uuids = self._get_uuids(documents)
for i in range(len(documents)):
self._client.index(
index=self._collection_name,
id=uuids[i],
document={
Field.CONTENT_KEY.value: documents[i].page_content,
Field.VECTOR.value: embeddings[i] or None,
Field.METADATA_KEY.value: documents[i].metadata or {},
},
)
self._client.indices.refresh(index=self._collection_name)
return uuids
def text_exists(self, id: str) -> bool:
return bool(self._client.exists(index=self._collection_name, id=id))
def delete_by_ids(self, ids: list[str]) -> None:
if not ids:
return
for id in ids:
self._client.delete(index=self._collection_name, id=id)
def delete_by_metadata_field(self, key: str, value: str) -> None:
query_str = {"query": {"match": {f"metadata.{key}": f"{value}"}}}
results = self._client.search(index=self._collection_name, body=query_str)
ids = [hit["_id"] for hit in results["hits"]["hits"]]
if ids:
self.delete_by_ids(ids)
def delete(self) -> None:
self._client.indices.delete(index=self._collection_name)
def search_by_vector(self, query_vector: list[float], **kwargs: Any) -> list[Document]:
top_k = kwargs.get("top_k", 4)
query = {
"size": top_k,
"query": {
"vector": {
Field.VECTOR.value: {
"vector": query_vector,
"topk": top_k,
}
}
},
}
results = self._client.search(index=self._collection_name, body=query)
docs_and_scores = []
for hit in results["hits"]["hits"]:
docs_and_scores.append(
(
Document(
page_content=hit["_source"][Field.CONTENT_KEY.value],
vector=hit["_source"][Field.VECTOR.value],
metadata=hit["_source"][Field.METADATA_KEY.value],
),
hit["_score"],
)
)
docs = []
for doc, score in docs_and_scores:
score_threshold = float(kwargs.get("score_threshold") or 0.0)
if score > score_threshold:
if doc.metadata is not None:
doc.metadata["score"] = score
docs.append(doc)
return docs
def search_by_full_text(self, query: str, **kwargs: Any) -> list[Document]:
query_str = {"match": {Field.CONTENT_KEY.value: query}}
results = self._client.search(index=self._collection_name, query=query_str, size=kwargs.get("top_k", 4))
docs = []
for hit in results["hits"]["hits"]:
docs.append(
Document(
page_content=hit["_source"][Field.CONTENT_KEY.value],
vector=hit["_source"][Field.VECTOR.value],
metadata=hit["_source"][Field.METADATA_KEY.value],
)
)
return docs
def create(self, texts: list[Document], embeddings: list[list[float]], **kwargs):
metadatas = [d.metadata if d.metadata is not None else {} for d in texts]
self.create_collection(embeddings, metadatas)
self.add_texts(texts, embeddings, **kwargs)
def create_collection(
self,
embeddings: list[list[float]],
metadatas: Optional[list[dict[Any, Any]]] = None,
index_params: Optional[dict] = None,
):
lock_name = f"vector_indexing_lock_{self._collection_name}"
with redis_client.lock(lock_name, timeout=20):
collection_exist_cache_key = f"vector_indexing_{self._collection_name}"
if redis_client.get(collection_exist_cache_key):
logger.info(f"Collection {self._collection_name} already exists.")
return
if not self._client.indices.exists(index=self._collection_name):
dim = len(embeddings[0])
mappings = {
"properties": {
Field.CONTENT_KEY.value: {"type": "text"},
Field.VECTOR.value: { # Make sure the dimension is correct here
"type": "vector",
"dimension": dim,
"indexing": True,
"algorithm": "GRAPH",
"metric": "cosine",
"neighbors": 32,
"efc": 128,
},
Field.METADATA_KEY.value: {
"type": "object",
"properties": {
"doc_id": {"type": "keyword"} # Map doc_id to keyword type
},
},
}
}
settings = {"index.vector": True}
self._client.indices.create(index=self._collection_name, mappings=mappings, settings=settings)
redis_client.set(collection_exist_cache_key, 1, ex=3600)
class HuaweiCloudVectorFactory(AbstractVectorFactory):
def init_vector(self, dataset: Dataset, attributes: list, embeddings: Embeddings) -> HuaweiCloudVector:
if dataset.index_struct_dict:
class_prefix: str = dataset.index_struct_dict["vector_store"]["class_prefix"]
collection_name = class_prefix.lower()
else:
dataset_id = dataset.id
collection_name = Dataset.gen_collection_name_by_id(dataset_id).lower()
dataset.index_struct = json.dumps(self.gen_index_struct_dict(VectorType.HUAWEI_CLOUD, collection_name))
return HuaweiCloudVector(
index_name=collection_name,
config=HuaweiCloudVectorConfig(
hosts=dify_config.HUAWEI_CLOUD_HOSTS or "http://localhost:9200",
username=dify_config.HUAWEI_CLOUD_USER,
password=dify_config.HUAWEI_CLOUD_PASSWORD,
),
)

@ -156,6 +156,10 @@ class Vector:
from core.rag.datasource.vdb.tablestore.tablestore_vector import TableStoreVectorFactory from core.rag.datasource.vdb.tablestore.tablestore_vector import TableStoreVectorFactory
return TableStoreVectorFactory return TableStoreVectorFactory
case VectorType.HUAWEI_CLOUD:
from core.rag.datasource.vdb.huawei.huawei_cloud_vector import HuaweiCloudVectorFactory
return HuaweiCloudVectorFactory
case _: case _:
raise ValueError(f"Vector store {vector_type} is not supported.") raise ValueError(f"Vector store {vector_type} is not supported.")

@ -26,3 +26,4 @@ class VectorType(StrEnum):
OCEANBASE = "oceanbase" OCEANBASE = "oceanbase"
OPENGAUSS = "opengauss" OPENGAUSS = "opengauss"
TABLESTORE = "tablestore" TABLESTORE = "tablestore"
HUAWEI_CLOUD = "huawei_cloud"

@ -869,7 +869,9 @@ class DatasetRetrieval:
) )
) )
metadata_condition = MetadataCondition( metadata_condition = MetadataCondition(
logical_operator=metadata_filtering_conditions.logical_operator, # type: ignore logical_operator=metadata_filtering_conditions.logical_operator
if metadata_filtering_conditions
else "or", # type: ignore
conditions=conditions, conditions=conditions,
) )
elif metadata_filtering_mode == "manual": elif metadata_filtering_mode == "manual":
@ -891,10 +893,10 @@ class DatasetRetrieval:
else: else:
raise ValueError("Invalid metadata filtering mode") raise ValueError("Invalid metadata filtering mode")
if filters: if filters:
if metadata_filtering_conditions.logical_operator == "or": # type: ignore if metadata_filtering_conditions and metadata_filtering_conditions.logical_operator == "and": # type: ignore
document_query = document_query.filter(or_(*filters))
else:
document_query = document_query.filter(and_(*filters)) document_query = document_query.filter(and_(*filters))
else:
document_query = document_query.filter(or_(*filters))
documents = document_query.all() documents = document_query.all()
# group by dataset_id # group by dataset_id
metadata_filter_document_ids = defaultdict(list) if documents else None # type: ignore metadata_filter_document_ids = defaultdict(list) if documents else None # type: ignore

@ -349,7 +349,9 @@ class KnowledgeRetrievalNode(LLMNode):
) )
) )
metadata_condition = MetadataCondition( metadata_condition = MetadataCondition(
logical_operator=node_data.metadata_filtering_conditions.logical_operator, # type: ignore logical_operator=node_data.metadata_filtering_conditions.logical_operator
if node_data.metadata_filtering_conditions
else "or", # type: ignore
conditions=conditions, conditions=conditions,
) )
elif node_data.metadata_filtering_mode == "manual": elif node_data.metadata_filtering_mode == "manual":
@ -380,7 +382,10 @@ class KnowledgeRetrievalNode(LLMNode):
else: else:
raise ValueError("Invalid metadata filtering mode") raise ValueError("Invalid metadata filtering mode")
if filters: if filters:
if node_data.metadata_filtering_conditions.logical_operator == "and": # type: ignore if (
node_data.metadata_filtering_conditions
and node_data.metadata_filtering_conditions.logical_operator == "and"
): # type: ignore
document_query = document_query.filter(and_(*filters)) document_query = document_query.filter(and_(*filters))
else: else:
document_query = document_query.filter(or_(*filters)) document_query = document_query.filter(or_(*filters))

@ -24,7 +24,7 @@ from core.model_runtime.entities import (
from core.model_runtime.entities.llm_entities import LLMResult, LLMUsage from core.model_runtime.entities.llm_entities import LLMResult, LLMUsage
from core.model_runtime.entities.message_entities import ( from core.model_runtime.entities.message_entities import (
AssistantPromptMessage, AssistantPromptMessage,
PromptMessageContent, PromptMessageContentUnionTypes,
PromptMessageRole, PromptMessageRole,
SystemPromptMessage, SystemPromptMessage,
UserPromptMessage, UserPromptMessage,
@ -594,8 +594,7 @@ class LLMNode(BaseNode[LLMNodeData]):
variable_pool: VariablePool, variable_pool: VariablePool,
jinja2_variables: Sequence[VariableSelector], jinja2_variables: Sequence[VariableSelector],
) -> tuple[Sequence[PromptMessage], Optional[Sequence[str]]]: ) -> tuple[Sequence[PromptMessage], Optional[Sequence[str]]]:
# FIXME: fix the type error cause prompt_messages is type quick a few times prompt_messages: list[PromptMessage] = []
prompt_messages: list[Any] = []
if isinstance(prompt_template, list): if isinstance(prompt_template, list):
# For chat model # For chat model
@ -657,12 +656,14 @@ class LLMNode(BaseNode[LLMNodeData]):
# For issue #11247 - Check if prompt content is a string or a list # For issue #11247 - Check if prompt content is a string or a list
prompt_content_type = type(prompt_content) prompt_content_type = type(prompt_content)
if prompt_content_type == str: if prompt_content_type == str:
prompt_content = str(prompt_content)
if "#histories#" in prompt_content: if "#histories#" in prompt_content:
prompt_content = prompt_content.replace("#histories#", memory_text) prompt_content = prompt_content.replace("#histories#", memory_text)
else: else:
prompt_content = memory_text + "\n" + prompt_content prompt_content = memory_text + "\n" + prompt_content
prompt_messages[0].content = prompt_content prompt_messages[0].content = prompt_content
elif prompt_content_type == list: elif prompt_content_type == list:
prompt_content = prompt_content if isinstance(prompt_content, list) else []
for content_item in prompt_content: for content_item in prompt_content:
if content_item.type == PromptMessageContentType.TEXT: if content_item.type == PromptMessageContentType.TEXT:
if "#histories#" in content_item.data: if "#histories#" in content_item.data:
@ -675,9 +676,10 @@ class LLMNode(BaseNode[LLMNodeData]):
# Add current query to the prompt message # Add current query to the prompt message
if sys_query: if sys_query:
if prompt_content_type == str: if prompt_content_type == str:
prompt_content = prompt_messages[0].content.replace("#sys.query#", sys_query) prompt_content = str(prompt_messages[0].content).replace("#sys.query#", sys_query)
prompt_messages[0].content = prompt_content prompt_messages[0].content = prompt_content
elif prompt_content_type == list: elif prompt_content_type == list:
prompt_content = prompt_content if isinstance(prompt_content, list) else []
for content_item in prompt_content: for content_item in prompt_content:
if content_item.type == PromptMessageContentType.TEXT: if content_item.type == PromptMessageContentType.TEXT:
content_item.data = sys_query + "\n" + content_item.data content_item.data = sys_query + "\n" + content_item.data
@ -707,7 +709,7 @@ class LLMNode(BaseNode[LLMNodeData]):
filtered_prompt_messages = [] filtered_prompt_messages = []
for prompt_message in prompt_messages: for prompt_message in prompt_messages:
if isinstance(prompt_message.content, list): if isinstance(prompt_message.content, list):
prompt_message_content = [] prompt_message_content: list[PromptMessageContentUnionTypes] = []
for content_item in prompt_message.content: for content_item in prompt_message.content:
# Skip content if features are not defined # Skip content if features are not defined
if not model_config.model_schema.features: if not model_config.model_schema.features:
@ -1132,7 +1134,9 @@ class LLMNode(BaseNode[LLMNodeData]):
) )
def _combine_message_content_with_role(*, contents: Sequence[PromptMessageContent], role: PromptMessageRole): def _combine_message_content_with_role(
*, contents: Optional[str | list[PromptMessageContentUnionTypes]] = None, role: PromptMessageRole
):
match role: match role:
case PromptMessageRole.USER: case PromptMessageRole.USER:
return UserPromptMessage(content=contents) return UserPromptMessage(content=contents)

@ -14,7 +14,7 @@ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExport
from opentelemetry.instrumentation.celery import CeleryInstrumentor from opentelemetry.instrumentation.celery import CeleryInstrumentor
from opentelemetry.instrumentation.flask import FlaskInstrumentor from opentelemetry.instrumentation.flask import FlaskInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
from opentelemetry.metrics import get_meter_provider, set_meter_provider from opentelemetry.metrics import get_meter, get_meter_provider, set_meter_provider
from opentelemetry.propagate import set_global_textmap from opentelemetry.propagate import set_global_textmap
from opentelemetry.propagators.b3 import B3Format from opentelemetry.propagators.b3 import B3Format
from opentelemetry.propagators.composite import CompositePropagator from opentelemetry.propagators.composite import CompositePropagator
@ -112,6 +112,11 @@ def is_celery_worker():
def init_flask_instrumentor(app: DifyApp): def init_flask_instrumentor(app: DifyApp):
meter = get_meter("http_metrics", version=dify_config.CURRENT_VERSION)
_http_response_counter = meter.create_counter(
"http.server.response.count", description="Total number of HTTP responses by status code", unit="{response}"
)
def response_hook(span: Span, status: str, response_headers: list): def response_hook(span: Span, status: str, response_headers: list):
if span and span.is_recording(): if span and span.is_recording():
if status.startswith("2"): if status.startswith("2"):
@ -119,6 +124,11 @@ def init_flask_instrumentor(app: DifyApp):
else: else:
span.set_status(StatusCode.ERROR, status) span.set_status(StatusCode.ERROR, status)
status = status.split(" ")[0]
status_code = int(status)
status_class = f"{status_code // 100}xx"
_http_response_counter.add(1, {"status_code": status_code, "status_class": status_class})
instrumentor = FlaskInstrumentor() instrumentor = FlaskInstrumentor()
if dify_config.DEBUG: if dify_config.DEBUG:
logging.info("Initializing Flask instrumentor") logging.info("Initializing Flask instrumentor")

@ -0,0 +1,63 @@
"""
Patch for OpenTelemetry context detach method to handle None tokens gracefully.
This patch addresses the issue where OpenTelemetry's context.detach() method raises a TypeError
when called with a None token. The error occurs in the contextvars_context.py file where it tries
to call reset() on a None token.
Related GitHub issue: https://github.com/langgenius/dify/issues/18496
Error being fixed:
```
Traceback (most recent call last):
File "opentelemetry/context/__init__.py", line 154, in detach
_RUNTIME_CONTEXT.detach(token)
File "opentelemetry/context/contextvars_context.py", line 50, in detach
self._current_context.reset(token) # type: ignore
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: expected an instance of Token, got None
```
Instead of modifying the third-party package directly, this patch monkey-patches the
context.detach method to gracefully handle None tokens.
"""
import logging
from functools import wraps
from opentelemetry import context
logger = logging.getLogger(__name__)
# Store the original detach method
original_detach = context.detach
# Create a patched version that handles None tokens
@wraps(original_detach)
def patched_detach(token):
"""
A patched version of context.detach that handles None tokens gracefully.
"""
if token is None:
logger.debug("Attempted to detach a None token, skipping")
return
return original_detach(token)
def is_enabled():
"""
Check if the extension is enabled.
Always enable this patch to prevent errors even when OpenTelemetry is disabled.
"""
return True
def init_app(app):
"""
Initialize the OpenTelemetry context patch.
"""
# Replace the original detach method with our patched version
context.detach = patched_detach
logger.info("OpenTelemetry context.detach patched to handle None tokens")

@ -19,6 +19,7 @@ file_fields = {
"mime_type": fields.String, "mime_type": fields.String,
"created_by": fields.String, "created_by": fields.String,
"created_at": TimestampField, "created_at": TimestampField,
"preview_url": fields.String,
} }
remote_file_info_fields = { remote_file_info_fields = {

@ -37,8 +37,12 @@ class SQLAlchemyWorkflowNodeExecutionRepository:
# If an engine is provided, create a sessionmaker from it # If an engine is provided, create a sessionmaker from it
if isinstance(session_factory, Engine): if isinstance(session_factory, Engine):
self._session_factory = sessionmaker(bind=session_factory, expire_on_commit=False) self._session_factory = sessionmaker(bind=session_factory, expire_on_commit=False)
else: elif isinstance(session_factory, sessionmaker):
self._session_factory = session_factory self._session_factory = session_factory
else:
raise ValueError(
f"Invalid session_factory type {type(session_factory).__name__}; expected sessionmaker or Engine"
)
self._tenant_id = tenant_id self._tenant_id = tenant_id
self._app_id = app_id self._app_id = app_id

@ -133,7 +133,7 @@ class WorkflowRunService:
params={ params={
"tenant_id": app_model.tenant_id, "tenant_id": app_model.tenant_id,
"app_id": app_model.id, "app_id": app_model.id,
"session_factory": db.session.get_bind, "session_factory": db.session.get_bind(),
} }
) )

@ -289,7 +289,7 @@ class WorkflowService:
params={ params={
"tenant_id": app_model.tenant_id, "tenant_id": app_model.tenant_id,
"app_id": app_model.id, "app_id": app_model.id,
"session_factory": db.session.get_bind, "session_factory": db.session.get_bind(),
} }
) )
repository.save(workflow_node_execution) repository.save(workflow_node_execution)

@ -193,7 +193,7 @@ def _delete_app_workflow_node_executions(tenant_id: str, app_id: str):
params={ params={
"tenant_id": tenant_id, "tenant_id": tenant_id,
"app_id": app_id, "app_id": app_id,
"session_factory": db.session.get_bind, "session_factory": db.session.get_bind(),
} }
) )

@ -0,0 +1,88 @@
import os
import pytest
from _pytest.monkeypatch import MonkeyPatch
from api.core.rag.datasource.vdb.field import Field
from elasticsearch import Elasticsearch
class MockIndicesClient:
def __init__(self):
pass
def create(self, index, mappings, settings):
return {"acknowledge": True}
def refresh(self, index):
return {"acknowledge": True}
def delete(self, index):
return {"acknowledge": True}
def exists(self, index):
return True
class MockClient:
def __init__(self, **kwargs):
self.indices = MockIndicesClient()
def index(self, **kwargs):
return {"acknowledge": True}
def exists(self, **kwargs):
return True
def delete(self, **kwargs):
return {"acknowledge": True}
def search(self, **kwargs):
return {
"took": 1,
"hits": {
"hits": [
{
"_source": {
Field.CONTENT_KEY.value: "abcdef",
Field.VECTOR.value: [1, 2],
Field.METADATA_KEY.value: {},
},
"_score": 1.0,
},
{
"_source": {
Field.CONTENT_KEY.value: "123456",
Field.VECTOR.value: [2, 2],
Field.METADATA_KEY.value: {},
},
"_score": 0.9,
},
{
"_source": {
Field.CONTENT_KEY.value: "a1b2c3",
Field.VECTOR.value: [3, 2],
Field.METADATA_KEY.value: {},
},
"_score": 0.8,
},
]
},
}
MOCK = os.getenv("MOCK_SWITCH", "false").lower() == "true"
@pytest.fixture
def setup_client_mock(request, monkeypatch: MonkeyPatch):
if MOCK:
monkeypatch.setattr(Elasticsearch, "__init__", MockClient.__init__)
monkeypatch.setattr(Elasticsearch, "index", MockClient.index)
monkeypatch.setattr(Elasticsearch, "exists", MockClient.exists)
monkeypatch.setattr(Elasticsearch, "delete", MockClient.delete)
monkeypatch.setattr(Elasticsearch, "search", MockClient.search)
yield
if MOCK:
monkeypatch.undo()

@ -0,0 +1,28 @@
from core.rag.datasource.vdb.huawei.huawei_cloud_vector import HuaweiCloudVector, HuaweiCloudVectorConfig
from tests.integration_tests.vdb.__mock.huaweicloudvectordb import setup_client_mock
from tests.integration_tests.vdb.test_vector_store import AbstractVectorTest, get_example_text, setup_mock_redis
class HuaweiCloudVectorTest(AbstractVectorTest):
def __init__(self):
super().__init__()
self.vector = HuaweiCloudVector(
"dify",
HuaweiCloudVectorConfig(
hosts="https://127.0.0.1:9200",
username="dify",
password="dify",
),
)
def search_by_vector(self):
hits_by_vector = self.vector.search_by_vector(query_vector=self.example_embedding)
assert len(hits_by_vector) == 3
def search_by_full_text(self):
hits_by_full_text = self.vector.search_by_full_text(query=get_example_text())
assert len(hits_by_full_text) == 3
def test_huawei_cloud_vector(setup_mock_redis, setup_client_mock):
HuaweiCloudVectorTest().run_all_tests()

@ -0,0 +1,27 @@
from core.model_runtime.entities.message_entities import (
ImagePromptMessageContent,
TextPromptMessageContent,
UserPromptMessage,
)
def test_build_prompt_message_with_prompt_message_contents():
prompt = UserPromptMessage(content=[TextPromptMessageContent(data="Hello, World!")])
assert isinstance(prompt.content, list)
assert isinstance(prompt.content[0], TextPromptMessageContent)
assert prompt.content[0].data == "Hello, World!"
def test_dump_prompt_message():
example_url = "https://example.com/image.jpg"
prompt = UserPromptMessage(
content=[
ImagePromptMessageContent(
url=example_url,
format="jpeg",
mime_type="image/jpeg",
)
]
)
data = prompt.model_dump()
assert data["content"][0].get("url") == example_url

@ -15,3 +15,4 @@ pytest api/tests/integration_tests/vdb/chroma \
api/tests/integration_tests/vdb/couchbase \ api/tests/integration_tests/vdb/couchbase \
api/tests/integration_tests/vdb/oceanbase \ api/tests/integration_tests/vdb/oceanbase \
api/tests/integration_tests/vdb/tidb_vector \ api/tests/integration_tests/vdb/tidb_vector \
api/tests/integration_tests/vdb/huawei \

@ -574,6 +574,11 @@ OPENGAUSS_MIN_CONNECTION=1
OPENGAUSS_MAX_CONNECTION=5 OPENGAUSS_MAX_CONNECTION=5
OPENGAUSS_ENABLE_PQ=false OPENGAUSS_ENABLE_PQ=false
# huawei cloud search service vector configurations, only available when VECTOR_STORE is `huawei_cloud`
HUAWEI_CLOUD_HOSTS=https://127.0.0.1:9200
HUAWEI_CLOUD_USER=admin
HUAWEI_CLOUD_PASSWORD=admin
# Upstash Vector configuration, only available when VECTOR_STORE is `upstash` # Upstash Vector configuration, only available when VECTOR_STORE is `upstash`
UPSTASH_VECTOR_URL=https://xxx-vector.upstash.io UPSTASH_VECTOR_URL=https://xxx-vector.upstash.io
UPSTASH_VECTOR_TOKEN=dify UPSTASH_VECTOR_TOKEN=dify
@ -1063,3 +1068,6 @@ OTEL_MAX_EXPORT_BATCH_SIZE=512
OTEL_METRIC_EXPORT_INTERVAL=60000 OTEL_METRIC_EXPORT_INTERVAL=60000
OTEL_BATCH_EXPORT_TIMEOUT=10000 OTEL_BATCH_EXPORT_TIMEOUT=10000
OTEL_METRIC_EXPORT_TIMEOUT=30000 OTEL_METRIC_EXPORT_TIMEOUT=30000
# Prevent Clickjacking
ALLOW_EMBED=false

@ -66,6 +66,7 @@ services:
NEXT_TELEMETRY_DISABLED: ${NEXT_TELEMETRY_DISABLED:-0} NEXT_TELEMETRY_DISABLED: ${NEXT_TELEMETRY_DISABLED:-0}
TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000} TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000}
CSP_WHITELIST: ${CSP_WHITELIST:-} CSP_WHITELIST: ${CSP_WHITELIST:-}
ALLOW_EMBED: ${ALLOW_EMBED:-false}
MARKETPLACE_API_URL: ${MARKETPLACE_API_URL:-https://marketplace.dify.ai} MARKETPLACE_API_URL: ${MARKETPLACE_API_URL:-https://marketplace.dify.ai}
MARKETPLACE_URL: ${MARKETPLACE_URL:-https://marketplace.dify.ai} MARKETPLACE_URL: ${MARKETPLACE_URL:-https://marketplace.dify.ai}
TOP_K_MAX_VALUE: ${TOP_K_MAX_VALUE:-} TOP_K_MAX_VALUE: ${TOP_K_MAX_VALUE:-}
@ -552,7 +553,7 @@ services:
volumes: volumes:
- ./volumes/opengauss/data:/var/lib/opengauss/data - ./volumes/opengauss/data:/var/lib/opengauss/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "netstat -lntp | grep tcp6 > /dev/null 2>&1"] test: [ "CMD-SHELL", "netstat -lntp | grep tcp6 > /dev/null 2>&1" ]
interval: 10s interval: 10s
timeout: 10s timeout: 10s
retries: 10 retries: 10

@ -266,6 +266,9 @@ x-shared-env: &shared-api-worker-env
OPENGAUSS_MIN_CONNECTION: ${OPENGAUSS_MIN_CONNECTION:-1} OPENGAUSS_MIN_CONNECTION: ${OPENGAUSS_MIN_CONNECTION:-1}
OPENGAUSS_MAX_CONNECTION: ${OPENGAUSS_MAX_CONNECTION:-5} OPENGAUSS_MAX_CONNECTION: ${OPENGAUSS_MAX_CONNECTION:-5}
OPENGAUSS_ENABLE_PQ: ${OPENGAUSS_ENABLE_PQ:-false} OPENGAUSS_ENABLE_PQ: ${OPENGAUSS_ENABLE_PQ:-false}
HUAWEI_CLOUD_HOSTS: ${HUAWEI_CLOUD_HOSTS:-https://127.0.0.1:9200}
HUAWEI_CLOUD_USER: ${HUAWEI_CLOUD_USER:-admin}
HUAWEI_CLOUD_PASSWORD: ${HUAWEI_CLOUD_PASSWORD:-admin}
UPSTASH_VECTOR_URL: ${UPSTASH_VECTOR_URL:-https://xxx-vector.upstash.io} UPSTASH_VECTOR_URL: ${UPSTASH_VECTOR_URL:-https://xxx-vector.upstash.io}
UPSTASH_VECTOR_TOKEN: ${UPSTASH_VECTOR_TOKEN:-dify} UPSTASH_VECTOR_TOKEN: ${UPSTASH_VECTOR_TOKEN:-dify}
TABLESTORE_ENDPOINT: ${TABLESTORE_ENDPOINT:-https://instance-name.cn-hangzhou.ots.aliyuncs.com} TABLESTORE_ENDPOINT: ${TABLESTORE_ENDPOINT:-https://instance-name.cn-hangzhou.ots.aliyuncs.com}
@ -471,6 +474,7 @@ x-shared-env: &shared-api-worker-env
OTEL_METRIC_EXPORT_INTERVAL: ${OTEL_METRIC_EXPORT_INTERVAL:-60000} OTEL_METRIC_EXPORT_INTERVAL: ${OTEL_METRIC_EXPORT_INTERVAL:-60000}
OTEL_BATCH_EXPORT_TIMEOUT: ${OTEL_BATCH_EXPORT_TIMEOUT:-10000} OTEL_BATCH_EXPORT_TIMEOUT: ${OTEL_BATCH_EXPORT_TIMEOUT:-10000}
OTEL_METRIC_EXPORT_TIMEOUT: ${OTEL_METRIC_EXPORT_TIMEOUT:-30000} OTEL_METRIC_EXPORT_TIMEOUT: ${OTEL_METRIC_EXPORT_TIMEOUT:-30000}
ALLOW_EMBED: ${ALLOW_EMBED:-false}
services: services:
# API service # API service
@ -539,6 +543,7 @@ services:
NEXT_TELEMETRY_DISABLED: ${NEXT_TELEMETRY_DISABLED:-0} NEXT_TELEMETRY_DISABLED: ${NEXT_TELEMETRY_DISABLED:-0}
TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000} TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000}
CSP_WHITELIST: ${CSP_WHITELIST:-} CSP_WHITELIST: ${CSP_WHITELIST:-}
ALLOW_EMBED: ${ALLOW_EMBED:-false}
MARKETPLACE_API_URL: ${MARKETPLACE_API_URL:-https://marketplace.dify.ai} MARKETPLACE_API_URL: ${MARKETPLACE_API_URL:-https://marketplace.dify.ai}
MARKETPLACE_URL: ${MARKETPLACE_URL:-https://marketplace.dify.ai} MARKETPLACE_URL: ${MARKETPLACE_URL:-https://marketplace.dify.ai}
TOP_K_MAX_VALUE: ${TOP_K_MAX_VALUE:-} TOP_K_MAX_VALUE: ${TOP_K_MAX_VALUE:-}
@ -1025,7 +1030,7 @@ services:
volumes: volumes:
- ./volumes/opengauss/data:/var/lib/opengauss/data - ./volumes/opengauss/data:/var/lib/opengauss/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "netstat -lntp | grep tcp6 > /dev/null 2>&1"] test: [ "CMD-SHELL", "netstat -lntp | grep tcp6 > /dev/null 2>&1" ]
interval: 10s interval: 10s
timeout: 10s timeout: 10s
retries: 10 retries: 10

@ -0,0 +1 @@
/vendor

@ -9,6 +9,21 @@ This is the PHP SDK for the Dify API, which allows you to easily integrate Dify
## Usage ## Usage
If you want to try the example, you can run `composer install` in this directory.
In exist project, copy the `dify-client.php` to you project, and merge the following to your `composer.json` file, then run `composer install && composer dump-autoload` to install. Guzzle does not require 7.9, other versions have not been tested, but you can try.
```json
{
"require": {
"guzzlehttp/guzzle": "^7.9"
},
"autoload": {
"files": ["path/to/dify-client.php"]
}
}
```
After installing the SDK, you can use it in your project like this: After installing the SDK, you can use it in your project like this:
```php ```php
@ -16,10 +31,6 @@ After installing the SDK, you can use it in your project like this:
require 'vendor/autoload.php'; require 'vendor/autoload.php';
use YourVendorName\DifyPHP\DifyClient;
use YourVendorName\DifyPHP\CompletionClient;
use YourVendorName\DifyPHP\ChatClient;
$apiKey = 'your-api-key-here'; $apiKey = 'your-api-key-here';
$difyClient = new DifyClient($apiKey); $difyClient = new DifyClient($apiKey);

@ -0,0 +1,9 @@
{
"require": {
"php": ">=7.2",
"guzzlehttp/guzzle": "^7.9"
},
"autoload": {
"files": ["dify-client.php"]
}
}

@ -0,0 +1,663 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "7827c548fdcc7e87cb0ae341dd2c6b1b",
"packages": [
{
"name": "guzzlehttp/guzzle",
"version": "7.9.2",
"source": {
"type": "git",
"url": "https://github.com/guzzle/guzzle.git",
"reference": "d281ed313b989f213357e3be1a179f02196ac99b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/d281ed313b989f213357e3be1a179f02196ac99b",
"reference": "d281ed313b989f213357e3be1a179f02196ac99b",
"shasum": "",
"mirrors": [
{
"url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%",
"preferred": true
}
]
},
"require": {
"ext-json": "*",
"guzzlehttp/promises": "^1.5.3 || ^2.0.3",
"guzzlehttp/psr7": "^2.7.0",
"php": "^7.2.5 || ^8.0",
"psr/http-client": "^1.0",
"symfony/deprecation-contracts": "^2.2 || ^3.0"
},
"provide": {
"psr/http-client-implementation": "1.0"
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
"ext-curl": "*",
"guzzle/client-integration-tests": "3.0.2",
"php-http/message-factory": "^1.1",
"phpunit/phpunit": "^8.5.39 || ^9.6.20",
"psr/log": "^1.1 || ^2.0 || ^3.0"
},
"suggest": {
"ext-curl": "Required for CURL handler support",
"ext-intl": "Required for Internationalized Domain Name (IDN) support",
"psr/log": "Required for using the Log middleware"
},
"type": "library",
"extra": {
"bamarni-bin": {
"bin-links": true,
"forward-command": false
}
},
"autoload": {
"files": [
"src/functions_include.php"
],
"psr-4": {
"GuzzleHttp\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Graham Campbell",
"email": "hello@gjcampbell.co.uk",
"homepage": "https://github.com/GrahamCampbell"
},
{
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
},
{
"name": "Jeremy Lindblom",
"email": "jeremeamia@gmail.com",
"homepage": "https://github.com/jeremeamia"
},
{
"name": "George Mponos",
"email": "gmponos@gmail.com",
"homepage": "https://github.com/gmponos"
},
{
"name": "Tobias Nyholm",
"email": "tobias.nyholm@gmail.com",
"homepage": "https://github.com/Nyholm"
},
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com",
"homepage": "https://github.com/sagikazarmark"
},
{
"name": "Tobias Schultze",
"email": "webmaster@tubo-world.de",
"homepage": "https://github.com/Tobion"
}
],
"description": "Guzzle is a PHP HTTP client library",
"keywords": [
"client",
"curl",
"framework",
"http",
"http client",
"psr-18",
"psr-7",
"rest",
"web service"
],
"support": {
"issues": "https://github.com/guzzle/guzzle/issues",
"source": "https://github.com/guzzle/guzzle/tree/7.9.2"
},
"funding": [
{
"url": "https://github.com/GrahamCampbell",
"type": "github"
},
{
"url": "https://github.com/Nyholm",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle",
"type": "tidelift"
}
],
"time": "2024-07-24T11:22:20+00:00"
},
{
"name": "guzzlehttp/promises",
"version": "2.2.0",
"source": {
"type": "git",
"url": "https://github.com/guzzle/promises.git",
"reference": "7c69f28996b0a6920945dd20b3857e499d9ca96c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/promises/zipball/7c69f28996b0a6920945dd20b3857e499d9ca96c",
"reference": "7c69f28996b0a6920945dd20b3857e499d9ca96c",
"shasum": "",
"mirrors": [
{
"url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%",
"preferred": true
}
]
},
"require": {
"php": "^7.2.5 || ^8.0"
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
"phpunit/phpunit": "^8.5.39 || ^9.6.20"
},
"type": "library",
"extra": {
"bamarni-bin": {
"bin-links": true,
"forward-command": false
}
},
"autoload": {
"psr-4": {
"GuzzleHttp\\Promise\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Graham Campbell",
"email": "hello@gjcampbell.co.uk",
"homepage": "https://github.com/GrahamCampbell"
},
{
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
},
{
"name": "Tobias Nyholm",
"email": "tobias.nyholm@gmail.com",
"homepage": "https://github.com/Nyholm"
},
{
"name": "Tobias Schultze",
"email": "webmaster@tubo-world.de",
"homepage": "https://github.com/Tobion"
}
],
"description": "Guzzle promises library",
"keywords": [
"promise"
],
"support": {
"issues": "https://github.com/guzzle/promises/issues",
"source": "https://github.com/guzzle/promises/tree/2.2.0"
},
"funding": [
{
"url": "https://github.com/GrahamCampbell",
"type": "github"
},
{
"url": "https://github.com/Nyholm",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises",
"type": "tidelift"
}
],
"time": "2025-03-27T13:27:01+00:00"
},
{
"name": "guzzlehttp/psr7",
"version": "2.7.1",
"source": {
"type": "git",
"url": "https://github.com/guzzle/psr7.git",
"reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/psr7/zipball/c2270caaabe631b3b44c85f99e5a04bbb8060d16",
"reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16",
"shasum": "",
"mirrors": [
{
"url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%",
"preferred": true
}
]
},
"require": {
"php": "^7.2.5 || ^8.0",
"psr/http-factory": "^1.0",
"psr/http-message": "^1.1 || ^2.0",
"ralouphie/getallheaders": "^3.0"
},
"provide": {
"psr/http-factory-implementation": "1.0",
"psr/http-message-implementation": "1.0"
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
"http-interop/http-factory-tests": "0.9.0",
"phpunit/phpunit": "^8.5.39 || ^9.6.20"
},
"suggest": {
"laminas/laminas-httphandlerrunner": "Emit PSR-7 responses"
},
"type": "library",
"extra": {
"bamarni-bin": {
"bin-links": true,
"forward-command": false
}
},
"autoload": {
"psr-4": {
"GuzzleHttp\\Psr7\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Graham Campbell",
"email": "hello@gjcampbell.co.uk",
"homepage": "https://github.com/GrahamCampbell"
},
{
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
},
{
"name": "George Mponos",
"email": "gmponos@gmail.com",
"homepage": "https://github.com/gmponos"
},
{
"name": "Tobias Nyholm",
"email": "tobias.nyholm@gmail.com",
"homepage": "https://github.com/Nyholm"
},
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com",
"homepage": "https://github.com/sagikazarmark"
},
{
"name": "Tobias Schultze",
"email": "webmaster@tubo-world.de",
"homepage": "https://github.com/Tobion"
},
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com",
"homepage": "https://sagikazarmark.hu"
}
],
"description": "PSR-7 message implementation that also provides common utility methods",
"keywords": [
"http",
"message",
"psr-7",
"request",
"response",
"stream",
"uri",
"url"
],
"support": {
"issues": "https://github.com/guzzle/psr7/issues",
"source": "https://github.com/guzzle/psr7/tree/2.7.1"
},
"funding": [
{
"url": "https://github.com/GrahamCampbell",
"type": "github"
},
{
"url": "https://github.com/Nyholm",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7",
"type": "tidelift"
}
],
"time": "2025-03-27T12:30:47+00:00"
},
{
"name": "psr/http-client",
"version": "1.0.3",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-client.git",
"reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90",
"reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90",
"shasum": "",
"mirrors": [
{
"url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%",
"preferred": true
}
]
},
"require": {
"php": "^7.0 || ^8.0",
"psr/http-message": "^1.0 || ^2.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Http\\Client\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for HTTP clients",
"homepage": "https://github.com/php-fig/http-client",
"keywords": [
"http",
"http-client",
"psr",
"psr-18"
],
"support": {
"source": "https://github.com/php-fig/http-client"
},
"time": "2023-09-23T14:17:50+00:00"
},
{
"name": "psr/http-factory",
"version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-factory.git",
"reference": "e616d01114759c4c489f93b099585439f795fe35"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/http-factory/zipball/e616d01114759c4c489f93b099585439f795fe35",
"reference": "e616d01114759c4c489f93b099585439f795fe35",
"shasum": "",
"mirrors": [
{
"url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%",
"preferred": true
}
]
},
"require": {
"php": ">=7.0.0",
"psr/http-message": "^1.0 || ^2.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Http\\Message\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interfaces for PSR-7 HTTP message factories",
"keywords": [
"factory",
"http",
"message",
"psr",
"psr-17",
"psr-7",
"request",
"response"
],
"support": {
"source": "https://github.com/php-fig/http-factory/tree/1.0.2"
},
"time": "2023-04-10T20:10:41+00:00"
},
{
"name": "psr/http-message",
"version": "2.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-message.git",
"reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71",
"reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71",
"shasum": "",
"mirrors": [
{
"url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%",
"preferred": true
}
]
},
"require": {
"php": "^7.2 || ^8.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Http\\Message\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for HTTP messages",
"homepage": "https://github.com/php-fig/http-message",
"keywords": [
"http",
"http-message",
"psr",
"psr-7",
"request",
"response"
],
"support": {
"source": "https://github.com/php-fig/http-message/tree/2.0"
},
"time": "2023-04-04T09:54:51+00:00"
},
{
"name": "ralouphie/getallheaders",
"version": "3.0.3",
"source": {
"type": "git",
"url": "https://github.com/ralouphie/getallheaders.git",
"reference": "120b605dfeb996808c31b6477290a714d356e822"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822",
"reference": "120b605dfeb996808c31b6477290a714d356e822",
"shasum": "",
"mirrors": [
{
"url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%",
"preferred": true
}
]
},
"require": {
"php": ">=5.6"
},
"require-dev": {
"php-coveralls/php-coveralls": "^2.1",
"phpunit/phpunit": "^5 || ^6.5"
},
"type": "library",
"autoload": {
"files": [
"src/getallheaders.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Ralph Khattar",
"email": "ralph.khattar@gmail.com"
}
],
"description": "A polyfill for getallheaders.",
"support": {
"issues": "https://github.com/ralouphie/getallheaders/issues",
"source": "https://github.com/ralouphie/getallheaders/tree/develop"
},
"time": "2019-03-08T08:55:37+00:00"
},
{
"name": "symfony/deprecation-contracts",
"version": "v3.5.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/deprecation-contracts.git",
"reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6",
"reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6",
"shasum": "",
"mirrors": [
{
"url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%",
"preferred": true
}
]
},
"require": {
"php": ">=8.1"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "3.5-dev"
},
"thanks": {
"name": "symfony/contracts",
"url": "https://github.com/symfony/contracts"
}
},
"autoload": {
"files": [
"function.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "A generic function and convention to trigger deprecation notices",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.1"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-09-25T14:20:29+00:00"
}
],
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": [],
"prefer-stable": false,
"prefer-lowest": false,
"platform": [],
"platform-dev": [],
"plugin-api-version": "2.6.0"
}

@ -1,7 +1,5 @@
<?php <?php
require 'vendor/autoload.php';
use GuzzleHttp\Client; use GuzzleHttp\Client;
class DifyClient { class DifyClient {
@ -11,7 +9,7 @@ class DifyClient {
public function __construct($api_key, $base_url = null) { public function __construct($api_key, $base_url = null) {
$this->api_key = $api_key; $this->api_key = $api_key;
$this->base_url = $base_url ?? "https://api.dify.ai/v1/"; $this->base_url = $base_url ?? 'https://api.dify.ai/v1/';
$this->client = new Client([ $this->client = new Client([
'base_uri' => $this->base_url, 'base_uri' => $this->base_url,
'headers' => [ 'headers' => [
@ -19,13 +17,6 @@ class DifyClient {
'Content-Type' => 'application/json', 'Content-Type' => 'application/json',
], ],
]); ]);
$this->file_client = new Client([
'base_uri' => $this->base_url,
'headers' => [
'Authorization' => 'Bearer ' . $this->api_key,
'Content-Type' => 'multipart/form-data',
],
]);
} }
protected function send_request($method, $endpoint, $data = null, $params = null, $stream = false) { protected function send_request($method, $endpoint, $data = null, $params = null, $stream = false) {
@ -58,7 +49,7 @@ class DifyClient {
'multipart' => $this->prepareMultipart($data, $files) 'multipart' => $this->prepareMultipart($data, $files)
]; ];
return $this->file_client->request('POST', 'files/upload', $options); return $this->client->request('POST', 'files/upload', $options);
} }
protected function prepareMultipart($data, $files) { protected function prepareMultipart($data, $files) {
@ -132,7 +123,7 @@ class ChatClient extends DifyClient {
public function get_suggestions($message_id, $user) { public function get_suggestions($message_id, $user) {
$params = [ $params = [
'user' => $user 'user' => $user
] ];
return $this->send_request('GET', "messages/{$message_id}/suggested", null, $params); return $this->send_request('GET', "messages/{$message_id}/suggested", null, $params);
} }
@ -188,10 +179,9 @@ class ChatClient extends DifyClient {
'user' => $user, 'user' => $user,
]; ];
$options = [ $options = [
'multipart' => $this->prepareMultipart($data, $files) 'multipart' => $this->prepareMultipart($data, $audio_file)
]; ];
return $this->file_client->request('POST', 'audio-to-text', $options); return $this->client->request('POST', 'audio-to-text', $options);
} }
} }

@ -29,6 +29,8 @@ NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS=60000
# CSP https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP # CSP https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP
NEXT_PUBLIC_CSP_WHITELIST= NEXT_PUBLIC_CSP_WHITELIST=
# Default is not allow to embed into iframe to prevent Clickjacking: https://owasp.org/www-community/attacks/Clickjacking
NEXT_PUBLIC_ALLOW_EMBED=
# Github Access Token, used for invoking Github API # Github Access Token, used for invoking Github API
NEXT_PUBLIC_GITHUB_ACCESS_TOKEN= NEXT_PUBLIC_GITHUB_ACCESS_TOKEN=

@ -62,13 +62,13 @@ const SettingsModal: FC<SettingsModalProps> = ({
const { notify } = useToastContext() const { notify } = useToastContext()
const ref = useRef(null) const ref = useRef(null)
const isExternal = currentDataset.provider === 'external' const isExternal = currentDataset.provider === 'external'
const [topK, setTopK] = useState(currentDataset?.external_retrieval_model.top_k ?? 2)
const [scoreThreshold, setScoreThreshold] = useState(currentDataset?.external_retrieval_model.score_threshold ?? 0.5)
const [scoreThresholdEnabled, setScoreThresholdEnabled] = useState(currentDataset?.external_retrieval_model.score_threshold_enabled ?? false)
const { setShowAccountSettingModal } = useModalContext() const { setShowAccountSettingModal } = useModalContext()
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const { isCurrentWorkspaceDatasetOperator } = useAppContext() const { isCurrentWorkspaceDatasetOperator } = useAppContext()
const [localeCurrentDataset, setLocaleCurrentDataset] = useState({ ...currentDataset }) const [localeCurrentDataset, setLocaleCurrentDataset] = useState({ ...currentDataset })
const [topK, setTopK] = useState(localeCurrentDataset?.external_retrieval_model.top_k ?? 2)
const [scoreThreshold, setScoreThreshold] = useState(localeCurrentDataset?.external_retrieval_model.score_threshold ?? 0.5)
const [scoreThresholdEnabled, setScoreThresholdEnabled] = useState(localeCurrentDataset?.external_retrieval_model.score_threshold_enabled ?? false)
const [selectedMemberIDs, setSelectedMemberIDs] = useState<string[]>(currentDataset.partial_member_list || []) const [selectedMemberIDs, setSelectedMemberIDs] = useState<string[]>(currentDataset.partial_member_list || [])
const [memberList, setMemberList] = useState<Member[]>([]) const [memberList, setMemberList] = useState<Member[]>([])
@ -88,6 +88,14 @@ const SettingsModal: FC<SettingsModalProps> = ({
setScoreThreshold(data.score_threshold) setScoreThreshold(data.score_threshold)
if (data.score_threshold_enabled !== undefined) if (data.score_threshold_enabled !== undefined)
setScoreThresholdEnabled(data.score_threshold_enabled) setScoreThresholdEnabled(data.score_threshold_enabled)
setLocaleCurrentDataset({
...localeCurrentDataset,
external_retrieval_model: {
...localeCurrentDataset?.external_retrieval_model,
...data,
},
})
} }
const handleSave = async () => { const handleSave = async () => {

@ -29,7 +29,7 @@ const OPTION_MAP = {
iframe: { iframe: {
getContent: (url: string, token: string) => getContent: (url: string, token: string) =>
`<iframe `<iframe
src="${url}${basePath}/chatbot/${token}" src="${url}${basePath}/chat/${token}"
style="width: 100%; height: 100%; min-height: 700px" style="width: 100%; height: 100%; min-height: 700px"
frameborder="0" frameborder="0"
allow="microphone"> allow="microphone">
@ -42,10 +42,10 @@ const OPTION_MAP = {
token: '${token}'${isTestEnv token: '${token}'${isTestEnv
? `, ? `,
isDev: true` isDev: true`
: ''}${IS_CE_EDITION : ''}${IS_CE_EDITION
? `, ? `,
baseUrl: '${url}${basePath}'` baseUrl: '${url}${basePath}'`
: ''}, : ''},
systemVariables: { systemVariables: {
// user_id: 'YOU CAN DEFINE USER ID HERE', // user_id: 'YOU CAN DEFINE USER ID HERE',
}, },

@ -80,8 +80,30 @@ export const useEmbeddedChatbot = () => {
}, []) }, [])
useEffect(() => { useEffect(() => {
if (appInfo?.site.default_language) const setLanguageFromParams = async () => {
changeLanguage(appInfo.site.default_language) // Check URL parameters for language override
const urlParams = new URLSearchParams(window.location.search)
const localeParam = urlParams.get('locale')
// Check for encoded system variables
const systemVariables = await getProcessedSystemVariablesFromUrlParams()
const localeFromSysVar = systemVariables.locale
if (localeParam) {
// If locale parameter exists in URL, use it instead of default
changeLanguage(localeParam)
}
else if (localeFromSysVar) {
// If locale is set as a system variable, use that
changeLanguage(localeFromSysVar)
}
else if (appInfo?.site.default_language) {
// Otherwise use the default from app config
changeLanguage(appInfo.site.default_language)
}
}
setLanguageFromParams()
}, [appInfo]) }, [appInfo])
const [conversationIdInfo, setConversationIdInfo] = useLocalStorageState<Record<string, Record<string, string>>>(CONVERSATION_ID_INFO, { const [conversationIdInfo, setConversationIdInfo] = useLocalStorageState<Record<string, Record<string, string>>>(CONVERSATION_ID_INFO, {

@ -1,116 +1,528 @@
import React, { useCallback, useEffect, useRef, useState } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import mermaid from 'mermaid' import mermaid from 'mermaid'
import { usePrevious } from 'ahooks'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline' import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'
import { cleanUpSvgCode } from './utils' import { MoonIcon, SunIcon } from '@heroicons/react/24/solid'
import {
cleanUpSvgCode,
isMermaidCodeComplete,
prepareMermaidCode,
processSvgForTheme,
svgToBase64,
waitForDOMElement,
} from './utils'
import LoadingAnim from '@/app/components/base/chat/chat/loading-anim' import LoadingAnim from '@/app/components/base/chat/chat/loading-anim'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
import ImagePreview from '@/app/components/base/image-uploader/image-preview' import ImagePreview from '@/app/components/base/image-uploader/image-preview'
import { Theme } from '@/types/app'
let mermaidAPI: any // Global flags and cache for mermaid
mermaidAPI = null let isMermaidInitialized = false
const diagramCache = new Map<string, string>()
let mermaidAPI: any = null
if (typeof window !== 'undefined') if (typeof window !== 'undefined')
mermaidAPI = mermaid.mermaidAPI mermaidAPI = mermaid.mermaidAPI
const svgToBase64 = (svgGraph: string) => { // Theme configurations
const svgBytes = new TextEncoder().encode(svgGraph) const THEMES = {
const blob = new Blob([svgBytes], { type: 'image/svg+xml;charset=utf-8' }) light: {
return new Promise((resolve, reject) => { name: 'Light Theme',
const reader = new FileReader() background: '#ffffff',
reader.onloadend = () => resolve(reader.result) primaryColor: '#ffffff',
reader.onerror = reject primaryBorderColor: '#000000',
reader.readAsDataURL(blob) primaryTextColor: '#000000',
}) secondaryColor: '#ffffff',
tertiaryColor: '#ffffff',
nodeColors: [
{ bg: '#f0f9ff', color: '#0369a1' },
{ bg: '#f0fdf4', color: '#166534' },
{ bg: '#fef2f2', color: '#b91c1c' },
{ bg: '#faf5ff', color: '#7e22ce' },
{ bg: '#fffbeb', color: '#b45309' },
],
connectionColor: '#74a0e0',
},
dark: {
name: 'Dark Theme',
background: '#1e293b',
primaryColor: '#334155',
primaryBorderColor: '#94a3b8',
primaryTextColor: '#e2e8f0',
secondaryColor: '#475569',
tertiaryColor: '#334155',
nodeColors: [
{ bg: '#164e63', color: '#e0f2fe' },
{ bg: '#14532d', color: '#dcfce7' },
{ bg: '#7f1d1d', color: '#fee2e2' },
{ bg: '#581c87', color: '#f3e8ff' },
{ bg: '#78350f', color: '#fef3c7' },
],
connectionColor: '#60a5fa',
},
} }
const Flowchart = ( /**
{ * Initializes mermaid library with default configuration
ref, */
...props const initMermaid = () => {
}: { if (typeof window !== 'undefined' && !isMermaidInitialized) {
PrimitiveCode: string try {
} & { mermaid.initialize({
ref: React.RefObject<unknown>; startOnLoad: false,
}, fontFamily: 'sans-serif',
) => { securityLevel: 'loose',
flowchart: {
htmlLabels: true,
useMaxWidth: true,
diagramPadding: 10,
curve: 'basis',
nodeSpacing: 50,
rankSpacing: 70,
},
gantt: {
titleTopMargin: 25,
barHeight: 20,
barGap: 4,
topPadding: 50,
leftPadding: 75,
gridLineStartPadding: 35,
fontSize: 11,
numberSectionStyles: 4,
axisFormat: '%Y-%m-%d',
},
maxTextSize: 50000,
})
isMermaidInitialized = true
}
catch (error) {
console.error('Mermaid initialization error:', error)
return null
}
}
return isMermaidInitialized
}
const Flowchart = React.forwardRef((props: {
PrimitiveCode: string
theme?: 'light' | 'dark'
}, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const [svgCode, setSvgCode] = useState(null) const [svgCode, setSvgCode] = useState<string | null>(null)
const [look, setLook] = useState<'classic' | 'handDrawn'>('classic') const [look, setLook] = useState<'classic' | 'handDrawn'>('classic')
const [isInitialized, setIsInitialized] = useState(false)
const prevPrimitiveCode = usePrevious(props.PrimitiveCode) const [currentTheme, setCurrentTheme] = useState<'light' | 'dark'>(props.theme || 'light')
const containerRef = useRef<HTMLDivElement>(null)
const chartId = useRef(`mermaid-chart-${Math.random().toString(36).substr(2, 9)}`).current
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const timeRef = useRef<number>(0) const renderTimeoutRef = useRef<NodeJS.Timeout>()
const [errMsg, setErrMsg] = useState('') const [errMsg, setErrMsg] = useState('')
const [imagePreviewUrl, setImagePreviewUrl] = useState('') const [imagePreviewUrl, setImagePreviewUrl] = useState('')
const [isCodeComplete, setIsCodeComplete] = useState(false)
const codeCompletionCheckRef = useRef<NodeJS.Timeout>()
// Create cache key from code, style and theme
const cacheKey = useMemo(() => {
return `${props.PrimitiveCode}-${look}-${currentTheme}`
}, [props.PrimitiveCode, look, currentTheme])
/**
* Renders Mermaid chart
*/
const renderMermaidChart = async (code: string, style: 'classic' | 'handDrawn') => {
if (style === 'handDrawn') {
// Special handling for hand-drawn style
if (containerRef.current)
containerRef.current.innerHTML = `<div id="${chartId}"></div>`
await new Promise(resolve => setTimeout(resolve, 30))
if (typeof window !== 'undefined' && mermaidAPI) {
// Prefer using mermaidAPI directly for hand-drawn style
return await mermaidAPI.render(chartId, code)
}
else {
// Fall back to standard rendering if mermaidAPI is not available
const { svg } = await mermaid.render(chartId, code)
return { svg }
}
}
else {
// Standard rendering for classic style - using the extracted waitForDOMElement function
const renderWithRetry = async () => {
if (containerRef.current)
containerRef.current.innerHTML = `<div id="${chartId}"></div>`
await new Promise(resolve => setTimeout(resolve, 30))
const { svg } = await mermaid.render(chartId, code)
return { svg }
}
return await waitForDOMElement(renderWithRetry)
}
}
/**
* Handle rendering errors
*/
const handleRenderError = (error: any) => {
console.error('Mermaid rendering error:', error)
const errorMsg = (error as Error).message
if (errorMsg.includes('getAttribute')) {
diagramCache.clear()
mermaid.initialize({
startOnLoad: false,
securityLevel: 'loose',
})
}
else {
setErrMsg(`Rendering chart failed, please refresh and try again ${look === 'handDrawn' ? 'Or try using classic mode' : ''}`)
}
if (look === 'handDrawn') {
try {
// Clear possible cache issues
diagramCache.delete(`${props.PrimitiveCode}-handDrawn-${currentTheme}`)
// Reset mermaid configuration
mermaid.initialize({
startOnLoad: false,
securityLevel: 'loose',
theme: 'default',
maxTextSize: 50000,
})
// Try rendering with standard mode
setLook('classic')
setErrMsg('Hand-drawn mode is not supported for this diagram. Switched to classic mode.')
// Delay error clearing
setTimeout(() => {
if (containerRef.current) {
// Try rendering again with standard mode, but can't call renderFlowchart directly due to circular dependency
// Instead set state to trigger re-render
setIsCodeComplete(true) // This will trigger useEffect re-render
}
}, 500)
}
catch (e) {
console.error('Reset after handDrawn error failed:', e)
}
}
setIsLoading(false)
}
// Initialize mermaid
useEffect(() => {
const api = initMermaid()
if (api)
setIsInitialized(true)
}, [])
// Update theme when prop changes
useEffect(() => {
if (props.theme)
setCurrentTheme(props.theme)
}, [props.theme])
// Validate mermaid code and check for completeness
useEffect(() => {
if (codeCompletionCheckRef.current)
clearTimeout(codeCompletionCheckRef.current)
// Reset code complete status when code changes
setIsCodeComplete(false)
// If no code or code is extremely short, don't proceed
if (!props.PrimitiveCode || props.PrimitiveCode.length < 10)
return
// Check if code already in cache - if so we know it's valid
if (diagramCache.has(cacheKey)) {
setIsCodeComplete(true)
return
}
// Initial check using the extracted isMermaidCodeComplete function
const isComplete = isMermaidCodeComplete(props.PrimitiveCode)
if (isComplete) {
setIsCodeComplete(true)
return
}
// Set a delay to check again in case code is still being generated
codeCompletionCheckRef.current = setTimeout(() => {
setIsCodeComplete(isMermaidCodeComplete(props.PrimitiveCode))
}, 300)
return () => {
if (codeCompletionCheckRef.current)
clearTimeout(codeCompletionCheckRef.current)
}
}, [props.PrimitiveCode, cacheKey])
/**
* Renders flowchart based on provided code
*/
const renderFlowchart = useCallback(async (primitiveCode: string) => {
if (!isInitialized || !containerRef.current) {
setIsLoading(false)
setErrMsg(!isInitialized ? 'Mermaid initialization failed' : 'Container element not found')
return
}
// Don't render if code is not complete yet
if (!isCodeComplete) {
setIsLoading(true)
return
}
// Return cached result if available
if (diagramCache.has(cacheKey)) {
setSvgCode(diagramCache.get(cacheKey) || null)
setIsLoading(false)
return
}
const renderFlowchart = useCallback(async (PrimitiveCode: string) => {
setSvgCode(null)
setIsLoading(true) setIsLoading(true)
setErrMsg('')
try { try {
if (typeof window !== 'undefined' && mermaidAPI) { let finalCode: string
const svgGraph = await mermaidAPI.render('flowchart', PrimitiveCode)
const base64Svg: any = await svgToBase64(cleanUpSvgCode(svgGraph.svg)) // Check if it's a gantt chart
const isGanttChart = primitiveCode.trim().startsWith('gantt')
if (isGanttChart) {
// For gantt charts, ensure each task is on its own line
// and preserve exact whitespace/format
finalCode = primitiveCode.trim()
}
else {
// Step 1: Clean and prepare Mermaid code using the extracted prepareMermaidCode function
finalCode = prepareMermaidCode(primitiveCode, look)
}
// Step 2: Render chart
const svgGraph = await renderMermaidChart(finalCode, look)
// Step 3: Apply theme to SVG using the extracted processSvgForTheme function
const processedSvg = processSvgForTheme(
svgGraph.svg,
currentTheme === Theme.dark,
look === 'handDrawn',
THEMES,
)
// Step 4: Clean SVG code and convert to base64 using the extracted functions
const cleanedSvg = cleanUpSvgCode(processedSvg)
const base64Svg = await svgToBase64(cleanedSvg)
if (base64Svg && typeof base64Svg === 'string') {
diagramCache.set(cacheKey, base64Svg)
setSvgCode(base64Svg) setSvgCode(base64Svg)
setIsLoading(false)
} }
setIsLoading(false)
} }
catch (error) { catch (error) {
if (prevPrimitiveCode === props.PrimitiveCode) { // Error handling
setIsLoading(false) handleRenderError(error)
setErrMsg((error as Error).message)
}
} }
}, [props.PrimitiveCode]) }, [chartId, isInitialized, cacheKey, isCodeComplete, look, currentTheme, t])
useEffect(() => { /**
if (typeof window !== 'undefined') { * Configure mermaid based on selected style and theme
mermaid.initialize({ */
startOnLoad: true, const configureMermaid = useCallback(() => {
theme: 'neutral', if (typeof window !== 'undefined' && isInitialized) {
look, const themeVars = THEMES[currentTheme]
flowchart: { const config: any = {
startOnLoad: false,
securityLevel: 'loose',
fontFamily: 'sans-serif',
maxTextSize: 50000,
gantt: {
titleTopMargin: 25,
barHeight: 20,
barGap: 4,
topPadding: 50,
leftPadding: 75,
gridLineStartPadding: 35,
fontSize: 11,
numberSectionStyles: 4,
axisFormat: '%Y-%m-%d',
},
}
if (look === 'classic') {
config.theme = currentTheme === 'dark' ? 'dark' : 'neutral'
config.flowchart = {
htmlLabels: true, htmlLabels: true,
useMaxWidth: true, useMaxWidth: true,
}, diagramPadding: 12,
}) nodeSpacing: 60,
rankSpacing: 80,
curve: 'linear',
ranker: 'tight-tree',
}
}
else {
config.theme = 'default'
config.themeCSS = `
.node rect { fill-opacity: 0.85; }
.edgePath .path { stroke-width: 1.5px; }
.label { font-family: 'sans-serif'; }
.edgeLabel { font-family: 'sans-serif'; }
.cluster rect { rx: 5px; ry: 5px; }
`
config.themeVariables = {
fontSize: '14px',
fontFamily: 'sans-serif',
}
config.flowchart = {
htmlLabels: true,
useMaxWidth: true,
diagramPadding: 10,
nodeSpacing: 40,
rankSpacing: 60,
curve: 'basis',
}
config.themeVariables.primaryBorderColor = currentTheme === 'dark' ? THEMES.dark.connectionColor : THEMES.light.connectionColor
}
renderFlowchart(props.PrimitiveCode) if (currentTheme === 'dark' && !config.themeVariables) {
config.themeVariables = {
background: themeVars.background,
primaryColor: themeVars.primaryColor,
primaryBorderColor: themeVars.primaryBorderColor,
primaryTextColor: themeVars.primaryTextColor,
secondaryColor: themeVars.secondaryColor,
tertiaryColor: themeVars.tertiaryColor,
fontFamily: 'sans-serif',
}
}
try {
mermaid.initialize(config)
return true
}
catch (error) {
console.error('Config error:', error)
return false
}
} }
}, [look]) return false
}, [currentTheme, isInitialized, look])
// Effect for theme and style configuration
useEffect(() => { useEffect(() => {
if (timeRef.current) if (diagramCache.has(cacheKey)) {
window.clearTimeout(timeRef.current) setSvgCode(diagramCache.get(cacheKey) || null)
setIsLoading(false)
return
}
timeRef.current = window.setTimeout(() => { if (configureMermaid() && containerRef.current && isCodeComplete)
renderFlowchart(props.PrimitiveCode) renderFlowchart(props.PrimitiveCode)
}, 300) }, [look, props.PrimitiveCode, renderFlowchart, isInitialized, cacheKey, currentTheme, isCodeComplete, configureMermaid])
}, [props.PrimitiveCode])
// Effect for rendering with debounce
useEffect(() => {
if (diagramCache.has(cacheKey)) {
setSvgCode(diagramCache.get(cacheKey) || null)
setIsLoading(false)
return
}
if (renderTimeoutRef.current)
clearTimeout(renderTimeoutRef.current)
if (isCodeComplete) {
renderTimeoutRef.current = setTimeout(() => {
if (isInitialized)
renderFlowchart(props.PrimitiveCode)
}, 300)
}
else {
setIsLoading(true)
}
return () => {
if (renderTimeoutRef.current)
clearTimeout(renderTimeoutRef.current)
}
}, [props.PrimitiveCode, renderFlowchart, isInitialized, cacheKey, isCodeComplete])
// Cleanup on unmount
useEffect(() => {
return () => {
if (containerRef.current)
containerRef.current.innerHTML = ''
if (renderTimeoutRef.current)
clearTimeout(renderTimeoutRef.current)
if (codeCompletionCheckRef.current)
clearTimeout(codeCompletionCheckRef.current)
}
}, [])
const toggleTheme = () => {
setCurrentTheme(prevTheme => prevTheme === 'light' ? Theme.dark : Theme.light)
diagramCache.clear()
}
// Style classes for theme-dependent elements
const themeClasses = {
container: cn('relative', {
'bg-white': currentTheme === Theme.light,
'bg-slate-900': currentTheme === Theme.dark,
}),
mermaidDiv: cn('mermaid relative h-auto w-full cursor-pointer', {
'bg-white': currentTheme === Theme.light,
'bg-slate-900': currentTheme === Theme.dark,
}),
errorMessage: cn('px-[26px] py-4', {
'text-red-500': currentTheme === Theme.light,
'text-red-400': currentTheme === Theme.dark,
}),
errorIcon: cn('h-6 w-6', {
'text-red-500': currentTheme === Theme.light,
'text-red-400': currentTheme === Theme.dark,
}),
segmented: cn('msh-segmented msh-segmented-sm css-23bs09 css-var-r1', {
'text-gray-700': currentTheme === Theme.light,
'text-gray-300': currentTheme === Theme.dark,
}),
themeToggle: cn('flex h-10 w-10 items-center justify-center rounded-full shadow-md backdrop-blur-sm transition-all duration-300', {
'bg-white/80 hover:bg-white hover:shadow-lg text-gray-700 border border-gray-200': currentTheme === Theme.light,
'bg-slate-800/80 hover:bg-slate-700 hover:shadow-lg text-yellow-300 border border-slate-600': currentTheme === Theme.dark,
}),
}
// Style classes for look options
const getLookButtonClass = (lookType: 'classic' | 'handDrawn') => {
return cn(
'system-sm-medium mb-4 flex h-8 w-[calc((100%-8px)/2)] cursor-pointer items-center justify-center rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg text-text-secondary',
look === lookType && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary',
currentTheme === Theme.dark && 'border-slate-600 bg-slate-800 text-slate-300',
look === lookType && currentTheme === Theme.dark && 'border-blue-500 bg-slate-700 text-white',
)
}
return ( return (
// eslint-disable-next-line ts/ban-ts-comment <div ref={ref as React.RefObject<HTMLDivElement>} className={themeClasses.container}>
// @ts-expect-error <div className={themeClasses.segmented}>
(<div ref={ref}>
<div className="msh-segmented msh-segmented-sm css-23bs09 css-var-r1">
<div className="msh-segmented-group"> <div className="msh-segmented-group">
<label className="msh-segmented-item m-2 flex w-[200px] items-center space-x-1"> <label className="msh-segmented-item m-2 flex w-[200px] items-center space-x-1">
<div key='classic' <div
className={cn('system-sm-medium mb-4 flex h-8 w-[calc((100%-8px)/2)] cursor-pointer items-center justify-center rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg text-text-secondary', key='classic'
look === 'classic' && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary', className={getLookButtonClass('classic')}
)}
onClick={() => setLook('classic')} onClick={() => setLook('classic')}
> >
<div className="msh-segmented-item-label">{t('app.mermaid.classic')}</div> <div className="msh-segmented-item-label">{t('app.mermaid.classic')}</div>
</div> </div>
<div key='handDrawn' <div
className={cn( key='handDrawn'
'system-sm-medium mb-4 flex h-8 w-[calc((100%-8px)/2)] cursor-pointer items-center justify-center rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg text-text-secondary', className={getLookButtonClass('handDrawn')}
look === 'handDrawn' && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary',
)}
onClick={() => setLook('handDrawn')} onClick={() => setLook('handDrawn')}
> >
<div className="msh-segmented-item-label">{t('app.mermaid.handDrawn')}</div> <div className="msh-segmented-item-label">{t('app.mermaid.handDrawn')}</div>
@ -118,31 +530,60 @@ const Flowchart = (
</label> </label>
</div> </div>
</div> </div>
{
svgCode <div ref={containerRef} style={{ position: 'absolute', visibility: 'hidden', height: 0, overflow: 'hidden' }} />
&& <div className="mermaid object-fit: cover h-auto w-full cursor-pointer" onClick={() => setImagePreviewUrl(svgCode)}>
{svgCode && <img src={svgCode} alt="mermaid_chart" />} {isLoading && !svgCode && (
<div className='px-[26px] py-4'>
<LoadingAnim type='text'/>
{!isCodeComplete && (
<div className="mt-2 text-sm text-gray-500">
{t('common.wait_for_completion', 'Waiting for diagram code to complete...')}
</div>
)}
</div> </div>
} )}
{isLoading
&& <div className='px-[26px] py-4'> {svgCode && (
<LoadingAnim type='text' /> <div className={themeClasses.mermaidDiv} style={{ objectFit: 'cover' }} onClick={() => setImagePreviewUrl(svgCode)}>
<div className="absolute bottom-2 left-2 z-[100]">
<button
onClick={(e) => {
e.stopPropagation()
toggleTheme()
}}
className={themeClasses.themeToggle}
title={(currentTheme === Theme.light ? t('app.theme.switchDark') : t('app.theme.switchLight')) || ''}
style={{ transform: 'translate3d(0, 0, 0)' }}
>
{currentTheme === Theme.light ? <MoonIcon className="h-5 w-5" /> : <SunIcon className="h-5 w-5" />}
</button>
</div>
<img
src={svgCode}
alt="mermaid_chart"
style={{ maxWidth: '100%' }}
onError={() => { setErrMsg('Chart rendering failed, please refresh and retry') }}
/>
</div> </div>
} )}
{
errMsg {errMsg && (
&& <div className='px-[26px] py-4'> <div className={themeClasses.errorMessage}>
<ExclamationTriangleIcon className='h-6 w-6 text-red-500' /> <div className="flex items-center">
&nbsp; <ExclamationTriangleIcon className={themeClasses.errorIcon}/>
{errMsg} <span className="ml-2">{errMsg}</span>
</div>
</div> </div>
} )}
{
imagePreviewUrl && (<ImagePreview title='mermaid_chart' url={imagePreviewUrl} onCancel={() => setImagePreviewUrl('')} />) {imagePreviewUrl && (
} <ImagePreview title='mermaid_chart' url={imagePreviewUrl} onCancel={() => setImagePreviewUrl('')} />
</div>) )}
</div>
) )
} })
Flowchart.displayName = 'Flowchart' Flowchart.displayName = 'Flowchart'

@ -1,3 +1,236 @@
export function cleanUpSvgCode(svgCode: string): string { export function cleanUpSvgCode(svgCode: string): string {
return svgCode.replaceAll('<br>', '<br/>') return svgCode.replaceAll('<br>', '<br/>')
} }
/**
* Preprocesses mermaid code to fix common syntax issues
*/
export function preprocessMermaidCode(code: string): string {
if (!code || typeof code !== 'string')
return ''
// First check if this is a gantt chart
if (code.trim().startsWith('gantt')) {
// For gantt charts, we need to ensure each task is on its own line
// Split the code into lines and process each line separately
const lines = code.split('\n').map(line => line.trim())
return lines.join('\n')
}
return code
// Replace English colons with Chinese colons in section nodes to avoid parsing issues
.replace(/section\s+([^:]+):/g, (match, sectionName) => `section ${sectionName}`)
// Fix common syntax issues
.replace(/fifopacket/g, 'rect')
// Ensure graph has direction
.replace(/^graph\s+((?:TB|BT|RL|LR)*)/, (match, direction) => {
return direction ? match : 'graph TD'
})
// Clean up empty lines and extra spaces
.trim()
}
/**
* Prepares mermaid code based on selected style
*/
export function prepareMermaidCode(code: string, style: 'classic' | 'handDrawn'): string {
let finalCode = preprocessMermaidCode(code)
// Special handling for gantt charts
if (finalCode.trim().startsWith('gantt')) {
// For gantt charts, preserve the structure exactly as is
return finalCode
}
if (style === 'handDrawn') {
finalCode = finalCode
// Remove style definitions that interfere with hand-drawn style
.replace(/style\s+[^\n]+/g, '')
.replace(/linkStyle\s+[^\n]+/g, '')
.replace(/^flowchart/, 'graph')
// Remove any styles that might interfere with hand-drawn style
.replace(/class="[^"]*"/g, '')
.replace(/fill="[^"]*"/g, '')
.replace(/stroke="[^"]*"/g, '')
// Ensure hand-drawn style charts always start with graph
if (!finalCode.startsWith('graph') && !finalCode.startsWith('flowchart'))
finalCode = `graph TD\n${finalCode}`
}
return finalCode
}
/**
* Converts SVG to base64 string for image rendering
*/
export function svgToBase64(svgGraph: string): Promise<string> {
if (!svgGraph)
return Promise.resolve('')
try {
// Ensure SVG has correct XML declaration
if (!svgGraph.includes('<?xml'))
svgGraph = `<?xml version="1.0" encoding="UTF-8"?>${svgGraph}`
const blob = new Blob([new TextEncoder().encode(svgGraph)], { type: 'image/svg+xml;charset=utf-8' })
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onloadend = () => resolve(reader.result as string)
reader.onerror = reject
reader.readAsDataURL(blob)
})
}
catch (error) {
console.error('Error converting SVG to base64:', error)
return Promise.resolve('')
}
}
/**
* Processes SVG for theme styling
*/
export function processSvgForTheme(
svg: string,
isDark: boolean,
isHandDrawn: boolean,
themes: {
light: any
dark: any
},
): string {
let processedSvg = svg
if (isDark) {
processedSvg = processedSvg
.replace(/style="fill: ?#000000"/g, 'style="fill: #e2e8f0"')
.replace(/style="stroke: ?#000000"/g, 'style="stroke: #94a3b8"')
.replace(/<rect [^>]*fill="#ffffff"/g, '<rect $& fill="#1e293b"')
if (isHandDrawn) {
processedSvg = processedSvg
.replace(/fill="#[a-fA-F0-9]{6}"/g, `fill="${themes.dark.nodeColors[0].bg}"`)
.replace(/stroke="#[a-fA-F0-9]{6}"/g, `stroke="${themes.dark.connectionColor}"`)
.replace(/stroke-width="1"/g, 'stroke-width="1.5"')
}
else {
let i = 0
themes.dark.nodeColors.forEach(() => {
const regex = /fill="#[a-fA-F0-9]{6}"[^>]*class="node-[^"]*"/g
processedSvg = processedSvg.replace(regex, (match: string) => {
const colorIndex = i % themes.dark.nodeColors.length
i++
return match.replace(/fill="#[a-fA-F0-9]{6}"/, `fill="${themes.dark.nodeColors[colorIndex].bg}"`)
})
})
processedSvg = processedSvg
.replace(/<path [^>]*stroke="#[a-fA-F0-9]{6}"/g,
`<path stroke="${themes.dark.connectionColor}" stroke-width="1.5"`)
.replace(/<(line|polyline) [^>]*stroke="#[a-fA-F0-9]{6}"/g,
`<$1 stroke="${themes.dark.connectionColor}" stroke-width="1.5"`)
}
}
else {
if (isHandDrawn) {
processedSvg = processedSvg
.replace(/fill="#[a-fA-F0-9]{6}"/g, `fill="${themes.light.nodeColors[0].bg}"`)
.replace(/stroke="#[a-fA-F0-9]{6}"/g, `stroke="${themes.light.connectionColor}"`)
.replace(/stroke-width="1"/g, 'stroke-width="1.5"')
}
else {
themes.light.nodeColors.forEach(() => {
const regex = /fill="#[a-fA-F0-9]{6}"[^>]*class="node-[^"]*"/g
let i = 0
processedSvg = processedSvg.replace(regex, (match: string) => {
const colorIndex = i % themes.light.nodeColors.length
i++
return match.replace(/fill="#[a-fA-F0-9]{6}"/, `fill="${themes.light.nodeColors[colorIndex].bg}"`)
})
})
processedSvg = processedSvg
.replace(/<path [^>]*stroke="#[a-fA-F0-9]{6}"/g,
`<path stroke="${themes.light.connectionColor}"`)
.replace(/<(line|polyline) [^>]*stroke="#[a-fA-F0-9]{6}"/g,
`<$1 stroke="${themes.light.connectionColor}"`)
}
}
return processedSvg
}
/**
* Checks if mermaid code is complete and valid
*/
export function isMermaidCodeComplete(code: string): boolean {
if (!code || code.trim().length === 0)
return false
try {
const trimmedCode = code.trim()
// Special handling for gantt charts
if (trimmedCode.startsWith('gantt')) {
// For gantt charts, check if it has at least a title and one task
const lines = trimmedCode.split('\n').filter(line => line.trim().length > 0)
return lines.length >= 3
}
// Check for basic syntax structure
const hasValidStart = /^(graph|flowchart|sequenceDiagram|classDiagram|classDef|class|stateDiagram|gantt|pie|er|journey|requirementDiagram)/.test(trimmedCode)
// Check for balanced brackets and parentheses
const isBalanced = (() => {
const stack = []
const pairs = { '{': '}', '[': ']', '(': ')' }
for (const char of trimmedCode) {
if (char in pairs) {
stack.push(char)
}
else if (Object.values(pairs).includes(char)) {
const last = stack.pop()
if (pairs[last as keyof typeof pairs] !== char)
return false
}
}
return stack.length === 0
})()
// Check for common syntax errors
const hasNoSyntaxErrors = !trimmedCode.includes('undefined')
&& !trimmedCode.includes('[object Object]')
&& trimmedCode.split('\n').every(line =>
!(line.includes('-->') && !line.match(/\S+\s*-->\s*\S+/)))
return hasValidStart && isBalanced && hasNoSyntaxErrors
}
catch (error) {
console.debug('Mermaid code validation error:', error)
return false
}
}
/**
* Helper to wait for DOM element with retry mechanism
*/
export function waitForDOMElement(callback: () => Promise<any>, maxAttempts = 3, delay = 100): Promise<any> {
return new Promise((resolve, reject) => {
let attempts = 0
const tryRender = async () => {
try {
resolve(await callback())
}
catch (error) {
attempts++
if (attempts < maxAttempts)
setTimeout(tryRender, delay)
else
reject(error)
}
}
tryRender()
})
}

@ -31,6 +31,7 @@ import { useOptions } from './hooks'
import type { PickerBlockMenuOption } from './menu' import type { PickerBlockMenuOption } from './menu'
import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars' import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
import { useEventEmitterContextContext } from '@/context/event-emitter' import { useEventEmitterContextContext } from '@/context/event-emitter'
import { KEY_ESCAPE_COMMAND } from 'lexical'
type ComponentPickerProps = { type ComponentPickerProps = {
triggerString: string triggerString: string
@ -118,6 +119,13 @@ const ComponentPicker = ({
editor.dispatchCommand(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, variables) editor.dispatchCommand(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, variables)
}, [editor, checkForTriggerMatch, triggerString]) }, [editor, checkForTriggerMatch, triggerString])
const handleClose = useCallback(() => {
ReactDOM.flushSync(() => {
const escapeEvent = new KeyboardEvent('keydown', { key: 'Escape' })
editor.dispatchCommand(KEY_ESCAPE_COMMAND, escapeEvent)
})
}, [editor])
const renderMenu = useCallback<MenuRenderFn<PickerBlockMenuOption>>(( const renderMenu = useCallback<MenuRenderFn<PickerBlockMenuOption>>((
anchorElementRef, anchorElementRef,
{ options, selectedIndex, selectOptionAndCleanUp, setHighlightedIndex }, { options, selectedIndex, selectOptionAndCleanUp, setHighlightedIndex },
@ -141,51 +149,54 @@ const ComponentPicker = ({
visibility: isPositioned ? 'visible' : 'hidden', visibility: isPositioned ? 'visible' : 'hidden',
}} }}
ref={refs.setFloating} ref={refs.setFloating}
data-testid="component-picker-container"
> >
{ {
options.map((option, index) => ( workflowVariableBlock?.show && (
<Fragment key={option.key}> <div className='p-1'>
{ <VarReferenceVars
// Divider searchBoxClassName='mt-1'
index !== 0 && options.at(index - 1)?.group !== option.group && ( vars={workflowVariableOptions}
<div className='my-1 h-px w-full -translate-x-1 bg-divider-subtle'></div> onChange={(variables: string[]) => {
) handleSelectWorkflowVariable(variables)
} }}
{option.renderMenuOption({ maxHeightClass='max-h-[34vh]'
queryString, isSupportFileVar={isSupportFileVar}
isSelected: selectedIndex === index, onClose={handleClose}
onSelect: () => { onBlur={handleClose}
selectOptionAndCleanUp(option) />
}, </div>
onSetHighlight: () => { )
setHighlightedIndex(index)
},
})}
</Fragment>
))
} }
{ {
workflowVariableBlock?.show && ( workflowVariableBlock?.show && !!options.length && (
<> <div className='my-1 h-px w-full -translate-x-1 bg-divider-subtle'></div>
{
(!!options.length) && (
<div className='my-1 h-px w-full -translate-x-1 bg-divider-subtle'></div>
)
}
<div className='p-1'>
<VarReferenceVars
hideSearch
vars={workflowVariableOptions}
onChange={(variables: string[]) => {
handleSelectWorkflowVariable(variables)
}}
maxHeightClass='max-h-[34vh]'
isSupportFileVar={isSupportFileVar}
/>
</div>
</>
) )
} }
<div data-testid="options-list">
{
options.map((option, index) => (
<Fragment key={option.key}>
{
// Divider
index !== 0 && options.at(index - 1)?.group !== option.group && (
<div className='my-1 h-px w-full -translate-x-1 bg-divider-subtle'></div>
)
}
{option.renderMenuOption({
queryString,
isSelected: selectedIndex === index,
onSelect: () => {
selectOptionAndCleanUp(option)
},
onSetHighlight: () => {
setHighlightedIndex(index)
},
})}
</Fragment>
))
}
</div>
</div> </div>
</div>, </div>,
anchorElementRef.current, anchorElementRef.current,
@ -193,7 +204,7 @@ const ComponentPicker = ({
} }
</> </>
) )
}, [allFlattenOptions.length, workflowVariableBlock?.show, refs, isPositioned, floatingStyles, queryString, workflowVariableOptions, handleSelectWorkflowVariable]) }, [allFlattenOptions.length, workflowVariableBlock?.show, refs, isPositioned, floatingStyles, queryString, workflowVariableOptions, handleSelectWorkflowVariable, handleClose, isSupportFileVar])
return ( return (
<LexicalTypeaheadMenuPlugin <LexicalTypeaheadMenuPlugin

@ -37,14 +37,16 @@ const OnBlurBlock: FC<OnBlurBlockProps> = ({
), ),
editor.registerCommand( editor.registerCommand(
BLUR_COMMAND, BLUR_COMMAND,
() => { (event) => {
ref.current = setTimeout(() => { // Check if the clicked target element is var-search-input
editor.dispatchCommand(KEY_ESCAPE_COMMAND, new KeyboardEvent('keydown', { key: 'Escape' })) const target = event?.relatedTarget as HTMLElement
}, 200) if (!target?.classList?.contains('var-search-input')) {
ref.current = setTimeout(() => {
if (onBlur) editor.dispatchCommand(KEY_ESCAPE_COMMAND, new KeyboardEvent('keydown', { key: 'Escape' }))
onBlur() }, 200)
if (onBlur)
onBlur()
}
return true return true
}, },
COMMAND_PRIORITY_EDITOR, COMMAND_PRIORITY_EDITOR,

@ -150,8 +150,8 @@ const PermissionSelector = ({ disabled, permission, value, memberList, onChange,
</div> </div>
</div> </div>
{isPartialMembers && ( {isPartialMembers && (
<div className='max-h-[360px] overflow-y-auto border-t-[1px] border-divider-regular p-1'> <div className='max-h-[360px] overflow-y-auto border-t-[1px] border-divider-regular pb-1 pl-1 pr-1'>
<div className='sticky left-0 top-0 p-2 pb-1'> <div className='sticky left-0 top-0 z-10 bg-white p-2 pb-1'>
<Input <Input
showLeftIcon showLeftIcon
showClearIcon showClearIcon

@ -74,7 +74,7 @@ const Popup: FC<PopupProps> = ({
/> />
<input <input
className='block h-[18px] grow appearance-none bg-transparent text-[13px] text-text-primary outline-none' className='block h-[18px] grow appearance-none bg-transparent text-[13px] text-text-primary outline-none'
placeholder='Search model' placeholder={t('datasetSettings.form.searchModel') || ''}
value={searchText} value={searchText}
onChange={e => setSearchText(e.target.value)} onChange={e => setSearchText(e.target.value)}
/> />

@ -1,83 +0,0 @@
'use client'
import { useMemo } from 'react'
import { useContext } from 'use-context-selector'
import { useTranslation } from 'react-i18next'
import type { Collection } from '../types'
import cn from '@/utils/classnames'
import AppIcon from '@/app/components/base/app-icon'
import { Tag01 } from '@/app/components/base/icons/src/vender/line/financeAndECommerce'
import I18n from '@/context/i18n'
import { getLanguage } from '@/i18n/language'
import { useStore as useLabelStore } from '@/app/components/tools/labels/store'
type Props = {
active: boolean
collection: Collection
onSelect: () => void
}
const ProviderCard = ({
active,
collection,
onSelect,
}: Props) => {
const { t } = useTranslation()
const { locale } = useContext(I18n)
const language = getLanguage(locale)
const labelList = useLabelStore(s => s.labelList)
const labelContent = useMemo(() => {
if (!collection.labels)
return ''
return collection.labels.map((name) => {
const label = labelList.find(item => item.name === name)
return label?.label[language]
}).filter(Boolean).join(', ')
}, [collection.labels, labelList, language])
return (
<div className={cn('group col-span-1 flex min-h-[160px] cursor-pointer flex-col rounded-xl border-2 border-solid border-transparent bg-white shadow-sm transition-all duration-200 ease-in-out hover:shadow-lg', active && '!border-primary-400')} onClick={onSelect}>
<div className='flex h-[66px] shrink-0 grow-0 items-center gap-3 px-[14px] pb-3 pt-[14px]'>
<div className='relative shrink-0'>
{typeof collection.icon === 'string' && (
<div className='h-10 w-10 rounded-md bg-cover bg-center bg-no-repeat' style={{ backgroundImage: `url(${collection.icon})` }} />
)}
{typeof collection.icon !== 'string' && (
<AppIcon
size='large'
icon={collection.icon.content}
background={collection.icon.background}
/>
)}
</div>
<div className='w-0 grow py-[1px]'>
<div className='flex items-center text-sm font-semibold leading-5 text-gray-800'>
<div className='truncate' title={collection.label[language]}>{collection.label[language]}</div>
</div>
<div className='flex items-center text-[10px] font-medium leading-[18px] text-gray-500'>
<div className='truncate'>{t('tools.author')}&nbsp;{collection.author}</div>
</div>
</div>
</div>
<div
className={cn(
'mb-2 max-h-[72px] grow px-[14px] text-xs leading-normal text-gray-500',
collection.labels?.length ? 'line-clamp-2' : 'line-clamp-4',
collection.labels?.length > 0 && 'group-hover:line-clamp-2 group-hover:max-h-[36px]',
)}
title={collection.description[language]}
>
{collection.description[language]}
</div>
{collection.labels?.length > 0 && (
<div className='mt-1 flex h-[42px] shrink-0 items-center pb-[6px] pl-[14px] pr-[6px] pt-1'>
<div className='relative flex w-full items-center gap-1 rounded-md py-[7px] text-gray-500' title={labelContent}>
<Tag01 className='h-3 w-3 shrink-0' />
<div className='grow truncate text-start text-xs font-normal leading-[18px]'>{labelContent}</div>
</div>
</div>
)}
</div>
)
}
export default ProviderCard

@ -1,40 +0,0 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import {
RiHammerFill,
} from '@remixicon/react'
import { Heart02 } from '@/app/components/base/icons/src/vender/solid/education'
import { BookOpen01 } from '@/app/components/base/icons/src/vender/line/education'
import { ArrowUpRight } from '@/app/components/base/icons/src/vender/line/arrows'
const Contribute: FC = () => {
const { t } = useTranslation()
return (
<a
href='https://github.com/langgenius/dify/blob/main/api/core/tools/README.md'
target='_blank'
rel='noopener noreferrer'
className="group col-span-1 flex min-h-[160px] cursor-pointer flex-col rounded-xl border-2 border-solid border-transparent bg-white bg-[url('~@/app/components/tools/provider/grid_bg.svg')] bg-cover bg-no-repeat shadow-sm transition-all duration-200 ease-in-out hover:shadow-lg"
>
<div className='flex h-[66px] shrink-0 grow-0 items-center gap-3 px-[14px] pb-3 pt-[14px]'>
<div className='relative flex shrink-0 items-center'>
<div className='z-10 flex rounded-[10px] border-[0.5px] border-primary-100 bg-white p-3 shadow-md'><RiHammerFill className='h-4 w-4 text-primary-600'/></div>
<div className='flex -translate-x-2 rounded-[10px] border-[0.5px] border-[#FCE7F6] bg-[#FEF6FB] p-3 shadow-md'><Heart02 className='h-4 w-4 text-[#EE46BC]'/></div>
</div>
</div>
<div className='mb-3 px-[14px] text-[15px] font-semibold leading-5'>
<div className='text-gradient'>{t('tools.contribute.line1')}</div>
<div className='text-gradient'>{t('tools.contribute.line2')}</div>
</div>
<div className='flex items-center space-x-1 border-t-[0.5px] border-black/5 px-4 py-3 text-[#155EEF]'>
<BookOpen01 className='h-3 w-3' />
<div className='grow text-xs font-normal leading-[18px]'>{t('tools.contribute.viewGuide')}</div>
<ArrowUpRight className='h-3 w-3' />
</div>
</a>
)
}
export default React.memo(Contribute)

@ -236,27 +236,29 @@ const ProviderDetail = ({
positionCenter={false} positionCenter={false}
panelClassName={cn('mb-2 mr-2 mt-[64px] !w-[420px] !max-w-[420px] justify-start rounded-2xl border-[0.5px] border-components-panel-border !bg-components-panel-bg !p-0 shadow-xl')} panelClassName={cn('mb-2 mr-2 mt-[64px] !w-[420px] !max-w-[420px] justify-start rounded-2xl border-[0.5px] border-components-panel-border !bg-components-panel-bg !p-0 shadow-xl')}
> >
<div className='p-4'> <div className='flex h-full flex-col p-4'>
<div className='mb-3 flex'> <div className="shrink-0">
<Icon src={collection.icon} /> <div className='mb-3 flex'>
<div className="ml-3 w-0 grow"> <Icon src={collection.icon} />
<div className="flex h-5 items-center"> <div className="ml-3 w-0 grow">
<Title title={collection.label[language]} /> <div className="flex h-5 items-center">
<Title title={collection.label[language]} />
</div>
<div className='mb-1 flex h-4 items-center justify-between'>
<OrgInfo
className="mt-0.5"
packageNameClassName='w-auto'
orgName={collection.author}
packageName={collection.name}
/>
</div>
</div> </div>
<div className='mb-1 flex h-4 items-center justify-between'> <div className='flex gap-1'>
<OrgInfo <ActionButton onClick={onHide}>
className="mt-0.5" <RiCloseLine className='h-4 w-4' />
packageNameClassName='w-auto' </ActionButton>
orgName={collection.author}
packageName={collection.name}
/>
</div> </div>
</div> </div>
<div className='flex gap-1'>
<ActionButton onClick={onHide}>
<RiCloseLine className='h-4 w-4' />
</ActionButton>
</div>
</div> </div>
{!!collection.description[language] && ( {!!collection.description[language] && (
<Description text={collection.description[language]} descriptionLineRows={2}></Description> <Description text={collection.description[language]} descriptionLineRows={2}></Description>
@ -292,85 +294,84 @@ const ProviderDetail = ({
</> </>
)} )}
</div> </div>
{/* Tools */} <div className='flex min-h-0 flex-1 flex-col pt-3'>
<div className='pt-3'>
{isDetailLoading && <div className='flex h-[200px]'><Loading type='app' /></div>} {isDetailLoading && <div className='flex h-[200px]'><Loading type='app' /></div>}
{/* Builtin type */} {!isDetailLoading && (
{!isDetailLoading && (collection.type === CollectionType.builtIn) && isAuthed && (
<div className='system-sm-semibold-uppercase mb-1 flex h-6 items-center justify-between text-text-secondary'>
{t('plugin.detailPanel.actionNum', { num: toolList.length, action: toolList.length > 1 ? 'actions' : 'action' })}
{needAuth && (
<Button
variant='secondary'
size='small'
onClick={() => {
if (collection.type === CollectionType.builtIn || collection.type === CollectionType.model)
showSettingAuthModal()
}}
disabled={!isCurrentWorkspaceManager}
>
<Indicator className='mr-2' color={'green'} />
{t('tools.auth.authorized')}
</Button>
)}
</div>
)}
{!isDetailLoading && (collection.type === CollectionType.builtIn) && needAuth && !isAuthed && (
<> <>
<div className='system-sm-semibold-uppercase text-text-secondary'> <div className="shrink-0">
<span className=''>{t('tools.includeToolNum', { num: toolList.length, action: toolList.length > 1 ? 'actions' : 'action' }).toLocaleUpperCase()}</span> {(collection.type === CollectionType.builtIn || collection.type === CollectionType.model) && isAuthed && (
<span className='px-1'>·</span> <div className='system-sm-semibold-uppercase mb-1 flex h-6 items-center justify-between text-text-secondary'>
<span className='text-util-colors-orange-orange-600'>{t('tools.auth.setup').toLocaleUpperCase()}</span> {t('plugin.detailPanel.actionNum', { num: toolList.length, action: toolList.length > 1 ? 'actions' : 'action' })}
{needAuth && (
<Button
variant='secondary'
size='small'
onClick={() => {
if (collection.type === CollectionType.builtIn || collection.type === CollectionType.model)
showSettingAuthModal()
}}
disabled={!isCurrentWorkspaceManager}
>
<Indicator className='mr-2' color={'green'} />
{t('tools.auth.authorized')}
</Button>
)}
</div>
)}
{(collection.type === CollectionType.builtIn || collection.type === CollectionType.model) && needAuth && !isAuthed && (
<>
<div className='system-sm-semibold-uppercase text-text-secondary'>
<span className=''>{t('tools.includeToolNum', { num: toolList.length, action: toolList.length > 1 ? 'actions' : 'action' }).toLocaleUpperCase()}</span>
<span className='px-1'>·</span>
<span className='text-util-colors-orange-orange-600'>{t('tools.auth.setup').toLocaleUpperCase()}</span>
</div>
<Button
variant='primary'
className={cn('my-3 w-full shrink-0')}
onClick={() => {
if (collection.type === CollectionType.builtIn || collection.type === CollectionType.model)
showSettingAuthModal()
}}
disabled={!isCurrentWorkspaceManager}
>
{t('tools.auth.unauthorized')}
</Button>
</>
)}
{(collection.type === CollectionType.custom) && (
<div className='system-sm-semibold-uppercase text-text-secondary'>
<span className=''>{t('tools.includeToolNum', { num: toolList.length, action: toolList.length > 1 ? 'actions' : 'action' }).toLocaleUpperCase()}</span>
</div>
)}
{(collection.type === CollectionType.workflow) && (
<div className='system-sm-semibold-uppercase text-text-secondary'>
<span className=''>{t('tools.createTool.toolInput.title').toLocaleUpperCase()}</span>
</div>
)}
</div> </div>
<Button <div className='mt-1 flex-1 overflow-y-auto py-2'>
variant='primary' {collection.type !== CollectionType.workflow && toolList.map(tool => (
className={cn('my-3 w-full shrink-0')} <ToolItem
onClick={() => { key={tool.name}
if (collection.type === CollectionType.builtIn || collection.type === CollectionType.model) disabled={false}
showSettingAuthModal() collection={collection}
}} tool={tool}
disabled={!isCurrentWorkspaceManager} isBuiltIn={isBuiltIn}
> isModel={isModel}
{t('tools.auth.unauthorized')} />
</Button> ))}
</> {collection.type === CollectionType.workflow && (customCollection as WorkflowToolProviderResponse)?.tool?.parameters.map(item => (
)} <div key={item.name} className='mb-1 py-1'>
{/* Custom type */} <div className='mb-1 flex items-center gap-2'>
{!isDetailLoading && (collection.type === CollectionType.custom) && ( <span className='code-sm-semibold text-text-secondary'>{item.name}</span>
<div className='system-sm-semibold-uppercase text-text-secondary'> <span className='system-xs-regular text-text-tertiary'>{item.type}</span>
<span className=''>{t('tools.includeToolNum', { num: toolList.length, action: toolList.length > 1 ? 'actions' : 'action' }).toLocaleUpperCase()}</span> <span className='system-xs-medium text-text-warning-secondary'>{item.required ? t('tools.createTool.toolInput.required') : ''}</span>
</div> </div>
)} <div className='system-xs-regular text-text-tertiary'>{item.llm_description}</div>
{/* Workflow type */}
{!isDetailLoading && (collection.type === CollectionType.workflow) && (
<div className='system-sm-semibold-uppercase text-text-secondary'>
<span className=''>{t('tools.createTool.toolInput.title').toLocaleUpperCase()}</span>
</div>
)}
{!isDetailLoading && (
<div className='mt-1 py-2'>
{collection.type !== CollectionType.workflow && toolList.map(tool => (
<ToolItem
key={tool.name}
disabled={false}
// disabled={needAuth && (isBuiltIn || isModel) && !isAuthed}
collection={collection}
tool={tool}
isBuiltIn={isBuiltIn}
isModel={isModel}
/>
))}
{collection.type === CollectionType.workflow && (customCollection as WorkflowToolProviderResponse)?.tool?.parameters.map(item => (
<div key={item.name} className='mb-1 py-1'>
<div className='mb-1 flex items-center gap-2'>
<span className='code-sm-semibold text-text-secondary'>{item.name}</span>
<span className='system-xs-regular text-text-tertiary'>{item.type}</span>
<span className='system-xs-medium text-text-warning-secondary'>{item.required ? t('tools.createTool.toolInput.required') : ''}</span>
</div> </div>
<div className='system-xs-regular text-text-tertiary'>{item.llm_description}</div> ))}
</div> </div>
))} </>
</div>
)} )}
</div> </div>
{showSettingAuth && ( {showSettingAuth && (

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 13 KiB

@ -7,6 +7,7 @@ import { WorkflowWithInnerContext } from '@/app/components/workflow'
import type { WorkflowProps } from '@/app/components/workflow' import type { WorkflowProps } from '@/app/components/workflow'
import WorkflowChildren from './workflow-children' import WorkflowChildren from './workflow-children'
import { import {
useAvailableNodesMetaData,
useNodesSyncDraft, useNodesSyncDraft,
useWorkflowRun, useWorkflowRun,
useWorkflowStartRun, useWorkflowStartRun,
@ -44,6 +45,7 @@ const WorkflowMain = ({
handleWorkflowStartRunInChatflow, handleWorkflowStartRunInChatflow,
handleWorkflowStartRunInWorkflow, handleWorkflowStartRunInWorkflow,
} = useWorkflowStartRun() } = useWorkflowStartRun()
const availableNodesMetaData = useAvailableNodesMetaData()
const hooksStore = useMemo(() => { const hooksStore = useMemo(() => {
return { return {
@ -57,6 +59,7 @@ const WorkflowMain = ({
handleStartWorkflowRun, handleStartWorkflowRun,
handleWorkflowStartRunInChatflow, handleWorkflowStartRunInChatflow,
handleWorkflowStartRunInWorkflow, handleWorkflowStartRunInWorkflow,
availableNodesMetaData,
} }
}, [ }, [
syncWorkflowDraftWhenPageClose, syncWorkflowDraftWhenPageClose,
@ -69,6 +72,7 @@ const WorkflowMain = ({
handleStartWorkflowRun, handleStartWorkflowRun,
handleWorkflowStartRunInChatflow, handleWorkflowStartRunInChatflow,
handleWorkflowStartRunInWorkflow, handleWorkflowStartRunInWorkflow,
availableNodesMetaData,
]) ])
return ( return (
@ -77,7 +81,7 @@ const WorkflowMain = ({
edges={edges} edges={edges}
viewport={viewport} viewport={viewport}
onWorkflowDataUpdate={handleWorkflowDataUpdate} onWorkflowDataUpdate={handleWorkflowDataUpdate}
hooksStore={hooksStore} hooksStore={hooksStore as any}
> >
<WorkflowChildren /> <WorkflowChildren />
</WorkflowWithInnerContext> </WorkflowWithInnerContext>

@ -4,3 +4,4 @@ export * from './use-nodes-sync-draft'
export * from './use-workflow-run' export * from './use-workflow-run'
export * from './use-workflow-start-run' export * from './use-workflow-start-run'
export * from './use-is-chat-mode' export * from './use-is-chat-mode'
export * from './use-available-nodes-meta-data'

@ -0,0 +1,60 @@
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useGetLanguage } from '@/context/i18n'
import StartDefault from '@/app/components/workflow/nodes/start/default'
import EndDefault from '@/app/components/workflow/nodes/end/default'
import AnswerDefault from '@/app/components/workflow/nodes/answer/default'
import { WORKFLOW_COMMON_NODES } from '@/app/components/workflow/constants/node'
import type { AvailableNodesMetaData } from '@/app/components/workflow/hooks-store/store'
import { useIsChatMode } from './use-is-chat-mode'
export const useAvailableNodesMetaData = () => {
const { t } = useTranslation()
const isChatMode = useIsChatMode()
const language = useGetLanguage()
console.log('isChatMode', isChatMode)
const mergedNodesMetaData = useMemo(() => [
...WORKFLOW_COMMON_NODES,
StartDefault,
...(
isChatMode
? [AnswerDefault]
: [EndDefault]
),
], [isChatMode])
const prefixLink = useMemo(() => {
if (language === 'zh_Hans')
return 'https://docs.dify.ai/zh-hans/guides/workflow/node/'
return 'https://docs.dify.ai/guides/workflow/node/'
}, [language])
const availableNodesMetaData = useMemo(() => mergedNodesMetaData.map((node) => {
return {
...node,
defaultValue: {
...node.defaultValue,
type: node.type,
},
title: t(`workflow.blocks.${node.type}`),
description: t(`workflow.blocksAbout.${node.type}`),
helpLinkUri: `${prefixLink}${node.helpLinkUri}`,
}
}), [mergedNodesMetaData, t, prefixLink])
const availableNodesMetaDataMap = useMemo(() => availableNodesMetaData.reduce((acc, node) => {
acc![node.type] = node
return acc
}, {} as AvailableNodesMetaData['nodesMap']), [availableNodesMetaData])
return useMemo(() => {
return {
nodes: availableNodesMetaData,
nodesMap: availableNodesMetaDataMap,
}
}, [availableNodesMetaData, availableNodesMetaDataMap])
}

@ -3,15 +3,17 @@ import {
NODE_WIDTH_X_OFFSET, NODE_WIDTH_X_OFFSET,
START_INITIAL_POSITION, START_INITIAL_POSITION,
} from '@/app/components/workflow/constants' } from '@/app/components/workflow/constants'
import { useNodesInitialData } from '@/app/components/workflow/hooks'
import { useIsChatMode } from './use-is-chat-mode' import { useIsChatMode } from './use-is-chat-mode'
import type { StartNodeType } from '@/app/components/workflow/nodes/start/types'
import startDefault from '@/app/components/workflow/nodes/start/default'
import llmDefault from '@/app/components/workflow/nodes/llm/default'
import answerDefault from '@/app/components/workflow/nodes/answer/default'
export const useWorkflowTemplate = () => { export const useWorkflowTemplate = () => {
const isChatMode = useIsChatMode() const isChatMode = useIsChatMode()
const nodesInitialData = useNodesInitialData()
const { newNode: startNode } = generateNewNode({ const { newNode: startNode } = generateNewNode({
data: nodesInitialData.start, data: startDefault.defaultValue as StartNodeType,
position: START_INITIAL_POSITION, position: START_INITIAL_POSITION,
}) })
@ -19,7 +21,7 @@ export const useWorkflowTemplate = () => {
const { newNode: llmNode } = generateNewNode({ const { newNode: llmNode } = generateNewNode({
id: 'llm', id: 'llm',
data: { data: {
...nodesInitialData.llm, ...llmDefault.defaultValue,
memory: { memory: {
window: { enabled: false, size: 10 }, window: { enabled: false, size: 10 },
query_prompt_template: '{{#sys.query#}}', query_prompt_template: '{{#sys.query#}}',
@ -35,7 +37,7 @@ export const useWorkflowTemplate = () => {
const { newNode: answerNode } = generateNewNode({ const { newNode: answerNode } = generateNewNode({
id: 'answer', id: 'answer',
data: { data: {
...nodesInitialData.answer, ...answerDefault.defaultValue,
answer: `{{#${llmNode.id}.text#}}`, answer: `{{#${llmNode.id}.text#}}`,
}, },
position: { position: {

@ -7,12 +7,8 @@ import { useTranslation } from 'react-i18next'
import { groupBy } from 'lodash-es' import { groupBy } from 'lodash-es'
import BlockIcon from '../block-icon' import BlockIcon from '../block-icon'
import { BlockEnum } from '../types' import { BlockEnum } from '../types'
import { import type { NodeDefault } from '../types'
useIsChatMode,
useNodesExtraData,
} from '../hooks'
import { BLOCK_CLASSIFICATIONS } from './constants' import { BLOCK_CLASSIFICATIONS } from './constants'
import { useBlocks } from './hooks'
import type { ToolDefaultValue } from './types' import type { ToolDefaultValue } from './types'
import Tooltip from '@/app/components/base/tooltip' import Tooltip from '@/app/components/base/tooltip'
import Badge from '@/app/components/base/badge' import Badge from '@/app/components/base/badge'
@ -21,23 +17,19 @@ type BlocksProps = {
searchText: string searchText: string
onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void
availableBlocksTypes?: BlockEnum[] availableBlocksTypes?: BlockEnum[]
blocks: NodeDefault[]
} }
const Blocks = ({ const Blocks = ({
searchText, searchText,
onSelect, onSelect,
availableBlocksTypes = [], availableBlocksTypes = [],
blocks,
}: BlocksProps) => { }: BlocksProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const isChatMode = useIsChatMode()
const nodesExtraData = useNodesExtraData()
const blocks = useBlocks()
const groups = useMemo(() => { const groups = useMemo(() => {
return BLOCK_CLASSIFICATIONS.reduce((acc, classification) => { return BLOCK_CLASSIFICATIONS.reduce((acc, classification) => {
const list = groupBy(blocks, 'classification')[classification].filter((block) => { const list = groupBy(blocks, 'classification')[classification].filter((block) => {
if (block.type === BlockEnum.Answer && !isChatMode)
return false
return block.title.toLowerCase().includes(searchText.toLowerCase()) && availableBlocksTypes.includes(block.type) return block.title.toLowerCase().includes(searchText.toLowerCase()) && availableBlocksTypes.includes(block.type)
}) })
@ -46,11 +38,11 @@ const Blocks = ({
[classification]: list, [classification]: list,
} }
}, {} as Record<string, typeof blocks>) }, {} as Record<string, typeof blocks>)
}, [blocks, isChatMode, searchText, availableBlocksTypes]) }, [blocks, searchText, availableBlocksTypes])
const isEmpty = Object.values(groups).every(list => !list.length) const isEmpty = Object.values(groups).every(list => !list.length)
const renderGroup = useCallback((classification: string) => { const renderGroup = useCallback((classification: string) => {
const list = groups[classification] const list = groups[classification].sort((a, b) => a.sort - b.sort)
return ( return (
<div <div
@ -78,7 +70,7 @@ const Blocks = ({
type={block.type} type={block.type}
/> />
<div className='system-md-medium mb-1 text-text-primary'>{block.title}</div> <div className='system-md-medium mb-1 text-text-primary'>{block.title}</div>
<div className='system-xs-regular text-text-tertiary'>{nodesExtraData[block.type].about}</div> <div className='system-xs-regular text-text-tertiary'>{block.description}</div>
</div> </div>
)} )}
> >
@ -106,7 +98,7 @@ const Blocks = ({
} }
</div> </div>
) )
}, [groups, nodesExtraData, onSelect, t]) }, [groups, onSelect, t])
return ( return (
<div className='max-h-[480px] overflow-y-auto p-1'> <div className='max-h-[480px] overflow-y-auto p-1'>

@ -1,113 +1,5 @@
import type { Block } from '../types'
import { BlockEnum } from '../types'
import { BlockClassificationEnum } from './types' import { BlockClassificationEnum } from './types'
export const BLOCKS: Block[] = [
{
classification: BlockClassificationEnum.Default,
type: BlockEnum.DataSource,
title: 'File upload',
description: '',
},
{
classification: BlockClassificationEnum.Default,
type: BlockEnum.Start,
title: 'Start',
description: '',
},
{
classification: BlockClassificationEnum.Default,
type: BlockEnum.LLM,
title: 'LLM',
},
{
classification: BlockClassificationEnum.Default,
type: BlockEnum.KnowledgeRetrieval,
title: 'Knowledge Retrieval',
},
{
classification: BlockClassificationEnum.Default,
type: BlockEnum.End,
title: 'End',
},
{
classification: BlockClassificationEnum.Default,
type: BlockEnum.Answer,
title: 'Direct Answer',
},
{
classification: BlockClassificationEnum.QuestionUnderstand,
type: BlockEnum.QuestionClassifier,
title: 'Question Classifier',
},
{
classification: BlockClassificationEnum.Logic,
type: BlockEnum.IfElse,
title: 'IF/ELSE',
},
{
classification: BlockClassificationEnum.Logic,
type: BlockEnum.LoopEnd,
title: 'Exit Loop',
description: '',
},
{
classification: BlockClassificationEnum.Logic,
type: BlockEnum.Iteration,
title: 'Iteration',
},
{
classification: BlockClassificationEnum.Logic,
type: BlockEnum.Loop,
title: 'Loop',
},
{
classification: BlockClassificationEnum.Transform,
type: BlockEnum.Code,
title: 'Code',
},
{
classification: BlockClassificationEnum.Transform,
type: BlockEnum.TemplateTransform,
title: 'Templating Transform',
},
{
classification: BlockClassificationEnum.Transform,
type: BlockEnum.VariableAggregator,
title: 'Variable Aggregator',
},
{
classification: BlockClassificationEnum.Transform,
type: BlockEnum.DocExtractor,
title: 'Doc Extractor',
},
{
classification: BlockClassificationEnum.Transform,
type: BlockEnum.Assigner,
title: 'Variable Assigner',
},
{
classification: BlockClassificationEnum.Transform,
type: BlockEnum.ParameterExtractor,
title: 'Parameter Extractor',
},
{
classification: BlockClassificationEnum.Utilities,
type: BlockEnum.HttpRequest,
title: 'HTTP Request',
},
{
classification: BlockClassificationEnum.Utilities,
type: BlockEnum.ListFilter,
title: 'List Filter',
},
{
classification: BlockClassificationEnum.Default,
type: BlockEnum.Agent,
title: 'Agent',
},
]
export const BLOCK_CLASSIFICATIONS: string[] = [ export const BLOCK_CLASSIFICATIONS: string[] = [
BlockClassificationEnum.Default, BlockClassificationEnum.Default,
BlockClassificationEnum.QuestionUnderstand, BlockClassificationEnum.QuestionUnderstand,

@ -3,23 +3,11 @@ import {
useState, useState,
} from 'react' } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { BLOCKS } from './constants'
import { import {
TabsEnum, TabsEnum,
ToolTypeEnum, ToolTypeEnum,
} from './types' } from './types'
export const useBlocks = () => {
const { t } = useTranslation()
return BLOCKS.map((block) => {
return {
...block,
title: t(`workflow.blocks.${block.type}`),
}
})
}
export const useTabs = (noBlocks?: boolean) => { export const useTabs = (noBlocks?: boolean) => {
const { t } = useTranslation() const { t } = useTranslation()
const tabs = useMemo(() => { const tabs = useMemo(() => {

@ -1,199 +1,36 @@
import type { import { useMemo } from 'react'
FC, import type { NodeSelectorProps } from './main'
MouseEventHandler, import NodeSelector from './main'
} from 'react' import { useHooksStore } from '@/app/components/workflow/hooks-store/store'
import { import { BlockEnum } from '@/app/components/workflow/types'
memo,
useCallback,
useMemo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import type {
OffsetOptions,
Placement,
} from '@floating-ui/react'
import type { BlockEnum, OnSelectBlock } from '../types'
import Tabs from './tabs'
import { TabsEnum } from './types'
import { useTabs } from './hooks'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import Input from '@/app/components/base/input'
// import SearchBox from '@/app/components/plugins/marketplace/search-box'
import cn from '@/utils/classnames'
import { const NodeSelectorWrapper = (props: NodeSelectorProps) => {
Plus02, const availableNodesMetaData = useHooksStore(s => s.availableNodesMetaData)
} from '@/app/components/base/icons/src/vender/line/general'
type NodeSelectorProps = { const blocks = useMemo(() => {
open?: boolean const result = availableNodesMetaData?.nodes || []
onOpenChange?: (open: boolean) => void console.log(result, 'result')
onSelect: OnSelectBlock
trigger?: (open: boolean) => React.ReactNode
placement?: Placement
offset?: OffsetOptions
triggerStyle?: React.CSSProperties
triggerClassName?: (open: boolean) => string
triggerInnerClassName?: string
popupClassName?: string
asChild?: boolean
availableBlocksTypes?: BlockEnum[]
disabled?: boolean
noBlocks?: boolean
}
const NodeSelector: FC<NodeSelectorProps> = ({
open: openFromProps,
onOpenChange,
onSelect,
trigger,
placement = 'right',
offset = 6,
triggerClassName,
triggerInnerClassName,
triggerStyle,
popupClassName,
asChild,
availableBlocksTypes,
disabled,
noBlocks = false,
}) => {
const { t } = useTranslation()
const [searchText, setSearchText] = useState('')
const [tags, setTags] = useState<string[]>([])
const [localOpen, setLocalOpen] = useState(false)
const open = openFromProps === undefined ? localOpen : openFromProps
const handleOpenChange = useCallback((newOpen: boolean) => {
setLocalOpen(newOpen)
if (!newOpen)
setSearchText('')
if (onOpenChange) return result.filter((block) => {
onOpenChange(newOpen) if (block.type === BlockEnum.Start)
}, [onOpenChange]) return false
const handleTrigger = useCallback<MouseEventHandler<HTMLDivElement>>((e) => {
if (disabled)
return
e.stopPropagation()
handleOpenChange(!open)
}, [handleOpenChange, open, disabled])
const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => {
handleOpenChange(false)
onSelect(type, toolDefaultValue)
}, [handleOpenChange, onSelect])
const { if (block.type === BlockEnum.IterationStart)
activeTab, return false
setActiveTab,
tabs,
} = useTabs()
const searchPlaceholder = useMemo(() => { if (block.type === BlockEnum.LoopStart)
if (activeTab === TabsEnum.Blocks) return false
return t('workflow.tabs.searchBlock')
if (activeTab === TabsEnum.Tools) return true
return t('workflow.tabs.searchTool') })
return '' }, [availableNodesMetaData?.nodes])
}, [activeTab, t])
return ( return (
<PortalToFollowElem <NodeSelector
placement={placement} {...props}
offset={offset} blocks={blocks}
open={open} />
onOpenChange={handleOpenChange}
>
<PortalToFollowElemTrigger
asChild={asChild}
onClick={handleTrigger}
className={triggerInnerClassName}
>
{
trigger
? trigger(open)
: (
<div
className={`
z-10 flex h-4
w-4 cursor-pointer items-center justify-center rounded-full bg-components-button-primary-bg text-text-primary-on-surface hover:bg-components-button-primary-bg-hover
${triggerClassName?.(open)}
`}
style={triggerStyle}
>
<Plus02 className='h-2.5 w-2.5' />
</div>
)
}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[1000]'>
<div className={cn(
'overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg backdrop-blur-[5px]',
popupClassName,
)}>
<div className='border-b border-divider-subtle bg-background-section-burn'>
<div className='flex h-9 items-center px-1 pt-1'>
{
tabs.map(tab => (
<div
key={tab.key}
className={cn(
'system-sm-medium mr-0.5 cursor-pointer rounded-t-lg px-3 py-2 text-text-tertiary hover:bg-state-base-hover',
activeTab === tab.key && 'bg-components-panel-bg text-text-accent shadow-sm',
)}
onClick={(e) => {
e.stopPropagation()
setActiveTab(tab.key)
}}
>
{tab.name}
</div>
))
}
</div>
<div className='relative z-[1] bg-components-panel-bg p-2'>
<Input
showLeftIcon
showClearIcon
autoFocus
value={searchText}
placeholder={searchPlaceholder}
onChange={e => setSearchText(e.target.value)}
onClear={() => setSearchText('')}
/>
</div>
</div>
{/* <div className='p-2' onClick={e => e.stopPropagation()}>
{activeTab === TabsEnum.Blocks && (
)}
{activeTab === TabsEnum.Tools && (
<SearchBox
search={searchText}
onSearchChange={setSearchText}
tags={tags}
onTagsChange={setTags}
size='small'
placeholder={t('plugin.searchTools')!}
/>
)}
</div> */}
<Tabs
activeTab={activeTab}
onSelect={handleSelect}
searchText={searchText}
tags={tags}
availableBlocksTypes={availableBlocksTypes}
noBlocks={noBlocks}
/>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
) )
} }
export default memo(NodeSelector) export default NodeSelectorWrapper

@ -0,0 +1,175 @@
import type {
FC,
MouseEventHandler,
} from 'react'
import {
memo,
useCallback,
useMemo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import type {
OffsetOptions,
Placement,
} from '@floating-ui/react'
import type {
BlockEnum,
NodeDefault,
OnSelectBlock,
} from '../types'
import Tabs from './tabs'
import { TabsEnum } from './types'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import Input from '@/app/components/base/input'
import SearchBox from '@/app/components/plugins/marketplace/search-box'
import {
Plus02,
} from '@/app/components/base/icons/src/vender/line/general'
export type NodeSelectorProps = {
open?: boolean
onOpenChange?: (open: boolean) => void
onSelect: OnSelectBlock
trigger?: (open: boolean) => React.ReactNode
placement?: Placement
offset?: OffsetOptions
triggerStyle?: React.CSSProperties
triggerClassName?: (open: boolean) => string
triggerInnerClassName?: string
popupClassName?: string
asChild?: boolean
availableBlocksTypes?: BlockEnum[]
disabled?: boolean
blocks?: NodeDefault[]
}
const NodeSelector: FC<NodeSelectorProps> = ({
open: openFromProps,
onOpenChange,
onSelect,
trigger,
placement = 'right',
offset = 6,
triggerClassName,
triggerInnerClassName,
triggerStyle,
popupClassName,
asChild,
availableBlocksTypes,
disabled,
blocks = [],
}) => {
const { t } = useTranslation()
const [searchText, setSearchText] = useState('')
const [tags, setTags] = useState<string[]>([])
const [localOpen, setLocalOpen] = useState(false)
const open = openFromProps === undefined ? localOpen : openFromProps
const handleOpenChange = useCallback((newOpen: boolean) => {
setLocalOpen(newOpen)
if (!newOpen)
setSearchText('')
if (onOpenChange)
onOpenChange(newOpen)
}, [onOpenChange])
const handleTrigger = useCallback<MouseEventHandler<HTMLDivElement>>((e) => {
if (disabled)
return
e.stopPropagation()
handleOpenChange(!open)
}, [handleOpenChange, open, disabled])
const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => {
handleOpenChange(false)
onSelect(type, toolDefaultValue)
}, [handleOpenChange, onSelect])
const [activeTab, setActiveTab] = useState(!blocks.length ? TabsEnum.Tools : TabsEnum.Blocks)
const handleActiveTabChange = useCallback((newActiveTab: TabsEnum) => {
setActiveTab(newActiveTab)
}, [])
const searchPlaceholder = useMemo(() => {
if (activeTab === TabsEnum.Blocks)
return t('workflow.tabs.searchBlock')
if (activeTab === TabsEnum.Tools)
return t('workflow.tabs.searchTool')
return ''
}, [activeTab, t])
return (
<PortalToFollowElem
placement={placement}
offset={offset}
open={open}
onOpenChange={handleOpenChange}
>
<PortalToFollowElemTrigger
asChild={asChild}
onClick={handleTrigger}
className={triggerInnerClassName}
>
{
trigger
? trigger(open)
: (
<div
className={`
z-10 flex h-4
w-4 cursor-pointer items-center justify-center rounded-full bg-components-button-primary-bg text-text-primary-on-surface hover:bg-components-button-primary-bg-hover
${triggerClassName?.(open)}
`}
style={triggerStyle}
>
<Plus02 className='h-2.5 w-2.5' />
</div>
)
}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[1000]'>
<div className={`rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg ${popupClassName}`}>
<div className='px-2 pt-2' onClick={e => e.stopPropagation()}>
{activeTab === TabsEnum.Blocks && (
<Input
showLeftIcon
showClearIcon
autoFocus
value={searchText}
placeholder={searchPlaceholder}
onChange={e => setSearchText(e.target.value)}
onClear={() => setSearchText('')}
/>
)}
{activeTab === TabsEnum.Tools && (
<SearchBox
search={searchText}
onSearchChange={setSearchText}
tags={tags}
onTagsChange={setTags}
size='small'
placeholder={t('plugin.searchTools')!}
/>
)}
</div>
<Tabs
activeTab={activeTab}
onActiveTabChange={handleActiveTabChange}
onSelect={handleSelect}
searchText={searchText}
tags={tags}
availableBlocksTypes={availableBlocksTypes}
blocks={blocks}
/>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default memo(NodeSelector)

@ -1,7 +1,10 @@
import type { FC } from 'react' import type { FC } from 'react'
import { memo } from 'react' import { memo } from 'react'
import { useAllBuiltInTools, useAllCustomTools, useAllWorkflowTools } from '@/service/use-tools' import { useAllBuiltInTools, useAllCustomTools, useAllWorkflowTools } from '@/service/use-tools'
import type { BlockEnum } from '../types' import type {
BlockEnum,
NodeDefault,
} from '../types'
import type { ToolDefaultValue } from './types' import type { ToolDefaultValue } from './types'
import { TabsEnum } from './types' import { TabsEnum } from './types'
import Blocks from './blocks' import Blocks from './blocks'
@ -13,7 +16,7 @@ export type TabsProps = {
tags: string[] tags: string[]
onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void
availableBlocksTypes?: BlockEnum[] availableBlocksTypes?: BlockEnum[]
noBlocks?: boolean blocks: NodeDefault[]
} }
const Tabs: FC<TabsProps> = ({ const Tabs: FC<TabsProps> = ({
activeTab, activeTab,
@ -21,7 +24,7 @@ const Tabs: FC<TabsProps> = ({
searchText, searchText,
onSelect, onSelect,
availableBlocksTypes, availableBlocksTypes,
noBlocks, blocks,
}) => { }) => {
const { data: buildInTools } = useAllBuiltInTools() const { data: buildInTools } = useAllBuiltInTools()
const { data: customTools } = useAllCustomTools() const { data: customTools } = useAllCustomTools()
@ -30,11 +33,12 @@ const Tabs: FC<TabsProps> = ({
return ( return (
<div onClick={e => e.stopPropagation()}> <div onClick={e => e.stopPropagation()}>
{ {
activeTab === TabsEnum.Blocks && !noBlocks && ( activeTab === TabsEnum.Blocks && !!blocks.length && (
<Blocks <Blocks
searchText={searchText} searchText={searchText}
onSelect={onSelect} onSelect={onSelect}
availableBlocksTypes={availableBlocksTypes} availableBlocksTypes={availableBlocksTypes}
blocks={blocks}
/> />
) )
} }

@ -1,5 +0,0 @@
import { BlockEnum } from './types'
export const ALL_AVAILABLE_BLOCKS = Object.values(BlockEnum)
export const ALL_CHAT_AVAILABLE_BLOCKS = ALL_AVAILABLE_BLOCKS.filter(key => key !== BlockEnum.End && key !== BlockEnum.Start) as BlockEnum[]
export const ALL_COMPLETION_AVAILABLE_BLOCKS = ALL_AVAILABLE_BLOCKS.filter(key => key !== BlockEnum.Answer && key !== BlockEnum.Start) as BlockEnum[]

@ -1,421 +1,5 @@
import type { Var } from './types' import type { Var } from './types'
import { BlockEnum, VarType } from './types' import { BlockEnum, VarType } from './types'
import StartNodeDefault from './nodes/start/default'
import AnswerDefault from './nodes/answer/default'
import LLMDefault from './nodes/llm/default'
import KnowledgeRetrievalDefault from './nodes/knowledge-retrieval/default'
import QuestionClassifierDefault from './nodes/question-classifier/default'
import IfElseDefault from './nodes/if-else/default'
import CodeDefault from './nodes/code/default'
import TemplateTransformDefault from './nodes/template-transform/default'
import HttpRequestDefault from './nodes/http/default'
import ParameterExtractorDefault from './nodes/parameter-extractor/default'
import ToolDefault from './nodes/tool/default'
import VariableAssignerDefault from './nodes/variable-assigner/default'
import AssignerDefault from './nodes/assigner/default'
import EndNodeDefault from './nodes/end/default'
import IterationDefault from './nodes/iteration/default'
import LoopDefault from './nodes/loop/default'
import DocExtractorDefault from './nodes/document-extractor/default'
import ListFilterDefault from './nodes/list-operator/default'
import IterationStartDefault from './nodes/iteration-start/default'
import AgentDefault from './nodes/agent/default'
import LoopStartDefault from './nodes/loop-start/default'
import LoopEndDefault from './nodes/loop-end/default'
import DataSourceDefault from './nodes/data-source/default'
type NodesExtraData = {
author: string
about: string
availablePrevNodes: BlockEnum[]
availableNextNodes: BlockEnum[]
getAvailablePrevNodes: (isChatMode: boolean) => BlockEnum[]
getAvailableNextNodes: (isChatMode: boolean) => BlockEnum[]
checkValid: any
}
export const NODES_EXTRA_DATA: Record<BlockEnum, NodesExtraData> = {
[BlockEnum.DataSource]: {
author: 'Dify',
about: '',
availablePrevNodes: [],
availableNextNodes: [],
getAvailablePrevNodes: DataSourceDefault.getAvailablePrevNodes,
getAvailableNextNodes: DataSourceDefault.getAvailableNextNodes,
checkValid: DataSourceDefault.checkValid,
},
[BlockEnum.Start]: {
author: 'Dify',
about: '',
availablePrevNodes: [],
availableNextNodes: [],
getAvailablePrevNodes: StartNodeDefault.getAvailablePrevNodes,
getAvailableNextNodes: StartNodeDefault.getAvailableNextNodes,
checkValid: StartNodeDefault.checkValid,
},
[BlockEnum.End]: {
author: 'Dify',
about: '',
availablePrevNodes: [],
availableNextNodes: [],
getAvailablePrevNodes: EndNodeDefault.getAvailablePrevNodes,
getAvailableNextNodes: EndNodeDefault.getAvailableNextNodes,
checkValid: EndNodeDefault.checkValid,
},
[BlockEnum.Answer]: {
author: 'Dify',
about: '',
availablePrevNodes: [],
availableNextNodes: [],
getAvailablePrevNodes: AnswerDefault.getAvailablePrevNodes,
getAvailableNextNodes: AnswerDefault.getAvailableNextNodes,
checkValid: AnswerDefault.checkValid,
},
[BlockEnum.LLM]: {
author: 'Dify',
about: '',
availablePrevNodes: [],
availableNextNodes: [],
getAvailablePrevNodes: LLMDefault.getAvailablePrevNodes,
getAvailableNextNodes: LLMDefault.getAvailableNextNodes,
checkValid: LLMDefault.checkValid,
},
[BlockEnum.KnowledgeRetrieval]: {
author: 'Dify',
about: '',
availablePrevNodes: [],
availableNextNodes: [],
getAvailablePrevNodes: KnowledgeRetrievalDefault.getAvailablePrevNodes,
getAvailableNextNodes: KnowledgeRetrievalDefault.getAvailableNextNodes,
checkValid: KnowledgeRetrievalDefault.checkValid,
},
[BlockEnum.IfElse]: {
author: 'Dify',
about: '',
availablePrevNodes: [],
availableNextNodes: [],
getAvailablePrevNodes: IfElseDefault.getAvailablePrevNodes,
getAvailableNextNodes: IfElseDefault.getAvailableNextNodes,
checkValid: IfElseDefault.checkValid,
},
[BlockEnum.Iteration]: {
author: 'Dify',
about: '',
availablePrevNodes: [],
availableNextNodes: [],
getAvailablePrevNodes: IterationDefault.getAvailablePrevNodes,
getAvailableNextNodes: IterationDefault.getAvailableNextNodes,
checkValid: IterationDefault.checkValid,
},
[BlockEnum.IterationStart]: {
author: 'Dify',
about: '',
availablePrevNodes: [],
availableNextNodes: [],
getAvailablePrevNodes: IterationStartDefault.getAvailablePrevNodes,
getAvailableNextNodes: IterationStartDefault.getAvailableNextNodes,
checkValid: IterationStartDefault.checkValid,
},
[BlockEnum.Loop]: {
author: 'AICT-Team',
about: '',
availablePrevNodes: [],
availableNextNodes: [],
getAvailablePrevNodes: LoopDefault.getAvailablePrevNodes,
getAvailableNextNodes: LoopDefault.getAvailableNextNodes,
checkValid: LoopDefault.checkValid,
},
[BlockEnum.LoopStart]: {
author: 'AICT-Team',
about: '',
availablePrevNodes: [],
availableNextNodes: [],
getAvailablePrevNodes: LoopStartDefault.getAvailablePrevNodes,
getAvailableNextNodes: LoopStartDefault.getAvailableNextNodes,
checkValid: LoopStartDefault.checkValid,
},
[BlockEnum.LoopEnd]: {
author: 'Dify',
about: '',
availablePrevNodes: [],
availableNextNodes: [],
getAvailablePrevNodes: LoopEndDefault.getAvailablePrevNodes,
getAvailableNextNodes: LoopEndDefault.getAvailableNextNodes,
checkValid: LoopEndDefault.checkValid,
},
[BlockEnum.Code]: {
author: 'Dify',
about: '',
availablePrevNodes: [],
availableNextNodes: [],
getAvailablePrevNodes: CodeDefault.getAvailablePrevNodes,
getAvailableNextNodes: CodeDefault.getAvailableNextNodes,
checkValid: CodeDefault.checkValid,
},
[BlockEnum.TemplateTransform]: {
author: 'Dify',
about: '',
availablePrevNodes: [],
availableNextNodes: [],
getAvailablePrevNodes: TemplateTransformDefault.getAvailablePrevNodes,
getAvailableNextNodes: TemplateTransformDefault.getAvailableNextNodes,
checkValid: TemplateTransformDefault.checkValid,
},
[BlockEnum.QuestionClassifier]: {
author: 'Dify',
about: '',
availablePrevNodes: [],
availableNextNodes: [],
getAvailablePrevNodes: QuestionClassifierDefault.getAvailablePrevNodes,
getAvailableNextNodes: QuestionClassifierDefault.getAvailableNextNodes,
checkValid: QuestionClassifierDefault.checkValid,
},
[BlockEnum.HttpRequest]: {
author: 'Dify',
about: '',
availablePrevNodes: [],
availableNextNodes: [],
getAvailablePrevNodes: HttpRequestDefault.getAvailablePrevNodes,
getAvailableNextNodes: HttpRequestDefault.getAvailableNextNodes,
checkValid: HttpRequestDefault.checkValid,
},
[BlockEnum.VariableAssigner]: {
author: 'Dify',
about: '',
availablePrevNodes: [],
availableNextNodes: [],
getAvailablePrevNodes: VariableAssignerDefault.getAvailablePrevNodes,
getAvailableNextNodes: VariableAssignerDefault.getAvailableNextNodes,
checkValid: VariableAssignerDefault.checkValid,
},
[BlockEnum.Assigner]: {
author: 'Dify',
about: '',
availablePrevNodes: [],
availableNextNodes: [],
getAvailablePrevNodes: AssignerDefault.getAvailablePrevNodes,
getAvailableNextNodes: AssignerDefault.getAvailableNextNodes,
checkValid: AssignerDefault.checkValid,
},
[BlockEnum.VariableAggregator]: {
author: 'Dify',
about: '',
availablePrevNodes: [],
availableNextNodes: [],
getAvailablePrevNodes: VariableAssignerDefault.getAvailablePrevNodes,
getAvailableNextNodes: VariableAssignerDefault.getAvailableNextNodes,
checkValid: VariableAssignerDefault.checkValid,
},
[BlockEnum.ParameterExtractor]: {
author: 'Dify',
about: '',
availablePrevNodes: [],
availableNextNodes: [],
getAvailablePrevNodes: ParameterExtractorDefault.getAvailablePrevNodes,
getAvailableNextNodes: ParameterExtractorDefault.getAvailableNextNodes,
checkValid: ParameterExtractorDefault.checkValid,
},
[BlockEnum.Tool]: {
author: 'Dify',
about: '',
availablePrevNodes: [],
availableNextNodes: [],
getAvailablePrevNodes: ToolDefault.getAvailablePrevNodes,
getAvailableNextNodes: ToolDefault.getAvailableNextNodes,
checkValid: ToolDefault.checkValid,
},
[BlockEnum.DocExtractor]: {
author: 'Dify',
about: '',
availablePrevNodes: [],
availableNextNodes: [],
getAvailablePrevNodes: DocExtractorDefault.getAvailablePrevNodes,
getAvailableNextNodes: DocExtractorDefault.getAvailableNextNodes,
checkValid: DocExtractorDefault.checkValid,
},
[BlockEnum.ListFilter]: {
author: 'Dify',
about: '',
availablePrevNodes: [],
availableNextNodes: [],
getAvailablePrevNodes: ListFilterDefault.getAvailablePrevNodes,
getAvailableNextNodes: ListFilterDefault.getAvailableNextNodes,
checkValid: ListFilterDefault.checkValid,
},
[BlockEnum.Agent]: {
author: 'Dify',
about: '',
availablePrevNodes: [],
availableNextNodes: [],
getAvailablePrevNodes: ListFilterDefault.getAvailablePrevNodes,
getAvailableNextNodes: ListFilterDefault.getAvailableNextNodes,
checkValid: AgentDefault.checkValid,
},
}
export const NODES_INITIAL_DATA = {
[BlockEnum.DataSource]: {
type: BlockEnum.DataSource,
title: '',
desc: '',
...DataSourceDefault.defaultValue,
},
[BlockEnum.Start]: {
type: BlockEnum.Start,
title: '',
desc: '',
...StartNodeDefault.defaultValue,
},
[BlockEnum.End]: {
type: BlockEnum.End,
title: '',
desc: '',
...EndNodeDefault.defaultValue,
},
[BlockEnum.Answer]: {
type: BlockEnum.Answer,
title: '',
desc: '',
...AnswerDefault.defaultValue,
},
[BlockEnum.LLM]: {
type: BlockEnum.LLM,
title: '',
desc: '',
variables: [],
...LLMDefault.defaultValue,
},
[BlockEnum.KnowledgeRetrieval]: {
type: BlockEnum.KnowledgeRetrieval,
title: '',
desc: '',
query_variable_selector: [],
dataset_ids: [],
retrieval_mode: 'single',
...KnowledgeRetrievalDefault.defaultValue,
},
[BlockEnum.IfElse]: {
type: BlockEnum.IfElse,
title: '',
desc: '',
...IfElseDefault.defaultValue,
},
[BlockEnum.Iteration]: {
type: BlockEnum.Iteration,
title: '',
desc: '',
...IterationDefault.defaultValue,
},
[BlockEnum.IterationStart]: {
type: BlockEnum.IterationStart,
title: '',
desc: '',
...IterationStartDefault.defaultValue,
},
[BlockEnum.Loop]: {
type: BlockEnum.Loop,
title: '',
desc: '',
...LoopDefault.defaultValue,
},
[BlockEnum.LoopStart]: {
type: BlockEnum.LoopStart,
title: '',
desc: '',
...LoopStartDefault.defaultValue,
},
[BlockEnum.LoopEnd]: {
type: BlockEnum.LoopEnd,
title: '',
desc: '',
...LoopEndDefault.defaultValue,
},
[BlockEnum.Code]: {
type: BlockEnum.Code,
title: '',
desc: '',
variables: [],
code_language: 'python3',
code: '',
outputs: [],
...CodeDefault.defaultValue,
},
[BlockEnum.TemplateTransform]: {
type: BlockEnum.TemplateTransform,
title: '',
desc: '',
variables: [],
template: '',
...TemplateTransformDefault.defaultValue,
},
[BlockEnum.QuestionClassifier]: {
type: BlockEnum.QuestionClassifier,
title: '',
desc: '',
query_variable_selector: [],
topics: [],
...QuestionClassifierDefault.defaultValue,
},
[BlockEnum.HttpRequest]: {
type: BlockEnum.HttpRequest,
title: '',
desc: '',
variables: [],
...HttpRequestDefault.defaultValue,
},
[BlockEnum.ParameterExtractor]: {
type: BlockEnum.ParameterExtractor,
title: '',
desc: '',
variables: [],
...ParameterExtractorDefault.defaultValue,
},
[BlockEnum.VariableAssigner]: {
type: BlockEnum.VariableAssigner,
title: '',
desc: '',
variables: [],
output_type: '',
...VariableAssignerDefault.defaultValue,
},
[BlockEnum.VariableAggregator]: {
type: BlockEnum.VariableAggregator,
title: '',
desc: '',
variables: [],
output_type: '',
...VariableAssignerDefault.defaultValue,
},
[BlockEnum.Assigner]: {
type: BlockEnum.Assigner,
title: '',
desc: '',
...AssignerDefault.defaultValue,
},
[BlockEnum.Tool]: {
type: BlockEnum.Tool,
title: '',
desc: '',
...ToolDefault.defaultValue,
},
[BlockEnum.DocExtractor]: {
type: BlockEnum.DocExtractor,
title: '',
desc: '',
...DocExtractorDefault.defaultValue,
},
[BlockEnum.ListFilter]: {
type: BlockEnum.ListFilter,
title: '',
desc: '',
...ListFilterDefault.defaultValue,
},
[BlockEnum.Agent]: {
type: BlockEnum.Agent,
title: '',
desc: '',
...AgentDefault.defaultValue,
},
}
export const MAX_ITERATION_PARALLEL_NUM = 10 export const MAX_ITERATION_PARALLEL_NUM = 10
export const MIN_ITERATION_PARALLEL_NUM = 1 export const MIN_ITERATION_PARALLEL_NUM = 1
export const DEFAULT_ITER_TIMES = 1 export const DEFAULT_ITER_TIMES = 1

@ -0,0 +1,42 @@
import llmDefault from '@/app/components/workflow/nodes/llm/default'
import knowledgeRetrievalDefault from '@/app/components/workflow/nodes/knowledge-retrieval/default'
import agentDefault from '@/app/components/workflow/nodes/agent/default'
import questionClassifierDefault from '@/app/components/workflow/nodes/question-classifier/default'
import ifElseDefault from '@/app/components/workflow/nodes/if-else/default'
import iterationDefault from '@/app/components/workflow/nodes/iteration/default'
import iterationStartDefault from '@/app/components/workflow/nodes/iteration-start/default'
import loopDefault from '@/app/components/workflow/nodes/loop/default'
import loopStartDefault from '@/app/components/workflow/nodes/loop-start/default'
import loopEndDefault from '@/app/components/workflow/nodes/loop-end/default'
import codeDefault from '@/app/components/workflow/nodes/code/default'
import templateTransformDefault from '@/app/components/workflow/nodes/template-transform/default'
import variableAggregatorDefault from '@/app/components/workflow/nodes/variable-assigner/default'
import documentExtractorDefault from '@/app/components/workflow/nodes/document-extractor/default'
import assignerDefault from '@/app/components/workflow/nodes/assigner/default'
import httpRequestDefault from '@/app/components/workflow/nodes/http/default'
import parameterExtractorDefault from '@/app/components/workflow/nodes/parameter-extractor/default'
import listOperatorDefault from '@/app/components/workflow/nodes/list-operator/default'
export const WORKFLOW_COMMON_NODES = [
llmDefault,
knowledgeRetrievalDefault,
agentDefault,
questionClassifierDefault,
ifElseDefault,
iterationDefault,
iterationStartDefault,
loopDefault,
loopStartDefault,
loopEndDefault,
codeDefault,
templateTransformDefault,
variableAggregatorDefault,
documentExtractorDefault,
assignerDefault,
parameterExtractorDefault,
httpRequestDefault,
listOperatorDefault,
]

@ -56,8 +56,9 @@ const CustomEdge = ({
}) })
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const { handleNodeAdd } = useNodesInteractions() const { handleNodeAdd } = useNodesInteractions()
const { availablePrevBlocks } = useAvailableBlocks((data as Edge['data'])!.targetType, (data as Edge['data'])?.isInIteration, (data as Edge['data'])?.isInLoop) const { availablePrevBlocks } = useAvailableBlocks((data as Edge['data'])!.targetType, (data as Edge['data'])?.isInIteration || (data as Edge['data'])?.isInLoop)
const { availableNextBlocks } = useAvailableBlocks((data as Edge['data'])!.sourceType, (data as Edge['data'])?.isInIteration, (data as Edge['data'])?.isInLoop) const { availableNextBlocks } = useAvailableBlocks((data as Edge['data'])!.sourceType, (data as Edge['data'])?.isInIteration || (data as Edge['data'])?.isInLoop)
console.log(availableNextBlocks, 'xx')
const { const {
_sourceRunningStatus, _sourceRunningStatus,
_targetRunningStatus, _targetRunningStatus,

@ -7,8 +7,16 @@ import {
} from 'zustand' } from 'zustand'
import { createStore } from 'zustand/vanilla' import { createStore } from 'zustand/vanilla'
import { HooksStoreContext } from './provider' import { HooksStoreContext } from './provider'
import type {
BlockEnum,
NodeDefault,
} from '@/app/components/workflow/types'
type CommonHooksFnMap = { export type AvailableNodesMetaData = {
nodes: NodeDefault[]
nodesMap?: Record<BlockEnum, NodeDefault<any>>
}
export type CommonHooksFnMap = {
doSyncWorkflowDraft: ( doSyncWorkflowDraft: (
notRefreshWhenSyncError?: boolean, notRefreshWhenSyncError?: boolean,
callback?: { callback?: {
@ -26,6 +34,7 @@ type CommonHooksFnMap = {
handleStartWorkflowRun: () => void handleStartWorkflowRun: () => void
handleWorkflowStartRunInWorkflow: () => void handleWorkflowStartRunInWorkflow: () => void
handleWorkflowStartRunInChatflow: () => void handleWorkflowStartRunInChatflow: () => void
availableNodesMetaData?: AvailableNodesMetaData
} }
export type Shape = { export type Shape = {
@ -43,6 +52,9 @@ export const createHooksStore = ({
handleStartWorkflowRun = noop, handleStartWorkflowRun = noop,
handleWorkflowStartRunInWorkflow = noop, handleWorkflowStartRunInWorkflow = noop,
handleWorkflowStartRunInChatflow = noop, handleWorkflowStartRunInChatflow = noop,
availableNodesMetaData = {
nodes: [],
},
}: Partial<Shape>) => { }: Partial<Shape>) => {
return createStore<Shape>(set => ({ return createStore<Shape>(set => ({
refreshAll: props => set(state => ({ ...state, ...props })), refreshAll: props => set(state => ({ ...state, ...props })),
@ -56,6 +68,7 @@ export const createHooksStore = ({
handleStartWorkflowRun, handleStartWorkflowRun,
handleWorkflowStartRunInWorkflow, handleWorkflowStartRunInWorkflow,
handleWorkflowStartRunInChatflow, handleWorkflowStartRunInChatflow,
availableNodesMetaData,
})) }))
} }

@ -1,7 +1,6 @@
export * from './use-edges-interactions' export * from './use-edges-interactions'
export * from './use-node-data-update' export * from './use-node-data-update'
export * from './use-nodes-interactions' export * from './use-nodes-interactions'
export * from './use-nodes-data'
export * from './use-nodes-sync-draft' export * from './use-nodes-sync-draft'
export * from './use-workflow' export * from './use-workflow'
export * from './use-workflow-run' export * from './use-workflow-run'
@ -16,3 +15,5 @@ export * from './use-shortcuts'
export * from './use-workflow-interactions' export * from './use-workflow-interactions'
export * from './use-workflow-mode' export * from './use-workflow-mode'
export * from './use-format-time-from-now' export * from './use-format-time-from-now'
export * from './use-nodes-meta-data'
export * from './use-available-blocks'

@ -0,0 +1,58 @@
import {
useCallback,
useMemo,
} from 'react'
import { BlockEnum } from '../types'
import { useNodesMetaData } from './use-nodes-meta-data'
const availableBlocksFilter = (nodeType: BlockEnum, inContainer?: boolean) => {
if (inContainer && (nodeType === BlockEnum.Iteration || nodeType === BlockEnum.Loop || nodeType === BlockEnum.End))
return false
if (!inContainer && nodeType === BlockEnum.LoopEnd)
return false
return true
}
export const useAvailableBlocks = (nodeType?: BlockEnum, inContainer?: boolean) => {
const {
nodes: availableNodes,
} = useNodesMetaData()
const availableNodesType = useMemo(() => availableNodes.map(node => node.type), [availableNodes])
const availablePrevBlocks = useMemo(() => {
if (!nodeType || nodeType === BlockEnum.Start)
return []
return availableNodesType
}, [availableNodesType, nodeType])
const availableNextBlocks = useMemo(() => {
if (!nodeType || nodeType === BlockEnum.End || nodeType === BlockEnum.LoopEnd)
return []
return availableNodesType
}, [availableNodesType, nodeType])
const getAvailableBlocks = useCallback((nodeType?: BlockEnum, inContainer?: boolean) => {
let availablePrevBlocks = availableNodesType
if (!nodeType || nodeType === BlockEnum.Start)
availablePrevBlocks = []
let availableNextBlocks = availableNodesType
if (!nodeType || nodeType === BlockEnum.End || nodeType === BlockEnum.LoopEnd)
availableNextBlocks = []
return {
availablePrevBlocks: availablePrevBlocks.filter(nType => availableBlocksFilter(nType, inContainer)),
availableNextBlocks: availableNextBlocks.filter(nType => availableBlocksFilter(nType, inContainer)),
}
}, [availableNodesType])
return useMemo(() => {
return {
getAvailableBlocks,
availablePrevBlocks: availablePrevBlocks.filter(nType => availableBlocksFilter(nType, inContainer)),
availableNextBlocks: availableNextBlocks.filter(nType => availableBlocksFilter(nType, inContainer)),
}
}, [getAvailableBlocks, availablePrevBlocks, availableNextBlocks, inContainer])
}

@ -22,7 +22,7 @@ import {
} from '../constants' } from '../constants'
import type { ToolNodeType } from '../nodes/tool/types' import type { ToolNodeType } from '../nodes/tool/types'
import { useIsChatMode } from './use-workflow' import { useIsChatMode } from './use-workflow'
import { useNodesExtraData } from './use-nodes-data' import { useNodesMetaData } from './use-nodes-meta-data'
import { useToastContext } from '@/app/components/base/toast' import { useToastContext } from '@/app/components/base/toast'
import { CollectionType } from '@/app/components/tools/types' import { CollectionType } from '@/app/components/tools/types'
import { useGetLanguage } from '@/context/i18n' import { useGetLanguage } from '@/context/i18n'
@ -37,7 +37,7 @@ import { fetchDatasets } from '@/service/datasets'
export const useChecklist = (nodes: Node[], edges: Edge[]) => { export const useChecklist = (nodes: Node[], edges: Edge[]) => {
const { t } = useTranslation() const { t } = useTranslation()
const language = useGetLanguage() const language = useGetLanguage()
const nodesExtraData = useNodesExtraData() const { nodesMap: nodesExtraData } = useNodesMetaData()
const isChatMode = useIsChatMode() const isChatMode = useIsChatMode()
const buildInTools = useStore(s => s.buildInTools) const buildInTools = useStore(s => s.buildInTools)
const customTools = useStore(s => s.customTools) const customTools = useStore(s => s.customTools)
@ -100,7 +100,7 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => {
if (node.type === CUSTOM_NODE) { if (node.type === CUSTOM_NODE) {
const checkData = getCheckData(node.data) const checkData = getCheckData(node.data)
const { errorMessage } = nodesExtraData[node.data.type].checkValid(checkData, t, moreDataForCheckValid) const { errorMessage } = nodesExtraData![node.data.type].checkValid(checkData, t, moreDataForCheckValid)
if (errorMessage || !validNodes.find(n => n.id === node.id)) { if (errorMessage || !validNodes.find(n => n.id === node.id)) {
list.push({ list.push({
@ -148,7 +148,7 @@ export const useChecklistBeforePublish = () => {
const { notify } = useToastContext() const { notify } = useToastContext()
const isChatMode = useIsChatMode() const isChatMode = useIsChatMode()
const store = useStoreApi() const store = useStoreApi()
const nodesExtraData = useNodesExtraData() const { nodesMap: nodesExtraData } = useNodesMetaData()
const { data: strategyProviders } = useStrategyProviders() const { data: strategyProviders } = useStrategyProviders()
const updateDatasetsDetail = useDatasetsDetailStore(s => s.updateDatasetsDetail) const updateDatasetsDetail = useDatasetsDetailStore(s => s.updateDatasetsDetail)
const updateTime = useRef(0) const updateTime = useRef(0)
@ -228,7 +228,7 @@ export const useChecklistBeforePublish = () => {
} }
const checkData = getCheckData(node.data, datasets) const checkData = getCheckData(node.data, datasets)
const { errorMessage } = nodesExtraData[node.data.type as BlockEnum].checkValid(checkData, t, moreDataForCheckValid) const { errorMessage } = nodesExtraData![node.data.type as BlockEnum].checkValid(checkData, t, moreDataForCheckValid)
if (errorMessage) { if (errorMessage) {
notify({ type: 'error', message: `[${node.data.title}] ${errorMessage}` }) notify({ type: 'error', message: `[${node.data.title}] ${errorMessage}` })

@ -1,77 +0,0 @@
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import produce from 'immer'
import { BlockEnum } from '../types'
import {
NODES_EXTRA_DATA,
NODES_INITIAL_DATA,
} from '../constants'
import { useIsChatMode } from './use-workflow'
export const useNodesInitialData = () => {
const { t } = useTranslation()
return useMemo(() => produce(NODES_INITIAL_DATA, (draft) => {
Object.keys(draft).forEach((key) => {
draft[key as BlockEnum].title = t(`workflow.blocks.${key}`)
})
}), [t])
}
export const useNodesExtraData = () => {
const { t } = useTranslation()
const isChatMode = useIsChatMode()
return useMemo(() => produce(NODES_EXTRA_DATA, (draft) => {
Object.keys(draft).forEach((key) => {
draft[key as BlockEnum].about = t(`workflow.blocksAbout.${key}`)
draft[key as BlockEnum].availablePrevNodes = draft[key as BlockEnum].getAvailablePrevNodes(isChatMode)
draft[key as BlockEnum].availableNextNodes = draft[key as BlockEnum].getAvailableNextNodes(isChatMode)
})
}), [t, isChatMode])
}
export const useAvailableBlocks = (nodeType?: BlockEnum, isInIteration?: boolean, isInLoop?: boolean) => {
const nodesExtraData = useNodesExtraData()
const availablePrevBlocks = useMemo(() => {
if (!nodeType)
return []
return nodesExtraData[nodeType].availablePrevNodes || []
}, [nodeType, nodesExtraData])
const availableNextBlocks = useMemo(() => {
if (!nodeType)
return []
return nodesExtraData[nodeType].availableNextNodes || []
}, [nodeType, nodesExtraData])
return useMemo(() => {
return {
availablePrevBlocks: availablePrevBlocks.filter((nType) => {
if (isInIteration && (nType === BlockEnum.Iteration || nType === BlockEnum.Loop || nType === BlockEnum.End))
return false
if (isInLoop && (nType === BlockEnum.Iteration || nType === BlockEnum.Loop || nType === BlockEnum.End))
return false
if (!isInLoop && nType === BlockEnum.LoopEnd)
return false
return true
}),
availableNextBlocks: availableNextBlocks.filter((nType) => {
if (isInIteration && (nType === BlockEnum.Iteration || nType === BlockEnum.Loop || nType === BlockEnum.End))
return false
if (isInLoop && (nType === BlockEnum.Iteration || nType === BlockEnum.Loop || nType === BlockEnum.End))
return false
if (!isInLoop && nType === BlockEnum.LoopEnd)
return false
return true
}),
}
}, [isInIteration, availablePrevBlocks, availableNextBlocks, isInLoop])
}

@ -31,7 +31,6 @@ import {
ITERATION_PADDING, ITERATION_PADDING,
LOOP_CHILDREN_Z_INDEX, LOOP_CHILDREN_Z_INDEX,
LOOP_PADDING, LOOP_PADDING,
NODES_INITIAL_DATA,
NODE_WIDTH_X_OFFSET, NODE_WIDTH_X_OFFSET,
X_OFFSET, X_OFFSET,
Y_OFFSET, Y_OFFSET,
@ -60,6 +59,7 @@ import {
useWorkflowReadOnly, useWorkflowReadOnly,
} from './use-workflow' } from './use-workflow'
import { WorkflowHistoryEvent, useWorkflowHistory } from './use-workflow-history' import { WorkflowHistoryEvent, useWorkflowHistory } from './use-workflow-history'
import { useNodesMetaData } from './use-nodes-meta-data'
export const useNodesInteractions = () => { export const useNodesInteractions = () => {
const { t } = useTranslation() const { t } = useTranslation()
@ -84,6 +84,7 @@ export const useNodesInteractions = () => {
handleNodeLoopChildrenCopy, handleNodeLoopChildrenCopy,
} = useNodeLoopInteractions() } = useNodeLoopInteractions()
const dragNodeStartPosition = useRef({ x: 0, y: 0 } as { x: number; y: number }) const dragNodeStartPosition = useRef({ x: 0, y: 0 } as { x: number; y: number })
const { nodesMap: nodesMetaDataMap } = useNodesMetaData()
const { saveStateToHistory, undo, redo } = useWorkflowHistory() const { saveStateToHistory, undo, redo } = useWorkflowHistory()
@ -682,6 +683,10 @@ export const useNodesInteractions = () => {
} = store.getState() } = store.getState()
const nodes = getNodes() const nodes = getNodes()
const nodesWithSameType = nodes.filter(node => node.data.type === nodeType) const nodesWithSameType = nodes.filter(node => node.data.type === nodeType)
const {
defaultValue,
title,
} = nodesMetaDataMap![nodeType]
const { const {
newNode, newNode,
newIterationStartNode, newIterationStartNode,
@ -689,8 +694,8 @@ export const useNodesInteractions = () => {
} = generateNewNode({ } = generateNewNode({
type: getNodeCustomTypeByNodeDataType(nodeType), type: getNodeCustomTypeByNodeDataType(nodeType),
data: { data: {
...NODES_INITIAL_DATA[nodeType], ...(defaultValue as any),
title: nodesWithSameType.length > 0 ? `${t(`workflow.blocks.${nodeType}`)} ${nodesWithSameType.length + 1}` : t(`workflow.blocks.${nodeType}`), title: nodesWithSameType.length > 0 ? `${title} ${nodesWithSameType.length + 1}` : title,
...(toolDefaultValue || {}), ...(toolDefaultValue || {}),
selected: true, selected: true,
_showAddVariablePopup: (nodeType === BlockEnum.VariableAssigner || nodeType === BlockEnum.VariableAggregator) && !!prevNodeId, _showAddVariablePopup: (nodeType === BlockEnum.VariableAssigner || nodeType === BlockEnum.VariableAggregator) && !!prevNodeId,
@ -1093,7 +1098,7 @@ export const useNodesInteractions = () => {
} }
handleSyncWorkflowDraft() handleSyncWorkflowDraft()
saveStateToHistory(WorkflowHistoryEvent.NodeAdd) saveStateToHistory(WorkflowHistoryEvent.NodeAdd)
}, [getNodesReadOnly, store, t, handleSyncWorkflowDraft, saveStateToHistory, workflowStore, getAfterNodesInSameBranch, checkNestedParallelLimit]) }, [getNodesReadOnly, store, handleSyncWorkflowDraft, saveStateToHistory, workflowStore, getAfterNodesInSameBranch, checkNestedParallelLimit, nodesMetaDataMap])
const handleNodeChange = useCallback(( const handleNodeChange = useCallback((
currentNodeId: string, currentNodeId: string,
@ -1114,6 +1119,10 @@ export const useNodesInteractions = () => {
const currentNode = nodes.find(node => node.id === currentNodeId)! const currentNode = nodes.find(node => node.id === currentNodeId)!
const connectedEdges = getConnectedEdges([currentNode], edges) const connectedEdges = getConnectedEdges([currentNode], edges)
const nodesWithSameType = nodes.filter(node => node.data.type === nodeType) const nodesWithSameType = nodes.filter(node => node.data.type === nodeType)
const {
defaultValue,
title,
} = nodesMetaDataMap![nodeType]
const { const {
newNode: newCurrentNode, newNode: newCurrentNode,
newIterationStartNode, newIterationStartNode,
@ -1121,8 +1130,8 @@ export const useNodesInteractions = () => {
} = generateNewNode({ } = generateNewNode({
type: getNodeCustomTypeByNodeDataType(nodeType), type: getNodeCustomTypeByNodeDataType(nodeType),
data: { data: {
...NODES_INITIAL_DATA[nodeType], ...(defaultValue as any),
title: nodesWithSameType.length > 0 ? `${t(`workflow.blocks.${nodeType}`)} ${nodesWithSameType.length + 1}` : t(`workflow.blocks.${nodeType}`), title: nodesWithSameType.length > 0 ? `${title} ${nodesWithSameType.length + 1}` : title,
...(toolDefaultValue || {}), ...(toolDefaultValue || {}),
_connectedSourceHandleIds: [], _connectedSourceHandleIds: [],
_connectedTargetHandleIds: [], _connectedTargetHandleIds: [],
@ -1175,7 +1184,7 @@ export const useNodesInteractions = () => {
handleSyncWorkflowDraft() handleSyncWorkflowDraft()
saveStateToHistory(WorkflowHistoryEvent.NodeChange) saveStateToHistory(WorkflowHistoryEvent.NodeChange)
}, [getNodesReadOnly, store, t, handleSyncWorkflowDraft, saveStateToHistory]) }, [getNodesReadOnly, store, handleSyncWorkflowDraft, saveStateToHistory, nodesMetaDataMap])
const handleNodesCancelSelected = useCallback(() => { const handleNodesCancelSelected = useCallback(() => {
const { const {
@ -1285,7 +1294,7 @@ export const useNodesInteractions = () => {
} = generateNewNode({ } = generateNewNode({
type: nodeToPaste.type, type: nodeToPaste.type,
data: { data: {
...NODES_INITIAL_DATA[nodeType], ...nodesMetaDataMap![nodeType].defaultValue,
...nodeToPaste.data, ...nodeToPaste.data,
selected: false, selected: false,
_isBundled: false, _isBundled: false,
@ -1361,7 +1370,7 @@ export const useNodesInteractions = () => {
saveStateToHistory(WorkflowHistoryEvent.NodePaste) saveStateToHistory(WorkflowHistoryEvent.NodePaste)
handleSyncWorkflowDraft() handleSyncWorkflowDraft()
} }
}, [getNodesReadOnly, workflowStore, store, reactflow, saveStateToHistory, handleSyncWorkflowDraft, handleNodeIterationChildrenCopy, handleNodeLoopChildrenCopy]) }, [getNodesReadOnly, workflowStore, store, reactflow, saveStateToHistory, handleSyncWorkflowDraft, handleNodeIterationChildrenCopy, handleNodeLoopChildrenCopy, nodesMetaDataMap])
const handleNodesDuplicate = useCallback((nodeId?: string) => { const handleNodesDuplicate = useCallback((nodeId?: string) => {
if (getNodesReadOnly()) if (getNodesReadOnly())

@ -0,0 +1,14 @@
import { useMemo } from 'react'
import type { AvailableNodesMetaData } from '@/app/components/workflow/hooks-store'
import { useHooksStore } from '@/app/components/workflow/hooks-store'
export const useNodesMetaData = () => {
const availableNodesMetaData = useHooksStore(s => s.availableNodesMetaData)
return useMemo(() => {
return {
nodes: availableNodesMetaData?.nodes || [],
nodesMap: availableNodesMetaData?.nodesMap || {},
} as AvailableNodesMetaData
}, [availableNodesMetaData])
}

@ -32,7 +32,7 @@ import {
} from '../constants' } from '../constants'
import { CUSTOM_NOTE_NODE } from '../note-node/constants' import { CUSTOM_NOTE_NODE } from '../note-node/constants'
import { findUsedVarNodes, getNodeOutputVars, updateNodeVars } from '../nodes/_base/components/variable/utils' import { findUsedVarNodes, getNodeOutputVars, updateNodeVars } from '../nodes/_base/components/variable/utils'
import { useNodesExtraData } from './use-nodes-data' import { useAvailableBlocks } from './use-available-blocks'
import { useStore as useAppStore } from '@/app/components/app/store' import { useStore as useAppStore } from '@/app/components/app/store'
import { import {
fetchAllBuiltInTools, fetchAllBuiltInTools,
@ -55,7 +55,7 @@ export const useWorkflow = () => {
const { t } = useTranslation() const { t } = useTranslation()
const store = useStoreApi() const store = useStoreApi()
const workflowStore = useWorkflowStore() const workflowStore = useWorkflowStore()
const nodesExtraData = useNodesExtraData() const { getAvailableBlocks } = useAvailableBlocks()
const setPanelWidth = useCallback((width: number) => { const setPanelWidth = useCallback((width: number) => {
localStorage.setItem('workflow-node-panel-width', `${width}`) localStorage.setItem('workflow-node-panel-width', `${width}`)
workflowStore.setState({ panelWidth: width }) workflowStore.setState({ panelWidth: width })
@ -361,8 +361,8 @@ export const useWorkflow = () => {
return false return false
if (sourceNode && targetNode) { if (sourceNode && targetNode) {
const sourceNodeAvailableNextNodes = nodesExtraData[sourceNode.data.type].availableNextNodes const sourceNodeAvailableNextNodes = getAvailableBlocks(sourceNode.data.type, !!sourceNode.parentId).availableNextBlocks
const targetNodeAvailablePrevNodes = [...nodesExtraData[targetNode.data.type].availablePrevNodes, BlockEnum.Start] const targetNodeAvailablePrevNodes = getAvailableBlocks(targetNode.data.type, !!targetNode.parentId).availablePrevBlocks
if (!sourceNodeAvailableNextNodes.includes(targetNode.data.type)) if (!sourceNodeAvailableNextNodes.includes(targetNode.data.type))
return false return false
@ -386,7 +386,7 @@ export const useWorkflow = () => {
} }
return !hasCycle(targetNode) return !hasCycle(targetNode)
}, [store, nodesExtraData, checkParallelLimit]) }, [store, checkParallelLimit, getAvailableBlocks])
const getNode = useCallback((nodeId?: string) => { const getNode = useCallback((nodeId?: string) => {
const { getNodes } = store.getState() const { getNodes } = store.getState()

@ -17,7 +17,7 @@ const ErrorHandleTip = ({
if (type === ErrorHandleTypeEnum.defaultValue) if (type === ErrorHandleTypeEnum.defaultValue)
return t('workflow.nodes.common.errorHandle.defaultValue.inLog') return t('workflow.nodes.common.errorHandle.defaultValue.inLog')
}, []) }, [t, type])
if (!type) if (!type)
return null return null

@ -38,7 +38,7 @@ const Add = ({
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const { handleNodeAdd } = useNodesInteractions() const { handleNodeAdd } = useNodesInteractions()
const { nodesReadOnly } = useNodesReadOnly() const { nodesReadOnly } = useNodesReadOnly()
const { availableNextBlocks } = useAvailableBlocks(nodeData.type, nodeData.isInIteration, nodeData.isInLoop) const { availableNextBlocks } = useAvailableBlocks(nodeData.type, nodeData.isInIteration || nodeData.isInLoop)
const { checkParallelLimit } = useWorkflow() const { checkParallelLimit } = useWorkflow()
const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => { const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => {

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

Loading…
Cancel
Save