diff --git a/README.md b/README.md index 87ebc9bafc..65e8001dd2 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Dify Cloud · Self-hosting · Documentation · - Enterprise inquiry + Dify edition overview

diff --git a/README_AR.md b/README_AR.md index e58f59da5d..4f93802fda 100644 --- a/README_AR.md +++ b/README_AR.md @@ -4,7 +4,7 @@ Dify Cloud · الاستضافة الذاتية · التوثيق · - استفسار الشركات (للإنجليزية فقط) + نظرة عامة على منتجات Dify

diff --git a/README_BN.md b/README_BN.md index 3ebc81af5d..7599fae9ff 100644 --- a/README_BN.md +++ b/README_BN.md @@ -8,7 +8,7 @@ ডিফাই ক্লাউড · সেল্ফ-হোস্টিং · ডকুমেন্টেশন · - ব্যাবসায়িক অনুসন্ধান + Dify পণ্যের রূপভেদ

diff --git a/README_CN.md b/README_CN.md index 6d3c601100..973629f459 100644 --- a/README_CN.md +++ b/README_CN.md @@ -4,7 +4,7 @@ Dify 云服务 · 自托管 · 文档 · - (需用英文)常见问题解答 / 联系团队 + Dify 产品形态总览

diff --git a/README_DE.md b/README_DE.md index b3b9bf3221..738c0e3b67 100644 --- a/README_DE.md +++ b/README_DE.md @@ -8,7 +8,7 @@ Dify Cloud · Selbstgehostetes · Dokumentation · - Anfrage an Unternehmen + Überblick über die Dify-Produkte

diff --git a/README_ES.md b/README_ES.md index d14afdd2eb..212268b73d 100644 --- a/README_ES.md +++ b/README_ES.md @@ -4,7 +4,7 @@ Dify Cloud · Auto-alojamiento · Documentación · - Consultas empresariales (en inglés) + Resumen de las ediciones de Dify

diff --git a/README_FR.md b/README_FR.md index 031196303e..89eea7d058 100644 --- a/README_FR.md +++ b/README_FR.md @@ -4,7 +4,7 @@ Dify Cloud · Auto-hébergement · Documentation · - Demande d’entreprise (en anglais seulement) + Présentation des différentes offres Dify

diff --git a/README_JA.md b/README_JA.md index 3b7a6f50db..adca219753 100644 --- a/README_JA.md +++ b/README_JA.md @@ -4,7 +4,7 @@ Dify Cloud · セルフホスティング · ドキュメント · - 企業のお問い合わせ(英語のみ) + Difyの各種エディションについて

diff --git a/README_KL.md b/README_KL.md index ccadb77274..17e6c9d509 100644 --- a/README_KL.md +++ b/README_KL.md @@ -4,7 +4,7 @@ Dify Cloud · Self-hosting · Documentation · - Commercial enquiries + Dify product editions

diff --git a/README_KR.md b/README_KR.md index c1a98f8b68..d44723f9b6 100644 --- a/README_KR.md +++ b/README_KR.md @@ -4,7 +4,7 @@ Dify 클라우드 · 셀프-호스팅 · 문서 · - 기업 문의 (영어만 가능) + Dify 제품 에디션 안내

diff --git a/README_PT.md b/README_PT.md index 5b3c782645..9dc2207279 100644 --- a/README_PT.md +++ b/README_PT.md @@ -8,7 +8,7 @@ Dify Cloud · Auto-hospedagem · Documentação · - Consultas empresariais + Visão geral das edições do Dify

diff --git a/README_SI.md b/README_SI.md index 7c0867c776..caa5975973 100644 --- a/README_SI.md +++ b/README_SI.md @@ -8,7 +8,7 @@ Dify Cloud · Samostojno gostovanje · Dokumentacija · - Povpraševanje za podjetja + Pregled ponudb izdelkov Dify

diff --git a/README_TR.md b/README_TR.md index f8890b00ef..ab2853a019 100644 --- a/README_TR.md +++ b/README_TR.md @@ -4,7 +4,7 @@ Dify Bulut · Kendi Sunucunuzda Barındırma · Dokümantasyon · - Yalnızca İngilizce: Kurumsal Sorgulama + Dify ürün seçeneklerine genel bakış

diff --git a/README_TW.md b/README_TW.md index 260f1e80ac..8263a22b64 100644 --- a/README_TW.md +++ b/README_TW.md @@ -8,7 +8,7 @@ Dify 雲端服務 · 自行託管 · 說明文件 · - 企業諮詢 + 產品方案概覽

diff --git a/README_VI.md b/README_VI.md index 15d2d5ae80..852ed7aaa0 100644 --- a/README_VI.md +++ b/README_VI.md @@ -4,7 +4,7 @@ Dify Cloud · Tự triển khai · Tài liệu · - Yêu cầu doanh nghiệp + Tổng quan các lựa chọn sản phẩm Dify

diff --git a/api/.env.example b/api/.env.example index 01ddb4adfd..b5820fcdc2 100644 --- a/api/.env.example +++ b/api/.env.example @@ -482,4 +482,7 @@ OTEL_MAX_QUEUE_SIZE=2048 OTEL_MAX_EXPORT_BATCH_SIZE=512 OTEL_METRIC_EXPORT_INTERVAL=60000 OTEL_BATCH_EXPORT_TIMEOUT=10000 -OTEL_METRIC_EXPORT_TIMEOUT=30000 \ No newline at end of file +OTEL_METRIC_EXPORT_TIMEOUT=30000 + +# Prevent Clickjacking +ALLOW_EMBED=false diff --git a/api/app_factory.py b/api/app_factory.py index 586f2ded9e..9648d770ab 100644 --- a/api/app_factory.py +++ b/api/app_factory.py @@ -52,6 +52,7 @@ def initialize_extensions(app: DifyApp): ext_mail, ext_migrate, ext_otel, + ext_otel_patch, ext_proxy_fix, ext_redis, ext_repositories, @@ -84,6 +85,7 @@ def initialize_extensions(app: DifyApp): ext_proxy_fix, ext_blueprints, ext_commands, + ext_otel_patch, # Apply patch before initializing OpenTelemetry ext_otel, ] for ext in extensions: diff --git a/api/configs/app_config.py b/api/configs/app_config.py index cb0adb751c..3a3ad35ee7 100644 --- a/api/configs/app_config.py +++ b/api/configs/app_config.py @@ -13,6 +13,7 @@ from .observability import ObservabilityConfig from .packaging import PackagingInfo from .remote_settings_sources import RemoteSettingsSource, RemoteSettingsSourceConfig, RemoteSettingsSourceName from .remote_settings_sources.apollo import ApolloSettingsSource +from .remote_settings_sources.nacos import NacosSettingsSource logger = logging.getLogger(__name__) @@ -34,6 +35,8 @@ class RemoteSettingsSourceFactory(PydanticBaseSettingsSource): match remote_source_name: case RemoteSettingsSourceName.APOLLO: remote_source = ApolloSettingsSource(current_state) + case RemoteSettingsSourceName.NACOS: + remote_source = NacosSettingsSource(current_state) case _: logger.warning(f"Unsupported remote source: {remote_source_name}") return {} diff --git a/api/configs/middleware/__init__.py b/api/configs/middleware/__init__.py index 15dfe0063b..c2ad24094a 100644 --- a/api/configs/middleware/__init__.py +++ b/api/configs/middleware/__init__.py @@ -22,6 +22,7 @@ from .vdb.baidu_vector_config import BaiduVectorDBConfig from .vdb.chroma_config import ChromaConfig from .vdb.couchbase_config import CouchbaseConfig from .vdb.elasticsearch_config import ElasticsearchConfig +from .vdb.huawei_cloud_config import HuaweiCloudConfig from .vdb.lindorm_config import LindormConfig from .vdb.milvus_config import MilvusConfig from .vdb.myscale_config import MyScaleConfig @@ -263,6 +264,7 @@ class MiddlewareConfig( VectorStoreConfig, AnalyticdbConfig, ChromaConfig, + HuaweiCloudConfig, MilvusConfig, MyScaleConfig, OpenSearchConfig, diff --git a/api/configs/middleware/vdb/huawei_cloud_config.py b/api/configs/middleware/vdb/huawei_cloud_config.py new file mode 100644 index 0000000000..2290c60499 --- /dev/null +++ b/api/configs/middleware/vdb/huawei_cloud_config.py @@ -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, + ) diff --git a/api/configs/remote_settings_sources/enums.py b/api/configs/remote_settings_sources/enums.py index 3081f2950f..dd998cac64 100644 --- a/api/configs/remote_settings_sources/enums.py +++ b/api/configs/remote_settings_sources/enums.py @@ -3,3 +3,4 @@ from enum import StrEnum class RemoteSettingsSourceName(StrEnum): APOLLO = "apollo" + NACOS = "nacos" diff --git a/api/configs/remote_settings_sources/nacos/__init__.py b/api/configs/remote_settings_sources/nacos/__init__.py new file mode 100644 index 0000000000..b1ce8e87bc --- /dev/null +++ b/api/configs/remote_settings_sources/nacos/__init__.py @@ -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 diff --git a/api/configs/remote_settings_sources/nacos/http_request.py b/api/configs/remote_settings_sources/nacos/http_request.py new file mode 100644 index 0000000000..2785bd955b --- /dev/null +++ b/api/configs/remote_settings_sources/nacos/http_request.py @@ -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 diff --git a/api/configs/remote_settings_sources/nacos/utils.py b/api/configs/remote_settings_sources/nacos/utils.py new file mode 100644 index 0000000000..f3372563b1 --- /dev/null +++ b/api/configs/remote_settings_sources/nacos/utils.py @@ -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 diff --git a/api/controllers/console/datasets/datasets.py b/api/controllers/console/datasets/datasets.py index 4644ac6299..752d124735 100644 --- a/api/controllers/console/datasets/datasets.py +++ b/api/controllers/console/datasets/datasets.py @@ -664,6 +664,7 @@ class DatasetRetrievalSettingApi(Resource): | VectorType.OPENGAUSS | VectorType.OCEANBASE | VectorType.TABLESTORE + | VectorType.HUAWEI_CLOUD | VectorType.TENCENT ): return { @@ -710,6 +711,7 @@ class DatasetRetrievalSettingMockApi(Resource): | VectorType.OCEANBASE | VectorType.TABLESTORE | VectorType.TENCENT + | VectorType.HUAWEI_CLOUD ): return { "retrieval_method": [ diff --git a/api/controllers/console/workspace/endpoint.py b/api/controllers/console/workspace/endpoint.py index a5bd2a4bcf..46dee20f8b 100644 --- a/api/controllers/console/workspace/endpoint.py +++ b/api/controllers/console/workspace/endpoint.py @@ -5,6 +5,7 @@ from werkzeug.exceptions import Forbidden from controllers.console import api from controllers.console.wraps import account_initialization_required, setup_required from core.model_runtime.utils.encoders import jsonable_encoder +from core.plugin.manager.exc import PluginPermissionDeniedError from libs.login import login_required from services.plugin.endpoint_service import EndpointService @@ -28,15 +29,18 @@ class EndpointCreateApi(Resource): settings = args["settings"] name = args["name"] - return { - "success": EndpointService.create_endpoint( - tenant_id=user.current_tenant_id, - user_id=user.id, - plugin_unique_identifier=plugin_unique_identifier, - name=name, - settings=settings, - ) - } + try: + return { + "success": EndpointService.create_endpoint( + tenant_id=user.current_tenant_id, + user_id=user.id, + plugin_unique_identifier=plugin_unique_identifier, + name=name, + settings=settings, + ) + } + except PluginPermissionDeniedError as e: + raise ValueError(e.description) from e class EndpointListApi(Resource): diff --git a/api/controllers/files/upload.py b/api/controllers/files/upload.py index ca5ea54435..28ee0eecf4 100644 --- a/api/controllers/files/upload.py +++ b/api/controllers/files/upload.py @@ -1,3 +1,5 @@ +from mimetypes import guess_extension + from flask import request from flask_restful import Resource, marshal_with # type: ignore 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.service_api.app.error import FileTooLargeError 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 services.file_service import FileService class PluginUploadFileApi(Resource): @@ -51,19 +53,26 @@ class PluginUploadFileApi(Resource): raise Forbidden("Invalid request.") try: - upload_file = FileService.upload_file( - filename=filename, - content=file.read(), + tool_file = ToolFileManager.create_file_by_raw( + user_id=user.id, + tenant_id=tenant_id, + file_binary=file.read(), mimetype=mimetype, - user=user, - source=None, + filename=filename, + 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: raise FileTooLargeError(file_too_large_error.description) except services.errors.file.UnsupportedFileTypeError: raise UnsupportedFileTypeError() - return upload_file, 201 + return tool_file, 201 api.add_resource(PluginUploadFileApi, "/files/upload/for-plugin") diff --git a/api/core/agent/base_agent_runner.py b/api/core/agent/base_agent_runner.py index 48c92ea2db..e648613605 100644 --- a/api/core/agent/base_agent_runner.py +++ b/api/core/agent/base_agent_runner.py @@ -21,14 +21,13 @@ from core.model_runtime.entities import ( AssistantPromptMessage, LLMUsage, PromptMessage, - PromptMessageContent, PromptMessageTool, SystemPromptMessage, TextPromptMessageContent, ToolPromptMessage, 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.model_providers.__base.large_language_model import LargeLanguageModel from core.prompt.utils.extract_thread_messages import extract_thread_messages @@ -501,7 +500,7 @@ class BaseAgentRunner(AppRunner): ) if not file_objs: return UserPromptMessage(content=message.query) - prompt_message_contents: list[PromptMessageContent] = [] + prompt_message_contents: list[PromptMessageContentUnionTypes] = [] prompt_message_contents.append(TextPromptMessageContent(data=message.query)) for file in file_objs: prompt_message_contents.append( diff --git a/api/core/agent/cot_chat_agent_runner.py b/api/core/agent/cot_chat_agent_runner.py index 7d407a4976..5ff89bdacb 100644 --- a/api/core/agent/cot_chat_agent_runner.py +++ b/api/core/agent/cot_chat_agent_runner.py @@ -5,12 +5,11 @@ from core.file import file_manager from core.model_runtime.entities import ( AssistantPromptMessage, PromptMessage, - PromptMessageContent, SystemPromptMessage, TextPromptMessageContent, 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 @@ -40,7 +39,7 @@ class CotChatAgentRunner(CotAgentRunner): Organize user query """ if self.files: - prompt_message_contents: list[PromptMessageContent] = [] + prompt_message_contents: list[PromptMessageContentUnionTypes] = [] prompt_message_contents.append(TextPromptMessageContent(data=query)) # get image detail config diff --git a/api/core/agent/fc_agent_runner.py b/api/core/agent/fc_agent_runner.py index f45fa5c66e..a1110e7709 100644 --- a/api/core/agent/fc_agent_runner.py +++ b/api/core/agent/fc_agent_runner.py @@ -15,14 +15,13 @@ from core.model_runtime.entities import ( LLMResultChunkDelta, LLMUsage, PromptMessage, - PromptMessageContent, PromptMessageContentType, SystemPromptMessage, TextPromptMessageContent, ToolPromptMessage, 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.tools.entities.tool_entities import ToolInvokeMeta from core.tools.tool_engine import ToolEngine @@ -395,7 +394,7 @@ class FunctionCallAgentRunner(BaseAgentRunner): Organize user query """ if self.files: - prompt_message_contents: list[PromptMessageContent] = [] + prompt_message_contents: list[PromptMessageContentUnionTypes] = [] prompt_message_contents.append(TextPromptMessageContent(data=query)) # get image detail config diff --git a/api/core/file/file_manager.py b/api/core/file/file_manager.py index 4ebe997ac5..9a204e9ff6 100644 --- a/api/core/file/file_manager.py +++ b/api/core/file/file_manager.py @@ -7,9 +7,9 @@ from core.model_runtime.entities import ( AudioPromptMessageContent, DocumentPromptMessageContent, ImagePromptMessageContent, - MultiModalPromptMessageContent, VideoPromptMessageContent, ) +from core.model_runtime.entities.message_entities import PromptMessageContentUnionTypes from extensions.ext_storage import storage from . import helpers @@ -43,7 +43,7 @@ def to_prompt_message_content( /, *, image_detail_config: ImagePromptMessageContent.DETAIL | None = None, -) -> MultiModalPromptMessageContent: +) -> PromptMessageContentUnionTypes: if f.extension is None: raise ValueError("Missing file extension") if f.mime_type is None: @@ -58,7 +58,7 @@ def to_prompt_message_content( if f.type == FileType.IMAGE: 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.AUDIO: AudioPromptMessageContent, FileType.VIDEO: VideoPromptMessageContent, diff --git a/api/core/memory/token_buffer_memory.py b/api/core/memory/token_buffer_memory.py index 3c90dd22a2..2254b3d4d5 100644 --- a/api/core/memory/token_buffer_memory.py +++ b/api/core/memory/token_buffer_memory.py @@ -8,11 +8,11 @@ from core.model_runtime.entities import ( AssistantPromptMessage, ImagePromptMessageContent, PromptMessage, - PromptMessageContent, PromptMessageRole, TextPromptMessageContent, UserPromptMessage, ) +from core.model_runtime.entities.message_entities import PromptMessageContentUnionTypes from core.prompt.utils.extract_thread_messages import extract_thread_messages from extensions.ext_database import db from factories import file_factory @@ -100,7 +100,7 @@ class TokenBufferMemory: if not file_objs: prompt_messages.append(UserPromptMessage(content=message.query)) else: - prompt_message_contents: list[PromptMessageContent] = [] + prompt_message_contents: list[PromptMessageContentUnionTypes] = [] prompt_message_contents.append(TextPromptMessageContent(data=message.query)) for file in file_objs: prompt_message = file_manager.to_prompt_message_content( diff --git a/api/core/model_runtime/entities/message_entities.py b/api/core/model_runtime/entities/message_entities.py index 3bed2460dd..b1c43d1455 100644 --- a/api/core/model_runtime/entities/message_entities.py +++ b/api/core/model_runtime/entities/message_entities.py @@ -1,6 +1,6 @@ from collections.abc import Sequence 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 @@ -61,11 +61,7 @@ class PromptMessageContentType(StrEnum): class PromptMessageContent(BaseModel): - """ - Model class for prompt message content. - """ - - type: PromptMessageContentType + pass class TextPromptMessageContent(PromptMessageContent): @@ -73,7 +69,7 @@ class TextPromptMessageContent(PromptMessageContent): Model class for text prompt message content. """ - type: PromptMessageContentType = PromptMessageContentType.TEXT + type: Literal[PromptMessageContentType.TEXT] = PromptMessageContentType.TEXT data: str @@ -82,7 +78,6 @@ class MultiModalPromptMessageContent(PromptMessageContent): Model class for multi-modal prompt message content. """ - type: PromptMessageContentType format: str = Field(default=..., description="the format 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") @@ -94,11 +89,11 @@ class MultiModalPromptMessageContent(PromptMessageContent): class VideoPromptMessageContent(MultiModalPromptMessageContent): - type: PromptMessageContentType = PromptMessageContentType.VIDEO + type: Literal[PromptMessageContentType.VIDEO] = PromptMessageContentType.VIDEO class AudioPromptMessageContent(MultiModalPromptMessageContent): - type: PromptMessageContentType = PromptMessageContentType.AUDIO + type: Literal[PromptMessageContentType.AUDIO] = PromptMessageContentType.AUDIO class ImagePromptMessageContent(MultiModalPromptMessageContent): @@ -110,12 +105,24 @@ class ImagePromptMessageContent(MultiModalPromptMessageContent): LOW = "low" HIGH = "high" - type: PromptMessageContentType = PromptMessageContentType.IMAGE + type: Literal[PromptMessageContentType.IMAGE] = PromptMessageContentType.IMAGE detail: DETAIL = DETAIL.LOW 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): @@ -124,7 +131,7 @@ class PromptMessage(BaseModel): """ role: PromptMessageRole - content: Optional[str | Sequence[PromptMessageContent]] = None + content: Optional[str | list[PromptMessageContentUnionTypes]] = None name: Optional[str] = None def is_empty(self) -> bool: diff --git a/api/core/prompt/advanced_prompt_transform.py b/api/core/prompt/advanced_prompt_transform.py index c7427f797e..25964ae063 100644 --- a/api/core/prompt/advanced_prompt_transform.py +++ b/api/core/prompt/advanced_prompt_transform.py @@ -9,13 +9,12 @@ from core.memory.token_buffer_memory import TokenBufferMemory from core.model_runtime.entities import ( AssistantPromptMessage, PromptMessage, - PromptMessageContent, PromptMessageRole, SystemPromptMessage, TextPromptMessageContent, 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.prompt_transform import PromptTransform from core.prompt.utils.prompt_template_parser import PromptTemplateParser @@ -125,7 +124,7 @@ class AdvancedPromptTransform(PromptTransform): prompt = Jinja2Formatter.format(prompt, prompt_inputs) if files: - prompt_message_contents: list[PromptMessageContent] = [] + prompt_message_contents: list[PromptMessageContentUnionTypes] = [] prompt_message_contents.append(TextPromptMessageContent(data=prompt)) for file in files: prompt_message_contents.append( @@ -201,7 +200,7 @@ class AdvancedPromptTransform(PromptTransform): prompt_messages = self._append_chat_histories(memory, memory_config, prompt_messages, model_config) if files and query is not None: - prompt_message_contents: list[PromptMessageContent] = [] + prompt_message_contents: list[PromptMessageContentUnionTypes] = [] prompt_message_contents.append(TextPromptMessageContent(data=query)) for file in files: prompt_message_contents.append( diff --git a/api/core/prompt/simple_prompt_transform.py b/api/core/prompt/simple_prompt_transform.py index ad56d84cb6..47808928f7 100644 --- a/api/core/prompt/simple_prompt_transform.py +++ b/api/core/prompt/simple_prompt_transform.py @@ -11,7 +11,7 @@ from core.memory.token_buffer_memory import TokenBufferMemory from core.model_runtime.entities.message_entities import ( ImagePromptMessageContent, PromptMessage, - PromptMessageContent, + PromptMessageContentUnionTypes, SystemPromptMessage, TextPromptMessageContent, UserPromptMessage, @@ -277,7 +277,7 @@ class SimplePromptTransform(PromptTransform): image_detail_config: Optional[ImagePromptMessageContent.DETAIL] = None, ) -> UserPromptMessage: if files: - prompt_message_contents: list[PromptMessageContent] = [] + prompt_message_contents: list[PromptMessageContentUnionTypes] = [] prompt_message_contents.append(TextPromptMessageContent(data=prompt)) for file in files: prompt_message_contents.append( diff --git a/api/core/rag/datasource/vdb/huawei/__init__.py b/api/core/rag/datasource/vdb/huawei/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/core/rag/datasource/vdb/huawei/huawei_cloud_vector.py b/api/core/rag/datasource/vdb/huawei/huawei_cloud_vector.py new file mode 100644 index 0000000000..89423eb160 --- /dev/null +++ b/api/core/rag/datasource/vdb/huawei/huawei_cloud_vector.py @@ -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, + ), + ) diff --git a/api/core/rag/datasource/vdb/vector_factory.py b/api/core/rag/datasource/vdb/vector_factory.py index 00601c38a1..05158cc7ca 100644 --- a/api/core/rag/datasource/vdb/vector_factory.py +++ b/api/core/rag/datasource/vdb/vector_factory.py @@ -156,6 +156,10 @@ class Vector: from core.rag.datasource.vdb.tablestore.tablestore_vector import TableStoreVectorFactory return TableStoreVectorFactory + case VectorType.HUAWEI_CLOUD: + from core.rag.datasource.vdb.huawei.huawei_cloud_vector import HuaweiCloudVectorFactory + + return HuaweiCloudVectorFactory case _: raise ValueError(f"Vector store {vector_type} is not supported.") diff --git a/api/core/rag/datasource/vdb/vector_type.py b/api/core/rag/datasource/vdb/vector_type.py index 940f12caef..0421be3458 100644 --- a/api/core/rag/datasource/vdb/vector_type.py +++ b/api/core/rag/datasource/vdb/vector_type.py @@ -26,3 +26,4 @@ class VectorType(StrEnum): OCEANBASE = "oceanbase" OPENGAUSS = "opengauss" TABLESTORE = "tablestore" + HUAWEI_CLOUD = "huawei_cloud" diff --git a/api/core/rag/retrieval/dataset_retrieval.py b/api/core/rag/retrieval/dataset_retrieval.py index 23ea775dec..4869a21e80 100644 --- a/api/core/rag/retrieval/dataset_retrieval.py +++ b/api/core/rag/retrieval/dataset_retrieval.py @@ -869,7 +869,9 @@ class DatasetRetrieval: ) ) 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, ) elif metadata_filtering_mode == "manual": @@ -891,10 +893,10 @@ class DatasetRetrieval: else: raise ValueError("Invalid metadata filtering mode") if filters: - if metadata_filtering_conditions.logical_operator == "or": # type: ignore - document_query = document_query.filter(or_(*filters)) - else: + if metadata_filtering_conditions and metadata_filtering_conditions.logical_operator == "and": # type: ignore document_query = document_query.filter(and_(*filters)) + else: + document_query = document_query.filter(or_(*filters)) documents = document_query.all() # group by dataset_id metadata_filter_document_ids = defaultdict(list) if documents else None # type: ignore diff --git a/api/core/workflow/nodes/knowledge_retrieval/knowledge_retrieval_node.py b/api/core/workflow/nodes/knowledge_retrieval/knowledge_retrieval_node.py index 07a711cc4e..4ec033572c 100644 --- a/api/core/workflow/nodes/knowledge_retrieval/knowledge_retrieval_node.py +++ b/api/core/workflow/nodes/knowledge_retrieval/knowledge_retrieval_node.py @@ -349,7 +349,9 @@ class KnowledgeRetrievalNode(LLMNode): ) ) 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, ) elif node_data.metadata_filtering_mode == "manual": @@ -380,7 +382,10 @@ class KnowledgeRetrievalNode(LLMNode): else: raise ValueError("Invalid metadata filtering mode") 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)) else: document_query = document_query.filter(or_(*filters)) diff --git a/api/core/workflow/nodes/llm/node.py b/api/core/workflow/nodes/llm/node.py index 8db7394e54..1089e7168e 100644 --- a/api/core/workflow/nodes/llm/node.py +++ b/api/core/workflow/nodes/llm/node.py @@ -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.message_entities import ( AssistantPromptMessage, - PromptMessageContent, + PromptMessageContentUnionTypes, PromptMessageRole, SystemPromptMessage, UserPromptMessage, @@ -594,8 +594,7 @@ class LLMNode(BaseNode[LLMNodeData]): variable_pool: VariablePool, jinja2_variables: Sequence[VariableSelector], ) -> tuple[Sequence[PromptMessage], Optional[Sequence[str]]]: - # FIXME: fix the type error cause prompt_messages is type quick a few times - prompt_messages: list[Any] = [] + prompt_messages: list[PromptMessage] = [] if isinstance(prompt_template, list): # For chat model @@ -657,12 +656,14 @@ class LLMNode(BaseNode[LLMNodeData]): # For issue #11247 - Check if prompt content is a string or a list prompt_content_type = type(prompt_content) if prompt_content_type == str: + prompt_content = str(prompt_content) if "#histories#" in prompt_content: prompt_content = prompt_content.replace("#histories#", memory_text) else: prompt_content = memory_text + "\n" + prompt_content prompt_messages[0].content = prompt_content elif prompt_content_type == list: + prompt_content = prompt_content if isinstance(prompt_content, list) else [] for content_item in prompt_content: if content_item.type == PromptMessageContentType.TEXT: if "#histories#" in content_item.data: @@ -675,9 +676,10 @@ class LLMNode(BaseNode[LLMNodeData]): # Add current query to the prompt message if sys_query: 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 elif prompt_content_type == list: + prompt_content = prompt_content if isinstance(prompt_content, list) else [] for content_item in prompt_content: if content_item.type == PromptMessageContentType.TEXT: content_item.data = sys_query + "\n" + content_item.data @@ -707,7 +709,7 @@ class LLMNode(BaseNode[LLMNodeData]): filtered_prompt_messages = [] for prompt_message in prompt_messages: if isinstance(prompt_message.content, list): - prompt_message_content = [] + prompt_message_content: list[PromptMessageContentUnionTypes] = [] for content_item in prompt_message.content: # Skip content if features are not defined 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: case PromptMessageRole.USER: return UserPromptMessage(content=contents) diff --git a/api/extensions/ext_otel.py b/api/extensions/ext_otel.py index 59ec0d0686..a2edd832ec 100644 --- a/api/extensions/ext_otel.py +++ b/api/extensions/ext_otel.py @@ -14,7 +14,7 @@ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExport from opentelemetry.instrumentation.celery import CeleryInstrumentor from opentelemetry.instrumentation.flask import FlaskInstrumentor 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.propagators.b3 import B3Format from opentelemetry.propagators.composite import CompositePropagator @@ -112,6 +112,11 @@ def is_celery_worker(): 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): if span and span.is_recording(): if status.startswith("2"): @@ -119,6 +124,11 @@ def init_flask_instrumentor(app: DifyApp): else: 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() if dify_config.DEBUG: logging.info("Initializing Flask instrumentor") diff --git a/api/extensions/ext_otel_patch.py b/api/extensions/ext_otel_patch.py new file mode 100644 index 0000000000..58309fe4d1 --- /dev/null +++ b/api/extensions/ext_otel_patch.py @@ -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") diff --git a/api/fields/file_fields.py b/api/fields/file_fields.py index f896c15f0f..dfc1b623d5 100644 --- a/api/fields/file_fields.py +++ b/api/fields/file_fields.py @@ -19,6 +19,7 @@ file_fields = { "mime_type": fields.String, "created_by": fields.String, "created_at": TimestampField, + "preview_url": fields.String, } remote_file_info_fields = { diff --git a/api/repositories/workflow_node_execution/sqlalchemy_repository.py b/api/repositories/workflow_node_execution/sqlalchemy_repository.py index 0594d816a2..e0ad384be6 100644 --- a/api/repositories/workflow_node_execution/sqlalchemy_repository.py +++ b/api/repositories/workflow_node_execution/sqlalchemy_repository.py @@ -37,8 +37,12 @@ class SQLAlchemyWorkflowNodeExecutionRepository: # If an engine is provided, create a sessionmaker from it if isinstance(session_factory, Engine): self._session_factory = sessionmaker(bind=session_factory, expire_on_commit=False) - else: + elif isinstance(session_factory, sessionmaker): 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._app_id = app_id diff --git a/api/services/workflow_run_service.py b/api/services/workflow_run_service.py index ff3b33eecd..8b7213eefb 100644 --- a/api/services/workflow_run_service.py +++ b/api/services/workflow_run_service.py @@ -133,7 +133,7 @@ class WorkflowRunService: params={ "tenant_id": app_model.tenant_id, "app_id": app_model.id, - "session_factory": db.session.get_bind, + "session_factory": db.session.get_bind(), } ) diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index 5cd5c55746..63e3791147 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -289,7 +289,7 @@ class WorkflowService: params={ "tenant_id": app_model.tenant_id, "app_id": app_model.id, - "session_factory": db.session.get_bind, + "session_factory": db.session.get_bind(), } ) repository.save(workflow_node_execution) diff --git a/api/tasks/remove_app_and_related_data_task.py b/api/tasks/remove_app_and_related_data_task.py index 4542b1b923..cd8981abf6 100644 --- a/api/tasks/remove_app_and_related_data_task.py +++ b/api/tasks/remove_app_and_related_data_task.py @@ -193,7 +193,7 @@ def _delete_app_workflow_node_executions(tenant_id: str, app_id: str): params={ "tenant_id": tenant_id, "app_id": app_id, - "session_factory": db.session.get_bind, + "session_factory": db.session.get_bind(), } ) diff --git a/api/tests/integration_tests/vdb/__mock/huaweicloudvectordb.py b/api/tests/integration_tests/vdb/__mock/huaweicloudvectordb.py new file mode 100644 index 0000000000..e1aba4e2c1 --- /dev/null +++ b/api/tests/integration_tests/vdb/__mock/huaweicloudvectordb.py @@ -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() diff --git a/api/tests/integration_tests/vdb/huawei/__init__.py b/api/tests/integration_tests/vdb/huawei/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/tests/integration_tests/vdb/huawei/test_huawei_cloud.py b/api/tests/integration_tests/vdb/huawei/test_huawei_cloud.py new file mode 100644 index 0000000000..943b2bc877 --- /dev/null +++ b/api/tests/integration_tests/vdb/huawei/test_huawei_cloud.py @@ -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() diff --git a/api/tests/unit_tests/core/prompt/test_prompt_message.py b/api/tests/unit_tests/core/prompt/test_prompt_message.py new file mode 100644 index 0000000000..e5da51d733 --- /dev/null +++ b/api/tests/unit_tests/core/prompt/test_prompt_message.py @@ -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 diff --git a/dev/pytest/pytest_vdb.sh b/dev/pytest/pytest_vdb.sh index c68a94c79b..dd03ca3514 100755 --- a/dev/pytest/pytest_vdb.sh +++ b/dev/pytest/pytest_vdb.sh @@ -15,3 +15,4 @@ pytest api/tests/integration_tests/vdb/chroma \ api/tests/integration_tests/vdb/couchbase \ api/tests/integration_tests/vdb/oceanbase \ api/tests/integration_tests/vdb/tidb_vector \ + api/tests/integration_tests/vdb/huawei \ diff --git a/docker/.env.example b/docker/.env.example index 82ef4174c2..0b80dccb37 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -574,6 +574,11 @@ OPENGAUSS_MIN_CONNECTION=1 OPENGAUSS_MAX_CONNECTION=5 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_URL=https://xxx-vector.upstash.io UPSTASH_VECTOR_TOKEN=dify @@ -1063,3 +1068,6 @@ OTEL_MAX_EXPORT_BATCH_SIZE=512 OTEL_METRIC_EXPORT_INTERVAL=60000 OTEL_BATCH_EXPORT_TIMEOUT=10000 OTEL_METRIC_EXPORT_TIMEOUT=30000 + +# Prevent Clickjacking +ALLOW_EMBED=false diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml index c6d41849ef..377ff9c117 100644 --- a/docker/docker-compose-template.yaml +++ b/docker/docker-compose-template.yaml @@ -66,6 +66,7 @@ services: NEXT_TELEMETRY_DISABLED: ${NEXT_TELEMETRY_DISABLED:-0} TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000} CSP_WHITELIST: ${CSP_WHITELIST:-} + ALLOW_EMBED: ${ALLOW_EMBED:-false} MARKETPLACE_API_URL: ${MARKETPLACE_API_URL:-https://marketplace.dify.ai} MARKETPLACE_URL: ${MARKETPLACE_URL:-https://marketplace.dify.ai} TOP_K_MAX_VALUE: ${TOP_K_MAX_VALUE:-} @@ -552,7 +553,7 @@ services: volumes: - ./volumes/opengauss/data:/var/lib/opengauss/data 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 timeout: 10s retries: 10 diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index def4b77c65..81fa651ed9 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -266,6 +266,9 @@ x-shared-env: &shared-api-worker-env OPENGAUSS_MIN_CONNECTION: ${OPENGAUSS_MIN_CONNECTION:-1} OPENGAUSS_MAX_CONNECTION: ${OPENGAUSS_MAX_CONNECTION:-5} 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_TOKEN: ${UPSTASH_VECTOR_TOKEN:-dify} TABLESTORE_ENDPOINT: ${TABLESTORE_ENDPOINT:-https://instance-name.cn-hangzhou.ots.aliyuncs.com} @@ -471,6 +474,7 @@ x-shared-env: &shared-api-worker-env OTEL_METRIC_EXPORT_INTERVAL: ${OTEL_METRIC_EXPORT_INTERVAL:-60000} OTEL_BATCH_EXPORT_TIMEOUT: ${OTEL_BATCH_EXPORT_TIMEOUT:-10000} OTEL_METRIC_EXPORT_TIMEOUT: ${OTEL_METRIC_EXPORT_TIMEOUT:-30000} + ALLOW_EMBED: ${ALLOW_EMBED:-false} services: # API service @@ -539,6 +543,7 @@ services: NEXT_TELEMETRY_DISABLED: ${NEXT_TELEMETRY_DISABLED:-0} TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000} CSP_WHITELIST: ${CSP_WHITELIST:-} + ALLOW_EMBED: ${ALLOW_EMBED:-false} MARKETPLACE_API_URL: ${MARKETPLACE_API_URL:-https://marketplace.dify.ai} MARKETPLACE_URL: ${MARKETPLACE_URL:-https://marketplace.dify.ai} TOP_K_MAX_VALUE: ${TOP_K_MAX_VALUE:-} @@ -1025,7 +1030,7 @@ services: volumes: - ./volumes/opengauss/data:/var/lib/opengauss/data 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 timeout: 10s retries: 10 diff --git a/sdks/php-client/.gitignore b/sdks/php-client/.gitignore new file mode 100644 index 0000000000..61ead86667 --- /dev/null +++ b/sdks/php-client/.gitignore @@ -0,0 +1 @@ +/vendor diff --git a/sdks/php-client/README.md b/sdks/php-client/README.md index b0a435bbaf..812980d834 100644 --- a/sdks/php-client/README.md +++ b/sdks/php-client/README.md @@ -9,6 +9,21 @@ This is the PHP SDK for the Dify API, which allows you to easily integrate Dify ## 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: ```php @@ -16,10 +31,6 @@ After installing the SDK, you can use it in your project like this: require 'vendor/autoload.php'; -use YourVendorName\DifyPHP\DifyClient; -use YourVendorName\DifyPHP\CompletionClient; -use YourVendorName\DifyPHP\ChatClient; - $apiKey = 'your-api-key-here'; $difyClient = new DifyClient($apiKey); diff --git a/sdks/php-client/composer.json b/sdks/php-client/composer.json new file mode 100644 index 0000000000..6e49e44075 --- /dev/null +++ b/sdks/php-client/composer.json @@ -0,0 +1,9 @@ +{ + "require": { + "php": ">=7.2", + "guzzlehttp/guzzle": "^7.9" + }, + "autoload": { + "files": ["dify-client.php"] + } +} diff --git a/sdks/php-client/composer.lock b/sdks/php-client/composer.lock new file mode 100644 index 0000000000..aa07dc03ef --- /dev/null +++ b/sdks/php-client/composer.lock @@ -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" +} diff --git a/sdks/php-client/dify-client.php b/sdks/php-client/dify-client.php index ccd61f091a..acb862093a 100644 --- a/sdks/php-client/dify-client.php +++ b/sdks/php-client/dify-client.php @@ -1,7 +1,5 @@ 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([ 'base_uri' => $this->base_url, 'headers' => [ @@ -19,13 +17,6 @@ class DifyClient { '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) { @@ -58,7 +49,7 @@ class DifyClient { '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) { @@ -132,7 +123,7 @@ class ChatClient extends DifyClient { public function get_suggestions($message_id, $user) { $params = [ 'user' => $user - ] + ]; return $this->send_request('GET', "messages/{$message_id}/suggested", null, $params); } @@ -188,10 +179,9 @@ class ChatClient extends DifyClient { 'user' => $user, ]; $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); } } diff --git a/web/.env.example b/web/.env.example index 1c3f42ddfc..51631c2437 100644 --- a/web/.env.example +++ b/web/.env.example @@ -29,6 +29,8 @@ NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS=60000 # CSP https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP 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 NEXT_PUBLIC_GITHUB_ACCESS_TOKEN= diff --git a/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx b/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx index 90885dacc8..645f6045f0 100644 --- a/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx +++ b/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx @@ -62,13 +62,13 @@ const SettingsModal: FC = ({ const { notify } = useToastContext() const ref = useRef(null) 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 [loading, setLoading] = useState(false) const { isCurrentWorkspaceDatasetOperator } = useAppContext() 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(currentDataset.partial_member_list || []) const [memberList, setMemberList] = useState([]) @@ -88,6 +88,14 @@ const SettingsModal: FC = ({ setScoreThreshold(data.score_threshold) if (data.score_threshold_enabled !== undefined) setScoreThresholdEnabled(data.score_threshold_enabled) + + setLocaleCurrentDataset({ + ...localeCurrentDataset, + external_retrieval_model: { + ...localeCurrentDataset?.external_retrieval_model, + ...data, + }, + }) } const handleSave = async () => { diff --git a/web/app/components/app/overview/embedded/index.tsx b/web/app/components/app/overview/embedded/index.tsx index 37fbd5e291..d4e5dd8898 100644 --- a/web/app/components/app/overview/embedded/index.tsx +++ b/web/app/components/app/overview/embedded/index.tsx @@ -29,7 +29,7 @@ const OPTION_MAP = { iframe: { getContent: (url: string, token: string) => `