Merge branch 'tracing-weave' of github.com:parambharat/dify into tracing-weave

pull/14262/head
Bharat Ramanathan 1 year ago
commit a2f8a49d5d

@ -6,7 +6,7 @@
本指南和 Dify 一样在不断完善中。如果有任何滞后于项目实际情况的地方,恳请谅解,我们也欢迎任何改进建议。 本指南和 Dify 一样在不断完善中。如果有任何滞后于项目实际情况的地方,恳请谅解,我们也欢迎任何改进建议。
关于许可证,请花一分钟阅读我们简短的[许可和贡献者协议](./LICENSE)。社区同时也遵循[行为准则](https://github.com/langgenius/.github/blob/main/CODE_OF_CONDUCT.md)。 关于许可证,请花一分钟阅读我们简短的[许可和贡献者协议](./LICENSE)。同时也遵循社区[行为准则](https://github.com/langgenius/.github/blob/main/CODE_OF_CONDUCT.md)。
## 开始之前 ## 开始之前

@ -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">

@ -483,3 +483,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

@ -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,
)

@ -9,7 +9,7 @@ class PackagingInfo(BaseSettings):
CURRENT_VERSION: str = Field( CURRENT_VERSION: str = Field(
description="Dify version", description="Dify version",
default="1.2.0", default="1.3.0",
) )
COMMIT_SHA: str = Field( COMMIT_SHA: str = Field(

@ -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

@ -80,8 +80,6 @@ class ChatMessageTextApi(Resource):
@account_initialization_required @account_initialization_required
@get_app_model @get_app_model
def post(self, app_model: App): def post(self, app_model: App):
from werkzeug.exceptions import InternalServerError
try: try:
parser = reqparse.RequestParser() parser = reqparse.RequestParser()
parser.add_argument("message_id", type=str, location="json") parser.add_argument("message_id", type=str, location="json")

@ -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,
),
)

@ -2,12 +2,12 @@ import array
import json import json
import re import re
import uuid import uuid
from contextlib import contextmanager
from typing import Any from typing import Any
import jieba.posseg as pseg # type: ignore import jieba.posseg as pseg # type: ignore
import numpy import numpy
import oracledb import oracledb
from oracledb.connection import Connection
from pydantic import BaseModel, model_validator from pydantic import BaseModel, model_validator
from configs import dify_config from configs import dify_config
@ -70,6 +70,7 @@ class OracleVector(BaseVector):
super().__init__(collection_name) super().__init__(collection_name)
self.pool = self._create_connection_pool(config) self.pool = self._create_connection_pool(config)
self.table_name = f"embedding_{collection_name}" self.table_name = f"embedding_{collection_name}"
self.config = config
def get_type(self) -> str: def get_type(self) -> str:
return VectorType.ORACLE return VectorType.ORACLE
@ -107,16 +108,19 @@ class OracleVector(BaseVector):
outconverter=self.numpy_converter_out, outconverter=self.numpy_converter_out,
) )
def _get_connection(self) -> Connection:
connection = oracledb.connect(user=self.config.user, password=self.config.password, dsn=self.config.dsn)
return connection
def _create_connection_pool(self, config: OracleVectorConfig): def _create_connection_pool(self, config: OracleVectorConfig):
pool_params = { pool_params = {
"user": config.user, "user": config.user,
"password": config.password, "password": config.password,
"dsn": config.dsn, "dsn": config.dsn,
"min": 1, "min": 1,
"max": 50, "max": 5,
"increment": 1, "increment": 1,
} }
if config.is_autonomous: if config.is_autonomous:
pool_params.update( pool_params.update(
{ {
@ -125,22 +129,8 @@ class OracleVector(BaseVector):
"wallet_password": config.wallet_password, "wallet_password": config.wallet_password,
} }
) )
return oracledb.create_pool(**pool_params) return oracledb.create_pool(**pool_params)
@contextmanager
def _get_cursor(self):
conn = self.pool.acquire()
conn.inputtypehandler = self.input_type_handler
conn.outputtypehandler = self.output_type_handler
cur = conn.cursor()
try:
yield cur
finally:
cur.close()
conn.commit()
conn.close()
def create(self, texts: list[Document], embeddings: list[list[float]], **kwargs): def create(self, texts: list[Document], embeddings: list[list[float]], **kwargs):
dimension = len(embeddings[0]) dimension = len(embeddings[0])
self._create_collection(dimension) self._create_collection(dimension)
@ -162,41 +152,68 @@ class OracleVector(BaseVector):
numpy.array(embeddings[i]), numpy.array(embeddings[i]),
) )
) )
# print(f"INSERT INTO {self.table_name} (id, text, meta, embedding) VALUES (:1, :2, :3, :4)") with self._get_connection() as conn:
with self._get_cursor() as cur: conn.inputtypehandler = self.input_type_handler
cur.executemany( conn.outputtypehandler = self.output_type_handler
f"INSERT INTO {self.table_name} (id, text, meta, embedding) VALUES (:1, :2, :3, :4)", values # with conn.cursor() as cur:
) # cur.executemany(
# f"INSERT INTO {self.table_name} (id, text, meta, embedding) VALUES (:1, :2, :3, :4)", values
# )
# conn.commit()
for value in values:
with conn.cursor() as cur:
try:
cur.execute(
f"""INSERT INTO {self.table_name} (id, text, meta, embedding)
VALUES (:1, :2, :3, :4)""",
value,
)
conn.commit()
except Exception as e:
print(e)
conn.close()
return pks return pks
def text_exists(self, id: str) -> bool: def text_exists(self, id: str) -> bool:
with self._get_cursor() as cur: with self._get_connection() as conn:
cur.execute(f"SELECT id FROM {self.table_name} WHERE id = '%s'" % (id,)) with conn.cursor() as cur:
return cur.fetchone() is not None cur.execute(f"SELECT id FROM {self.table_name} WHERE id = '%s'" % (id,))
return cur.fetchone() is not None
conn.close()
def get_by_ids(self, ids: list[str]) -> list[Document]: def get_by_ids(self, ids: list[str]) -> list[Document]:
with self._get_cursor() as cur: with self._get_connection() as conn:
cur.execute(f"SELECT meta, text FROM {self.table_name} WHERE id IN %s", (tuple(ids),)) with conn.cursor() as cur:
docs = [] cur.execute(f"SELECT meta, text FROM {self.table_name} WHERE id IN %s", (tuple(ids),))
for record in cur: docs = []
docs.append(Document(page_content=record[1], metadata=record[0])) for record in cur:
docs.append(Document(page_content=record[1], metadata=record[0]))
self.pool.release(connection=conn)
conn.close()
return docs return docs
def delete_by_ids(self, ids: list[str]) -> None: def delete_by_ids(self, ids: list[str]) -> None:
if not ids: if not ids:
return return
with self._get_cursor() as cur: with self._get_connection() as conn:
cur.execute(f"DELETE FROM {self.table_name} WHERE id IN %s" % (tuple(ids),)) with conn.cursor() as cur:
cur.execute(f"DELETE FROM {self.table_name} WHERE id IN %s" % (tuple(ids),))
conn.commit()
conn.close()
def delete_by_metadata_field(self, key: str, value: str) -> None: def delete_by_metadata_field(self, key: str, value: str) -> None:
with self._get_cursor() as cur: with self._get_connection() as conn:
cur.execute(f"DELETE FROM {self.table_name} WHERE meta->>%s = %s", (key, value)) with conn.cursor() as cur:
cur.execute(f"DELETE FROM {self.table_name} WHERE meta->>%s = %s", (key, value))
conn.commit()
conn.close()
def search_by_vector(self, query_vector: list[float], **kwargs: Any) -> list[Document]: def search_by_vector(self, query_vector: list[float], **kwargs: Any) -> list[Document]:
""" """
Search the nearest neighbors to a vector. Search the nearest neighbors to a vector.
:param query_vector: The input vector to search for similar items. :param query_vector: The input vector to search for similar items.
:param top_k: The number of nearest neighbors to return, default is 5.
:return: List of Documents that are nearest to the query vector. :return: List of Documents that are nearest to the query vector.
""" """
top_k = kwargs.get("top_k", 4) top_k = kwargs.get("top_k", 4)
@ -205,20 +222,25 @@ class OracleVector(BaseVector):
if document_ids_filter: if document_ids_filter:
document_ids = ", ".join(f"'{id}'" for id in document_ids_filter) document_ids = ", ".join(f"'{id}'" for id in document_ids_filter)
where_clause = f"WHERE metadata->>'document_id' in ({document_ids})" where_clause = f"WHERE metadata->>'document_id' in ({document_ids})"
with self._get_cursor() as cur: with self._get_connection() as conn:
cur.execute( conn.inputtypehandler = self.input_type_handler
f"SELECT meta, text, vector_distance(embedding,:1) AS distance FROM {self.table_name}" conn.outputtypehandler = self.output_type_handler
f" {where_clause} ORDER BY distance fetch first {top_k} rows only", with conn.cursor() as cur:
[numpy.array(query_vector)], cur.execute(
) f"""SELECT meta, text, vector_distance(embedding,(select to_vector(:1) from dual),cosine)
docs = [] AS distance FROM {self.table_name}
score_threshold = float(kwargs.get("score_threshold") or 0.0) {where_clause} ORDER BY distance fetch first {top_k} rows only""",
for record in cur: [numpy.array(query_vector)],
metadata, text, distance = record )
score = 1 - distance docs = []
metadata["score"] = score score_threshold = float(kwargs.get("score_threshold") or 0.0)
if score > score_threshold: for record in cur:
docs.append(Document(page_content=text, metadata=metadata)) metadata, text, distance = record
score = 1 - distance
metadata["score"] = score
if score > score_threshold:
docs.append(Document(page_content=text, metadata=metadata))
conn.close()
return docs return docs
def search_by_full_text(self, query: str, **kwargs: Any) -> list[Document]: def search_by_full_text(self, query: str, **kwargs: Any) -> list[Document]:
@ -228,7 +250,7 @@ class OracleVector(BaseVector):
top_k = kwargs.get("top_k", 5) top_k = kwargs.get("top_k", 5)
# just not implement fetch by score_threshold now, may be later # just not implement fetch by score_threshold now, may be later
# score_threshold = float(kwargs.get("score_threshold") or 0.0) score_threshold = float(kwargs.get("score_threshold") or 0.0)
if len(query) > 0: if len(query) > 0:
# Check which language the query is in # Check which language the query is in
zh_pattern = re.compile("[\u4e00-\u9fa5]+") zh_pattern = re.compile("[\u4e00-\u9fa5]+")
@ -239,7 +261,7 @@ class OracleVector(BaseVector):
words = pseg.cut(query) words = pseg.cut(query)
current_entity = "" current_entity = ""
for word, pos in words: for word, pos in words:
if pos in {"nr", "Ng", "eng", "nz", "n", "ORG", "v"}: # nr: 人名ns: 地名,nt: 机构名 if pos in {"nr", "Ng", "eng", "nz", "n", "ORG", "v"}: # nr: 人名, ns: 地名, nt: 机构名
current_entity += word current_entity += word
else: else:
if current_entity: if current_entity:
@ -260,30 +282,35 @@ class OracleVector(BaseVector):
for token in all_tokens: for token in all_tokens:
if token not in stop_words: if token not in stop_words:
entities.append(token) entities.append(token)
with self._get_cursor() as cur: with self._get_connection() as conn:
document_ids_filter = kwargs.get("document_ids_filter") with conn.cursor() as cur:
where_clause = "" document_ids_filter = kwargs.get("document_ids_filter")
if document_ids_filter: where_clause = ""
document_ids = ", ".join(f"'{id}'" for id in document_ids_filter) if document_ids_filter:
where_clause = f" AND metadata->>'document_id' in ({document_ids}) " document_ids = ", ".join(f"'{id}'" for id in document_ids_filter)
cur.execute( where_clause = f" AND metadata->>'document_id' in ({document_ids}) "
f"select meta, text, embedding FROM {self.table_name}" cur.execute(
f"WHERE CONTAINS(text, :1, 1) > 0 {where_clause} " f"""select meta, text, embedding FROM {self.table_name}
f"order by score(1) desc fetch first {top_k} rows only", WHERE CONTAINS(text, :kk, 1) > 0 {where_clause}
[" ACCUM ".join(entities)], order by score(1) desc fetch first {top_k} rows only""",
) kk=" ACCUM ".join(entities),
docs = [] )
for record in cur: docs = []
metadata, text, embedding = record for record in cur:
docs.append(Document(page_content=text, vector=embedding, metadata=metadata)) metadata, text, embedding = record
docs.append(Document(page_content=text, vector=embedding, metadata=metadata))
conn.close()
return docs return docs
else: else:
return [Document(page_content="", metadata={})] return [Document(page_content="", metadata={})]
return [] return []
def delete(self) -> None: def delete(self) -> None:
with self._get_cursor() as cur: with self._get_connection() as conn:
cur.execute(f"DROP TABLE IF EXISTS {self.table_name} cascade constraints") with conn.cursor() as cur:
cur.execute(f"DROP TABLE IF EXISTS {self.table_name} cascade constraints")
conn.commit()
conn.close()
def _create_collection(self, dimension: int): def _create_collection(self, dimension: int):
cache_key = f"vector_indexing_{self._collection_name}" cache_key = f"vector_indexing_{self._collection_name}"
@ -293,11 +320,14 @@ class OracleVector(BaseVector):
if redis_client.get(collection_exist_cache_key): if redis_client.get(collection_exist_cache_key):
return return
with self._get_cursor() as cur: with self._get_connection() as conn:
cur.execute(SQL_CREATE_TABLE.format(table_name=self.table_name)) with conn.cursor() as cur:
redis_client.set(collection_exist_cache_key, 1, ex=3600) cur.execute(SQL_CREATE_TABLE.format(table_name=self.table_name))
with self._get_cursor() as cur: redis_client.set(collection_exist_cache_key, 1, ex=3600)
cur.execute(SQL_CREATE_INDEX.format(table_name=self.table_name)) with conn.cursor() as cur:
cur.execute(SQL_CREATE_INDEX.format(table_name=self.table_name))
conn.commit()
conn.close()
class OracleVectorFactory(AbstractVectorFactory): class OracleVectorFactory(AbstractVectorFactory):

@ -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 = {

@ -3,8 +3,8 @@ import re
import uuid import uuid
from collections.abc import Mapping from collections.abc import Mapping
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum, StrEnum
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING, Any, Literal, Optional, cast
from core.plugin.entities.plugin import GenericProviderID from core.plugin.entities.plugin import GenericProviderID
from core.tools.entities.tool_entities import ToolProviderType from core.tools.entities.tool_entities import ToolProviderType
@ -13,9 +13,6 @@ from services.plugin.plugin_service import PluginService
if TYPE_CHECKING: if TYPE_CHECKING:
from models.workflow import Workflow from models.workflow import Workflow
from enum import StrEnum
from typing import TYPE_CHECKING, Any, Literal, cast
import sqlalchemy as sa import sqlalchemy as sa
from flask import request from flask import request
from flask_login import UserMixin # type: ignore from flask_login import UserMixin # type: ignore

@ -1,14 +1,12 @@
import json import json
from collections.abc import Mapping, Sequence from collections.abc import Mapping, Sequence
from datetime import UTC, datetime from datetime import UTC, datetime
from enum import Enum from enum import Enum, StrEnum
from typing import TYPE_CHECKING, Any, Optional, Self, Union from typing import TYPE_CHECKING, Any, Optional, Self, Union
from uuid import uuid4 from uuid import uuid4
if TYPE_CHECKING: if TYPE_CHECKING:
from models.model import AppMode from models.model import AppMode
from enum import StrEnum
from typing import TYPE_CHECKING
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import Index, PrimaryKeyConstraint, func from sqlalchemy import Index, PrimaryKeyConstraint, func
@ -245,6 +243,13 @@ class Workflow(Base):
@property @property
def tool_published(self) -> bool: def tool_published(self) -> bool:
"""
DEPRECATED: This property is not accurate for determining if a workflow is published as a tool.
It only checks if there's a WorkflowToolProvider for the app, not if this specific workflow version
is the one being used by the tool.
For accurate checking, use a direct query with tenant_id, app_id, and version.
"""
from models.tools import WorkflowToolProvider from models.tools import WorkflowToolProvider
return ( return (

@ -179,7 +179,7 @@ vdb = [
"couchbase~=4.3.0", "couchbase~=4.3.0",
"elasticsearch==8.14.0", "elasticsearch==8.14.0",
"opensearch-py==2.4.0", "opensearch-py==2.4.0",
"oracledb~=2.2.1", "oracledb==3.0.0",
"pgvecto-rs[sqlalchemy]~=0.2.1", "pgvecto-rs[sqlalchemy]~=0.2.1",
"pgvector==0.2.5", "pgvector==0.2.5",
"pymilvus~=2.5.0", "pymilvus~=2.5.0",

@ -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

@ -40,7 +40,7 @@ IMPORT_INFO_REDIS_KEY_PREFIX = "app_import_info:"
CHECK_DEPENDENCIES_REDIS_KEY_PREFIX = "app_check_dependencies:" CHECK_DEPENDENCIES_REDIS_KEY_PREFIX = "app_check_dependencies:"
IMPORT_INFO_REDIS_EXPIRY = 10 * 60 # 10 minutes IMPORT_INFO_REDIS_EXPIRY = 10 * 60 # 10 minutes
DSL_MAX_SIZE = 10 * 1024 * 1024 # 10MB DSL_MAX_SIZE = 10 * 1024 * 1024 # 10MB
CURRENT_DSL_VERSION = "0.1.5" CURRENT_DSL_VERSION = "0.2.0"
class ImportMode(StrEnum): class ImportMode(StrEnum):

@ -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(),
} }
) )

@ -28,6 +28,7 @@ from extensions.ext_database import db
from models.account import Account from models.account import Account
from models.enums import CreatedByRole from models.enums import CreatedByRole
from models.model import App, AppMode from models.model import App, AppMode
from models.tools import WorkflowToolProvider
from models.workflow import ( from models.workflow import (
Workflow, Workflow,
WorkflowNodeExecution, WorkflowNodeExecution,
@ -288,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)
@ -523,8 +524,19 @@ class WorkflowService:
# Cannot delete a workflow that's currently in use by an app # Cannot delete a workflow that's currently in use by an app
raise WorkflowInUseError(f"Cannot delete workflow that is currently in use by app '{app.name}'") raise WorkflowInUseError(f"Cannot delete workflow that is currently in use by app '{app.name}'")
# Check if this workflow is published as a tool # Don't use workflow.tool_published as it's not accurate for specific workflow versions
if workflow.tool_published: # Check if there's a tool provider using this specific workflow version
tool_provider = (
session.query(WorkflowToolProvider)
.filter(
WorkflowToolProvider.tenant_id == workflow.tenant_id,
WorkflowToolProvider.app_id == workflow.app_id,
WorkflowToolProvider.version == workflow.version,
)
.first()
)
if tool_provider:
# Cannot delete a workflow that's published as a tool # Cannot delete a workflow that's published as a tool
raise WorkflowInUseError("Cannot delete workflow that is published as a tool") raise WorkflowInUseError("Cannot delete workflow that is published as a tool")

@ -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

@ -40,6 +40,10 @@ def workflow_setup():
def test_delete_workflow_success(workflow_setup): def test_delete_workflow_success(workflow_setup):
# Setup mocks # Setup mocks
# Mock the tool provider query to return None (not published as a tool)
workflow_setup["session"].query.return_value.filter.return_value.first.return_value = None
workflow_setup["session"].scalar = MagicMock( workflow_setup["session"].scalar = MagicMock(
side_effect=[workflow_setup["workflow"], None] side_effect=[workflow_setup["workflow"], None]
) # Return workflow first, then None for app ) # Return workflow first, then None for app
@ -97,7 +101,12 @@ def test_delete_workflow_in_use_by_app_error(workflow_setup):
def test_delete_workflow_published_as_tool_error(workflow_setup): def test_delete_workflow_published_as_tool_error(workflow_setup):
# Setup mocks # Setup mocks
workflow_setup["workflow"].tool_published = True from models.tools import WorkflowToolProvider
# Mock the tool provider query
mock_tool_provider = MagicMock(spec=WorkflowToolProvider)
workflow_setup["session"].query.return_value.filter.return_value.first.return_value = mock_tool_provider
workflow_setup["session"].scalar = MagicMock( workflow_setup["session"].scalar = MagicMock(
side_effect=[workflow_setup["workflow"], None] side_effect=[workflow_setup["workflow"], None]
) # Return workflow first, then None for app ) # Return workflow first, then None for app

@ -1480,7 +1480,7 @@ vdb = [
{ name = "couchbase", specifier = "~=4.3.0" }, { name = "couchbase", specifier = "~=4.3.0" },
{ name = "elasticsearch", specifier = "==8.14.0" }, { name = "elasticsearch", specifier = "==8.14.0" },
{ name = "opensearch-py", specifier = "==2.4.0" }, { name = "opensearch-py", specifier = "==2.4.0" },
{ name = "oracledb", specifier = "~=2.2.1" }, { name = "oracledb", specifier = "==3.0.0" },
{ name = "pgvecto-rs", extras = ["sqlalchemy"], specifier = "~=0.2.1" }, { name = "pgvecto-rs", extras = ["sqlalchemy"], specifier = "~=0.2.1" },
{ name = "pgvector", specifier = "==0.2.5" }, { name = "pgvector", specifier = "==0.2.5" },
{ name = "pymilvus", specifier = "~=2.5.0" }, { name = "pymilvus", specifier = "~=2.5.0" },
@ -3687,23 +3687,23 @@ wheels = [
[[package]] [[package]]
name = "oracledb" name = "oracledb"
version = "2.2.1" version = "3.0.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "cryptography" }, { name = "cryptography" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/36/fb/3fbacb351833dd794abb184303a5761c4bb33df9d770fd15d01ead2ff738/oracledb-2.2.1.tar.gz", hash = "sha256:8464c6f0295f3318daf6c2c72c83c2dcbc37e13f8fd44e3e39ff8665f442d6b6", size = 580818 } sdist = { url = "https://files.pythonhosted.org/packages/bf/39/712f797b75705c21148fa1d98651f63c2e5cc6876e509a0a9e2f5b406572/oracledb-3.0.0.tar.gz", hash = "sha256:64dc86ee5c032febc556798b06e7b000ef6828bb0252084f6addacad3363db85", size = 840431 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/74/b7/a4238295944670fb8cc50a8cc082e0af5a0440bfb1c2bac2b18429c0a579/oracledb-2.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:fb6d9a4d7400398b22edb9431334f9add884dec9877fd9c4ae531e1ccc6ee1fd", size = 3551303 }, { url = "https://files.pythonhosted.org/packages/fa/bf/d872c4b3fc15cd3261fe0ea72b21d181700c92dbc050160e161654987062/oracledb-3.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:52daa9141c63dfa75c07d445e9bb7f69f43bfb3c5a173ecc48c798fe50288d26", size = 4312963 },
{ url = "https://files.pythonhosted.org/packages/4f/5f/98481d44976cd2b3086361f2d50026066b24090b0e6cd1f2a12c824e9717/oracledb-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:07757c240afbb4f28112a6affc2c5e4e34b8a92e5bb9af81a40fba398da2b028", size = 12258455 }, { url = "https://files.pythonhosted.org/packages/b1/ea/01ee29e76a610a53bb34fdc1030f04b7669c3f80b25f661e07850fc6160e/oracledb-3.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af98941789df4c6aaaf4338f5b5f6b7f2c8c3fe6f8d6a9382f177f350868747a", size = 2661536 },
{ url = "https://files.pythonhosted.org/packages/e9/54/06b2540286e2b63f60877d6f3c6c40747e216b6eeda0756260e194897076/oracledb-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63daec72f853c47179e98493e9b732909d96d495bdceb521c5973a3940d28142", size = 12317476 }, { url = "https://files.pythonhosted.org/packages/3d/8e/ad380e34a46819224423b4773e58c350bc6269643c8969604097ced8c3bc/oracledb-3.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9812bb48865aaec35d73af54cd1746679f2a8a13cbd1412ab371aba2e39b3943", size = 2867461 },
{ url = "https://files.pythonhosted.org/packages/4d/1a/67814439a4e24df83281a72cb0ba433d6b74e1bff52a9975b87a725bcba5/oracledb-2.2.1-cp311-cp311-win32.whl", hash = "sha256:fec5318d1e0ada7e4674574cb6c8d1665398e8b9c02982279107212f05df1660", size = 1369368 }, { url = "https://files.pythonhosted.org/packages/96/09/ecc4384a27fd6e1e4de824ae9c160e4ad3aaebdaade5b4bdcf56a4d1ff63/oracledb-3.0.0-cp311-cp311-win32.whl", hash = "sha256:6c27fe0de64f2652e949eb05b3baa94df9b981a4a45fa7f8a991e1afb450c8e2", size = 1752046 },
{ url = "https://files.pythonhosted.org/packages/e3/b8/b2a8f0607be17f58ec6689ad5fd15c2956f4996c64547325e96439570edf/oracledb-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:5134dccb5a11bc755abf02fd49be6dc8141dfcae4b650b55d40509323d00b5c2", size = 1655035 }, { url = "https://files.pythonhosted.org/packages/62/e8/f34bde24050c6e55eeba46b23b2291f2dd7fd272fa8b322dcbe71be55778/oracledb-3.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:f922709672002f0b40997456f03a95f03e5712a86c61159951c5ce09334325e0", size = 2101210 },
{ url = "https://files.pythonhosted.org/packages/24/5b/2fff762243030f31a6b1561fc8eeb142e69ba6ebd3e7fbe4a2c82f0eb6f0/oracledb-2.2.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:ac5716bc9a48247fdf563f5f4ec097f5c9f074a60fd130cdfe16699208ca29b5", size = 3583960 }, { url = "https://files.pythonhosted.org/packages/6f/fc/24590c3a3d41e58494bd3c3b447a62835138e5f9b243d9f8da0cfb5da8dc/oracledb-3.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:acd0e747227dea01bebe627b07e958bf36588a337539f24db629dc3431d3f7eb", size = 4351993 },
{ url = "https://files.pythonhosted.org/packages/e6/88/34117ae830e7338af7c0481f1c0fc6eda44d558e12f9203b45b491e53071/oracledb-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c150bddb882b7c73fb462aa2d698744da76c363e404570ed11d05b65811d96c3", size = 11749006 }, { url = "https://files.pythonhosted.org/packages/b7/b6/1f3b0b7bb94d53e8857d77b2e8dbdf6da091dd7e377523e24b79dac4fd71/oracledb-3.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8b402f77c22af031cd0051aea2472ecd0635c1b452998f511aa08b7350c90a4", size = 2532640 },
{ url = "https://files.pythonhosted.org/packages/9d/58/bac788f18c21f727955652fe238de2d24a12c2b455ed4db18a6d23ff781e/oracledb-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:193e1888411bc21187ade4b16b76820bd1e8f216e25602f6cd0a97d45723c1dc", size = 11950663 }, { url = "https://files.pythonhosted.org/packages/72/1a/1815f6c086ab49c00921cf155ff5eede5267fb29fcec37cb246339a5ce4d/oracledb-3.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:378a27782e9a37918bd07a5a1427a77cb6f777d0a5a8eac9c070d786f50120ef", size = 2765949 },
{ url = "https://files.pythonhosted.org/packages/3b/e2/005f66ae919c6f7c73e06863256cf43aa844330e2dc61a5f9779ae44a801/oracledb-2.2.1-cp312-cp312-win32.whl", hash = "sha256:44a960f8bbb0711af222e0a9690e037b6a2a382e0559ae8eeb9cfafe26c7a3bc", size = 1324255 }, { url = "https://files.pythonhosted.org/packages/33/8d/208900f8d372909792ee70b2daad3f7361181e55f2217c45ed9dff658b54/oracledb-3.0.0-cp312-cp312-win32.whl", hash = "sha256:54a28c2cb08316a527cd1467740a63771cc1c1164697c932aa834c0967dc4efc", size = 1709373 },
{ url = "https://files.pythonhosted.org/packages/e6/25/759eb2143134513382e66d874c4aacfd691dec3fef7141170cfa6c1b154f/oracledb-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:470136add32f0d0084225c793f12a52b61b52c3dc00c9cd388ec6a3db3a7643e", size = 1613047 }, { url = "https://files.pythonhosted.org/packages/0c/5e/c21754f19c896102793c3afec2277e2180aa7d505e4d7fcca24b52d14e4f/oracledb-3.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:8289bad6d103ce42b140e40576cf0c81633e344d56e2d738b539341eacf65624", size = 2056452 },
] ]
[[package]] [[package]]

@ -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

@ -2,7 +2,7 @@ x-shared-env: &shared-api-worker-env
services: services:
# API service # API service
api: api:
image: langgenius/dify-api:1.2.0 image: langgenius/dify-api:1.3.0
restart: always restart: always
environment: environment:
# Use the shared environment variables. # Use the shared environment variables.
@ -31,7 +31,7 @@ services:
# worker service # worker service
# The Celery worker for processing the queue. # The Celery worker for processing the queue.
worker: worker:
image: langgenius/dify-api:1.2.0 image: langgenius/dify-api:1.3.0
restart: always restart: always
environment: environment:
# Use the shared environment variables. # Use the shared environment variables.
@ -57,7 +57,7 @@ services:
# Frontend web application. # Frontend web application.
web: web:
image: langgenius/dify-web:1.2.0 image: langgenius/dify-web:1.3.0
restart: always restart: always
environment: environment:
CONSOLE_API_URL: ${CONSOLE_API_URL:-} CONSOLE_API_URL: ${CONSOLE_API_URL:-}
@ -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:-}
@ -141,7 +142,7 @@ services:
# plugin daemon # plugin daemon
plugin_daemon: plugin_daemon:
image: langgenius/dify-plugin-daemon:0.0.7-local image: langgenius/dify-plugin-daemon:0.0.8-local
restart: always restart: always
environment: environment:
# Use the shared environment variables. # Use the shared environment variables.
@ -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

@ -71,7 +71,7 @@ services:
# plugin daemon # plugin daemon
plugin_daemon: plugin_daemon:
image: langgenius/dify-plugin-daemon:0.0.7-local image: langgenius/dify-plugin-daemon:0.0.8-local
restart: always restart: always
env_file: env_file:
- ./middleware.env - ./middleware.env

@ -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,11 +474,12 @@ 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
api: api:
image: langgenius/dify-api:1.2.0 image: langgenius/dify-api:1.3.0
restart: always restart: always
environment: environment:
# Use the shared environment variables. # Use the shared environment variables.
@ -504,7 +508,7 @@ services:
# worker service # worker service
# The Celery worker for processing the queue. # The Celery worker for processing the queue.
worker: worker:
image: langgenius/dify-api:1.2.0 image: langgenius/dify-api:1.3.0
restart: always restart: always
environment: environment:
# Use the shared environment variables. # Use the shared environment variables.
@ -530,7 +534,7 @@ services:
# Frontend web application. # Frontend web application.
web: web:
image: langgenius/dify-web:1.2.0 image: langgenius/dify-web:1.3.0
restart: always restart: always
environment: environment:
CONSOLE_API_URL: ${CONSOLE_API_URL:-} CONSOLE_API_URL: ${CONSOLE_API_URL:-}
@ -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:-}
@ -614,7 +619,7 @@ services:
# plugin daemon # plugin daemon
plugin_daemon: plugin_daemon:
image: langgenius/dify-plugin-daemon:0.0.7-local image: langgenius/dify-plugin-daemon:0.0.8-local
restart: always restart: always
environment: environment:
# Use the shared environment variables. # Use the shared environment variables.
@ -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=

@ -163,7 +163,7 @@ const SettingBuiltInTool: FC<Props> = ({
footer={null} footer={null}
mask={false} mask={false}
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')}
> >
<> <>
{isLoading && <Loading type='app' />} {isLoading && <Loading type='app' />}

@ -97,7 +97,7 @@ const Item: FC<ItemProps> = ({
<RiDeleteBinLine className='h-4 w-4' /> <RiDeleteBinLine className='h-4 w-4' />
</div> </div>
</div> </div>
<Drawer isOpen={showSettingsModal} onClose={() => setShowSettingsModal(false)} footer={null} mask={isMobile} panelClassname='mt-16 mx-2 sm:mr-2 mb-3 !p-0 !max-w-[640px] rounded-xl'> <Drawer isOpen={showSettingsModal} onClose={() => setShowSettingsModal(false)} footer={null} mask={isMobile} panelClassName='mt-16 mx-2 sm:mr-2 mb-3 !p-0 !max-w-[640px] rounded-xl'>
<SettingsModal <SettingsModal
currentDataset={config} currentDataset={config}
onCancel={() => setShowSettingsModal(false)} onCancel={() => setShowSettingsModal(false)}

@ -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 () => {

@ -743,7 +743,7 @@ const ConversationList: FC<IConversationList> = ({ logs, appDetail, onRefresh })
onClose={onCloseDrawer} onClose={onCloseDrawer}
mask={isMobile} mask={isMobile}
footer={null} footer={null}
panelClassname='mt-16 mx-2 sm:mr-2 mb-4 !p-0 !max-w-[640px] rounded-xl bg-components-panel-bg' panelClassName='mt-16 mx-2 sm:mr-2 mb-4 !p-0 !max-w-[640px] rounded-xl bg-components-panel-bg'
> >
<DrawerContext.Provider value={{ <DrawerContext.Provider value={{
onClose: onCloseDrawer, onClose: onCloseDrawer,

@ -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,12 +42,13 @@ 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',
// conversation_id: 'YOU CAN DEFINE CONVERSATION ID HERE, IT MUST BE A VALID UUID',
}, },
} }
</script> </script>

@ -134,7 +134,7 @@ const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => {
onClose={onCloseDrawer} onClose={onCloseDrawer}
mask={isMobile} mask={isMobile}
footer={null} footer={null}
panelClassname='mt-16 mx-2 sm:mr-2 mb-3 !p-0 !max-w-[600px] rounded-xl border border-components-panel-border' panelClassName='mt-16 mx-2 sm:mr-2 mb-3 !p-0 !max-w-[600px] rounded-xl border border-components-panel-border'
> >
<DetailPanel onClose={onCloseDrawer} runID={currentLog?.workflow_run.id || ''} /> <DetailPanel onClose={onCloseDrawer} runID={currentLog?.workflow_run.id || ''} />
</Drawer> </Drawer>

@ -73,21 +73,46 @@ export const useEmbeddedChatbot = () => {
const appId = useMemo(() => appData?.app_id, [appData]) const appId = useMemo(() => appData?.app_id, [appData])
const [userId, setUserId] = useState<string>() const [userId, setUserId] = useState<string>()
const [conversationId, setConversationId] = useState<string>()
useEffect(() => { useEffect(() => {
getProcessedSystemVariablesFromUrlParams().then(({ user_id }) => { getProcessedSystemVariablesFromUrlParams().then(({ user_id, conversation_id }) => {
setUserId(user_id) setUserId(user_id)
setConversationId(conversation_id)
}) })
}, []) }, [])
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, {
defaultValue: {}, defaultValue: {},
}) })
const currentConversationId = useMemo(() => conversationIdInfo?.[appId || '']?.[userId || 'DEFAULT'] || '', [appId, conversationIdInfo, userId]) const currentConversationId = useMemo(() => conversationIdInfo?.[appId || '']?.[userId || 'DEFAULT'] || conversationId || '',
[appId, conversationIdInfo, userId, conversationId])
const handleConversationIdInfoChange = useCallback((changeConversationId: string) => { const handleConversationIdInfoChange = useCallback((changeConversationId: string) => {
if (appId) { if (appId) {
let prevValue = conversationIdInfo?.[appId || ''] let prevValue = conversationIdInfo?.[appId || '']

@ -9,6 +9,8 @@ import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
type Props = { type Props = {
isShow: boolean isShow: boolean
onHide: () => void onHide: () => void
dialogClassName?: string
dialogBackdropClassName?: string
panelClassName?: string panelClassName?: string
maxWidthClassName?: string maxWidthClassName?: string
contentClassName?: string contentClassName?: string
@ -26,6 +28,8 @@ type Props = {
const DrawerPlus: FC<Props> = ({ const DrawerPlus: FC<Props> = ({
isShow, isShow,
onHide, onHide,
dialogClassName = '',
dialogBackdropClassName = '',
panelClassName = '', panelClassName = '',
maxWidthClassName = '!max-w-[640px]', maxWidthClassName = '!max-w-[640px]',
height = 'calc(100vh - 72px)', height = 'calc(100vh - 72px)',
@ -55,7 +59,9 @@ const DrawerPlus: FC<Props> = ({
footer={null} footer={null}
mask={isMobile || isShowMask} mask={isMobile || isShowMask}
positionCenter={positionCenter} positionCenter={positionCenter}
panelClassname={cn('mx-2 mb-3 mt-16 rounded-xl !p-0 sm:mr-2', panelClassName, maxWidthClassName)} dialogClassName={dialogClassName}
dialogBackdropClassName={dialogBackdropClassName}
panelClassName={cn('mx-2 mb-3 mt-16 rounded-xl !p-0 sm:mr-2', panelClassName, maxWidthClassName)}
> >
<div <div
className={cn(contentClassName, 'flex w-full flex-col rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg shadow-xl')} className={cn(contentClassName, 'flex w-full flex-col rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg shadow-xl')}

@ -8,7 +8,9 @@ import cn from '@/utils/classnames'
export type IDrawerProps = { export type IDrawerProps = {
title?: string title?: string
description?: string description?: string
panelClassname?: string dialogClassName?: string
dialogBackdropClassName?: string
panelClassName?: string
children: React.ReactNode children: React.ReactNode
footer?: React.ReactNode footer?: React.ReactNode
mask?: boolean mask?: boolean
@ -25,7 +27,9 @@ export type IDrawerProps = {
export default function Drawer({ export default function Drawer({
title = '', title = '',
description = '', description = '',
panelClassname = '', dialogClassName = '',
dialogBackdropClassName = '',
panelClassName = '',
children, children,
footer, footer,
mask = true, mask = true,
@ -44,17 +48,17 @@ export default function Drawer({
unmount={unmount} unmount={unmount}
open={isOpen} open={isOpen}
onClose={() => !clickOutsideNotOpen && onClose()} onClose={() => !clickOutsideNotOpen && onClose()}
className="fixed inset-0 z-[80] overflow-y-auto" className={cn('fixed inset-0 z-[30] overflow-y-auto', dialogClassName)}
> >
<div className={cn('flex h-screen w-screen justify-end', positionCenter && '!justify-center')}> <div className={cn('flex h-screen w-screen justify-end', positionCenter && '!justify-center')}>
{/* mask */} {/* mask */}
<DialogBackdrop <DialogBackdrop
className={cn('fixed inset-0 z-[90]', mask && 'bg-black bg-opacity-30')} className={cn('fixed inset-0 z-[40]', mask && 'bg-black/30', dialogBackdropClassName)}
onClick={() => { onClick={() => {
!clickOutsideNotOpen && onClose() !clickOutsideNotOpen && onClose()
}} }}
/> />
<div className={cn('relative z-[100] flex w-full max-w-sm flex-col justify-between overflow-hidden bg-components-panel-bg p-6 text-left align-middle shadow-xl', panelClassname)}> <div className={cn('relative z-[50] flex w-full max-w-sm flex-col justify-between overflow-hidden bg-components-panel-bg p-6 text-left align-middle shadow-xl', panelClassName)}>
<> <>
<div className='flex justify-between'> <div className='flex justify-between'>
{title && <DialogTitle {title && <DialogTitle

@ -252,7 +252,7 @@ const Img = ({ src }: any) => {
return <div className="markdown-img-wrapper"><ImageGallery srcs={[src]} /></div> return <div className="markdown-img-wrapper"><ImageGallery srcs={[src]} /></div>
} }
const Link = ({ node, ...props }: any) => { const Link = ({ node, children, ...props }: any) => {
if (node.properties?.href && node.properties.href?.toString().startsWith('abbr')) { if (node.properties?.href && node.properties.href?.toString().startsWith('abbr')) {
// eslint-disable-next-line react-hooks/rules-of-hooks // eslint-disable-next-line react-hooks/rules-of-hooks
const { onSend } = useChatContext() const { onSend } = useChatContext()
@ -261,7 +261,7 @@ const Link = ({ node, ...props }: any) => {
return <abbr className="cursor-pointer underline !decoration-primary-700 decoration-dashed" onClick={() => onSend?.(hidden_text)} title={node.children[0]?.value}>{node.children[0]?.value}</abbr> return <abbr className="cursor-pointer underline !decoration-primary-700 decoration-dashed" onClick={() => onSend?.(hidden_text)} title={node.children[0]?.value}>{node.children[0]?.value}</abbr>
} }
else { else {
return <a {...props} target="_blank" className="cursor-pointer underline !decoration-primary-700 decoration-dashed">{node.children[0] ? node.children[0]?.value : 'Download'}</a> return <a {...props} target="_blank" className="cursor-pointer underline !decoration-primary-700 decoration-dashed">{children || 'Download'}</a>
} }
} }

@ -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 cursor-pointer h-auto w-full relative', {
'bg-white': currentTheme === Theme.light,
'bg-slate-900': currentTheme === Theme.dark,
}),
errorMessage: cn('py-4 px-[26px]', {
'text-red-500': currentTheme === Theme.light,
'text-red-400': currentTheme === Theme.dark,
}),
errorIcon: cn('w-6 h-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 items-center justify-center w-10 h-10 rounded-full transition-all duration-300 shadow-md backdrop-blur-sm', {
'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(
'flex items-center justify-center mb-4 w-[calc((100%-8px)/2)] h-8 rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg cursor-pointer system-sm-medium 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 flex items-center space-x-1 m-2 w-[200px]">
<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='py-4 px-[26px]'>
<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 left-2 bottom-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,

@ -20,7 +20,7 @@ const FullScreenDrawer: FC<IFullScreenDrawerProps> = ({
<Drawer <Drawer
isOpen={isOpen} isOpen={isOpen}
onClose={onClose} onClose={onClose}
panelClassname={classNames('!p-0 bg-components-panel-bg', panelClassName={classNames('!p-0 bg-components-panel-bg',
fullScreen fullScreen
? '!max-w-full !w-full' ? '!max-w-full !w-full'
: 'mt-16 mr-2 mb-2 !max-w-[560px] !w-[560px] border-[0.5px] border-components-panel-border rounded-xl', : 'mt-16 mr-2 mb-2 !max-w-[560px] !w-[560px] border-[0.5px] border-components-panel-border rounded-xl',

@ -277,7 +277,7 @@ const DocumentDetail: FC<Props> = ({ datasetId, documentId }) => {
} }
</div> </div>
} }
<FloatRightContainer showClose isOpen={showMetadata} onClose={() => setShowMetadata(false)} isMobile={isMobile} panelClassname='!justify-start' footer={null}> <FloatRightContainer showClose isOpen={showMetadata} onClose={() => setShowMetadata(false)} isMobile={isMobile} panelClassName='!justify-start' footer={null}>
<Metadata <Metadata
className='mr-2 mt-3' className='mr-2 mt-3'
datasetId={datasetId} datasetId={datasetId}

@ -29,6 +29,8 @@ import { useChildSegmentListKey, useSegmentListKey } from '@/service/knowledge/u
import useEditDocumentMetadata from '../metadata/hooks/use-edit-dataset-metadata' import useEditDocumentMetadata from '../metadata/hooks/use-edit-dataset-metadata'
import DatasetMetadataDrawer from '../metadata/metadata-dataset/dataset-metadata-drawer' import DatasetMetadataDrawer from '../metadata/metadata-dataset/dataset-metadata-drawer'
import StatusWithAction from '../common/document-status-with-action/status-with-action' import StatusWithAction from '../common/document-status-with-action/status-with-action'
import { LanguagesSupported } from '@/i18n/language'
import { getLocaleOnClient } from '@/i18n'
const FolderPlusIcon = ({ className }: React.SVGProps<SVGElement>) => { const FolderPlusIcon = ({ className }: React.SVGProps<SVGElement>) => {
return <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" className={className ?? ''}> return <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" className={className ?? ''}>
@ -98,7 +100,7 @@ const Documents: FC<IDocumentsProps> = ({ datasetId }) => {
const isDataSourceWeb = dataset?.data_source_type === DataSourceType.WEB const isDataSourceWeb = dataset?.data_source_type === DataSourceType.WEB
const isDataSourceFile = dataset?.data_source_type === DataSourceType.FILE const isDataSourceFile = dataset?.data_source_type === DataSourceType.FILE
const embeddingAvailable = !!dataset?.embedding_available const embeddingAvailable = !!dataset?.embedding_available
const locale = getLocaleOnClient()
const debouncedSearchValue = useDebounce(searchValue, { wait: 500 }) const debouncedSearchValue = useDebounce(searchValue, { wait: 500 })
const { data: documentsRes, isFetching: isListLoading } = useDocumentList({ const { data: documentsRes, isFetching: isListLoading } = useDocumentList({
@ -260,7 +262,12 @@ const Documents: FC<IDocumentsProps> = ({ datasetId }) => {
<a <a
className='flex items-center text-text-accent' className='flex items-center text-text-accent'
target='_blank' target='_blank'
href='https://docs.dify.ai/guides/knowledge-base/integrate-knowledge-within-application'> href={
locale === LanguagesSupported[1]
? 'https://docs.dify.ai/v/zh-hans/guides/knowledge-base/integrate-knowledge-within-application'
: 'https://docs.dify.ai/guides/knowledge-base/integrate-knowledge-within-application'
}
>
<span>{t('datasetDocuments.list.learnMore')}</span> <span>{t('datasetDocuments.list.learnMore')}</span>
<RiExternalLinkLine className='h-3 w-3' /> <RiExternalLinkLine className='h-3 w-3' />
</a> </a>

@ -176,7 +176,7 @@ const HitTestingPage: FC<Props> = ({ datasetId }: Props) => {
<RecordsEmpty /> <RecordsEmpty />
)} )}
</div> </div>
<FloatRightContainer panelClassname='!justify-start !overflow-y-auto' showClose isMobile={isMobile} isOpen={isShowRightPanel} onClose={hideRightPanel} footer={null}> <FloatRightContainer panelClassName='!justify-start !overflow-y-auto' showClose isMobile={isMobile} isOpen={isShowRightPanel} onClose={hideRightPanel} footer={null}>
<div className='flex flex-col pt-3'> <div className='flex flex-col pt-3'>
{/* {renderHitResults(generalResultData)} */} {/* {renderHitResults(generalResultData)} */}
{submitLoading {submitLoading
@ -197,7 +197,7 @@ const HitTestingPage: FC<Props> = ({ datasetId }: Props) => {
} }
</div> </div>
</FloatRightContainer> </FloatRightContainer>
<Drawer unmount={true} isOpen={isShowModifyRetrievalModal} onClose={() => setIsShowModifyRetrievalModal(false)} footer={null} mask={isMobile} panelClassname='mt-16 mx-2 sm:mr-2 mb-3 !p-0 !max-w-[640px] rounded-xl'> <Drawer unmount={true} isOpen={isShowModifyRetrievalModal} onClose={() => setIsShowModifyRetrievalModal(false)} footer={null} mask={isMobile} panelClassName='mt-16 mx-2 sm:mr-2 mb-3 !p-0 !max-w-[640px] rounded-xl'>
<ModifyRetrievalModal <ModifyRetrievalModal
indexMethod={currentDataset?.indexing_technique || ''} indexMethod={currentDataset?.indexing_technique || ''}
value={retrievalConfig} value={retrievalConfig}

@ -173,7 +173,7 @@ const DatasetMetadataDrawer: FC<Props> = ({
showClose showClose
title={t('dataset.metadata.metadata')} title={t('dataset.metadata.metadata')}
footer={null} footer={null}
panelClassname='px-4 block !max-w-[420px] my-2 rounded-l-2xl' panelClassName='px-4 block !max-w-[420px] my-2 rounded-l-2xl'
> >
<div className='h-full overflow-y-auto'> <div className='h-full overflow-y-auto'>
<div className='system-sm-regular text-text-tertiary'>{t(`${i18nPrefix}.description`)}</div> <div className='system-sm-regular text-text-tertiary'>{t(`${i18nPrefix}.description`)}</div>

@ -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)}
/> />

@ -32,7 +32,9 @@ const ListWithCollection = ({
return ( return (
<> <>
{ {
marketplaceCollections.map(collection => ( marketplaceCollections.filter((collection) => {
return marketplaceCollectionPluginsMap[collection.name]?.length
}).map(collection => (
<div <div
key={collection.name} key={collection.name}
className='py-3' className='py-3'

@ -46,7 +46,7 @@ const EndpointModal: FC<Props> = ({
footer={null} footer={null}
mask mask
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 pb-2'> <div className='p-4 pb-2'>

@ -38,7 +38,7 @@ const PluginDetailPanel: FC<Props> = ({
footer={null} footer={null}
mask={false} mask={false}
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')}
> >
{detail && ( {detail && (
<> <>

@ -78,7 +78,7 @@ const StrategyDetail: FC<Props> = ({
footer={null} footer={null}
mask={false} mask={false}
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')}
> >
<> <>
{/* header */} {/* header */}

@ -178,7 +178,7 @@ const AddToolModal: FC<Props> = ({
clickOutsideNotOpen clickOutsideNotOpen
onClose={onHide} onClose={onHide}
footer={null} footer={null}
panelClassname={cn('mx-2 mb-3 mt-16 rounded-xl !p-0 sm:mr-2', 'mt-2 !w-[640px]', '!max-w-[640px]')} panelClassName={cn('mx-2 mb-3 mt-16 rounded-xl !p-0 sm:mr-2', 'mt-2 !w-[640px]', '!max-w-[640px]')}
> >
<div <div
className='flex w-full rounded-xl border-[0.5px] border-gray-200 bg-white shadow-xl' className='flex w-full rounded-xl border-[0.5px] border-gray-200 bg-white shadow-xl'

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

Loading…
Cancel
Save