diff --git a/api/.env.example b/api/.env.example index baa9c382c8..a7ea6cf937 100644 --- a/api/.env.example +++ b/api/.env.example @@ -17,6 +17,11 @@ APP_WEB_URL=http://127.0.0.1:3000 # Files URL FILES_URL=http://127.0.0.1:5001 +# INTERNAL_FILES_URL is used for plugin daemon communication within Docker network. +# Set this to the internal Docker service URL for proper plugin file access. +# Example: INTERNAL_FILES_URL=http://api:5001 +INTERNAL_FILES_URL=http://127.0.0.1:5001 + # The time in seconds after the signature is rejected FILES_ACCESS_TIMEOUT=300 diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index df15b92c35..963fcbedf9 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -237,6 +237,13 @@ class FileAccessConfig(BaseSettings): default="", ) + INTERNAL_FILES_URL: str = Field( + description="Internal base URL for file access within Docker network," + " used for plugin daemon and internal service communication." + " Falls back to FILES_URL if not specified.", + default="", + ) + FILES_ACCESS_TIMEOUT: int = Field( description="Expiration time in seconds for file access URLs", default=300, diff --git a/api/configs/packaging/__init__.py b/api/configs/packaging/__init__.py index 298574d9f8..f511e20e6b 100644 --- a/api/configs/packaging/__init__.py +++ b/api/configs/packaging/__init__.py @@ -8,11 +8,6 @@ class PackagingInfo(PyProjectTomlConfig): Packaging build information """ - CURRENT_VERSION: str = Field( - description="Dify version", - default="1.5.0", - ) - COMMIT_SHA: str = Field( description="SHA-1 checksum of the git commit used to build the app", default="", diff --git a/api/controllers/console/__init__.py b/api/controllers/console/__init__.py index dbdcdc46ce..e25f92399c 100644 --- a/api/controllers/console/__init__.py +++ b/api/controllers/console/__init__.py @@ -56,6 +56,7 @@ from .app import ( conversation, conversation_variables, generator, + mcp_server, message, model_config, ops_trace, diff --git a/api/controllers/console/app/audio.py b/api/controllers/console/app/audio.py index 5f2def8d8e..665cf1aede 100644 --- a/api/controllers/console/app/audio.py +++ b/api/controllers/console/app/audio.py @@ -90,23 +90,11 @@ class ChatMessageTextApi(Resource): message_id = args.get("message_id", None) text = args.get("text", None) - if ( - app_model.mode in {AppMode.ADVANCED_CHAT.value, AppMode.WORKFLOW.value} - and app_model.workflow - and app_model.workflow.features_dict - ): - text_to_speech = app_model.workflow.features_dict.get("text_to_speech") - if text_to_speech is None: - raise ValueError("TTS is not enabled") - voice = args.get("voice") or text_to_speech.get("voice") - else: - try: - if app_model.app_model_config is None: - raise ValueError("AppModelConfig not found") - voice = args.get("voice") or app_model.app_model_config.text_to_speech_dict.get("voice") - except Exception: - voice = None - response = AudioService.transcript_tts(app_model=app_model, text=text, message_id=message_id, voice=voice) + voice = args.get("voice", None) + + response = AudioService.transcript_tts( + app_model=app_model, text=text, voice=voice, message_id=message_id, is_draft=True + ) return response except services.errors.app_model_config.AppModelConfigBrokenError: logging.exception("App model config broken.") diff --git a/api/controllers/console/app/mcp_server.py b/api/controllers/console/app/mcp_server.py new file mode 100644 index 0000000000..ccda97d80c --- /dev/null +++ b/api/controllers/console/app/mcp_server.py @@ -0,0 +1,106 @@ +import json +from enum import StrEnum + +from flask_login import current_user +from flask_restful import Resource, marshal_with, reqparse +from werkzeug.exceptions import NotFound + +from controllers.console import api +from controllers.console.app.wraps import get_app_model +from controllers.console.wraps import account_initialization_required, setup_required +from extensions.ext_database import db +from fields.app_fields import app_server_fields +from libs.login import login_required +from models.model import AppMCPServer + + +class AppMCPServerStatus(StrEnum): + ACTIVE = "active" + INACTIVE = "inactive" + + +class AppMCPServerController(Resource): + @setup_required + @login_required + @account_initialization_required + @get_app_model + @marshal_with(app_server_fields) + def get(self, app_model): + server = db.session.query(AppMCPServer).filter(AppMCPServer.app_id == app_model.id).first() + return server + + @setup_required + @login_required + @account_initialization_required + @get_app_model + @marshal_with(app_server_fields) + def post(self, app_model): + # The role of the current user in the ta table must be editor, admin, or owner + if not current_user.is_editor: + raise NotFound() + parser = reqparse.RequestParser() + parser.add_argument("description", type=str, required=True, location="json") + parser.add_argument("parameters", type=dict, required=True, location="json") + args = parser.parse_args() + server = AppMCPServer( + name=app_model.name, + description=args["description"], + parameters=json.dumps(args["parameters"], ensure_ascii=False), + status=AppMCPServerStatus.ACTIVE, + app_id=app_model.id, + tenant_id=current_user.current_tenant_id, + server_code=AppMCPServer.generate_server_code(16), + ) + db.session.add(server) + db.session.commit() + return server + + @setup_required + @login_required + @account_initialization_required + @get_app_model + @marshal_with(app_server_fields) + def put(self, app_model): + if not current_user.is_editor: + raise NotFound() + parser = reqparse.RequestParser() + parser.add_argument("id", type=str, required=True, location="json") + parser.add_argument("description", type=str, required=True, location="json") + parser.add_argument("parameters", type=dict, required=True, location="json") + parser.add_argument("status", type=str, required=False, location="json") + args = parser.parse_args() + server = db.session.query(AppMCPServer).filter(AppMCPServer.id == args["id"]).first() + if not server: + raise NotFound() + server.description = args["description"] + server.parameters = json.dumps(args["parameters"], ensure_ascii=False) + if args["status"]: + if args["status"] not in [status.value for status in AppMCPServerStatus]: + raise ValueError("Invalid status") + server.status = args["status"] + db.session.commit() + return server + + +class AppMCPServerRefreshController(Resource): + @setup_required + @login_required + @account_initialization_required + @marshal_with(app_server_fields) + def get(self, server_id): + if not current_user.is_editor: + raise NotFound() + server = ( + db.session.query(AppMCPServer) + .filter(AppMCPServer.id == server_id and AppMCPServer.tenant_id == current_user.current_tenant_id) + .first() + ) + if not server: + raise NotFound() + server.server_code = AppMCPServer.generate_server_code(16) + db.session.commit() + return server + + +api.add_resource(AppMCPServerController, "/apps//server") +api.add_resource(AppMCPServerRefreshController, "/apps//server/refresh") diff --git a/api/controllers/console/explore/audio.py b/api/controllers/console/explore/audio.py index 54bc590677..d564a00a76 100644 --- a/api/controllers/console/explore/audio.py +++ b/api/controllers/console/explore/audio.py @@ -18,7 +18,6 @@ from controllers.console.app.error import ( from controllers.console.explore.wraps import InstalledAppResource from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError from core.model_runtime.errors.invoke import InvokeError -from models.model import AppMode from services.audio_service import AudioService from services.errors.audio import ( AudioTooLargeServiceError, @@ -79,19 +78,9 @@ class ChatTextApi(InstalledAppResource): message_id = args.get("message_id", None) text = args.get("text", None) - if ( - app_model.mode in {AppMode.ADVANCED_CHAT.value, AppMode.WORKFLOW.value} - and app_model.workflow - and app_model.workflow.features_dict - ): - text_to_speech = app_model.workflow.features_dict.get("text_to_speech") - voice = args.get("voice") or text_to_speech.get("voice") - else: - try: - voice = args.get("voice") or app_model.app_model_config.text_to_speech_dict.get("voice") - except Exception: - voice = None - response = AudioService.transcript_tts(app_model=app_model, message_id=message_id, voice=voice, text=text) + voice = args.get("voice", None) + + response = AudioService.transcript_tts(app_model=app_model, text=text, voice=voice, message_id=message_id) return response except services.errors.app_model_config.AppModelConfigBrokenError: logging.exception("App model config broken.") diff --git a/api/controllers/console/workspace/tool_providers.py b/api/controllers/console/workspace/tool_providers.py index f71cf34d4a..666759a566 100644 --- a/api/controllers/console/workspace/tool_providers.py +++ b/api/controllers/console/workspace/tool_providers.py @@ -1,5 +1,7 @@ import io +from urllib.parse import urlparse +from flask import redirect, send_file from flask import make_response, redirect, request, send_file from flask_login import current_user from flask_restful import ( @@ -16,6 +18,11 @@ from controllers.console.wraps import ( enterprise_license_required, setup_required, ) +from controllers.console.wraps import account_initialization_required, enterprise_license_required, setup_required +from core.mcp.auth.auth_flow import auth, handle_callback +from core.mcp.auth.auth_provider import OAuthClientProvider +from core.mcp.error import MCPAuthError, MCPError +from core.mcp.mcp_client import MCPClient from core.model_runtime.utils.encoders import jsonable_encoder from core.plugin.entities.plugin import ToolProviderID from core.plugin.impl.oauth import OAuthHandler @@ -26,11 +33,24 @@ from libs.login import login_required from services.plugin.oauth_service import OAuthProxyService from services.tools.api_tools_manage_service import ApiToolManageService from services.tools.builtin_tools_manage_service import BuiltinToolManageService +from services.tools.mcp_tools_mange_service import MCPToolManageService from services.tools.tool_labels_service import ToolLabelsService from services.tools.tools_manage_service import ToolCommonService +from services.tools.tools_transform_service import ToolTransformService from services.tools.workflow_tools_manage_service import WorkflowToolManageService +def is_valid_url(url: str) -> bool: + if not url: + return False + + try: + parsed = urlparse(url) + return all([parsed.scheme, parsed.netloc]) and parsed.scheme in ["http", "https"] + except Exception: + return False + + class ToolProviderListApi(Resource): @setup_required @login_required @@ -45,7 +65,7 @@ class ToolProviderListApi(Resource): req.add_argument( "type", type=str, - choices=["builtin", "model", "api", "workflow"], + choices=["builtin", "model", "api", "workflow", "mcp"], required=False, nullable=True, location="args", @@ -831,6 +851,166 @@ api.add_resource(ToolOAuthCallback, "/oauth/plugin//tool/callback api.add_resource(ToolOAuthCustomClient, "/workspaces/current/tool-provider/builtin//oauth/custom-client") +class ToolProviderMCPApi(Resource): + @setup_required + @login_required + @account_initialization_required + def post(self): + parser = reqparse.RequestParser() + parser.add_argument("server_url", type=str, required=True, nullable=False, location="json") + parser.add_argument("name", type=str, required=True, nullable=False, location="json") + parser.add_argument("icon", type=str, required=True, nullable=False, location="json") + parser.add_argument("icon_type", type=str, required=True, nullable=False, location="json") + parser.add_argument("icon_background", type=str, required=False, nullable=True, location="json", default="") + parser.add_argument("server_identifier", type=str, required=True, nullable=False, location="json") + args = parser.parse_args() + user = current_user + if not is_valid_url(args["server_url"]): + raise ValueError("Server URL is not valid.") + return jsonable_encoder( + MCPToolManageService.create_mcp_provider( + tenant_id=user.current_tenant_id, + server_url=args["server_url"], + name=args["name"], + icon=args["icon"], + icon_type=args["icon_type"], + icon_background=args["icon_background"], + user_id=user.id, + server_identifier=args["server_identifier"], + ) + ) + + @setup_required + @login_required + @account_initialization_required + def put(self): + parser = reqparse.RequestParser() + parser.add_argument("server_url", type=str, required=True, nullable=False, location="json") + parser.add_argument("name", type=str, required=True, nullable=False, location="json") + parser.add_argument("icon", type=str, required=True, nullable=False, location="json") + parser.add_argument("icon_type", type=str, required=True, nullable=False, location="json") + parser.add_argument("icon_background", type=str, required=False, nullable=True, location="json") + parser.add_argument("provider_id", type=str, required=True, nullable=False, location="json") + parser.add_argument("server_identifier", type=str, required=True, nullable=False, location="json") + args = parser.parse_args() + if not is_valid_url(args["server_url"]): + if "[__HIDDEN__]" in args["server_url"]: + pass + else: + raise ValueError("Server URL is not valid.") + MCPToolManageService.update_mcp_provider( + tenant_id=current_user.current_tenant_id, + provider_id=args["provider_id"], + server_url=args["server_url"], + name=args["name"], + icon=args["icon"], + icon_type=args["icon_type"], + icon_background=args["icon_background"], + server_identifier=args["server_identifier"], + ) + return {"result": "success"} + + @setup_required + @login_required + @account_initialization_required + def delete(self): + parser = reqparse.RequestParser() + parser.add_argument("provider_id", type=str, required=True, nullable=False, location="json") + args = parser.parse_args() + MCPToolManageService.delete_mcp_tool(tenant_id=current_user.current_tenant_id, provider_id=args["provider_id"]) + return {"result": "success"} + + +class ToolMCPAuthApi(Resource): + @setup_required + @login_required + @account_initialization_required + def post(self): + parser = reqparse.RequestParser() + parser.add_argument("provider_id", type=str, required=True, nullable=False, location="json") + parser.add_argument("authorization_code", type=str, required=False, nullable=True, location="json") + args = parser.parse_args() + provider_id = args["provider_id"] + tenant_id = current_user.current_tenant_id + provider = MCPToolManageService.get_mcp_provider_by_provider_id(provider_id, tenant_id) + if not provider: + raise ValueError("provider not found") + try: + with MCPClient( + provider.decrypted_server_url, + provider_id, + tenant_id, + authed=False, + authorization_code=args["authorization_code"], + for_list=True, + ): + MCPToolManageService.update_mcp_provider_credentials( + mcp_provider=provider, + credentials=provider.decrypted_credentials, + authed=True, + ) + return {"result": "success"} + + except MCPAuthError: + auth_provider = OAuthClientProvider(provider_id, tenant_id, for_list=True) + return auth(auth_provider, provider.decrypted_server_url, args["authorization_code"]) + except MCPError as e: + MCPToolManageService.update_mcp_provider_credentials( + mcp_provider=provider, + credentials={}, + authed=False, + ) + raise ValueError(f"Failed to connect to MCP server: {e}") from e + + +class ToolMCPDetailApi(Resource): + @setup_required + @login_required + @account_initialization_required + def get(self, provider_id): + user = current_user + provider = MCPToolManageService.get_mcp_provider_by_provider_id(provider_id, user.current_tenant_id) + return jsonable_encoder(ToolTransformService.mcp_provider_to_user_provider(provider, for_list=True)) + + +class ToolMCPListAllApi(Resource): + @setup_required + @login_required + @account_initialization_required + def get(self): + user = current_user + tenant_id = user.current_tenant_id + + tools = MCPToolManageService.retrieve_mcp_tools(tenant_id=tenant_id) + + return [tool.to_dict() for tool in tools] + + +class ToolMCPUpdateApi(Resource): + @setup_required + @login_required + @account_initialization_required + def get(self, provider_id): + tenant_id = current_user.current_tenant_id + tools = MCPToolManageService.list_mcp_tool_from_remote_server( + tenant_id=tenant_id, + provider_id=provider_id, + ) + return jsonable_encoder(tools) + + +class ToolMCPCallbackApi(Resource): + def get(self): + parser = reqparse.RequestParser() + parser.add_argument("code", type=str, required=True, nullable=False, location="args") + parser.add_argument("state", type=str, required=True, nullable=False, location="args") + args = parser.parse_args() + state_key = args["state"] + authorization_code = args["code"] + handle_callback(state_key, authorization_code) + return redirect(f"{dify_config.CONSOLE_WEB_URL}/oauth-callback") + + # tool provider api.add_resource(ToolProviderListApi, "/workspaces/current/tool-providers") @@ -876,8 +1056,15 @@ api.add_resource(ToolWorkflowProviderDeleteApi, "/workspaces/current/tool-provid api.add_resource(ToolWorkflowProviderGetApi, "/workspaces/current/tool-provider/workflow/get") api.add_resource(ToolWorkflowProviderListToolApi, "/workspaces/current/tool-provider/workflow/tools") +# mcp tool provider +api.add_resource(ToolMCPDetailApi, "/workspaces/current/tool-provider/mcp/tools/") +api.add_resource(ToolProviderMCPApi, "/workspaces/current/tool-provider/mcp") +api.add_resource(ToolMCPUpdateApi, "/workspaces/current/tool-provider/mcp/update/") +api.add_resource(ToolMCPAuthApi, "/workspaces/current/tool-provider/mcp/auth") +api.add_resource(ToolMCPCallbackApi, "/mcp/oauth/callback") + api.add_resource(ToolBuiltinListApi, "/workspaces/current/tools/builtin") api.add_resource(ToolApiListApi, "/workspaces/current/tools/api") +api.add_resource(ToolMCPListAllApi, "/workspaces/current/tools/mcp") api.add_resource(ToolWorkflowListApi, "/workspaces/current/tools/workflow") - api.add_resource(ToolLabelsApi, "/workspaces/current/tool-labels") diff --git a/api/controllers/files/upload.py b/api/controllers/files/upload.py index f1a15793c7..15f93d2774 100644 --- a/api/controllers/files/upload.py +++ b/api/controllers/files/upload.py @@ -87,7 +87,5 @@ class PluginUploadFileApi(Resource): except services.errors.file.UnsupportedFileTypeError: raise UnsupportedFileTypeError() - return tool_file, 201 - api.add_resource(PluginUploadFileApi, "/files/upload/for-plugin") diff --git a/api/controllers/mcp/__init__.py b/api/controllers/mcp/__init__.py new file mode 100644 index 0000000000..1b3e0a5621 --- /dev/null +++ b/api/controllers/mcp/__init__.py @@ -0,0 +1,8 @@ +from flask import Blueprint + +from libs.external_api import ExternalApi + +bp = Blueprint("mcp", __name__, url_prefix="/mcp") +api = ExternalApi(bp) + +from . import mcp diff --git a/api/controllers/mcp/mcp.py b/api/controllers/mcp/mcp.py new file mode 100644 index 0000000000..ead728bfb0 --- /dev/null +++ b/api/controllers/mcp/mcp.py @@ -0,0 +1,104 @@ +from flask_restful import Resource, reqparse +from pydantic import ValidationError + +from controllers.console.app.mcp_server import AppMCPServerStatus +from controllers.mcp import api +from core.app.app_config.entities import VariableEntity +from core.mcp import types +from core.mcp.server.streamable_http import MCPServerStreamableHTTPRequestHandler +from core.mcp.types import ClientNotification, ClientRequest +from core.mcp.utils import create_mcp_error_response +from extensions.ext_database import db +from libs import helper +from models.model import App, AppMCPServer, AppMode + + +class MCPAppApi(Resource): + def post(self, server_code): + def int_or_str(value): + if isinstance(value, (int, str)): + return value + else: + return None + + parser = reqparse.RequestParser() + parser.add_argument("jsonrpc", type=str, required=True, location="json") + parser.add_argument("method", type=str, required=True, location="json") + parser.add_argument("params", type=dict, required=False, location="json") + parser.add_argument("id", type=int_or_str, required=False, location="json") + args = parser.parse_args() + + request_id = args.get("id") + + server = db.session.query(AppMCPServer).filter(AppMCPServer.server_code == server_code).first() + if not server: + return helper.compact_generate_response( + create_mcp_error_response(request_id, types.INVALID_REQUEST, "Server Not Found") + ) + + if server.status != AppMCPServerStatus.ACTIVE: + return helper.compact_generate_response( + create_mcp_error_response(request_id, types.INVALID_REQUEST, "Server is not active") + ) + + app = db.session.query(App).filter(App.id == server.app_id).first() + if not app: + return helper.compact_generate_response( + create_mcp_error_response(request_id, types.INVALID_REQUEST, "App Not Found") + ) + + if app.mode in {AppMode.ADVANCED_CHAT.value, AppMode.WORKFLOW.value}: + workflow = app.workflow + if workflow is None: + return helper.compact_generate_response( + create_mcp_error_response(request_id, types.INVALID_REQUEST, "App is unavailable") + ) + + user_input_form = workflow.user_input_form(to_old_structure=True) + else: + app_model_config = app.app_model_config + if app_model_config is None: + return helper.compact_generate_response( + create_mcp_error_response(request_id, types.INVALID_REQUEST, "App is unavailable") + ) + + features_dict = app_model_config.to_dict() + user_input_form = features_dict.get("user_input_form", []) + converted_user_input_form: list[VariableEntity] = [] + try: + for item in user_input_form: + variable_type = item.get("type", "") or list(item.keys())[0] + variable = item[variable_type] + converted_user_input_form.append( + VariableEntity( + type=variable_type, + variable=variable.get("variable"), + description=variable.get("description") or "", + label=variable.get("label"), + required=variable.get("required", False), + max_length=variable.get("max_length"), + options=variable.get("options") or [], + ) + ) + except ValidationError as e: + return helper.compact_generate_response( + create_mcp_error_response(request_id, types.INVALID_PARAMS, f"Invalid user_input_form: {str(e)}") + ) + + try: + request: ClientRequest | ClientNotification = ClientRequest.model_validate(args) + except ValidationError as e: + try: + notification = ClientNotification.model_validate(args) + request = notification + except ValidationError as e: + return helper.compact_generate_response( + create_mcp_error_response(request_id, types.INVALID_PARAMS, f"Invalid MCP request: {str(e)}") + ) + + mcp_server_handler = MCPServerStreamableHTTPRequestHandler(app, request, converted_user_input_form) + response = mcp_server_handler.handle() + return helper.compact_generate_response(response) + + +api.add_resource(MCPAppApi, "/server//mcp") diff --git a/api/controllers/service_api/app/audio.py b/api/controllers/service_api/app/audio.py index 2682c2e7f1..848863cf1b 100644 --- a/api/controllers/service_api/app/audio.py +++ b/api/controllers/service_api/app/audio.py @@ -20,7 +20,7 @@ from controllers.service_api.app.error import ( from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError from core.model_runtime.errors.invoke import InvokeError -from models.model import App, AppMode, EndUser +from models.model import App, EndUser from services.audio_service import AudioService from services.errors.audio import ( AudioTooLargeServiceError, @@ -78,20 +78,9 @@ class TextApi(Resource): message_id = args.get("message_id", None) text = args.get("text", None) - if ( - app_model.mode in {AppMode.ADVANCED_CHAT.value, AppMode.WORKFLOW.value} - and app_model.workflow - and app_model.workflow.features_dict - ): - text_to_speech = app_model.workflow.features_dict.get("text_to_speech", {}) - voice = args.get("voice") or text_to_speech.get("voice") - else: - try: - voice = args.get("voice") or app_model.app_model_config.text_to_speech_dict.get("voice") - except Exception: - voice = None + voice = args.get("voice", None) response = AudioService.transcript_tts( - app_model=app_model, message_id=message_id, end_user=end_user.external_user_id, voice=voice, text=text + app_model=app_model, text=text, voice=voice, end_user=end_user.external_user_id, message_id=message_id ) return response diff --git a/api/controllers/service_api/dataset/document.py b/api/controllers/service_api/dataset/document.py index d64d9df059..d571b21a0a 100644 --- a/api/controllers/service_api/dataset/document.py +++ b/api/controllers/service_api/dataset/document.py @@ -211,6 +211,9 @@ class DocumentAddByFileApi(DatasetApiResource): if not dataset: raise ValueError("Dataset does not exist.") + if dataset.provider == "external": + raise ValueError("External datasets are not supported.") + indexing_technique = args.get("indexing_technique") or dataset.indexing_technique if not indexing_technique: raise ValueError("indexing_technique is required.") @@ -301,6 +304,9 @@ class DocumentUpdateByFileApi(DatasetApiResource): if not dataset: raise ValueError("Dataset does not exist.") + if dataset.provider == "external": + raise ValueError("External datasets are not supported.") + # indexing_technique is already set in dataset since this is an update args["indexing_technique"] = dataset.indexing_technique diff --git a/api/controllers/web/audio.py b/api/controllers/web/audio.py index 06d9ad7564..2919ca9af4 100644 --- a/api/controllers/web/audio.py +++ b/api/controllers/web/audio.py @@ -19,7 +19,7 @@ from controllers.web.error import ( from controllers.web.wraps import WebApiResource from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError from core.model_runtime.errors.invoke import InvokeError -from models.model import App, AppMode +from models.model import App from services.audio_service import AudioService from services.errors.audio import ( AudioTooLargeServiceError, @@ -77,21 +77,9 @@ class TextApi(WebApiResource): message_id = args.get("message_id", None) text = args.get("text", None) - if ( - app_model.mode in {AppMode.ADVANCED_CHAT.value, AppMode.WORKFLOW.value} - and app_model.workflow - and app_model.workflow.features_dict - ): - text_to_speech = app_model.workflow.features_dict.get("text_to_speech", {}) - voice = args.get("voice") or text_to_speech.get("voice") - else: - try: - voice = args.get("voice") or app_model.app_model_config.text_to_speech_dict.get("voice") - except Exception: - voice = None - + voice = args.get("voice", None) response = AudioService.transcript_tts( - app_model=app_model, message_id=message_id, end_user=end_user.external_user_id, voice=voice, text=text + app_model=app_model, text=text, voice=voice, end_user=end_user.external_user_id, message_id=message_id ) return response diff --git a/api/core/agent/base_agent_runner.py b/api/core/agent/base_agent_runner.py index 6998e4d29a..0d304de97a 100644 --- a/api/core/agent/base_agent_runner.py +++ b/api/core/agent/base_agent_runner.py @@ -161,10 +161,14 @@ class BaseAgentRunner(AppRunner): if parameter.type == ToolParameter.ToolParameterType.SELECT: enum = [option.value for option in parameter.options] if parameter.options else [] - message_tool.parameters["properties"][parameter.name] = { - "type": parameter_type, - "description": parameter.llm_description or "", - } + message_tool.parameters["properties"][parameter.name] = ( + { + "type": parameter_type, + "description": parameter.llm_description or "", + } + if parameter.input_schema is None + else parameter.input_schema + ) if len(enum) > 0: message_tool.parameters["properties"][parameter.name]["enum"] = enum @@ -254,10 +258,14 @@ class BaseAgentRunner(AppRunner): if parameter.type == ToolParameter.ToolParameterType.SELECT: enum = [option.value for option in parameter.options] if parameter.options else [] - prompt_tool.parameters["properties"][parameter.name] = { - "type": parameter_type, - "description": parameter.llm_description or "", - } + prompt_tool.parameters["properties"][parameter.name] = ( + { + "type": parameter_type, + "description": parameter.llm_description or "", + } + if parameter.input_schema is None + else parameter.input_schema + ) if len(enum) > 0: prompt_tool.parameters["properties"][parameter.name]["enum"] = enum diff --git a/api/core/agent/plugin_entities.py b/api/core/agent/plugin_entities.py index 9c722baa23..3b48288710 100644 --- a/api/core/agent/plugin_entities.py +++ b/api/core/agent/plugin_entities.py @@ -85,7 +85,7 @@ class AgentStrategyEntity(BaseModel): description: I18nObject = Field(..., description="The description of the agent strategy") output_schema: Optional[dict] = None features: Optional[list[AgentFeature]] = None - + meta_version: Optional[str] = None # pydantic configs model_config = ConfigDict(protected_namespaces=()) diff --git a/api/core/agent/strategy/plugin.py b/api/core/agent/strategy/plugin.py index 79b074cf95..4cfcfbf86a 100644 --- a/api/core/agent/strategy/plugin.py +++ b/api/core/agent/strategy/plugin.py @@ -15,10 +15,12 @@ class PluginAgentStrategy(BaseAgentStrategy): tenant_id: str declaration: AgentStrategyEntity + meta_version: str | None = None - def __init__(self, tenant_id: str, declaration: AgentStrategyEntity): + def __init__(self, tenant_id: str, declaration: AgentStrategyEntity, meta_version: str | None): self.tenant_id = tenant_id self.declaration = declaration + self.meta_version = meta_version def get_parameters(self) -> Sequence[AgentStrategyParameter]: return self.declaration.parameters diff --git a/api/core/app/task_pipeline/based_generate_task_pipeline.py b/api/core/app/task_pipeline/based_generate_task_pipeline.py index 5331c0cc94..3ed0c3352f 100644 --- a/api/core/app/task_pipeline/based_generate_task_pipeline.py +++ b/api/core/app/task_pipeline/based_generate_task_pipeline.py @@ -19,6 +19,7 @@ from core.app.entities.task_entities import ( from core.errors.error import QuotaExceededError from core.model_runtime.errors.invoke import InvokeAuthorizationError, InvokeError from core.moderation.output_moderation import ModerationRule, OutputModeration +from models.enums import MessageStatus from models.model import Message logger = logging.getLogger(__name__) @@ -62,7 +63,7 @@ class BasedGenerateTaskPipeline: return err err_desc = self._error_to_desc(err) - message.status = "error" + message.status = MessageStatus.ERROR message.error = err_desc return err diff --git a/api/core/entities/parameter_entities.py b/api/core/entities/parameter_entities.py index b071bfa5b1..2fa347c204 100644 --- a/api/core/entities/parameter_entities.py +++ b/api/core/entities/parameter_entities.py @@ -21,6 +21,9 @@ class CommonParameterType(StrEnum): DYNAMIC_SELECT = "dynamic-select" # TOOL_SELECTOR = "tool-selector" + # MCP object and array type parameters + ARRAY = "array" + OBJECT = "object" class AppSelectorScope(StrEnum): diff --git a/api/core/file/helpers.py b/api/core/file/helpers.py index 73fabdb11b..335ad2266a 100644 --- a/api/core/file/helpers.py +++ b/api/core/file/helpers.py @@ -21,7 +21,9 @@ def get_signed_file_url(upload_file_id: str) -> str: def get_signed_file_url_for_plugin(filename: str, mimetype: str, tenant_id: str, user_id: str) -> str: - url = f"{dify_config.FILES_URL}/files/upload/for-plugin" + # Plugin access should use internal URL for Docker network communication + base_url = dify_config.INTERNAL_FILES_URL or dify_config.FILES_URL + url = f"{base_url}/files/upload/for-plugin" if user_id is None: user_id = "DEFAULT-USER" diff --git a/api/core/file/models.py b/api/core/file/models.py index aa3b5f629c..f61334e7bc 100644 --- a/api/core/file/models.py +++ b/api/core/file/models.py @@ -51,7 +51,7 @@ class File(BaseModel): # It should be set to `ToolFile.id` when `transfer_method` is `tool_file`. related_id: Optional[str] = None filename: Optional[str] = None - extension: Optional[str] = Field(default=None, description="File extension, should contains dot") + extension: Optional[str] = Field(default=None, description="File extension, should contain dot") mime_type: Optional[str] = None size: int = -1 diff --git a/api/core/file/upload_file_parser.py b/api/core/file/upload_file_parser.py deleted file mode 100644 index 96b2884811..0000000000 --- a/api/core/file/upload_file_parser.py +++ /dev/null @@ -1,67 +0,0 @@ -import base64 -import logging -import time -from typing import Optional - -from configs import dify_config -from constants import IMAGE_EXTENSIONS -from core.helper.url_signer import UrlSigner -from extensions.ext_storage import storage - - -class UploadFileParser: - @classmethod - def get_image_data(cls, upload_file, force_url: bool = False) -> Optional[str]: - if not upload_file: - return None - - if upload_file.extension not in IMAGE_EXTENSIONS: - return None - - if dify_config.MULTIMODAL_SEND_FORMAT == "url" or force_url: - return cls.get_signed_temp_image_url(upload_file.id) - else: - # get image file base64 - try: - data = storage.load(upload_file.key) - except FileNotFoundError: - logging.exception(f"File not found: {upload_file.key}") - return None - - encoded_string = base64.b64encode(data).decode("utf-8") - return f"data:{upload_file.mime_type};base64,{encoded_string}" - - @classmethod - def get_signed_temp_image_url(cls, upload_file_id) -> str: - """ - get signed url from upload file - - :param upload_file_id: the id of UploadFile object - :return: - """ - base_url = dify_config.FILES_URL - image_preview_url = f"{base_url}/files/{upload_file_id}/image-preview" - - return UrlSigner.get_signed_url(url=image_preview_url, sign_key=upload_file_id, prefix="image-preview") - - @classmethod - def verify_image_file_signature(cls, upload_file_id: str, timestamp: str, nonce: str, sign: str) -> bool: - """ - verify signature - - :param upload_file_id: file id - :param timestamp: timestamp - :param nonce: nonce - :param sign: signature - :return: - """ - result = UrlSigner.verify( - sign_key=upload_file_id, timestamp=timestamp, nonce=nonce, sign=sign, prefix="image-preview" - ) - - # verify signature - if not result: - return False - - current_time = int(time.time()) - return current_time - int(timestamp) <= dify_config.FILES_ACCESS_TIMEOUT diff --git a/api/core/helper/code_executor/template_transformer.py b/api/core/helper/code_executor/template_transformer.py index baa792b5bc..84f212a9c1 100644 --- a/api/core/helper/code_executor/template_transformer.py +++ b/api/core/helper/code_executor/template_transformer.py @@ -28,7 +28,7 @@ class TemplateTransformer(ABC): def extract_result_str_from_response(cls, response: str): result = re.search(rf"{cls._result_tag}(.*){cls._result_tag}", response, re.DOTALL) if not result: - raise ValueError("Failed to parse result") + raise ValueError(f"Failed to parse result: no result tag found in response. Response: {response[:200]}...") return result.group(1) @classmethod @@ -38,16 +38,53 @@ class TemplateTransformer(ABC): :param response: response :return: """ + try: - result = json.loads(cls.extract_result_str_from_response(response)) - except json.JSONDecodeError: - raise ValueError("failed to parse response") + result_str = cls.extract_result_str_from_response(response) + result = json.loads(result_str) + except json.JSONDecodeError as e: + raise ValueError(f"Failed to parse JSON response: {str(e)}. Response content: {result_str[:200]}...") + except ValueError as e: + # Re-raise ValueError from extract_result_str_from_response + raise e + except Exception as e: + raise ValueError(f"Unexpected error during response transformation: {str(e)}") + + # Check if the result contains an error + if isinstance(result, dict) and "error" in result: + raise ValueError(f"JavaScript execution error: {result['error']}") + if not isinstance(result, dict): - raise ValueError("result must be a dict") + raise ValueError(f"Result must be a dict, got {type(result).__name__}") if not all(isinstance(k, str) for k in result): - raise ValueError("result keys must be strings") + raise ValueError("Result keys must be strings") + + # Post-process the result to convert scientific notation strings back to numbers + result = cls._post_process_result(result) return result + @classmethod + def _post_process_result(cls, result: dict[Any, Any]) -> dict[Any, Any]: + """ + Post-process the result to convert scientific notation strings back to numbers + """ + + def convert_scientific_notation(value): + if isinstance(value, str): + # Check if the string looks like scientific notation + if re.match(r"^-?\d+\.?\d*e[+-]\d+$", value, re.IGNORECASE): + try: + return float(value) + except ValueError: + pass + elif isinstance(value, dict): + return {k: convert_scientific_notation(v) for k, v in value.items()} + elif isinstance(value, list): + return [convert_scientific_notation(v) for v in value] + return value + + return convert_scientific_notation(result) # type: ignore[no-any-return] + @classmethod @abstractmethod def get_runner_script(cls) -> str: diff --git a/api/core/helper/lru_cache.py b/api/core/helper/lru_cache.py deleted file mode 100644 index 81501d2e4e..0000000000 --- a/api/core/helper/lru_cache.py +++ /dev/null @@ -1,22 +0,0 @@ -from collections import OrderedDict -from typing import Any - - -class LRUCache: - def __init__(self, capacity: int): - self.cache: OrderedDict[Any, Any] = OrderedDict() - self.capacity = capacity - - def get(self, key: Any) -> Any: - if key not in self.cache: - return None - else: - self.cache.move_to_end(key) # move the key to the end of the OrderedDict - return self.cache[key] - - def put(self, key: Any, value: Any) -> None: - if key in self.cache: - self.cache.move_to_end(key) - self.cache[key] = value - if len(self.cache) > self.capacity: - self.cache.popitem(last=False) # pop the first item diff --git a/api/core/indexing_runner.py b/api/core/indexing_runner.py index f2fe306179..305a9190d5 100644 --- a/api/core/indexing_runner.py +++ b/api/core/indexing_runner.py @@ -317,9 +317,10 @@ class IndexingRunner: image_upload_file_ids = get_image_upload_file_ids(document.page_content) for upload_file_id in image_upload_file_ids: image_file = db.session.query(UploadFile).filter(UploadFile.id == upload_file_id).first() + if image_file is None: + continue try: - if image_file: - storage.delete(image_file.key) + storage.delete(image_file.key) except Exception: logging.exception( "Delete image_files failed while indexing_estimate, \ diff --git a/api/core/llm_generator/output_parser/structured_output.py b/api/core/llm_generator/output_parser/structured_output.py index 0aaf5abef0..151cef1bc3 100644 --- a/api/core/llm_generator/output_parser/structured_output.py +++ b/api/core/llm_generator/output_parser/structured_output.py @@ -23,6 +23,7 @@ from core.model_runtime.entities.message_entities import ( PromptMessage, PromptMessageTool, SystemPromptMessage, + TextPromptMessageContent, ) from core.model_runtime.entities.model_entities import AIModelEntity, ParameterRule @@ -170,10 +171,15 @@ def invoke_llm_with_structured_output( system_fingerprint: Optional[str] = None for event in llm_result: if isinstance(event, LLMResultChunk): + prompt_messages = event.prompt_messages + system_fingerprint = event.system_fingerprint + if isinstance(event.delta.message.content, str): result_text += event.delta.message.content - prompt_messages = event.prompt_messages - system_fingerprint = event.system_fingerprint + elif isinstance(event.delta.message.content, list): + for item in event.delta.message.content: + if isinstance(item, TextPromptMessageContent): + result_text += item.data yield LLMResultChunkWithStructuredOutput( model=model_schema.model, diff --git a/api/core/mcp/__init__.py b/api/core/mcp/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/core/mcp/auth/auth_flow.py b/api/core/mcp/auth/auth_flow.py new file mode 100644 index 0000000000..b63478e822 --- /dev/null +++ b/api/core/mcp/auth/auth_flow.py @@ -0,0 +1,342 @@ +import base64 +import hashlib +import json +import os +import secrets +import urllib.parse +from typing import Optional +from urllib.parse import urljoin + +import requests +from pydantic import BaseModel, ValidationError + +from core.mcp.auth.auth_provider import OAuthClientProvider +from core.mcp.types import ( + OAuthClientInformation, + OAuthClientInformationFull, + OAuthClientMetadata, + OAuthMetadata, + OAuthTokens, +) +from extensions.ext_redis import redis_client + +LATEST_PROTOCOL_VERSION = "1.0" +OAUTH_STATE_EXPIRY_SECONDS = 5 * 60 # 5 minutes expiry +OAUTH_STATE_REDIS_KEY_PREFIX = "oauth_state:" + + +class OAuthCallbackState(BaseModel): + provider_id: str + tenant_id: str + server_url: str + metadata: OAuthMetadata | None = None + client_information: OAuthClientInformation + code_verifier: str + redirect_uri: str + + +def generate_pkce_challenge() -> tuple[str, str]: + """Generate PKCE challenge and verifier.""" + code_verifier = base64.urlsafe_b64encode(os.urandom(40)).decode("utf-8") + code_verifier = code_verifier.replace("=", "").replace("+", "-").replace("/", "_") + + code_challenge_hash = hashlib.sha256(code_verifier.encode("utf-8")).digest() + code_challenge = base64.urlsafe_b64encode(code_challenge_hash).decode("utf-8") + code_challenge = code_challenge.replace("=", "").replace("+", "-").replace("/", "_") + + return code_verifier, code_challenge + + +def _create_secure_redis_state(state_data: OAuthCallbackState) -> str: + """Create a secure state parameter by storing state data in Redis and returning a random state key.""" + # Generate a secure random state key + state_key = secrets.token_urlsafe(32) + + # Store the state data in Redis with expiration + redis_key = f"{OAUTH_STATE_REDIS_KEY_PREFIX}{state_key}" + redis_client.setex(redis_key, OAUTH_STATE_EXPIRY_SECONDS, state_data.model_dump_json()) + + return state_key + + +def _retrieve_redis_state(state_key: str) -> OAuthCallbackState: + """Retrieve and decode OAuth state data from Redis using the state key, then delete it.""" + redis_key = f"{OAUTH_STATE_REDIS_KEY_PREFIX}{state_key}" + + # Get state data from Redis + state_data = redis_client.get(redis_key) + + if not state_data: + raise ValueError("State parameter has expired or does not exist") + + # Delete the state data from Redis immediately after retrieval to prevent reuse + redis_client.delete(redis_key) + + try: + # Parse and validate the state data + oauth_state = OAuthCallbackState.model_validate_json(state_data) + + return oauth_state + except ValidationError as e: + raise ValueError(f"Invalid state parameter: {str(e)}") + + +def handle_callback(state_key: str, authorization_code: str) -> OAuthCallbackState: + """Handle the callback from the OAuth provider.""" + # Retrieve state data from Redis (state is automatically deleted after retrieval) + full_state_data = _retrieve_redis_state(state_key) + + tokens = exchange_authorization( + full_state_data.server_url, + full_state_data.metadata, + full_state_data.client_information, + authorization_code, + full_state_data.code_verifier, + full_state_data.redirect_uri, + ) + provider = OAuthClientProvider(full_state_data.provider_id, full_state_data.tenant_id, for_list=True) + provider.save_tokens(tokens) + return full_state_data + + +def discover_oauth_metadata(server_url: str, protocol_version: Optional[str] = None) -> Optional[OAuthMetadata]: + """Looks up RFC 8414 OAuth 2.0 Authorization Server Metadata.""" + url = urljoin(server_url, "/.well-known/oauth-authorization-server") + + try: + headers = {"MCP-Protocol-Version": protocol_version or LATEST_PROTOCOL_VERSION} + response = requests.get(url, headers=headers) + if response.status_code == 404: + return None + if not response.ok: + raise ValueError(f"HTTP {response.status_code} trying to load well-known OAuth metadata") + return OAuthMetadata.model_validate(response.json()) + except requests.RequestException as e: + if isinstance(e, requests.ConnectionError): + response = requests.get(url) + if response.status_code == 404: + return None + if not response.ok: + raise ValueError(f"HTTP {response.status_code} trying to load well-known OAuth metadata") + return OAuthMetadata.model_validate(response.json()) + raise + + +def start_authorization( + server_url: str, + metadata: Optional[OAuthMetadata], + client_information: OAuthClientInformation, + redirect_url: str, + provider_id: str, + tenant_id: str, +) -> tuple[str, str]: + """Begins the authorization flow with secure Redis state storage.""" + response_type = "code" + code_challenge_method = "S256" + + if metadata: + authorization_url = metadata.authorization_endpoint + if response_type not in metadata.response_types_supported: + raise ValueError(f"Incompatible auth server: does not support response type {response_type}") + if ( + not metadata.code_challenge_methods_supported + or code_challenge_method not in metadata.code_challenge_methods_supported + ): + raise ValueError( + f"Incompatible auth server: does not support code challenge method {code_challenge_method}" + ) + else: + authorization_url = urljoin(server_url, "/authorize") + + code_verifier, code_challenge = generate_pkce_challenge() + + # Prepare state data with all necessary information + state_data = OAuthCallbackState( + provider_id=provider_id, + tenant_id=tenant_id, + server_url=server_url, + metadata=metadata, + client_information=client_information, + code_verifier=code_verifier, + redirect_uri=redirect_url, + ) + + # Store state data in Redis and generate secure state key + state_key = _create_secure_redis_state(state_data) + + params = { + "response_type": response_type, + "client_id": client_information.client_id, + "code_challenge": code_challenge, + "code_challenge_method": code_challenge_method, + "redirect_uri": redirect_url, + "state": state_key, + } + + authorization_url = f"{authorization_url}?{urllib.parse.urlencode(params)}" + return authorization_url, code_verifier + + +def exchange_authorization( + server_url: str, + metadata: Optional[OAuthMetadata], + client_information: OAuthClientInformation, + authorization_code: str, + code_verifier: str, + redirect_uri: str, +) -> OAuthTokens: + """Exchanges an authorization code for an access token.""" + grant_type = "authorization_code" + + if metadata: + token_url = metadata.token_endpoint + if metadata.grant_types_supported and grant_type not in metadata.grant_types_supported: + raise ValueError(f"Incompatible auth server: does not support grant type {grant_type}") + else: + token_url = urljoin(server_url, "/token") + + params = { + "grant_type": grant_type, + "client_id": client_information.client_id, + "code": authorization_code, + "code_verifier": code_verifier, + "redirect_uri": redirect_uri, + } + + if client_information.client_secret: + params["client_secret"] = client_information.client_secret + + response = requests.post(token_url, data=params) + if not response.ok: + raise ValueError(f"Token exchange failed: HTTP {response.status_code}") + return OAuthTokens.model_validate(response.json()) + + +def refresh_authorization( + server_url: str, + metadata: Optional[OAuthMetadata], + client_information: OAuthClientInformation, + refresh_token: str, +) -> OAuthTokens: + """Exchange a refresh token for an updated access token.""" + grant_type = "refresh_token" + + if metadata: + token_url = metadata.token_endpoint + if metadata.grant_types_supported and grant_type not in metadata.grant_types_supported: + raise ValueError(f"Incompatible auth server: does not support grant type {grant_type}") + else: + token_url = urljoin(server_url, "/token") + + params = { + "grant_type": grant_type, + "client_id": client_information.client_id, + "refresh_token": refresh_token, + } + + if client_information.client_secret: + params["client_secret"] = client_information.client_secret + + response = requests.post(token_url, data=params) + if not response.ok: + raise ValueError(f"Token refresh failed: HTTP {response.status_code}") + return OAuthTokens.parse_obj(response.json()) + + +def register_client( + server_url: str, + metadata: Optional[OAuthMetadata], + client_metadata: OAuthClientMetadata, +) -> OAuthClientInformationFull: + """Performs OAuth 2.0 Dynamic Client Registration.""" + if metadata: + if not metadata.registration_endpoint: + raise ValueError("Incompatible auth server: does not support dynamic client registration") + registration_url = metadata.registration_endpoint + else: + registration_url = urljoin(server_url, "/register") + + response = requests.post( + registration_url, + json=client_metadata.model_dump(), + headers={"Content-Type": "application/json"}, + ) + if not response.ok: + response.raise_for_status() + return OAuthClientInformationFull.model_validate(response.json()) + + +def auth( + provider: OAuthClientProvider, + server_url: str, + authorization_code: Optional[str] = None, + state_param: Optional[str] = None, + for_list: bool = False, +) -> dict[str, str]: + """Orchestrates the full auth flow with a server using secure Redis state storage.""" + metadata = discover_oauth_metadata(server_url) + + # Handle client registration if needed + client_information = provider.client_information() + if not client_information: + if authorization_code is not None: + raise ValueError("Existing OAuth client information is required when exchanging an authorization code") + try: + full_information = register_client(server_url, metadata, provider.client_metadata) + except requests.RequestException as e: + raise ValueError(f"Could not register OAuth client: {e}") + provider.save_client_information(full_information) + client_information = full_information + + # Exchange authorization code for tokens + if authorization_code is not None: + if not state_param: + raise ValueError("State parameter is required when exchanging authorization code") + + try: + # Retrieve state data from Redis using state key + full_state_data = _retrieve_redis_state(state_param) + + code_verifier = full_state_data.code_verifier + redirect_uri = full_state_data.redirect_uri + + if not code_verifier or not redirect_uri: + raise ValueError("Missing code_verifier or redirect_uri in state data") + + except (json.JSONDecodeError, ValueError) as e: + raise ValueError(f"Invalid state parameter: {e}") + + tokens = exchange_authorization( + server_url, + metadata, + client_information, + authorization_code, + code_verifier, + redirect_uri, + ) + provider.save_tokens(tokens) + return {"result": "success"} + + provider_tokens = provider.tokens() + + # Handle token refresh or new authorization + if provider_tokens and provider_tokens.refresh_token: + try: + new_tokens = refresh_authorization(server_url, metadata, client_information, provider_tokens.refresh_token) + provider.save_tokens(new_tokens) + return {"result": "success"} + except Exception as e: + raise ValueError(f"Could not refresh OAuth tokens: {e}") + + # Start new authorization flow + authorization_url, code_verifier = start_authorization( + server_url, + metadata, + client_information, + provider.redirect_url, + provider.mcp_provider.id, + provider.mcp_provider.tenant_id, + ) + + provider.save_code_verifier(code_verifier) + return {"authorization_url": authorization_url} diff --git a/api/core/mcp/auth/auth_provider.py b/api/core/mcp/auth/auth_provider.py new file mode 100644 index 0000000000..cd55dbf64f --- /dev/null +++ b/api/core/mcp/auth/auth_provider.py @@ -0,0 +1,81 @@ +from typing import Optional + +from configs import dify_config +from core.mcp.types import ( + OAuthClientInformation, + OAuthClientInformationFull, + OAuthClientMetadata, + OAuthTokens, +) +from models.tools import MCPToolProvider +from services.tools.mcp_tools_mange_service import MCPToolManageService + +LATEST_PROTOCOL_VERSION = "1.0" + + +class OAuthClientProvider: + mcp_provider: MCPToolProvider + + def __init__(self, provider_id: str, tenant_id: str, for_list: bool = False): + if for_list: + self.mcp_provider = MCPToolManageService.get_mcp_provider_by_provider_id(provider_id, tenant_id) + else: + self.mcp_provider = MCPToolManageService.get_mcp_provider_by_server_identifier(provider_id, tenant_id) + + @property + def redirect_url(self) -> str: + """The URL to redirect the user agent to after authorization.""" + return dify_config.CONSOLE_API_URL + "/console/api/mcp/oauth/callback" + + @property + def client_metadata(self) -> OAuthClientMetadata: + """Metadata about this OAuth client.""" + return OAuthClientMetadata( + redirect_uris=[self.redirect_url], + token_endpoint_auth_method="none", + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + client_name="Dify", + client_uri="https://github.com/langgenius/dify", + ) + + def client_information(self) -> Optional[OAuthClientInformation]: + """Loads information about this OAuth client.""" + client_information = self.mcp_provider.decrypted_credentials.get("client_information", {}) + if not client_information: + return None + return OAuthClientInformation.model_validate(client_information) + + def save_client_information(self, client_information: OAuthClientInformationFull) -> None: + """Saves client information after dynamic registration.""" + MCPToolManageService.update_mcp_provider_credentials( + self.mcp_provider, + {"client_information": client_information.model_dump()}, + ) + + def tokens(self) -> Optional[OAuthTokens]: + """Loads any existing OAuth tokens for the current session.""" + credentials = self.mcp_provider.decrypted_credentials + if not credentials: + return None + return OAuthTokens( + access_token=credentials.get("access_token", ""), + token_type=credentials.get("token_type", "Bearer"), + expires_in=int(credentials.get("expires_in", "3600") or 3600), + refresh_token=credentials.get("refresh_token", ""), + ) + + def save_tokens(self, tokens: OAuthTokens) -> None: + """Stores new OAuth tokens for the current session.""" + # update mcp provider credentials + token_dict = tokens.model_dump() + MCPToolManageService.update_mcp_provider_credentials(self.mcp_provider, token_dict, authed=True) + + def save_code_verifier(self, code_verifier: str) -> None: + """Saves a PKCE code verifier for the current session.""" + MCPToolManageService.update_mcp_provider_credentials(self.mcp_provider, {"code_verifier": code_verifier}) + + def code_verifier(self) -> str: + """Loads the PKCE code verifier for the current session.""" + # get code verifier from mcp provider credentials + return str(self.mcp_provider.decrypted_credentials.get("code_verifier", "")) diff --git a/api/core/mcp/client/sse_client.py b/api/core/mcp/client/sse_client.py new file mode 100644 index 0000000000..91debcc8f9 --- /dev/null +++ b/api/core/mcp/client/sse_client.py @@ -0,0 +1,361 @@ +import logging +import queue +from collections.abc import Generator +from concurrent.futures import ThreadPoolExecutor +from contextlib import contextmanager +from typing import Any, TypeAlias, final +from urllib.parse import urljoin, urlparse + +import httpx +from sseclient import SSEClient + +from core.mcp import types +from core.mcp.error import MCPAuthError, MCPConnectionError +from core.mcp.types import SessionMessage +from core.mcp.utils import create_ssrf_proxy_mcp_http_client, ssrf_proxy_sse_connect + +logger = logging.getLogger(__name__) + +DEFAULT_QUEUE_READ_TIMEOUT = 3 + + +@final +class _StatusReady: + def __init__(self, endpoint_url: str): + self._endpoint_url = endpoint_url + + +@final +class _StatusError: + def __init__(self, exc: Exception): + self._exc = exc + + +# Type aliases for better readability +ReadQueue: TypeAlias = queue.Queue[SessionMessage | Exception | None] +WriteQueue: TypeAlias = queue.Queue[SessionMessage | Exception | None] +StatusQueue: TypeAlias = queue.Queue[_StatusReady | _StatusError] + + +def remove_request_params(url: str) -> str: + """Remove request parameters from URL, keeping only the path.""" + return urljoin(url, urlparse(url).path) + + +class SSETransport: + """SSE client transport implementation.""" + + def __init__( + self, + url: str, + headers: dict[str, Any] | None = None, + timeout: float = 5.0, + sse_read_timeout: float = 5 * 60, + ) -> None: + """Initialize the SSE transport. + + Args: + url: The SSE endpoint URL. + headers: Optional headers to include in requests. + timeout: HTTP timeout for regular operations. + sse_read_timeout: Timeout for SSE read operations. + """ + self.url = url + self.headers = headers or {} + self.timeout = timeout + self.sse_read_timeout = sse_read_timeout + self.endpoint_url: str | None = None + + def _validate_endpoint_url(self, endpoint_url: str) -> bool: + """Validate that the endpoint URL matches the connection origin. + + Args: + endpoint_url: The endpoint URL to validate. + + Returns: + True if valid, False otherwise. + """ + url_parsed = urlparse(self.url) + endpoint_parsed = urlparse(endpoint_url) + + return url_parsed.netloc == endpoint_parsed.netloc and url_parsed.scheme == endpoint_parsed.scheme + + def _handle_endpoint_event(self, sse_data: str, status_queue: StatusQueue) -> None: + """Handle an 'endpoint' SSE event. + + Args: + sse_data: The SSE event data. + status_queue: Queue to put status updates. + """ + endpoint_url = urljoin(self.url, sse_data) + logger.info(f"Received endpoint URL: {endpoint_url}") + + if not self._validate_endpoint_url(endpoint_url): + error_msg = f"Endpoint origin does not match connection origin: {endpoint_url}" + logger.error(error_msg) + status_queue.put(_StatusError(ValueError(error_msg))) + return + + status_queue.put(_StatusReady(endpoint_url)) + + def _handle_message_event(self, sse_data: str, read_queue: ReadQueue) -> None: + """Handle a 'message' SSE event. + + Args: + sse_data: The SSE event data. + read_queue: Queue to put parsed messages. + """ + try: + message = types.JSONRPCMessage.model_validate_json(sse_data) + logger.debug(f"Received server message: {message}") + session_message = SessionMessage(message) + read_queue.put(session_message) + except Exception as exc: + logger.exception("Error parsing server message") + read_queue.put(exc) + + def _handle_sse_event(self, sse, read_queue: ReadQueue, status_queue: StatusQueue) -> None: + """Handle a single SSE event. + + Args: + sse: The SSE event object. + read_queue: Queue for message events. + status_queue: Queue for status events. + """ + match sse.event: + case "endpoint": + self._handle_endpoint_event(sse.data, status_queue) + case "message": + self._handle_message_event(sse.data, read_queue) + case _: + logger.warning(f"Unknown SSE event: {sse.event}") + + def sse_reader(self, event_source, read_queue: ReadQueue, status_queue: StatusQueue) -> None: + """Read and process SSE events. + + Args: + event_source: The SSE event source. + read_queue: Queue to put received messages. + status_queue: Queue to put status updates. + """ + try: + for sse in event_source.iter_sse(): + self._handle_sse_event(sse, read_queue, status_queue) + except httpx.ReadError as exc: + logger.debug(f"SSE reader shutting down normally: {exc}") + except Exception as exc: + read_queue.put(exc) + finally: + read_queue.put(None) + + def _send_message(self, client: httpx.Client, endpoint_url: str, message: SessionMessage) -> None: + """Send a single message to the server. + + Args: + client: HTTP client to use. + endpoint_url: The endpoint URL to send to. + message: The message to send. + """ + response = client.post( + endpoint_url, + json=message.message.model_dump( + by_alias=True, + mode="json", + exclude_none=True, + ), + ) + response.raise_for_status() + logger.debug(f"Client message sent successfully: {response.status_code}") + + def post_writer(self, client: httpx.Client, endpoint_url: str, write_queue: WriteQueue) -> None: + """Handle writing messages to the server. + + Args: + client: HTTP client to use. + endpoint_url: The endpoint URL to send messages to. + write_queue: Queue to read messages from. + """ + try: + while True: + try: + message = write_queue.get(timeout=DEFAULT_QUEUE_READ_TIMEOUT) + if message is None: + break + if isinstance(message, Exception): + write_queue.put(message) + continue + + self._send_message(client, endpoint_url, message) + + except queue.Empty: + continue + except httpx.ReadError as exc: + logger.debug(f"Post writer shutting down normally: {exc}") + except Exception as exc: + logger.exception("Error writing messages") + write_queue.put(exc) + finally: + write_queue.put(None) + + def _wait_for_endpoint(self, status_queue: StatusQueue) -> str: + """Wait for the endpoint URL from the status queue. + + Args: + status_queue: Queue to read status from. + + Returns: + The endpoint URL. + + Raises: + ValueError: If endpoint URL is not received or there's an error. + """ + try: + status = status_queue.get(timeout=1) + except queue.Empty: + raise ValueError("failed to get endpoint URL") + + if isinstance(status, _StatusReady): + return status._endpoint_url + elif isinstance(status, _StatusError): + raise status._exc + else: + raise ValueError("failed to get endpoint URL") + + def connect( + self, + executor: ThreadPoolExecutor, + client: httpx.Client, + event_source, + ) -> tuple[ReadQueue, WriteQueue]: + """Establish connection and start worker threads. + + Args: + executor: Thread pool executor. + client: HTTP client. + event_source: SSE event source. + + Returns: + Tuple of (read_queue, write_queue). + """ + read_queue: ReadQueue = queue.Queue() + write_queue: WriteQueue = queue.Queue() + status_queue: StatusQueue = queue.Queue() + + # Start SSE reader thread + executor.submit(self.sse_reader, event_source, read_queue, status_queue) + + # Wait for endpoint URL + endpoint_url = self._wait_for_endpoint(status_queue) + self.endpoint_url = endpoint_url + + # Start post writer thread + executor.submit(self.post_writer, client, endpoint_url, write_queue) + + return read_queue, write_queue + + +@contextmanager +def sse_client( + url: str, + headers: dict[str, Any] | None = None, + timeout: float = 5.0, + sse_read_timeout: float = 5 * 60, +) -> Generator[tuple[ReadQueue, WriteQueue], None, None]: + """ + Client transport for SSE. + `sse_read_timeout` determines how long (in seconds) the client will wait for a new + event before disconnecting. All other HTTP operations are controlled by `timeout`. + + Args: + url: The SSE endpoint URL. + headers: Optional headers to include in requests. + timeout: HTTP timeout for regular operations. + sse_read_timeout: Timeout for SSE read operations. + + Yields: + Tuple of (read_queue, write_queue) for message communication. + """ + transport = SSETransport(url, headers, timeout, sse_read_timeout) + + read_queue: ReadQueue | None = None + write_queue: WriteQueue | None = None + + with ThreadPoolExecutor() as executor: + try: + with create_ssrf_proxy_mcp_http_client(headers=transport.headers) as client: + with ssrf_proxy_sse_connect( + url, timeout=httpx.Timeout(timeout, read=sse_read_timeout), client=client + ) as event_source: + event_source.response.raise_for_status() + + read_queue, write_queue = transport.connect(executor, client, event_source) + + yield read_queue, write_queue + + except httpx.HTTPStatusError as exc: + if exc.response.status_code == 401: + raise MCPAuthError() + raise MCPConnectionError() + except Exception: + logger.exception("Error connecting to SSE endpoint") + raise + finally: + # Clean up queues + if read_queue: + read_queue.put(None) + if write_queue: + write_queue.put(None) + + +def send_message(http_client: httpx.Client, endpoint_url: str, session_message: SessionMessage) -> None: + """ + Send a message to the server using the provided HTTP client. + + Args: + http_client: The HTTP client to use for sending + endpoint_url: The endpoint URL to send the message to + session_message: The message to send + """ + try: + response = http_client.post( + endpoint_url, + json=session_message.message.model_dump( + by_alias=True, + mode="json", + exclude_none=True, + ), + ) + response.raise_for_status() + logger.debug(f"Client message sent successfully: {response.status_code}") + except Exception as exc: + logger.exception("Error sending message") + raise + + +def read_messages( + sse_client: SSEClient, +) -> Generator[SessionMessage | Exception, None, None]: + """ + Read messages from the SSE client. + + Args: + sse_client: The SSE client to read from + + Yields: + SessionMessage or Exception for each event received + """ + try: + for sse in sse_client.events(): + if sse.event == "message": + try: + message = types.JSONRPCMessage.model_validate_json(sse.data) + logger.debug(f"Received server message: {message}") + yield SessionMessage(message) + except Exception as exc: + logger.exception("Error parsing server message") + yield exc + else: + logger.warning(f"Unknown SSE event: {sse.event}") + except Exception as exc: + logger.exception("Error reading SSE messages") + yield exc diff --git a/api/core/mcp/client/streamable_client.py b/api/core/mcp/client/streamable_client.py new file mode 100644 index 0000000000..fbd8d05f9e --- /dev/null +++ b/api/core/mcp/client/streamable_client.py @@ -0,0 +1,476 @@ +""" +StreamableHTTP Client Transport Module + +This module implements the StreamableHTTP transport for MCP clients, +providing support for HTTP POST requests with optional SSE streaming responses +and session management. +""" + +import logging +import queue +from collections.abc import Callable, Generator +from concurrent.futures import ThreadPoolExecutor +from contextlib import contextmanager +from dataclasses import dataclass +from datetime import timedelta +from typing import Any, cast + +import httpx +from httpx_sse import EventSource, ServerSentEvent + +from core.mcp.types import ( + ClientMessageMetadata, + ErrorData, + JSONRPCError, + JSONRPCMessage, + JSONRPCNotification, + JSONRPCRequest, + JSONRPCResponse, + RequestId, + SessionMessage, +) +from core.mcp.utils import create_ssrf_proxy_mcp_http_client, ssrf_proxy_sse_connect + +logger = logging.getLogger(__name__) + + +SessionMessageOrError = SessionMessage | Exception | None +# Queue types with clearer names for their roles +ServerToClientQueue = queue.Queue[SessionMessageOrError] # Server to client messages +ClientToServerQueue = queue.Queue[SessionMessage | None] # Client to server messages +GetSessionIdCallback = Callable[[], str | None] + +MCP_SESSION_ID = "mcp-session-id" +LAST_EVENT_ID = "last-event-id" +CONTENT_TYPE = "content-type" +ACCEPT = "Accept" + + +JSON = "application/json" +SSE = "text/event-stream" + +DEFAULT_QUEUE_READ_TIMEOUT = 3 + + +class StreamableHTTPError(Exception): + """Base exception for StreamableHTTP transport errors.""" + + pass + + +class ResumptionError(StreamableHTTPError): + """Raised when resumption request is invalid.""" + + pass + + +@dataclass +class RequestContext: + """Context for a request operation.""" + + client: httpx.Client + headers: dict[str, str] + session_id: str | None + session_message: SessionMessage + metadata: ClientMessageMetadata | None + server_to_client_queue: ServerToClientQueue # Renamed for clarity + sse_read_timeout: timedelta + + +class StreamableHTTPTransport: + """StreamableHTTP client transport implementation.""" + + def __init__( + self, + url: str, + headers: dict[str, Any] | None = None, + timeout: timedelta = timedelta(seconds=30), + sse_read_timeout: timedelta = timedelta(seconds=60 * 5), + ) -> None: + """Initialize the StreamableHTTP transport. + + Args: + url: The endpoint URL. + headers: Optional headers to include in requests. + timeout: HTTP timeout for regular operations. + sse_read_timeout: Timeout for SSE read operations. + """ + self.url = url + self.headers = headers or {} + self.timeout = timeout + self.sse_read_timeout = sse_read_timeout + self.session_id: str | None = None + self.request_headers = { + ACCEPT: f"{JSON}, {SSE}", + CONTENT_TYPE: JSON, + **self.headers, + } + + def _update_headers_with_session(self, base_headers: dict[str, str]) -> dict[str, str]: + """Update headers with session ID if available.""" + headers = base_headers.copy() + if self.session_id: + headers[MCP_SESSION_ID] = self.session_id + return headers + + def _is_initialization_request(self, message: JSONRPCMessage) -> bool: + """Check if the message is an initialization request.""" + return isinstance(message.root, JSONRPCRequest) and message.root.method == "initialize" + + def _is_initialized_notification(self, message: JSONRPCMessage) -> bool: + """Check if the message is an initialized notification.""" + return isinstance(message.root, JSONRPCNotification) and message.root.method == "notifications/initialized" + + def _maybe_extract_session_id_from_response( + self, + response: httpx.Response, + ) -> None: + """Extract and store session ID from response headers.""" + new_session_id = response.headers.get(MCP_SESSION_ID) + if new_session_id: + self.session_id = new_session_id + logger.info(f"Received session ID: {self.session_id}") + + def _handle_sse_event( + self, + sse: ServerSentEvent, + server_to_client_queue: ServerToClientQueue, + original_request_id: RequestId | None = None, + resumption_callback: Callable[[str], None] | None = None, + ) -> bool: + """Handle an SSE event, returning True if the response is complete.""" + if sse.event == "message": + try: + message = JSONRPCMessage.model_validate_json(sse.data) + logger.debug(f"SSE message: {message}") + + # If this is a response and we have original_request_id, replace it + if original_request_id is not None and isinstance(message.root, JSONRPCResponse | JSONRPCError): + message.root.id = original_request_id + + session_message = SessionMessage(message) + # Put message in queue that goes to client + server_to_client_queue.put(session_message) + + # Call resumption token callback if we have an ID + if sse.id and resumption_callback: + resumption_callback(sse.id) + + # If this is a response or error return True indicating completion + # Otherwise, return False to continue listening + return isinstance(message.root, JSONRPCResponse | JSONRPCError) + + except Exception as exc: + # Put exception in queue that goes to client + server_to_client_queue.put(exc) + return False + elif sse.event == "ping": + logger.debug("Received ping event") + return False + else: + logger.warning(f"Unknown SSE event: {sse.event}") + return False + + def handle_get_stream( + self, + client: httpx.Client, + server_to_client_queue: ServerToClientQueue, + ) -> None: + """Handle GET stream for server-initiated messages.""" + try: + if not self.session_id: + return + + headers = self._update_headers_with_session(self.request_headers) + + with ssrf_proxy_sse_connect( + self.url, + headers=headers, + timeout=httpx.Timeout(self.timeout.seconds, read=self.sse_read_timeout.seconds), + client=client, + method="GET", + ) as event_source: + event_source.response.raise_for_status() + logger.debug("GET SSE connection established") + + for sse in event_source.iter_sse(): + self._handle_sse_event(sse, server_to_client_queue) + + except Exception as exc: + logger.debug(f"GET stream error (non-fatal): {exc}") + + def _handle_resumption_request(self, ctx: RequestContext) -> None: + """Handle a resumption request using GET with SSE.""" + headers = self._update_headers_with_session(ctx.headers) + if ctx.metadata and ctx.metadata.resumption_token: + headers[LAST_EVENT_ID] = ctx.metadata.resumption_token + else: + raise ResumptionError("Resumption request requires a resumption token") + + # Extract original request ID to map responses + original_request_id = None + if isinstance(ctx.session_message.message.root, JSONRPCRequest): + original_request_id = ctx.session_message.message.root.id + + with ssrf_proxy_sse_connect( + self.url, + headers=headers, + timeout=httpx.Timeout(self.timeout.seconds, read=ctx.sse_read_timeout.seconds), + client=ctx.client, + method="GET", + ) as event_source: + event_source.response.raise_for_status() + logger.debug("Resumption GET SSE connection established") + + for sse in event_source.iter_sse(): + is_complete = self._handle_sse_event( + sse, + ctx.server_to_client_queue, + original_request_id, + ctx.metadata.on_resumption_token_update if ctx.metadata else None, + ) + if is_complete: + break + + def _handle_post_request(self, ctx: RequestContext) -> None: + """Handle a POST request with response processing.""" + headers = self._update_headers_with_session(ctx.headers) + message = ctx.session_message.message + is_initialization = self._is_initialization_request(message) + + with ctx.client.stream( + "POST", + self.url, + json=message.model_dump(by_alias=True, mode="json", exclude_none=True), + headers=headers, + ) as response: + if response.status_code == 202: + logger.debug("Received 202 Accepted") + return + + if response.status_code == 404: + if isinstance(message.root, JSONRPCRequest): + self._send_session_terminated_error( + ctx.server_to_client_queue, + message.root.id, + ) + return + + response.raise_for_status() + if is_initialization: + self._maybe_extract_session_id_from_response(response) + + content_type = cast(str, response.headers.get(CONTENT_TYPE, "").lower()) + + if content_type.startswith(JSON): + self._handle_json_response(response, ctx.server_to_client_queue) + elif content_type.startswith(SSE): + self._handle_sse_response(response, ctx) + else: + self._handle_unexpected_content_type( + content_type, + ctx.server_to_client_queue, + ) + + def _handle_json_response( + self, + response: httpx.Response, + server_to_client_queue: ServerToClientQueue, + ) -> None: + """Handle JSON response from the server.""" + try: + content = response.read() + message = JSONRPCMessage.model_validate_json(content) + session_message = SessionMessage(message) + server_to_client_queue.put(session_message) + except Exception as exc: + server_to_client_queue.put(exc) + + def _handle_sse_response(self, response: httpx.Response, ctx: RequestContext) -> None: + """Handle SSE response from the server.""" + try: + event_source = EventSource(response) + for sse in event_source.iter_sse(): + is_complete = self._handle_sse_event( + sse, + ctx.server_to_client_queue, + resumption_callback=(ctx.metadata.on_resumption_token_update if ctx.metadata else None), + ) + if is_complete: + break + except Exception as e: + ctx.server_to_client_queue.put(e) + + def _handle_unexpected_content_type( + self, + content_type: str, + server_to_client_queue: ServerToClientQueue, + ) -> None: + """Handle unexpected content type in response.""" + error_msg = f"Unexpected content type: {content_type}" + logger.error(error_msg) + server_to_client_queue.put(ValueError(error_msg)) + + def _send_session_terminated_error( + self, + server_to_client_queue: ServerToClientQueue, + request_id: RequestId, + ) -> None: + """Send a session terminated error response.""" + jsonrpc_error = JSONRPCError( + jsonrpc="2.0", + id=request_id, + error=ErrorData(code=32600, message="Session terminated by server"), + ) + session_message = SessionMessage(JSONRPCMessage(jsonrpc_error)) + server_to_client_queue.put(session_message) + + def post_writer( + self, + client: httpx.Client, + client_to_server_queue: ClientToServerQueue, + server_to_client_queue: ServerToClientQueue, + start_get_stream: Callable[[], None], + ) -> None: + """Handle writing requests to the server. + + This method processes messages from the client_to_server_queue and sends them to the server. + Responses are written to the server_to_client_queue. + """ + while True: + try: + # Read message from client queue with timeout to check stop_event periodically + session_message = client_to_server_queue.get(timeout=DEFAULT_QUEUE_READ_TIMEOUT) + if session_message is None: + break + + message = session_message.message + metadata = ( + session_message.metadata if isinstance(session_message.metadata, ClientMessageMetadata) else None + ) + + # Check if this is a resumption request + is_resumption = bool(metadata and metadata.resumption_token) + + logger.debug(f"Sending client message: {message}") + + # Handle initialized notification + if self._is_initialized_notification(message): + start_get_stream() + + ctx = RequestContext( + client=client, + headers=self.request_headers, + session_id=self.session_id, + session_message=session_message, + metadata=metadata, + server_to_client_queue=server_to_client_queue, # Queue to write responses to client + sse_read_timeout=self.sse_read_timeout, + ) + + if is_resumption: + self._handle_resumption_request(ctx) + else: + self._handle_post_request(ctx) + except queue.Empty: + continue + except Exception as exc: + server_to_client_queue.put(exc) + + def terminate_session(self, client: httpx.Client) -> None: + """Terminate the session by sending a DELETE request.""" + if not self.session_id: + return + + try: + headers = self._update_headers_with_session(self.request_headers) + response = client.delete(self.url, headers=headers) + + if response.status_code == 405: + logger.debug("Server does not allow session termination") + elif response.status_code != 200: + logger.warning(f"Session termination failed: {response.status_code}") + except Exception as exc: + logger.warning(f"Session termination failed: {exc}") + + def get_session_id(self) -> str | None: + """Get the current session ID.""" + return self.session_id + + +@contextmanager +def streamablehttp_client( + url: str, + headers: dict[str, Any] | None = None, + timeout: timedelta = timedelta(seconds=30), + sse_read_timeout: timedelta = timedelta(seconds=60 * 5), + terminate_on_close: bool = True, +) -> Generator[ + tuple[ + ServerToClientQueue, # Queue for receiving messages FROM server + ClientToServerQueue, # Queue for sending messages TO server + GetSessionIdCallback, + ], + None, + None, +]: + """ + Client transport for StreamableHTTP. + + `sse_read_timeout` determines how long (in seconds) the client will wait for a new + event before disconnecting. All other HTTP operations are controlled by `timeout`. + + Yields: + Tuple containing: + - server_to_client_queue: Queue for reading messages FROM the server + - client_to_server_queue: Queue for sending messages TO the server + - get_session_id_callback: Function to retrieve the current session ID + """ + transport = StreamableHTTPTransport(url, headers, timeout, sse_read_timeout) + + # Create queues with clear directional meaning + server_to_client_queue: ServerToClientQueue = queue.Queue() # For messages FROM server TO client + client_to_server_queue: ClientToServerQueue = queue.Queue() # For messages FROM client TO server + + with ThreadPoolExecutor(max_workers=2) as executor: + try: + with create_ssrf_proxy_mcp_http_client( + headers=transport.request_headers, + timeout=httpx.Timeout(transport.timeout.seconds, read=transport.sse_read_timeout.seconds), + ) as client: + # Define callbacks that need access to thread pool + def start_get_stream() -> None: + """Start a worker thread to handle server-initiated messages.""" + executor.submit(transport.handle_get_stream, client, server_to_client_queue) + + # Start the post_writer worker thread + executor.submit( + transport.post_writer, + client, + client_to_server_queue, # Queue for messages FROM client TO server + server_to_client_queue, # Queue for messages FROM server TO client + start_get_stream, + ) + + try: + yield ( + server_to_client_queue, # Queue for receiving messages FROM server + client_to_server_queue, # Queue for sending messages TO server + transport.get_session_id, + ) + finally: + if transport.session_id and terminate_on_close: + transport.terminate_session(client) + + # Signal threads to stop + client_to_server_queue.put(None) + finally: + # Clear any remaining items and add None sentinel to unblock any waiting threads + try: + while not client_to_server_queue.empty(): + client_to_server_queue.get_nowait() + except queue.Empty: + pass + + client_to_server_queue.put(None) + server_to_client_queue.put(None) diff --git a/api/core/mcp/entities.py b/api/core/mcp/entities.py new file mode 100644 index 0000000000..7553c10a2e --- /dev/null +++ b/api/core/mcp/entities.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass +from typing import Any, Generic, TypeVar + +from core.mcp.session.base_session import BaseSession +from core.mcp.types import LATEST_PROTOCOL_VERSION, RequestId, RequestParams + +SUPPORTED_PROTOCOL_VERSIONS: list[str] = ["2024-11-05", LATEST_PROTOCOL_VERSION] + + +SessionT = TypeVar("SessionT", bound=BaseSession[Any, Any, Any, Any, Any]) +LifespanContextT = TypeVar("LifespanContextT") + + +@dataclass +class RequestContext(Generic[SessionT, LifespanContextT]): + request_id: RequestId + meta: RequestParams.Meta | None + session: SessionT + lifespan_context: LifespanContextT diff --git a/api/core/mcp/error.py b/api/core/mcp/error.py new file mode 100644 index 0000000000..92ea7bde09 --- /dev/null +++ b/api/core/mcp/error.py @@ -0,0 +1,10 @@ +class MCPError(Exception): + pass + + +class MCPConnectionError(MCPError): + pass + + +class MCPAuthError(MCPConnectionError): + pass diff --git a/api/core/mcp/mcp_client.py b/api/core/mcp/mcp_client.py new file mode 100644 index 0000000000..e9036de8c6 --- /dev/null +++ b/api/core/mcp/mcp_client.py @@ -0,0 +1,150 @@ +import logging +from collections.abc import Callable +from contextlib import AbstractContextManager, ExitStack +from types import TracebackType +from typing import Any, Optional, cast +from urllib.parse import urlparse + +from core.mcp.client.sse_client import sse_client +from core.mcp.client.streamable_client import streamablehttp_client +from core.mcp.error import MCPAuthError, MCPConnectionError +from core.mcp.session.client_session import ClientSession +from core.mcp.types import Tool + +logger = logging.getLogger(__name__) + + +class MCPClient: + def __init__( + self, + server_url: str, + provider_id: str, + tenant_id: str, + authed: bool = True, + authorization_code: Optional[str] = None, + for_list: bool = False, + ): + # Initialize info + self.provider_id = provider_id + self.tenant_id = tenant_id + self.client_type = "streamable" + self.server_url = server_url + + # Authentication info + self.authed = authed + self.authorization_code = authorization_code + if authed: + from core.mcp.auth.auth_provider import OAuthClientProvider + + self.provider = OAuthClientProvider(self.provider_id, self.tenant_id, for_list=for_list) + self.token = self.provider.tokens() + + # Initialize session and client objects + self._session: Optional[ClientSession] = None + self._streams_context: Optional[AbstractContextManager[Any]] = None + self._session_context: Optional[ClientSession] = None + self.exit_stack = ExitStack() + + # Whether the client has been initialized + self._initialized = False + + def __enter__(self): + self._initialize() + self._initialized = True + return self + + def __exit__( + self, exc_type: Optional[type], exc_value: Optional[BaseException], traceback: Optional[TracebackType] + ): + self.cleanup() + + def _initialize( + self, + ): + """Initialize the client with fallback to SSE if streamable connection fails""" + connection_methods: dict[str, Callable[..., AbstractContextManager[Any]]] = { + "mcp": streamablehttp_client, + "sse": sse_client, + } + + parsed_url = urlparse(self.server_url) + path = parsed_url.path + method_name = path.rstrip("/").split("/")[-1] if path else "" + try: + client_factory = connection_methods[method_name] + self.connect_server(client_factory, method_name) + except KeyError: + try: + self.connect_server(sse_client, "sse") + except MCPConnectionError: + self.connect_server(streamablehttp_client, "mcp") + + def connect_server( + self, client_factory: Callable[..., AbstractContextManager[Any]], method_name: str, first_try: bool = True + ): + from core.mcp.auth.auth_flow import auth + + try: + headers = ( + {"Authorization": f"{self.token.token_type.capitalize()} {self.token.access_token}"} + if self.authed and self.token + else {} + ) + self._streams_context = client_factory(url=self.server_url, headers=headers) + if self._streams_context is None: + raise MCPConnectionError("Failed to create connection context") + + # Use exit_stack to manage context managers properly + if method_name == "mcp": + read_stream, write_stream, _ = self.exit_stack.enter_context(self._streams_context) + streams = (read_stream, write_stream) + else: # sse_client + streams = self.exit_stack.enter_context(self._streams_context) + + self._session_context = ClientSession(*streams) + self._session = self.exit_stack.enter_context(self._session_context) + session = cast(ClientSession, self._session) + session.initialize() + return + + except MCPAuthError: + if not self.authed: + raise + try: + auth(self.provider, self.server_url, self.authorization_code) + except Exception as e: + raise ValueError(f"Failed to authenticate: {e}") + self.token = self.provider.tokens() + if first_try: + return self.connect_server(client_factory, method_name, first_try=False) + + except MCPConnectionError: + raise + + def list_tools(self) -> list[Tool]: + """Connect to an MCP server running with SSE transport""" + # List available tools to verify connection + if not self._initialized or not self._session: + raise ValueError("Session not initialized.") + response = self._session.list_tools() + tools = response.tools + return tools + + def invoke_tool(self, tool_name: str, tool_args: dict): + """Call a tool""" + if not self._initialized or not self._session: + raise ValueError("Session not initialized.") + return self._session.call_tool(tool_name, tool_args) + + def cleanup(self): + """Clean up resources""" + try: + # ExitStack will handle proper cleanup of all managed context managers + self.exit_stack.close() + self._session = None + self._session_context = None + self._streams_context = None + self._initialized = False + except Exception as e: + logging.exception("Error during cleanup") + raise ValueError(f"Error during cleanup: {e}") diff --git a/api/core/mcp/server/streamable_http.py b/api/core/mcp/server/streamable_http.py new file mode 100644 index 0000000000..1c2cf570e2 --- /dev/null +++ b/api/core/mcp/server/streamable_http.py @@ -0,0 +1,228 @@ +import json +import logging +from collections.abc import Mapping +from typing import Any, cast + +from configs import dify_config +from controllers.web.passport import generate_session_id +from core.app.app_config.entities import VariableEntity, VariableEntityType +from core.app.entities.app_invoke_entities import InvokeFrom +from core.app.features.rate_limiting.rate_limit import RateLimitGenerator +from core.mcp import types +from core.mcp.types import INTERNAL_ERROR, INVALID_PARAMS, METHOD_NOT_FOUND +from core.mcp.utils import create_mcp_error_response +from core.model_runtime.utils.encoders import jsonable_encoder +from extensions.ext_database import db +from models.model import App, AppMCPServer, AppMode, EndUser +from services.app_generate_service import AppGenerateService + +""" +Apply to MCP HTTP streamable server with stateless http +""" +logger = logging.getLogger(__name__) + + +class MCPServerStreamableHTTPRequestHandler: + def __init__( + self, app: App, request: types.ClientRequest | types.ClientNotification, user_input_form: list[VariableEntity] + ): + self.app = app + self.request = request + mcp_server = db.session.query(AppMCPServer).filter(AppMCPServer.app_id == self.app.id).first() + if not mcp_server: + raise ValueError("MCP server not found") + self.mcp_server: AppMCPServer = mcp_server + self.end_user = self.retrieve_end_user() + self.user_input_form = user_input_form + + @property + def request_type(self): + return type(self.request.root) + + @property + def parameter_schema(self): + parameters, required = self._convert_input_form_to_parameters(self.user_input_form) + if self.app.mode in {AppMode.COMPLETION.value, AppMode.WORKFLOW.value}: + return { + "type": "object", + "properties": parameters, + "required": required, + } + return { + "type": "object", + "properties": { + "query": {"type": "string", "description": "User Input/Question content"}, + **parameters, + }, + "required": ["query", *required], + } + + @property + def capabilities(self): + return types.ServerCapabilities( + tools=types.ToolsCapability(listChanged=False), + ) + + def response(self, response: types.Result | str): + if isinstance(response, str): + sse_content = f"event: ping\ndata: {response}\n\n".encode() + yield sse_content + return + json_response = types.JSONRPCResponse( + jsonrpc="2.0", + id=(self.request.root.model_extra or {}).get("id", 1), + result=response.model_dump(by_alias=True, mode="json", exclude_none=True), + ) + json_data = json.dumps(jsonable_encoder(json_response)) + + sse_content = f"event: message\ndata: {json_data}\n\n".encode() + + yield sse_content + + def error_response(self, code: int, message: str, data=None): + request_id = (self.request.root.model_extra or {}).get("id", 1) or 1 + return create_mcp_error_response(request_id, code, message, data) + + def handle(self): + handle_map = { + types.InitializeRequest: self.initialize, + types.ListToolsRequest: self.list_tools, + types.CallToolRequest: self.invoke_tool, + types.InitializedNotification: self.handle_notification, + types.PingRequest: self.handle_ping, + } + try: + if self.request_type in handle_map: + return self.response(handle_map[self.request_type]()) + else: + return self.error_response(METHOD_NOT_FOUND, f"Method not found: {self.request_type}") + except ValueError as e: + logger.exception("Invalid params") + return self.error_response(INVALID_PARAMS, str(e)) + except Exception as e: + logger.exception("Internal server error") + return self.error_response(INTERNAL_ERROR, f"Internal server error: {str(e)}") + + def handle_notification(self): + return "ping" + + def handle_ping(self): + return types.EmptyResult() + + def initialize(self): + request = cast(types.InitializeRequest, self.request.root) + client_info = request.params.clientInfo + client_name = f"{client_info.name}@{client_info.version}" + if not self.end_user: + end_user = EndUser( + tenant_id=self.app.tenant_id, + app_id=self.app.id, + type="mcp", + name=client_name, + session_id=generate_session_id(), + external_user_id=self.mcp_server.id, + ) + db.session.add(end_user) + db.session.commit() + return types.InitializeResult( + protocolVersion=types.SERVER_LATEST_PROTOCOL_VERSION, + capabilities=self.capabilities, + serverInfo=types.Implementation(name="Dify", version=dify_config.project.version), + instructions=self.mcp_server.description, + ) + + def list_tools(self): + if not self.end_user: + raise ValueError("User not found") + return types.ListToolsResult( + tools=[ + types.Tool( + name=self.app.name, + description=self.mcp_server.description, + inputSchema=self.parameter_schema, + ) + ], + ) + + def invoke_tool(self): + if not self.end_user: + raise ValueError("User not found") + request = cast(types.CallToolRequest, self.request.root) + args = request.params.arguments + if not args: + raise ValueError("No arguments provided") + if self.app.mode in {AppMode.WORKFLOW.value}: + args = {"inputs": args} + elif self.app.mode in {AppMode.COMPLETION.value}: + args = {"query": "", "inputs": args} + else: + args = {"query": args["query"], "inputs": {k: v for k, v in args.items() if k != "query"}} + response = AppGenerateService.generate( + self.app, + self.end_user, + args, + InvokeFrom.SERVICE_API, + streaming=self.app.mode == AppMode.AGENT_CHAT.value, + ) + answer = "" + if isinstance(response, RateLimitGenerator): + for item in response.generator: + data = item + if isinstance(data, str) and data.startswith("data: "): + try: + json_str = data[6:].strip() + parsed_data = json.loads(json_str) + if parsed_data.get("event") == "agent_thought": + answer += parsed_data.get("thought", "") + except json.JSONDecodeError: + continue + if isinstance(response, Mapping): + if self.app.mode in { + AppMode.ADVANCED_CHAT.value, + AppMode.COMPLETION.value, + AppMode.CHAT.value, + AppMode.AGENT_CHAT.value, + }: + answer = response["answer"] + elif self.app.mode in {AppMode.WORKFLOW.value}: + answer = json.dumps(response["data"]["outputs"], ensure_ascii=False) + else: + raise ValueError("Invalid app mode") + # Not support image yet + return types.CallToolResult(content=[types.TextContent(text=answer, type="text")]) + + def retrieve_end_user(self): + return ( + db.session.query(EndUser) + .filter(EndUser.external_user_id == self.mcp_server.id, EndUser.type == "mcp") + .first() + ) + + def _convert_input_form_to_parameters(self, user_input_form: list[VariableEntity]): + parameters: dict[str, dict[str, Any]] = {} + required = [] + for item in user_input_form: + parameters[item.variable] = {} + if item.type in ( + VariableEntityType.FILE, + VariableEntityType.FILE_LIST, + VariableEntityType.EXTERNAL_DATA_TOOL, + ): + continue + if item.required: + required.append(item.variable) + # if the workflow republished, the parameters not changed + # we should not raise error here + try: + description = self.mcp_server.parameters_dict[item.variable] + except KeyError: + description = "" + parameters[item.variable]["description"] = description + if item.type in (VariableEntityType.TEXT_INPUT, VariableEntityType.PARAGRAPH): + parameters[item.variable]["type"] = "string" + elif item.type == VariableEntityType.SELECT: + parameters[item.variable]["type"] = "string" + parameters[item.variable]["enum"] = item.options + elif item.type == VariableEntityType.NUMBER: + parameters[item.variable]["type"] = "float" + return parameters, required diff --git a/api/core/mcp/session/base_session.py b/api/core/mcp/session/base_session.py new file mode 100644 index 0000000000..1c0f582501 --- /dev/null +++ b/api/core/mcp/session/base_session.py @@ -0,0 +1,397 @@ +import logging +import queue +from collections.abc import Callable +from concurrent.futures import ThreadPoolExecutor +from contextlib import ExitStack +from datetime import timedelta +from types import TracebackType +from typing import Any, Generic, Self, TypeVar + +from httpx import HTTPStatusError +from pydantic import BaseModel + +from core.mcp.error import MCPAuthError, MCPConnectionError +from core.mcp.types import ( + CancelledNotification, + ClientNotification, + ClientRequest, + ClientResult, + ErrorData, + JSONRPCError, + JSONRPCMessage, + JSONRPCNotification, + JSONRPCRequest, + JSONRPCResponse, + MessageMetadata, + RequestId, + RequestParams, + ServerMessageMetadata, + ServerNotification, + ServerRequest, + ServerResult, + SessionMessage, +) + +SendRequestT = TypeVar("SendRequestT", ClientRequest, ServerRequest) +SendResultT = TypeVar("SendResultT", ClientResult, ServerResult) +SendNotificationT = TypeVar("SendNotificationT", ClientNotification, ServerNotification) +ReceiveRequestT = TypeVar("ReceiveRequestT", ClientRequest, ServerRequest) +ReceiveResultT = TypeVar("ReceiveResultT", bound=BaseModel) +ReceiveNotificationT = TypeVar("ReceiveNotificationT", ClientNotification, ServerNotification) +DEFAULT_RESPONSE_READ_TIMEOUT = 1.0 + + +class RequestResponder(Generic[ReceiveRequestT, SendResultT]): + """Handles responding to MCP requests and manages request lifecycle. + + This class MUST be used as a context manager to ensure proper cleanup and + cancellation handling: + + Example: + with request_responder as resp: + resp.respond(result) + + The context manager ensures: + 1. Proper cancellation scope setup and cleanup + 2. Request completion tracking + 3. Cleanup of in-flight requests + """ + + request: ReceiveRequestT + _session: Any + _on_complete: Callable[["RequestResponder[ReceiveRequestT, SendResultT]"], Any] + + def __init__( + self, + request_id: RequestId, + request_meta: RequestParams.Meta | None, + request: ReceiveRequestT, + session: """BaseSession[ + SendRequestT, + SendNotificationT, + SendResultT, + ReceiveRequestT, + ReceiveNotificationT + ]""", + on_complete: Callable[["RequestResponder[ReceiveRequestT, SendResultT]"], Any], + ) -> None: + self.request_id = request_id + self.request_meta = request_meta + self.request = request + self._session = session + self._completed = False + self._on_complete = on_complete + self._entered = False # Track if we're in a context manager + + def __enter__(self) -> "RequestResponder[ReceiveRequestT, SendResultT]": + """Enter the context manager, enabling request cancellation tracking.""" + self._entered = True + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + """Exit the context manager, performing cleanup and notifying completion.""" + try: + if self._completed: + self._on_complete(self) + finally: + self._entered = False + + def respond(self, response: SendResultT | ErrorData) -> None: + """Send a response for this request. + + Must be called within a context manager block. + Raises: + RuntimeError: If not used within a context manager + AssertionError: If request was already responded to + """ + if not self._entered: + raise RuntimeError("RequestResponder must be used as a context manager") + assert not self._completed, "Request already responded to" + + self._completed = True + + self._session._send_response(request_id=self.request_id, response=response) + + def cancel(self) -> None: + """Cancel this request and mark it as completed.""" + if not self._entered: + raise RuntimeError("RequestResponder must be used as a context manager") + + self._completed = True # Mark as completed so it's removed from in_flight + # Send an error response to indicate cancellation + self._session._send_response( + request_id=self.request_id, + response=ErrorData(code=0, message="Request cancelled", data=None), + ) + + +class BaseSession( + Generic[ + SendRequestT, + SendNotificationT, + SendResultT, + ReceiveRequestT, + ReceiveNotificationT, + ], +): + """ + Implements an MCP "session" on top of read/write streams, including features + like request/response linking, notifications, and progress. + + This class is a context manager that automatically starts processing + messages when entered. + """ + + _response_streams: dict[RequestId, queue.Queue[JSONRPCResponse | JSONRPCError]] + _request_id: int + _in_flight: dict[RequestId, RequestResponder[ReceiveRequestT, SendResultT]] + _receive_request_type: type[ReceiveRequestT] + _receive_notification_type: type[ReceiveNotificationT] + + def __init__( + self, + read_stream: queue.Queue, + write_stream: queue.Queue, + receive_request_type: type[ReceiveRequestT], + receive_notification_type: type[ReceiveNotificationT], + # If none, reading will never time out + read_timeout_seconds: timedelta | None = None, + ) -> None: + self._read_stream = read_stream + self._write_stream = write_stream + self._response_streams = {} + self._request_id = 0 + self._receive_request_type = receive_request_type + self._receive_notification_type = receive_notification_type + self._session_read_timeout_seconds = read_timeout_seconds + self._in_flight = {} + self._exit_stack = ExitStack() + + def __enter__(self) -> Self: + self._executor = ThreadPoolExecutor() + self._receiver_future = self._executor.submit(self._receive_loop) + return self + + def check_receiver_status(self) -> None: + if self._receiver_future.done(): + self._receiver_future.result() + + def __exit__( + self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None + ) -> None: + self._exit_stack.close() + self._read_stream.put(None) + self._write_stream.put(None) + + def send_request( + self, + request: SendRequestT, + result_type: type[ReceiveResultT], + request_read_timeout_seconds: timedelta | None = None, + metadata: MessageMetadata = None, + ) -> ReceiveResultT: + """ + Sends a request and wait for a response. Raises an McpError if the + response contains an error. If a request read timeout is provided, it + will take precedence over the session read timeout. + + Do not use this method to emit notifications! Use send_notification() + instead. + """ + self.check_receiver_status() + + request_id = self._request_id + self._request_id = request_id + 1 + + response_queue: queue.Queue[JSONRPCResponse | JSONRPCError] = queue.Queue() + self._response_streams[request_id] = response_queue + + try: + jsonrpc_request = JSONRPCRequest( + jsonrpc="2.0", + id=request_id, + **request.model_dump(by_alias=True, mode="json", exclude_none=True), + ) + + self._write_stream.put(SessionMessage(message=JSONRPCMessage(jsonrpc_request), metadata=metadata)) + timeout = DEFAULT_RESPONSE_READ_TIMEOUT + if request_read_timeout_seconds is not None: + timeout = float(request_read_timeout_seconds.total_seconds()) + elif self._session_read_timeout_seconds is not None: + timeout = float(self._session_read_timeout_seconds.total_seconds()) + while True: + try: + response_or_error = response_queue.get(timeout=timeout) + break + except queue.Empty: + self.check_receiver_status() + continue + + if response_or_error is None: + raise MCPConnectionError( + ErrorData( + code=500, + message="No response received", + ) + ) + elif isinstance(response_or_error, JSONRPCError): + if response_or_error.error.code == 401: + raise MCPAuthError( + ErrorData(code=response_or_error.error.code, message=response_or_error.error.message) + ) + else: + raise MCPConnectionError( + ErrorData(code=response_or_error.error.code, message=response_or_error.error.message) + ) + else: + return result_type.model_validate(response_or_error.result) + + finally: + self._response_streams.pop(request_id, None) + + def send_notification( + self, + notification: SendNotificationT, + related_request_id: RequestId | None = None, + ) -> None: + """ + Emits a notification, which is a one-way message that does not expect + a response. + """ + self.check_receiver_status() + + # Some transport implementations may need to set the related_request_id + # to attribute to the notifications to the request that triggered them. + jsonrpc_notification = JSONRPCNotification( + jsonrpc="2.0", + **notification.model_dump(by_alias=True, mode="json", exclude_none=True), + ) + session_message = SessionMessage( + message=JSONRPCMessage(jsonrpc_notification), + metadata=ServerMessageMetadata(related_request_id=related_request_id) if related_request_id else None, + ) + self._write_stream.put(session_message) + + def _send_response(self, request_id: RequestId, response: SendResultT | ErrorData) -> None: + if isinstance(response, ErrorData): + jsonrpc_error = JSONRPCError(jsonrpc="2.0", id=request_id, error=response) + session_message = SessionMessage(message=JSONRPCMessage(jsonrpc_error)) + self._write_stream.put(session_message) + else: + jsonrpc_response = JSONRPCResponse( + jsonrpc="2.0", + id=request_id, + result=response.model_dump(by_alias=True, mode="json", exclude_none=True), + ) + session_message = SessionMessage(message=JSONRPCMessage(jsonrpc_response)) + self._write_stream.put(session_message) + + def _receive_loop(self) -> None: + """ + Main message processing loop. + In a real synchronous implementation, this would likely run in a separate thread. + """ + while True: + try: + # Attempt to receive a message (this would be blocking in a synchronous context) + message = self._read_stream.get(timeout=DEFAULT_RESPONSE_READ_TIMEOUT) + if message is None: + break + if isinstance(message, HTTPStatusError): + response_queue = self._response_streams.get(self._request_id - 1) + if response_queue is not None: + response_queue.put( + JSONRPCError( + jsonrpc="2.0", + id=self._request_id - 1, + error=ErrorData(code=message.response.status_code, message=message.args[0]), + ) + ) + else: + self._handle_incoming(RuntimeError(f"Received response with an unknown request ID: {message}")) + elif isinstance(message, Exception): + self._handle_incoming(message) + elif isinstance(message.message.root, JSONRPCRequest): + validated_request = self._receive_request_type.model_validate( + message.message.root.model_dump(by_alias=True, mode="json", exclude_none=True) + ) + + responder = RequestResponder( + request_id=message.message.root.id, + request_meta=validated_request.root.params.meta if validated_request.root.params else None, + request=validated_request, + session=self, + on_complete=lambda r: self._in_flight.pop(r.request_id, None), + ) + + self._in_flight[responder.request_id] = responder + self._received_request(responder) + + if not responder._completed: + self._handle_incoming(responder) + + elif isinstance(message.message.root, JSONRPCNotification): + try: + notification = self._receive_notification_type.model_validate( + message.message.root.model_dump(by_alias=True, mode="json", exclude_none=True) + ) + # Handle cancellation notifications + if isinstance(notification.root, CancelledNotification): + cancelled_id = notification.root.params.requestId + if cancelled_id in self._in_flight: + self._in_flight[cancelled_id].cancel() + else: + self._received_notification(notification) + self._handle_incoming(notification) + except Exception as e: + # For other validation errors, log and continue + logging.warning(f"Failed to validate notification: {e}. Message was: {message.message.root}") + else: # Response or error + response_queue = self._response_streams.get(message.message.root.id) + if response_queue is not None: + response_queue.put(message.message.root) + else: + self._handle_incoming(RuntimeError(f"Server Error: {message}")) + except queue.Empty: + continue + except Exception as e: + logging.exception("Error in message processing loop") + raise + + def _received_request(self, responder: RequestResponder[ReceiveRequestT, SendResultT]) -> None: + """ + Can be overridden by subclasses to handle a request without needing to + listen on the message stream. + + If the request is responded to within this method, it will not be + forwarded on to the message stream. + """ + pass + + def _received_notification(self, notification: ReceiveNotificationT) -> None: + """ + Can be overridden by subclasses to handle a notification without needing + to listen on the message stream. + """ + pass + + def send_progress_notification( + self, progress_token: str | int, progress: float, total: float | None = None + ) -> None: + """ + Sends a progress notification for a request that is currently being + processed. + """ + pass + + def _handle_incoming( + self, + req: RequestResponder[ReceiveRequestT, SendResultT] | ReceiveNotificationT | Exception, + ) -> None: + """A generic handler for incoming messages. Overwritten by subclasses.""" + pass diff --git a/api/core/mcp/session/client_session.py b/api/core/mcp/session/client_session.py new file mode 100644 index 0000000000..ed2ad508ab --- /dev/null +++ b/api/core/mcp/session/client_session.py @@ -0,0 +1,365 @@ +from datetime import timedelta +from typing import Any, Protocol + +from pydantic import AnyUrl, TypeAdapter + +from configs import dify_config +from core.mcp import types +from core.mcp.entities import SUPPORTED_PROTOCOL_VERSIONS, RequestContext +from core.mcp.session.base_session import BaseSession, RequestResponder + +DEFAULT_CLIENT_INFO = types.Implementation(name="Dify", version=dify_config.project.version) + + +class SamplingFnT(Protocol): + def __call__( + self, + context: RequestContext["ClientSession", Any], + params: types.CreateMessageRequestParams, + ) -> types.CreateMessageResult | types.ErrorData: ... + + +class ListRootsFnT(Protocol): + def __call__(self, context: RequestContext["ClientSession", Any]) -> types.ListRootsResult | types.ErrorData: ... + + +class LoggingFnT(Protocol): + def __call__( + self, + params: types.LoggingMessageNotificationParams, + ) -> None: ... + + +class MessageHandlerFnT(Protocol): + def __call__( + self, + message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception, + ) -> None: ... + + +def _default_message_handler( + message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception, +) -> None: + if isinstance(message, Exception): + raise ValueError(str(message)) + elif isinstance(message, (types.ServerNotification | RequestResponder)): + pass + + +def _default_sampling_callback( + context: RequestContext["ClientSession", Any], + params: types.CreateMessageRequestParams, +) -> types.CreateMessageResult | types.ErrorData: + return types.ErrorData( + code=types.INVALID_REQUEST, + message="Sampling not supported", + ) + + +def _default_list_roots_callback( + context: RequestContext["ClientSession", Any], +) -> types.ListRootsResult | types.ErrorData: + return types.ErrorData( + code=types.INVALID_REQUEST, + message="List roots not supported", + ) + + +def _default_logging_callback( + params: types.LoggingMessageNotificationParams, +) -> None: + pass + + +ClientResponse: TypeAdapter[types.ClientResult | types.ErrorData] = TypeAdapter(types.ClientResult | types.ErrorData) + + +class ClientSession( + BaseSession[ + types.ClientRequest, + types.ClientNotification, + types.ClientResult, + types.ServerRequest, + types.ServerNotification, + ] +): + def __init__( + self, + read_stream, + write_stream, + read_timeout_seconds: timedelta | None = None, + sampling_callback: SamplingFnT | None = None, + list_roots_callback: ListRootsFnT | None = None, + logging_callback: LoggingFnT | None = None, + message_handler: MessageHandlerFnT | None = None, + client_info: types.Implementation | None = None, + ) -> None: + super().__init__( + read_stream, + write_stream, + types.ServerRequest, + types.ServerNotification, + read_timeout_seconds=read_timeout_seconds, + ) + self._client_info = client_info or DEFAULT_CLIENT_INFO + self._sampling_callback = sampling_callback or _default_sampling_callback + self._list_roots_callback = list_roots_callback or _default_list_roots_callback + self._logging_callback = logging_callback or _default_logging_callback + self._message_handler = message_handler or _default_message_handler + + def initialize(self) -> types.InitializeResult: + sampling = types.SamplingCapability() + roots = types.RootsCapability( + # TODO: Should this be based on whether we + # _will_ send notifications, or only whether + # they're supported? + listChanged=True, + ) + + result = self.send_request( + types.ClientRequest( + types.InitializeRequest( + method="initialize", + params=types.InitializeRequestParams( + protocolVersion=types.LATEST_PROTOCOL_VERSION, + capabilities=types.ClientCapabilities( + sampling=sampling, + experimental=None, + roots=roots, + ), + clientInfo=self._client_info, + ), + ) + ), + types.InitializeResult, + ) + + if result.protocolVersion not in SUPPORTED_PROTOCOL_VERSIONS: + raise RuntimeError(f"Unsupported protocol version from the server: {result.protocolVersion}") + + self.send_notification( + types.ClientNotification(types.InitializedNotification(method="notifications/initialized")) + ) + + return result + + def send_ping(self) -> types.EmptyResult: + """Send a ping request.""" + return self.send_request( + types.ClientRequest( + types.PingRequest( + method="ping", + ) + ), + types.EmptyResult, + ) + + def send_progress_notification( + self, progress_token: str | int, progress: float, total: float | None = None + ) -> None: + """Send a progress notification.""" + self.send_notification( + types.ClientNotification( + types.ProgressNotification( + method="notifications/progress", + params=types.ProgressNotificationParams( + progressToken=progress_token, + progress=progress, + total=total, + ), + ), + ) + ) + + def set_logging_level(self, level: types.LoggingLevel) -> types.EmptyResult: + """Send a logging/setLevel request.""" + return self.send_request( + types.ClientRequest( + types.SetLevelRequest( + method="logging/setLevel", + params=types.SetLevelRequestParams(level=level), + ) + ), + types.EmptyResult, + ) + + def list_resources(self) -> types.ListResourcesResult: + """Send a resources/list request.""" + return self.send_request( + types.ClientRequest( + types.ListResourcesRequest( + method="resources/list", + ) + ), + types.ListResourcesResult, + ) + + def list_resource_templates(self) -> types.ListResourceTemplatesResult: + """Send a resources/templates/list request.""" + return self.send_request( + types.ClientRequest( + types.ListResourceTemplatesRequest( + method="resources/templates/list", + ) + ), + types.ListResourceTemplatesResult, + ) + + def read_resource(self, uri: AnyUrl) -> types.ReadResourceResult: + """Send a resources/read request.""" + return self.send_request( + types.ClientRequest( + types.ReadResourceRequest( + method="resources/read", + params=types.ReadResourceRequestParams(uri=uri), + ) + ), + types.ReadResourceResult, + ) + + def subscribe_resource(self, uri: AnyUrl) -> types.EmptyResult: + """Send a resources/subscribe request.""" + return self.send_request( + types.ClientRequest( + types.SubscribeRequest( + method="resources/subscribe", + params=types.SubscribeRequestParams(uri=uri), + ) + ), + types.EmptyResult, + ) + + def unsubscribe_resource(self, uri: AnyUrl) -> types.EmptyResult: + """Send a resources/unsubscribe request.""" + return self.send_request( + types.ClientRequest( + types.UnsubscribeRequest( + method="resources/unsubscribe", + params=types.UnsubscribeRequestParams(uri=uri), + ) + ), + types.EmptyResult, + ) + + def call_tool( + self, + name: str, + arguments: dict[str, Any] | None = None, + read_timeout_seconds: timedelta | None = None, + ) -> types.CallToolResult: + """Send a tools/call request.""" + + return self.send_request( + types.ClientRequest( + types.CallToolRequest( + method="tools/call", + params=types.CallToolRequestParams(name=name, arguments=arguments), + ) + ), + types.CallToolResult, + request_read_timeout_seconds=read_timeout_seconds, + ) + + def list_prompts(self) -> types.ListPromptsResult: + """Send a prompts/list request.""" + return self.send_request( + types.ClientRequest( + types.ListPromptsRequest( + method="prompts/list", + ) + ), + types.ListPromptsResult, + ) + + def get_prompt(self, name: str, arguments: dict[str, str] | None = None) -> types.GetPromptResult: + """Send a prompts/get request.""" + return self.send_request( + types.ClientRequest( + types.GetPromptRequest( + method="prompts/get", + params=types.GetPromptRequestParams(name=name, arguments=arguments), + ) + ), + types.GetPromptResult, + ) + + def complete( + self, + ref: types.ResourceReference | types.PromptReference, + argument: dict[str, str], + ) -> types.CompleteResult: + """Send a completion/complete request.""" + return self.send_request( + types.ClientRequest( + types.CompleteRequest( + method="completion/complete", + params=types.CompleteRequestParams( + ref=ref, + argument=types.CompletionArgument(**argument), + ), + ) + ), + types.CompleteResult, + ) + + def list_tools(self) -> types.ListToolsResult: + """Send a tools/list request.""" + return self.send_request( + types.ClientRequest( + types.ListToolsRequest( + method="tools/list", + ) + ), + types.ListToolsResult, + ) + + def send_roots_list_changed(self) -> None: + """Send a roots/list_changed notification.""" + self.send_notification( + types.ClientNotification( + types.RootsListChangedNotification( + method="notifications/roots/list_changed", + ) + ) + ) + + def _received_request(self, responder: RequestResponder[types.ServerRequest, types.ClientResult]) -> None: + ctx = RequestContext[ClientSession, Any]( + request_id=responder.request_id, + meta=responder.request_meta, + session=self, + lifespan_context=None, + ) + + match responder.request.root: + case types.CreateMessageRequest(params=params): + with responder: + response = self._sampling_callback(ctx, params) + client_response = ClientResponse.validate_python(response) + responder.respond(client_response) + + case types.ListRootsRequest(): + with responder: + list_roots_response = self._list_roots_callback(ctx) + client_response = ClientResponse.validate_python(list_roots_response) + responder.respond(client_response) + + case types.PingRequest(): + with responder: + return responder.respond(types.ClientResult(root=types.EmptyResult())) + + def _handle_incoming( + self, + req: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception, + ) -> None: + """Handle incoming messages by forwarding to the message handler.""" + self._message_handler(req) + + def _received_notification(self, notification: types.ServerNotification) -> None: + """Handle notifications from the server.""" + # Process specific notification types + match notification.root: + case types.LoggingMessageNotification(params=params): + self._logging_callback(params) + case _: + pass diff --git a/api/core/mcp/types.py b/api/core/mcp/types.py new file mode 100644 index 0000000000..99d985a781 --- /dev/null +++ b/api/core/mcp/types.py @@ -0,0 +1,1217 @@ +from collections.abc import Callable +from dataclasses import dataclass +from typing import ( + Annotated, + Any, + Generic, + Literal, + Optional, + TypeAlias, + TypeVar, +) + +from pydantic import BaseModel, ConfigDict, Field, FileUrl, RootModel +from pydantic.networks import AnyUrl, UrlConstraints + +""" +Model Context Protocol bindings for Python + +These bindings were generated from https://github.com/modelcontextprotocol/specification, +using Claude, with a prompt something like the following: + +Generate idiomatic Python bindings for this schema for MCP, or the "Model Context +Protocol." The schema is defined in TypeScript, but there's also a JSON Schema version +for reference. + +* For the bindings, let's use Pydantic V2 models. +* Each model should allow extra fields everywhere, by specifying `model_config = + ConfigDict(extra='allow')`. Do this in every case, instead of a custom base class. +* Union types should be represented with a Pydantic `RootModel`. +* Define additional model classes instead of using dictionaries. Do this even if they're + not separate types in the schema. +""" +# Client support both version, not support 2025-06-18 yet. +LATEST_PROTOCOL_VERSION = "2025-03-26" +# Server support 2024-11-05 to allow claude to use. +SERVER_LATEST_PROTOCOL_VERSION = "2024-11-05" +ProgressToken = str | int +Cursor = str +Role = Literal["user", "assistant"] +RequestId = Annotated[int | str, Field(union_mode="left_to_right")] +AnyFunction: TypeAlias = Callable[..., Any] + + +class RequestParams(BaseModel): + class Meta(BaseModel): + progressToken: ProgressToken | None = None + """ + If specified, the caller requests out-of-band progress notifications for + this request (as represented by notifications/progress). The value of this + parameter is an opaque token that will be attached to any subsequent + notifications. The receiver is not obligated to provide these notifications. + """ + + model_config = ConfigDict(extra="allow") + + meta: Meta | None = Field(alias="_meta", default=None) + + +class NotificationParams(BaseModel): + class Meta(BaseModel): + model_config = ConfigDict(extra="allow") + + meta: Meta | None = Field(alias="_meta", default=None) + """ + This parameter name is reserved by MCP to allow clients and servers to attach + additional metadata to their notifications. + """ + + +RequestParamsT = TypeVar("RequestParamsT", bound=RequestParams | dict[str, Any] | None) +NotificationParamsT = TypeVar("NotificationParamsT", bound=NotificationParams | dict[str, Any] | None) +MethodT = TypeVar("MethodT", bound=str) + + +class Request(BaseModel, Generic[RequestParamsT, MethodT]): + """Base class for JSON-RPC requests.""" + + method: MethodT + params: RequestParamsT + model_config = ConfigDict(extra="allow") + + +class PaginatedRequest(Request[RequestParamsT, MethodT]): + cursor: Cursor | None = None + """ + An opaque token representing the current pagination position. + If provided, the server should return results starting after this cursor. + """ + + +class Notification(BaseModel, Generic[NotificationParamsT, MethodT]): + """Base class for JSON-RPC notifications.""" + + method: MethodT + params: NotificationParamsT + model_config = ConfigDict(extra="allow") + + +class Result(BaseModel): + """Base class for JSON-RPC results.""" + + model_config = ConfigDict(extra="allow") + + meta: dict[str, Any] | None = Field(alias="_meta", default=None) + """ + This result property is reserved by the protocol to allow clients and servers to + attach additional metadata to their responses. + """ + + +class PaginatedResult(Result): + nextCursor: Cursor | None = None + """ + An opaque token representing the pagination position after the last returned result. + If present, there may be more results available. + """ + + +class JSONRPCRequest(Request[dict[str, Any] | None, str]): + """A request that expects a response.""" + + jsonrpc: Literal["2.0"] + id: RequestId + method: str + params: dict[str, Any] | None = None + + +class JSONRPCNotification(Notification[dict[str, Any] | None, str]): + """A notification which does not expect a response.""" + + jsonrpc: Literal["2.0"] + params: dict[str, Any] | None = None + + +class JSONRPCResponse(BaseModel): + """A successful (non-error) response to a request.""" + + jsonrpc: Literal["2.0"] + id: RequestId + result: dict[str, Any] + model_config = ConfigDict(extra="allow") + + +# Standard JSON-RPC error codes +PARSE_ERROR = -32700 +INVALID_REQUEST = -32600 +METHOD_NOT_FOUND = -32601 +INVALID_PARAMS = -32602 +INTERNAL_ERROR = -32603 + + +class ErrorData(BaseModel): + """Error information for JSON-RPC error responses.""" + + code: int + """The error type that occurred.""" + + message: str + """ + A short description of the error. The message SHOULD be limited to a concise single + sentence. + """ + + data: Any | None = None + """ + Additional information about the error. The value of this member is defined by the + sender (e.g. detailed error information, nested errors etc.). + """ + + model_config = ConfigDict(extra="allow") + + +class JSONRPCError(BaseModel): + """A response to a request that indicates an error occurred.""" + + jsonrpc: Literal["2.0"] + id: str | int + error: ErrorData + model_config = ConfigDict(extra="allow") + + +class JSONRPCMessage(RootModel[JSONRPCRequest | JSONRPCNotification | JSONRPCResponse | JSONRPCError]): + pass + + +class EmptyResult(Result): + """A response that indicates success but carries no data.""" + + +class Implementation(BaseModel): + """Describes the name and version of an MCP implementation.""" + + name: str + version: str + model_config = ConfigDict(extra="allow") + + +class RootsCapability(BaseModel): + """Capability for root operations.""" + + listChanged: bool | None = None + """Whether the client supports notifications for changes to the roots list.""" + model_config = ConfigDict(extra="allow") + + +class SamplingCapability(BaseModel): + """Capability for logging operations.""" + + model_config = ConfigDict(extra="allow") + + +class ClientCapabilities(BaseModel): + """Capabilities a client may support.""" + + experimental: dict[str, dict[str, Any]] | None = None + """Experimental, non-standard capabilities that the client supports.""" + sampling: SamplingCapability | None = None + """Present if the client supports sampling from an LLM.""" + roots: RootsCapability | None = None + """Present if the client supports listing roots.""" + model_config = ConfigDict(extra="allow") + + +class PromptsCapability(BaseModel): + """Capability for prompts operations.""" + + listChanged: bool | None = None + """Whether this server supports notifications for changes to the prompt list.""" + model_config = ConfigDict(extra="allow") + + +class ResourcesCapability(BaseModel): + """Capability for resources operations.""" + + subscribe: bool | None = None + """Whether this server supports subscribing to resource updates.""" + listChanged: bool | None = None + """Whether this server supports notifications for changes to the resource list.""" + model_config = ConfigDict(extra="allow") + + +class ToolsCapability(BaseModel): + """Capability for tools operations.""" + + listChanged: bool | None = None + """Whether this server supports notifications for changes to the tool list.""" + model_config = ConfigDict(extra="allow") + + +class LoggingCapability(BaseModel): + """Capability for logging operations.""" + + model_config = ConfigDict(extra="allow") + + +class ServerCapabilities(BaseModel): + """Capabilities that a server may support.""" + + experimental: dict[str, dict[str, Any]] | None = None + """Experimental, non-standard capabilities that the server supports.""" + logging: LoggingCapability | None = None + """Present if the server supports sending log messages to the client.""" + prompts: PromptsCapability | None = None + """Present if the server offers any prompt templates.""" + resources: ResourcesCapability | None = None + """Present if the server offers any resources to read.""" + tools: ToolsCapability | None = None + """Present if the server offers any tools to call.""" + model_config = ConfigDict(extra="allow") + + +class InitializeRequestParams(RequestParams): + """Parameters for the initialize request.""" + + protocolVersion: str | int + """The latest version of the Model Context Protocol that the client supports.""" + capabilities: ClientCapabilities + clientInfo: Implementation + model_config = ConfigDict(extra="allow") + + +class InitializeRequest(Request[InitializeRequestParams, Literal["initialize"]]): + """ + This request is sent from the client to the server when it first connects, asking it + to begin initialization. + """ + + method: Literal["initialize"] + params: InitializeRequestParams + + +class InitializeResult(Result): + """After receiving an initialize request from the client, the server sends this.""" + + protocolVersion: str | int + """The version of the Model Context Protocol that the server wants to use.""" + capabilities: ServerCapabilities + serverInfo: Implementation + instructions: str | None = None + """Instructions describing how to use the server and its features.""" + + +class InitializedNotification(Notification[NotificationParams | None, Literal["notifications/initialized"]]): + """ + This notification is sent from the client to the server after initialization has + finished. + """ + + method: Literal["notifications/initialized"] + params: NotificationParams | None = None + + +class PingRequest(Request[RequestParams | None, Literal["ping"]]): + """ + A ping, issued by either the server or the client, to check that the other party is + still alive. + """ + + method: Literal["ping"] + params: RequestParams | None = None + + +class ProgressNotificationParams(NotificationParams): + """Parameters for progress notifications.""" + + progressToken: ProgressToken + """ + The progress token which was given in the initial request, used to associate this + notification with the request that is proceeding. + """ + progress: float + """ + The progress thus far. This should increase every time progress is made, even if the + total is unknown. + """ + total: float | None = None + """Total number of items to process (or total progress required), if known.""" + model_config = ConfigDict(extra="allow") + + +class ProgressNotification(Notification[ProgressNotificationParams, Literal["notifications/progress"]]): + """ + An out-of-band notification used to inform the receiver of a progress update for a + long-running request. + """ + + method: Literal["notifications/progress"] + params: ProgressNotificationParams + + +class ListResourcesRequest(PaginatedRequest[RequestParams | None, Literal["resources/list"]]): + """Sent from the client to request a list of resources the server has.""" + + method: Literal["resources/list"] + params: RequestParams | None = None + + +class Annotations(BaseModel): + audience: list[Role] | None = None + priority: Annotated[float, Field(ge=0.0, le=1.0)] | None = None + model_config = ConfigDict(extra="allow") + + +class Resource(BaseModel): + """A known resource that the server is capable of reading.""" + + uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] + """The URI of this resource.""" + name: str + """A human-readable name for this resource.""" + description: str | None = None + """A description of what this resource represents.""" + mimeType: str | None = None + """The MIME type of this resource, if known.""" + size: int | None = None + """ + The size of the raw resource content, in bytes (i.e., before base64 encoding + or any tokenization), if known. + + This can be used by Hosts to display file sizes and estimate context window usage. + """ + annotations: Annotations | None = None + model_config = ConfigDict(extra="allow") + + +class ResourceTemplate(BaseModel): + """A template description for resources available on the server.""" + + uriTemplate: str + """ + A URI template (according to RFC 6570) that can be used to construct resource + URIs. + """ + name: str + """A human-readable name for the type of resource this template refers to.""" + description: str | None = None + """A human-readable description of what this template is for.""" + mimeType: str | None = None + """ + The MIME type for all resources that match this template. This should only be + included if all resources matching this template have the same type. + """ + annotations: Annotations | None = None + model_config = ConfigDict(extra="allow") + + +class ListResourcesResult(PaginatedResult): + """The server's response to a resources/list request from the client.""" + + resources: list[Resource] + + +class ListResourceTemplatesRequest(PaginatedRequest[RequestParams | None, Literal["resources/templates/list"]]): + """Sent from the client to request a list of resource templates the server has.""" + + method: Literal["resources/templates/list"] + params: RequestParams | None = None + + +class ListResourceTemplatesResult(PaginatedResult): + """The server's response to a resources/templates/list request from the client.""" + + resourceTemplates: list[ResourceTemplate] + + +class ReadResourceRequestParams(RequestParams): + """Parameters for reading a resource.""" + + uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] + """ + The URI of the resource to read. The URI can use any protocol; it is up to the + server how to interpret it. + """ + model_config = ConfigDict(extra="allow") + + +class ReadResourceRequest(Request[ReadResourceRequestParams, Literal["resources/read"]]): + """Sent from the client to the server, to read a specific resource URI.""" + + method: Literal["resources/read"] + params: ReadResourceRequestParams + + +class ResourceContents(BaseModel): + """The contents of a specific resource or sub-resource.""" + + uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] + """The URI of this resource.""" + mimeType: str | None = None + """The MIME type of this resource, if known.""" + model_config = ConfigDict(extra="allow") + + +class TextResourceContents(ResourceContents): + """Text contents of a resource.""" + + text: str + """ + The text of the item. This must only be set if the item can actually be represented + as text (not binary data). + """ + + +class BlobResourceContents(ResourceContents): + """Binary contents of a resource.""" + + blob: str + """A base64-encoded string representing the binary data of the item.""" + + +class ReadResourceResult(Result): + """The server's response to a resources/read request from the client.""" + + contents: list[TextResourceContents | BlobResourceContents] + + +class ResourceListChangedNotification( + Notification[NotificationParams | None, Literal["notifications/resources/list_changed"]] +): + """ + An optional notification from the server to the client, informing it that the list + of resources it can read from has changed. + """ + + method: Literal["notifications/resources/list_changed"] + params: NotificationParams | None = None + + +class SubscribeRequestParams(RequestParams): + """Parameters for subscribing to a resource.""" + + uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] + """ + The URI of the resource to subscribe to. The URI can use any protocol; it is up to + the server how to interpret it. + """ + model_config = ConfigDict(extra="allow") + + +class SubscribeRequest(Request[SubscribeRequestParams, Literal["resources/subscribe"]]): + """ + Sent from the client to request resources/updated notifications from the server + whenever a particular resource changes. + """ + + method: Literal["resources/subscribe"] + params: SubscribeRequestParams + + +class UnsubscribeRequestParams(RequestParams): + """Parameters for unsubscribing from a resource.""" + + uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] + """The URI of the resource to unsubscribe from.""" + model_config = ConfigDict(extra="allow") + + +class UnsubscribeRequest(Request[UnsubscribeRequestParams, Literal["resources/unsubscribe"]]): + """ + Sent from the client to request cancellation of resources/updated notifications from + the server. + """ + + method: Literal["resources/unsubscribe"] + params: UnsubscribeRequestParams + + +class ResourceUpdatedNotificationParams(NotificationParams): + """Parameters for resource update notifications.""" + + uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] + """ + The URI of the resource that has been updated. This might be a sub-resource of the + one that the client actually subscribed to. + """ + model_config = ConfigDict(extra="allow") + + +class ResourceUpdatedNotification( + Notification[ResourceUpdatedNotificationParams, Literal["notifications/resources/updated"]] +): + """ + A notification from the server to the client, informing it that a resource has + changed and may need to be read again. + """ + + method: Literal["notifications/resources/updated"] + params: ResourceUpdatedNotificationParams + + +class ListPromptsRequest(PaginatedRequest[RequestParams | None, Literal["prompts/list"]]): + """Sent from the client to request a list of prompts and prompt templates.""" + + method: Literal["prompts/list"] + params: RequestParams | None = None + + +class PromptArgument(BaseModel): + """An argument for a prompt template.""" + + name: str + """The name of the argument.""" + description: str | None = None + """A human-readable description of the argument.""" + required: bool | None = None + """Whether this argument must be provided.""" + model_config = ConfigDict(extra="allow") + + +class Prompt(BaseModel): + """A prompt or prompt template that the server offers.""" + + name: str + """The name of the prompt or prompt template.""" + description: str | None = None + """An optional description of what this prompt provides.""" + arguments: list[PromptArgument] | None = None + """A list of arguments to use for templating the prompt.""" + model_config = ConfigDict(extra="allow") + + +class ListPromptsResult(PaginatedResult): + """The server's response to a prompts/list request from the client.""" + + prompts: list[Prompt] + + +class GetPromptRequestParams(RequestParams): + """Parameters for getting a prompt.""" + + name: str + """The name of the prompt or prompt template.""" + arguments: dict[str, str] | None = None + """Arguments to use for templating the prompt.""" + model_config = ConfigDict(extra="allow") + + +class GetPromptRequest(Request[GetPromptRequestParams, Literal["prompts/get"]]): + """Used by the client to get a prompt provided by the server.""" + + method: Literal["prompts/get"] + params: GetPromptRequestParams + + +class TextContent(BaseModel): + """Text content for a message.""" + + type: Literal["text"] + text: str + """The text content of the message.""" + annotations: Annotations | None = None + model_config = ConfigDict(extra="allow") + + +class ImageContent(BaseModel): + """Image content for a message.""" + + type: Literal["image"] + data: str + """The base64-encoded image data.""" + mimeType: str + """ + The MIME type of the image. Different providers may support different + image types. + """ + annotations: Annotations | None = None + model_config = ConfigDict(extra="allow") + + +class SamplingMessage(BaseModel): + """Describes a message issued to or received from an LLM API.""" + + role: Role + content: TextContent | ImageContent + model_config = ConfigDict(extra="allow") + + +class EmbeddedResource(BaseModel): + """ + The contents of a resource, embedded into a prompt or tool call result. + + It is up to the client how best to render embedded resources for the benefit + of the LLM and/or the user. + """ + + type: Literal["resource"] + resource: TextResourceContents | BlobResourceContents + annotations: Annotations | None = None + model_config = ConfigDict(extra="allow") + + +class PromptMessage(BaseModel): + """Describes a message returned as part of a prompt.""" + + role: Role + content: TextContent | ImageContent | EmbeddedResource + model_config = ConfigDict(extra="allow") + + +class GetPromptResult(Result): + """The server's response to a prompts/get request from the client.""" + + description: str | None = None + """An optional description for the prompt.""" + messages: list[PromptMessage] + + +class PromptListChangedNotification( + Notification[NotificationParams | None, Literal["notifications/prompts/list_changed"]] +): + """ + An optional notification from the server to the client, informing it that the list + of prompts it offers has changed. + """ + + method: Literal["notifications/prompts/list_changed"] + params: NotificationParams | None = None + + +class ListToolsRequest(PaginatedRequest[RequestParams | None, Literal["tools/list"]]): + """Sent from the client to request a list of tools the server has.""" + + method: Literal["tools/list"] + params: RequestParams | None = None + + +class ToolAnnotations(BaseModel): + """ + Additional properties describing a Tool to clients. + + NOTE: all properties in ToolAnnotations are **hints**. + They are not guaranteed to provide a faithful description of + tool behavior (including descriptive properties like `title`). + + Clients should never make tool use decisions based on ToolAnnotations + received from untrusted servers. + """ + + title: str | None = None + """A human-readable title for the tool.""" + + readOnlyHint: bool | None = None + """ + If true, the tool does not modify its environment. + Default: false + """ + + destructiveHint: bool | None = None + """ + If true, the tool may perform destructive updates to its environment. + If false, the tool performs only additive updates. + (This property is meaningful only when `readOnlyHint == false`) + Default: true + """ + + idempotentHint: bool | None = None + """ + If true, calling the tool repeatedly with the same arguments + will have no additional effect on the its environment. + (This property is meaningful only when `readOnlyHint == false`) + Default: false + """ + + openWorldHint: bool | None = None + """ + If true, this tool may interact with an "open world" of external + entities. If false, the tool's domain of interaction is closed. + For example, the world of a web search tool is open, whereas that + of a memory tool is not. + Default: true + """ + model_config = ConfigDict(extra="allow") + + +class Tool(BaseModel): + """Definition for a tool the client can call.""" + + name: str + """The name of the tool.""" + description: str | None = None + """A human-readable description of the tool.""" + inputSchema: dict[str, Any] + """A JSON Schema object defining the expected parameters for the tool.""" + annotations: ToolAnnotations | None = None + """Optional additional tool information.""" + model_config = ConfigDict(extra="allow") + + +class ListToolsResult(PaginatedResult): + """The server's response to a tools/list request from the client.""" + + tools: list[Tool] + + +class CallToolRequestParams(RequestParams): + """Parameters for calling a tool.""" + + name: str + arguments: dict[str, Any] | None = None + model_config = ConfigDict(extra="allow") + + +class CallToolRequest(Request[CallToolRequestParams, Literal["tools/call"]]): + """Used by the client to invoke a tool provided by the server.""" + + method: Literal["tools/call"] + params: CallToolRequestParams + + +class CallToolResult(Result): + """The server's response to a tool call.""" + + content: list[TextContent | ImageContent | EmbeddedResource] + isError: bool = False + + +class ToolListChangedNotification(Notification[NotificationParams | None, Literal["notifications/tools/list_changed"]]): + """ + An optional notification from the server to the client, informing it that the list + of tools it offers has changed. + """ + + method: Literal["notifications/tools/list_changed"] + params: NotificationParams | None = None + + +LoggingLevel = Literal["debug", "info", "notice", "warning", "error", "critical", "alert", "emergency"] + + +class SetLevelRequestParams(RequestParams): + """Parameters for setting the logging level.""" + + level: LoggingLevel + """The level of logging that the client wants to receive from the server.""" + model_config = ConfigDict(extra="allow") + + +class SetLevelRequest(Request[SetLevelRequestParams, Literal["logging/setLevel"]]): + """A request from the client to the server, to enable or adjust logging.""" + + method: Literal["logging/setLevel"] + params: SetLevelRequestParams + + +class LoggingMessageNotificationParams(NotificationParams): + """Parameters for logging message notifications.""" + + level: LoggingLevel + """The severity of this log message.""" + logger: str | None = None + """An optional name of the logger issuing this message.""" + data: Any + """ + The data to be logged, such as a string message or an object. Any JSON serializable + type is allowed here. + """ + model_config = ConfigDict(extra="allow") + + +class LoggingMessageNotification(Notification[LoggingMessageNotificationParams, Literal["notifications/message"]]): + """Notification of a log message passed from server to client.""" + + method: Literal["notifications/message"] + params: LoggingMessageNotificationParams + + +IncludeContext = Literal["none", "thisServer", "allServers"] + + +class ModelHint(BaseModel): + """Hints to use for model selection.""" + + name: str | None = None + """A hint for a model name.""" + + model_config = ConfigDict(extra="allow") + + +class ModelPreferences(BaseModel): + """ + The server's preferences for model selection, requested by the client during + sampling. + + Because LLMs can vary along multiple dimensions, choosing the "best" model is + rarely straightforward. Different models excel in different areas—some are + faster but less capable, others are more capable but more expensive, and so + on. This interface allows servers to express their priorities across multiple + dimensions to help clients make an appropriate selection for their use case. + + These preferences are always advisory. The client MAY ignore them. It is also + up to the client to decide how to interpret these preferences and how to + balance them against other considerations. + """ + + hints: list[ModelHint] | None = None + """ + Optional hints to use for model selection. + + If multiple hints are specified, the client MUST evaluate them in order + (such that the first match is taken). + + The client SHOULD prioritize these hints over the numeric priorities, but + MAY still use the priorities to select from ambiguous matches. + """ + + costPriority: float | None = None + """ + How much to prioritize cost when selecting a model. A value of 0 means cost + is not important, while a value of 1 means cost is the most important + factor. + """ + + speedPriority: float | None = None + """ + How much to prioritize sampling speed (latency) when selecting a model. A + value of 0 means speed is not important, while a value of 1 means speed is + the most important factor. + """ + + intelligencePriority: float | None = None + """ + How much to prioritize intelligence and capabilities when selecting a + model. A value of 0 means intelligence is not important, while a value of 1 + means intelligence is the most important factor. + """ + + model_config = ConfigDict(extra="allow") + + +class CreateMessageRequestParams(RequestParams): + """Parameters for creating a message.""" + + messages: list[SamplingMessage] + modelPreferences: ModelPreferences | None = None + """ + The server's preferences for which model to select. The client MAY ignore + these preferences. + """ + systemPrompt: str | None = None + """An optional system prompt the server wants to use for sampling.""" + includeContext: IncludeContext | None = None + """ + A request to include context from one or more MCP servers (including the caller), to + be attached to the prompt. + """ + temperature: float | None = None + maxTokens: int + """The maximum number of tokens to sample, as requested by the server.""" + stopSequences: list[str] | None = None + metadata: dict[str, Any] | None = None + """Optional metadata to pass through to the LLM provider.""" + model_config = ConfigDict(extra="allow") + + +class CreateMessageRequest(Request[CreateMessageRequestParams, Literal["sampling/createMessage"]]): + """A request from the server to sample an LLM via the client.""" + + method: Literal["sampling/createMessage"] + params: CreateMessageRequestParams + + +StopReason = Literal["endTurn", "stopSequence", "maxTokens"] | str + + +class CreateMessageResult(Result): + """The client's response to a sampling/create_message request from the server.""" + + role: Role + content: TextContent | ImageContent + model: str + """The name of the model that generated the message.""" + stopReason: StopReason | None = None + """The reason why sampling stopped, if known.""" + + +class ResourceReference(BaseModel): + """A reference to a resource or resource template definition.""" + + type: Literal["ref/resource"] + uri: str + """The URI or URI template of the resource.""" + model_config = ConfigDict(extra="allow") + + +class PromptReference(BaseModel): + """Identifies a prompt.""" + + type: Literal["ref/prompt"] + name: str + """The name of the prompt or prompt template""" + model_config = ConfigDict(extra="allow") + + +class CompletionArgument(BaseModel): + """The argument's information for completion requests.""" + + name: str + """The name of the argument""" + value: str + """The value of the argument to use for completion matching.""" + model_config = ConfigDict(extra="allow") + + +class CompleteRequestParams(RequestParams): + """Parameters for completion requests.""" + + ref: ResourceReference | PromptReference + argument: CompletionArgument + model_config = ConfigDict(extra="allow") + + +class CompleteRequest(Request[CompleteRequestParams, Literal["completion/complete"]]): + """A request from the client to the server, to ask for completion options.""" + + method: Literal["completion/complete"] + params: CompleteRequestParams + + +class Completion(BaseModel): + """Completion information.""" + + values: list[str] + """An array of completion values. Must not exceed 100 items.""" + total: int | None = None + """ + The total number of completion options available. This can exceed the number of + values actually sent in the response. + """ + hasMore: bool | None = None + """ + Indicates whether there are additional completion options beyond those provided in + the current response, even if the exact total is unknown. + """ + model_config = ConfigDict(extra="allow") + + +class CompleteResult(Result): + """The server's response to a completion/complete request""" + + completion: Completion + + +class ListRootsRequest(Request[RequestParams | None, Literal["roots/list"]]): + """ + Sent from the server to request a list of root URIs from the client. Roots allow + servers to ask for specific directories or files to operate on. A common example + for roots is providing a set of repositories or directories a server should operate + on. + + This request is typically used when the server needs to understand the file system + structure or access specific locations that the client has permission to read from. + """ + + method: Literal["roots/list"] + params: RequestParams | None = None + + +class Root(BaseModel): + """Represents a root directory or file that the server can operate on.""" + + uri: FileUrl + """ + The URI identifying the root. This *must* start with file:// for now. + This restriction may be relaxed in future versions of the protocol to allow + other URI schemes. + """ + name: str | None = None + """ + An optional name for the root. This can be used to provide a human-readable + identifier for the root, which may be useful for display purposes or for + referencing the root in other parts of the application. + """ + model_config = ConfigDict(extra="allow") + + +class ListRootsResult(Result): + """ + The client's response to a roots/list request from the server. + This result contains an array of Root objects, each representing a root directory + or file that the server can operate on. + """ + + roots: list[Root] + + +class RootsListChangedNotification( + Notification[NotificationParams | None, Literal["notifications/roots/list_changed"]] +): + """ + A notification from the client to the server, informing it that the list of + roots has changed. + + This notification should be sent whenever the client adds, removes, or + modifies any root. The server should then request an updated list of roots + using the ListRootsRequest. + """ + + method: Literal["notifications/roots/list_changed"] + params: NotificationParams | None = None + + +class CancelledNotificationParams(NotificationParams): + """Parameters for cancellation notifications.""" + + requestId: RequestId + """The ID of the request to cancel.""" + reason: str | None = None + """An optional string describing the reason for the cancellation.""" + model_config = ConfigDict(extra="allow") + + +class CancelledNotification(Notification[CancelledNotificationParams, Literal["notifications/cancelled"]]): + """ + This notification can be sent by either side to indicate that it is canceling a + previously-issued request. + """ + + method: Literal["notifications/cancelled"] + params: CancelledNotificationParams + + +class ClientRequest( + RootModel[ + PingRequest + | InitializeRequest + | CompleteRequest + | SetLevelRequest + | GetPromptRequest + | ListPromptsRequest + | ListResourcesRequest + | ListResourceTemplatesRequest + | ReadResourceRequest + | SubscribeRequest + | UnsubscribeRequest + | CallToolRequest + | ListToolsRequest + ] +): + pass + + +class ClientNotification( + RootModel[CancelledNotification | ProgressNotification | InitializedNotification | RootsListChangedNotification] +): + pass + + +class ClientResult(RootModel[EmptyResult | CreateMessageResult | ListRootsResult]): + pass + + +class ServerRequest(RootModel[PingRequest | CreateMessageRequest | ListRootsRequest]): + pass + + +class ServerNotification( + RootModel[ + CancelledNotification + | ProgressNotification + | LoggingMessageNotification + | ResourceUpdatedNotification + | ResourceListChangedNotification + | ToolListChangedNotification + | PromptListChangedNotification + ] +): + pass + + +class ServerResult( + RootModel[ + EmptyResult + | InitializeResult + | CompleteResult + | GetPromptResult + | ListPromptsResult + | ListResourcesResult + | ListResourceTemplatesResult + | ReadResourceResult + | CallToolResult + | ListToolsResult + ] +): + pass + + +ResumptionToken = str + +ResumptionTokenUpdateCallback = Callable[[ResumptionToken], None] + + +@dataclass +class ClientMessageMetadata: + """Metadata specific to client messages.""" + + resumption_token: ResumptionToken | None = None + on_resumption_token_update: Callable[[ResumptionToken], None] | None = None + + +@dataclass +class ServerMessageMetadata: + """Metadata specific to server messages.""" + + related_request_id: RequestId | None = None + request_context: object | None = None + + +MessageMetadata = ClientMessageMetadata | ServerMessageMetadata | None + + +@dataclass +class SessionMessage: + """A message with specific metadata for transport-specific features.""" + + message: JSONRPCMessage + metadata: MessageMetadata = None + + +class OAuthClientMetadata(BaseModel): + client_name: str + redirect_uris: list[str] + grant_types: Optional[list[str]] = None + response_types: Optional[list[str]] = None + token_endpoint_auth_method: Optional[str] = None + client_uri: Optional[str] = None + scope: Optional[str] = None + + +class OAuthClientInformation(BaseModel): + client_id: str + client_secret: Optional[str] = None + + +class OAuthClientInformationFull(OAuthClientInformation): + client_name: str | None = None + redirect_uris: list[str] + scope: Optional[str] = None + grant_types: Optional[list[str]] = None + response_types: Optional[list[str]] = None + token_endpoint_auth_method: Optional[str] = None + + +class OAuthTokens(BaseModel): + access_token: str + token_type: str + expires_in: Optional[int] = None + refresh_token: Optional[str] = None + scope: Optional[str] = None + + +class OAuthMetadata(BaseModel): + authorization_endpoint: str + token_endpoint: str + registration_endpoint: Optional[str] = None + response_types_supported: list[str] + grant_types_supported: Optional[list[str]] = None + code_challenge_methods_supported: Optional[list[str]] = None diff --git a/api/core/mcp/utils.py b/api/core/mcp/utils.py new file mode 100644 index 0000000000..a54badcd4c --- /dev/null +++ b/api/core/mcp/utils.py @@ -0,0 +1,114 @@ +import json + +import httpx + +from configs import dify_config +from core.mcp.types import ErrorData, JSONRPCError +from core.model_runtime.utils.encoders import jsonable_encoder + +HTTP_REQUEST_NODE_SSL_VERIFY = dify_config.HTTP_REQUEST_NODE_SSL_VERIFY + +STATUS_FORCELIST = [429, 500, 502, 503, 504] + + +def create_ssrf_proxy_mcp_http_client( + headers: dict[str, str] | None = None, + timeout: httpx.Timeout | None = None, +) -> httpx.Client: + """Create an HTTPX client with SSRF proxy configuration for MCP connections. + + Args: + headers: Optional headers to include in the client + timeout: Optional timeout configuration + + Returns: + Configured httpx.Client with proxy settings + """ + if dify_config.SSRF_PROXY_ALL_URL: + return httpx.Client( + verify=HTTP_REQUEST_NODE_SSL_VERIFY, + headers=headers or {}, + timeout=timeout, + follow_redirects=True, + proxy=dify_config.SSRF_PROXY_ALL_URL, + ) + elif dify_config.SSRF_PROXY_HTTP_URL and dify_config.SSRF_PROXY_HTTPS_URL: + proxy_mounts = { + "http://": httpx.HTTPTransport(proxy=dify_config.SSRF_PROXY_HTTP_URL, verify=HTTP_REQUEST_NODE_SSL_VERIFY), + "https://": httpx.HTTPTransport( + proxy=dify_config.SSRF_PROXY_HTTPS_URL, verify=HTTP_REQUEST_NODE_SSL_VERIFY + ), + } + return httpx.Client( + verify=HTTP_REQUEST_NODE_SSL_VERIFY, + headers=headers or {}, + timeout=timeout, + follow_redirects=True, + mounts=proxy_mounts, + ) + else: + return httpx.Client( + verify=HTTP_REQUEST_NODE_SSL_VERIFY, + headers=headers or {}, + timeout=timeout, + follow_redirects=True, + ) + + +def ssrf_proxy_sse_connect(url, **kwargs): + """Connect to SSE endpoint with SSRF proxy protection. + + This function creates an SSE connection using the configured proxy settings + to prevent SSRF attacks when connecting to external endpoints. + + Args: + url: The SSE endpoint URL + **kwargs: Additional arguments passed to the SSE connection + + Returns: + EventSource object for SSE streaming + """ + from httpx_sse import connect_sse + + # Extract client if provided, otherwise create one + client = kwargs.pop("client", None) + if client is None: + # Create client with SSRF proxy configuration + timeout = kwargs.pop( + "timeout", + httpx.Timeout( + timeout=dify_config.SSRF_DEFAULT_TIME_OUT, + connect=dify_config.SSRF_DEFAULT_CONNECT_TIME_OUT, + read=dify_config.SSRF_DEFAULT_READ_TIME_OUT, + write=dify_config.SSRF_DEFAULT_WRITE_TIME_OUT, + ), + ) + headers = kwargs.pop("headers", {}) + client = create_ssrf_proxy_mcp_http_client(headers=headers, timeout=timeout) + client_provided = False + else: + client_provided = True + + # Extract method if provided, default to GET + method = kwargs.pop("method", "GET") + + try: + return connect_sse(client, method, url, **kwargs) + except Exception: + # If we created the client, we need to clean it up on error + if not client_provided: + client.close() + raise + + +def create_mcp_error_response(request_id: int | str | None, code: int, message: str, data=None): + """Create MCP error response""" + error_data = ErrorData(code=code, message=message, data=data) + json_response = JSONRPCError( + jsonrpc="2.0", + id=request_id or 1, + error=error_data, + ) + json_data = json.dumps(jsonable_encoder(json_response)) + sse_content = f"event: message\ndata: {json_data}\n\n".encode() + yield sse_content diff --git a/api/core/model_runtime/entities/llm_entities.py b/api/core/model_runtime/entities/llm_entities.py index e52b0eba55..ace2c1f770 100644 --- a/api/core/model_runtime/entities/llm_entities.py +++ b/api/core/model_runtime/entities/llm_entities.py @@ -53,6 +53,37 @@ class LLMUsage(ModelUsage): latency=0.0, ) + @classmethod + def from_metadata(cls, metadata: dict) -> "LLMUsage": + """ + Create LLMUsage instance from metadata dictionary with default values. + + Args: + metadata: Dictionary containing usage metadata + + Returns: + LLMUsage instance with values from metadata or defaults + """ + total_tokens = metadata.get("total_tokens", 0) + completion_tokens = metadata.get("completion_tokens", 0) + if total_tokens > 0 and completion_tokens == 0: + completion_tokens = total_tokens + + return cls( + prompt_tokens=metadata.get("prompt_tokens", 0), + completion_tokens=completion_tokens, + total_tokens=total_tokens, + prompt_unit_price=Decimal(str(metadata.get("prompt_unit_price", 0))), + completion_unit_price=Decimal(str(metadata.get("completion_unit_price", 0))), + total_price=Decimal(str(metadata.get("total_price", 0))), + currency=metadata.get("currency", "USD"), + prompt_price_unit=Decimal(str(metadata.get("prompt_price_unit", 0))), + completion_price_unit=Decimal(str(metadata.get("completion_price_unit", 0))), + prompt_price=Decimal(str(metadata.get("prompt_price", 0))), + completion_price=Decimal(str(metadata.get("completion_price", 0))), + latency=metadata.get("latency", 0.0), + ) + def plus(self, other: "LLMUsage") -> "LLMUsage": """ Add two LLMUsage instances together. diff --git a/api/core/model_runtime/entities/provider_entities.py b/api/core/model_runtime/entities/provider_entities.py index d0f9ee13e5..c9aa8d1474 100644 --- a/api/core/model_runtime/entities/provider_entities.py +++ b/api/core/model_runtime/entities/provider_entities.py @@ -123,6 +123,8 @@ class ProviderEntity(BaseModel): description: Optional[I18nObject] = None icon_small: Optional[I18nObject] = None icon_large: Optional[I18nObject] = None + icon_small_dark: Optional[I18nObject] = None + icon_large_dark: Optional[I18nObject] = None background: Optional[str] = None help: Optional[ProviderHelpEntity] = None supported_model_types: Sequence[ModelType] diff --git a/api/core/ops/aliyun_trace/__init__.py b/api/core/ops/aliyun_trace/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/core/ops/aliyun_trace/aliyun_trace.py b/api/core/ops/aliyun_trace/aliyun_trace.py new file mode 100644 index 0000000000..b18a6905fe --- /dev/null +++ b/api/core/ops/aliyun_trace/aliyun_trace.py @@ -0,0 +1,487 @@ +import json +import logging +from collections.abc import Sequence +from typing import Optional +from urllib.parse import urljoin + +from opentelemetry.trace import Status, StatusCode +from sqlalchemy.orm import Session, sessionmaker + +from core.ops.aliyun_trace.data_exporter.traceclient import ( + TraceClient, + convert_datetime_to_nanoseconds, + convert_to_span_id, + convert_to_trace_id, + generate_span_id, +) +from core.ops.aliyun_trace.entities.aliyun_trace_entity import SpanData +from core.ops.aliyun_trace.entities.semconv import ( + GEN_AI_COMPLETION, + GEN_AI_FRAMEWORK, + GEN_AI_MODEL_NAME, + GEN_AI_PROMPT, + GEN_AI_PROMPT_TEMPLATE_TEMPLATE, + GEN_AI_PROMPT_TEMPLATE_VARIABLE, + GEN_AI_RESPONSE_FINISH_REASON, + GEN_AI_SESSION_ID, + GEN_AI_SPAN_KIND, + GEN_AI_SYSTEM, + GEN_AI_USAGE_INPUT_TOKENS, + GEN_AI_USAGE_OUTPUT_TOKENS, + GEN_AI_USAGE_TOTAL_TOKENS, + GEN_AI_USER_ID, + INPUT_VALUE, + OUTPUT_VALUE, + RETRIEVAL_DOCUMENT, + RETRIEVAL_QUERY, + TOOL_DESCRIPTION, + TOOL_NAME, + TOOL_PARAMETERS, + GenAISpanKind, +) +from core.ops.base_trace_instance import BaseTraceInstance +from core.ops.entities.config_entity import AliyunConfig +from core.ops.entities.trace_entity import ( + BaseTraceInfo, + DatasetRetrievalTraceInfo, + GenerateNameTraceInfo, + MessageTraceInfo, + ModerationTraceInfo, + SuggestedQuestionTraceInfo, + ToolTraceInfo, + WorkflowTraceInfo, +) +from core.rag.models.document import Document +from core.repositories import SQLAlchemyWorkflowNodeExecutionRepository +from core.workflow.entities.workflow_node_execution import ( + WorkflowNodeExecution, + WorkflowNodeExecutionMetadataKey, + WorkflowNodeExecutionStatus, +) +from core.workflow.nodes import NodeType +from models import Account, App, EndUser, TenantAccountJoin, WorkflowNodeExecutionTriggeredFrom, db + +logger = logging.getLogger(__name__) + + +class AliyunDataTrace(BaseTraceInstance): + def __init__( + self, + aliyun_config: AliyunConfig, + ): + super().__init__(aliyun_config) + base_url = aliyun_config.endpoint.rstrip("/") + endpoint = urljoin(base_url, f"adapt_{aliyun_config.license_key}/api/otlp/traces") + self.trace_client = TraceClient(service_name=aliyun_config.app_name, endpoint=endpoint) + + def trace(self, trace_info: BaseTraceInfo): + if isinstance(trace_info, WorkflowTraceInfo): + self.workflow_trace(trace_info) + if isinstance(trace_info, MessageTraceInfo): + self.message_trace(trace_info) + if isinstance(trace_info, ModerationTraceInfo): + pass + if isinstance(trace_info, SuggestedQuestionTraceInfo): + self.suggested_question_trace(trace_info) + if isinstance(trace_info, DatasetRetrievalTraceInfo): + self.dataset_retrieval_trace(trace_info) + if isinstance(trace_info, ToolTraceInfo): + self.tool_trace(trace_info) + if isinstance(trace_info, GenerateNameTraceInfo): + pass + + def api_check(self): + return self.trace_client.api_check() + + def get_project_url(self): + try: + return self.trace_client.get_project_url() + except Exception as e: + logger.info(f"Aliyun get run url failed: {str(e)}", exc_info=True) + raise ValueError(f"Aliyun get run url failed: {str(e)}") + + def workflow_trace(self, trace_info: WorkflowTraceInfo): + trace_id = convert_to_trace_id(trace_info.workflow_run_id) + workflow_span_id = convert_to_span_id(trace_info.workflow_run_id, "workflow") + self.add_workflow_span(trace_id, workflow_span_id, trace_info) + + workflow_node_executions = self.get_workflow_node_executions(trace_info) + for node_execution in workflow_node_executions: + node_span = self.build_workflow_node_span(node_execution, trace_id, trace_info, workflow_span_id) + self.trace_client.add_span(node_span) + + def message_trace(self, trace_info: MessageTraceInfo): + message_data = trace_info.message_data + if message_data is None: + return + message_id = trace_info.message_id + + user_id = message_data.from_account_id + if message_data.from_end_user_id: + end_user_data: Optional[EndUser] = ( + db.session.query(EndUser).filter(EndUser.id == message_data.from_end_user_id).first() + ) + if end_user_data is not None: + user_id = end_user_data.session_id + + status: Status = Status(StatusCode.OK) + if trace_info.error: + status = Status(StatusCode.ERROR, trace_info.error) + + trace_id = convert_to_trace_id(message_id) + message_span_id = convert_to_span_id(message_id, "message") + message_span = SpanData( + trace_id=trace_id, + parent_span_id=None, + span_id=message_span_id, + name="message", + start_time=convert_datetime_to_nanoseconds(trace_info.start_time), + end_time=convert_datetime_to_nanoseconds(trace_info.end_time), + attributes={ + GEN_AI_SESSION_ID: trace_info.metadata.get("conversation_id", ""), + GEN_AI_USER_ID: str(user_id), + GEN_AI_SPAN_KIND: GenAISpanKind.CHAIN.value, + GEN_AI_FRAMEWORK: "dify", + INPUT_VALUE: json.dumps(trace_info.inputs, ensure_ascii=False), + OUTPUT_VALUE: str(trace_info.outputs), + }, + status=status, + ) + self.trace_client.add_span(message_span) + + app_model_config = getattr(trace_info.message_data, "app_model_config", {}) + pre_prompt = getattr(app_model_config, "pre_prompt", "") + inputs_data = getattr(trace_info.message_data, "inputs", {}) + llm_span = SpanData( + trace_id=trace_id, + parent_span_id=message_span_id, + span_id=convert_to_span_id(message_id, "llm"), + name="llm", + start_time=convert_datetime_to_nanoseconds(trace_info.start_time), + end_time=convert_datetime_to_nanoseconds(trace_info.end_time), + attributes={ + GEN_AI_SESSION_ID: trace_info.metadata.get("conversation_id", ""), + GEN_AI_USER_ID: str(user_id), + GEN_AI_SPAN_KIND: GenAISpanKind.LLM.value, + GEN_AI_FRAMEWORK: "dify", + GEN_AI_MODEL_NAME: trace_info.metadata.get("ls_model_name", ""), + GEN_AI_SYSTEM: trace_info.metadata.get("ls_provider", ""), + GEN_AI_USAGE_INPUT_TOKENS: str(trace_info.message_tokens), + GEN_AI_USAGE_OUTPUT_TOKENS: str(trace_info.answer_tokens), + GEN_AI_USAGE_TOTAL_TOKENS: str(trace_info.total_tokens), + GEN_AI_PROMPT_TEMPLATE_VARIABLE: json.dumps(inputs_data, ensure_ascii=False), + GEN_AI_PROMPT_TEMPLATE_TEMPLATE: pre_prompt, + GEN_AI_PROMPT: json.dumps(trace_info.inputs, ensure_ascii=False), + GEN_AI_COMPLETION: str(trace_info.outputs), + INPUT_VALUE: json.dumps(trace_info.inputs, ensure_ascii=False), + OUTPUT_VALUE: str(trace_info.outputs), + }, + status=status, + ) + self.trace_client.add_span(llm_span) + + def dataset_retrieval_trace(self, trace_info: DatasetRetrievalTraceInfo): + if trace_info.message_data is None: + return + message_id = trace_info.message_id + + documents_data = extract_retrieval_documents(trace_info.documents) + dataset_retrieval_span = SpanData( + trace_id=convert_to_trace_id(message_id), + parent_span_id=convert_to_span_id(message_id, "message"), + span_id=generate_span_id(), + name="dataset_retrieval", + start_time=convert_datetime_to_nanoseconds(trace_info.start_time), + end_time=convert_datetime_to_nanoseconds(trace_info.end_time), + attributes={ + GEN_AI_SPAN_KIND: GenAISpanKind.RETRIEVER.value, + GEN_AI_FRAMEWORK: "dify", + RETRIEVAL_QUERY: str(trace_info.inputs), + RETRIEVAL_DOCUMENT: json.dumps(documents_data, ensure_ascii=False), + INPUT_VALUE: str(trace_info.inputs), + OUTPUT_VALUE: json.dumps(documents_data, ensure_ascii=False), + }, + ) + self.trace_client.add_span(dataset_retrieval_span) + + def tool_trace(self, trace_info: ToolTraceInfo): + if trace_info.message_data is None: + return + message_id = trace_info.message_id + + status: Status = Status(StatusCode.OK) + if trace_info.error: + status = Status(StatusCode.ERROR, trace_info.error) + + tool_span = SpanData( + trace_id=convert_to_trace_id(message_id), + parent_span_id=convert_to_span_id(message_id, "message"), + span_id=generate_span_id(), + name=trace_info.tool_name, + start_time=convert_datetime_to_nanoseconds(trace_info.start_time), + end_time=convert_datetime_to_nanoseconds(trace_info.end_time), + attributes={ + GEN_AI_SPAN_KIND: GenAISpanKind.TOOL.value, + GEN_AI_FRAMEWORK: "dify", + TOOL_NAME: trace_info.tool_name, + TOOL_DESCRIPTION: json.dumps(trace_info.tool_config, ensure_ascii=False), + TOOL_PARAMETERS: json.dumps(trace_info.tool_inputs, ensure_ascii=False), + INPUT_VALUE: json.dumps(trace_info.inputs, ensure_ascii=False), + OUTPUT_VALUE: str(trace_info.tool_outputs), + }, + status=status, + ) + self.trace_client.add_span(tool_span) + + def get_workflow_node_executions(self, trace_info: WorkflowTraceInfo) -> Sequence[WorkflowNodeExecution]: + # through workflow_run_id get all_nodes_execution using repository + session_factory = sessionmaker(bind=db.engine) + # Find the app's creator account + with Session(db.engine, expire_on_commit=False) as session: + # Get the app to find its creator + app_id = trace_info.metadata.get("app_id") + if not app_id: + raise ValueError("No app_id found in trace_info metadata") + + app = session.query(App).filter(App.id == app_id).first() + if not app: + raise ValueError(f"App with id {app_id} not found") + + if not app.created_by: + raise ValueError(f"App with id {app_id} has no creator (created_by is None)") + + service_account = session.query(Account).filter(Account.id == app.created_by).first() + if not service_account: + raise ValueError(f"Creator account with id {app.created_by} not found for app {app_id}") + current_tenant = ( + session.query(TenantAccountJoin).filter_by(account_id=service_account.id, current=True).first() + ) + if not current_tenant: + raise ValueError(f"Current tenant not found for account {service_account.id}") + service_account.set_tenant_id(current_tenant.tenant_id) + workflow_node_execution_repository = SQLAlchemyWorkflowNodeExecutionRepository( + session_factory=session_factory, + user=service_account, + app_id=trace_info.metadata.get("app_id"), + triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN, + ) + # Get all executions for this workflow run + workflow_node_executions = workflow_node_execution_repository.get_by_workflow_run( + workflow_run_id=trace_info.workflow_run_id + ) + return workflow_node_executions + + def build_workflow_node_span( + self, node_execution: WorkflowNodeExecution, trace_id: int, trace_info: WorkflowTraceInfo, workflow_span_id: int + ): + try: + if node_execution.node_type == NodeType.LLM: + node_span = self.build_workflow_llm_span(trace_id, workflow_span_id, trace_info, node_execution) + elif node_execution.node_type == NodeType.KNOWLEDGE_RETRIEVAL: + node_span = self.build_workflow_retrieval_span(trace_id, workflow_span_id, trace_info, node_execution) + elif node_execution.node_type == NodeType.TOOL: + node_span = self.build_workflow_tool_span(trace_id, workflow_span_id, trace_info, node_execution) + else: + node_span = self.build_workflow_task_span(trace_id, workflow_span_id, trace_info, node_execution) + return node_span + except Exception: + return None + + def get_workflow_node_status(self, node_execution: WorkflowNodeExecution) -> Status: + span_status: Status = Status(StatusCode.UNSET) + if node_execution.status == WorkflowNodeExecutionStatus.SUCCEEDED: + span_status = Status(StatusCode.OK) + elif node_execution.status in [WorkflowNodeExecutionStatus.FAILED, WorkflowNodeExecutionStatus.EXCEPTION]: + span_status = Status(StatusCode.ERROR, str(node_execution.error)) + return span_status + + def build_workflow_task_span( + self, trace_id: int, workflow_span_id: int, trace_info: WorkflowTraceInfo, node_execution: WorkflowNodeExecution + ) -> SpanData: + return SpanData( + trace_id=trace_id, + parent_span_id=workflow_span_id, + span_id=convert_to_span_id(node_execution.id, "node"), + name=node_execution.title, + start_time=convert_datetime_to_nanoseconds(node_execution.created_at), + end_time=convert_datetime_to_nanoseconds(node_execution.finished_at), + attributes={ + GEN_AI_SESSION_ID: trace_info.metadata.get("conversation_id", ""), + GEN_AI_SPAN_KIND: GenAISpanKind.TASK.value, + GEN_AI_FRAMEWORK: "dify", + INPUT_VALUE: json.dumps(node_execution.inputs, ensure_ascii=False), + OUTPUT_VALUE: json.dumps(node_execution.outputs, ensure_ascii=False), + }, + status=self.get_workflow_node_status(node_execution), + ) + + def build_workflow_tool_span( + self, trace_id: int, workflow_span_id: int, trace_info: WorkflowTraceInfo, node_execution: WorkflowNodeExecution + ) -> SpanData: + tool_des = {} + if node_execution.metadata: + tool_des = node_execution.metadata.get(WorkflowNodeExecutionMetadataKey.TOOL_INFO, {}) + return SpanData( + trace_id=trace_id, + parent_span_id=workflow_span_id, + span_id=convert_to_span_id(node_execution.id, "node"), + name=node_execution.title, + start_time=convert_datetime_to_nanoseconds(node_execution.created_at), + end_time=convert_datetime_to_nanoseconds(node_execution.finished_at), + attributes={ + GEN_AI_SPAN_KIND: GenAISpanKind.TOOL.value, + GEN_AI_FRAMEWORK: "dify", + TOOL_NAME: node_execution.title, + TOOL_DESCRIPTION: json.dumps(tool_des, ensure_ascii=False), + TOOL_PARAMETERS: json.dumps(node_execution.inputs if node_execution.inputs else {}, ensure_ascii=False), + INPUT_VALUE: json.dumps(node_execution.inputs if node_execution.inputs else {}, ensure_ascii=False), + OUTPUT_VALUE: json.dumps(node_execution.outputs, ensure_ascii=False), + }, + status=self.get_workflow_node_status(node_execution), + ) + + def build_workflow_retrieval_span( + self, trace_id: int, workflow_span_id: int, trace_info: WorkflowTraceInfo, node_execution: WorkflowNodeExecution + ) -> SpanData: + input_value = "" + if node_execution.inputs: + input_value = str(node_execution.inputs.get("query", "")) + output_value = "" + if node_execution.outputs: + output_value = json.dumps(node_execution.outputs.get("result", []), ensure_ascii=False) + return SpanData( + trace_id=trace_id, + parent_span_id=workflow_span_id, + span_id=convert_to_span_id(node_execution.id, "node"), + name=node_execution.title, + start_time=convert_datetime_to_nanoseconds(node_execution.created_at), + end_time=convert_datetime_to_nanoseconds(node_execution.finished_at), + attributes={ + GEN_AI_SPAN_KIND: GenAISpanKind.RETRIEVER.value, + GEN_AI_FRAMEWORK: "dify", + RETRIEVAL_QUERY: input_value, + RETRIEVAL_DOCUMENT: output_value, + INPUT_VALUE: input_value, + OUTPUT_VALUE: output_value, + }, + status=self.get_workflow_node_status(node_execution), + ) + + def build_workflow_llm_span( + self, trace_id: int, workflow_span_id: int, trace_info: WorkflowTraceInfo, node_execution: WorkflowNodeExecution + ) -> SpanData: + process_data = node_execution.process_data or {} + outputs = node_execution.outputs or {} + usage_data = process_data.get("usage", {}) if "usage" in process_data else outputs.get("usage", {}) + return SpanData( + trace_id=trace_id, + parent_span_id=workflow_span_id, + span_id=convert_to_span_id(node_execution.id, "node"), + name=node_execution.title, + start_time=convert_datetime_to_nanoseconds(node_execution.created_at), + end_time=convert_datetime_to_nanoseconds(node_execution.finished_at), + attributes={ + GEN_AI_SESSION_ID: trace_info.metadata.get("conversation_id", ""), + GEN_AI_SPAN_KIND: GenAISpanKind.LLM.value, + GEN_AI_FRAMEWORK: "dify", + GEN_AI_MODEL_NAME: process_data.get("model_name", ""), + GEN_AI_SYSTEM: process_data.get("model_provider", ""), + GEN_AI_USAGE_INPUT_TOKENS: str(usage_data.get("prompt_tokens", 0)), + GEN_AI_USAGE_OUTPUT_TOKENS: str(usage_data.get("completion_tokens", 0)), + GEN_AI_USAGE_TOTAL_TOKENS: str(usage_data.get("total_tokens", 0)), + GEN_AI_PROMPT: json.dumps(process_data.get("prompts", []), ensure_ascii=False), + GEN_AI_COMPLETION: str(outputs.get("text", "")), + GEN_AI_RESPONSE_FINISH_REASON: outputs.get("finish_reason", ""), + INPUT_VALUE: json.dumps(process_data.get("prompts", []), ensure_ascii=False), + OUTPUT_VALUE: str(outputs.get("text", "")), + }, + status=self.get_workflow_node_status(node_execution), + ) + + def add_workflow_span(self, trace_id: int, workflow_span_id: int, trace_info: WorkflowTraceInfo): + message_span_id = None + if trace_info.message_id: + message_span_id = convert_to_span_id(trace_info.message_id, "message") + user_id = trace_info.metadata.get("user_id") + status: Status = Status(StatusCode.OK) + if trace_info.error: + status = Status(StatusCode.ERROR, trace_info.error) + if message_span_id: # chatflow + message_span = SpanData( + trace_id=trace_id, + parent_span_id=None, + span_id=message_span_id, + name="message", + start_time=convert_datetime_to_nanoseconds(trace_info.start_time), + end_time=convert_datetime_to_nanoseconds(trace_info.end_time), + attributes={ + GEN_AI_SESSION_ID: trace_info.metadata.get("conversation_id", ""), + GEN_AI_USER_ID: str(user_id), + GEN_AI_SPAN_KIND: GenAISpanKind.CHAIN.value, + GEN_AI_FRAMEWORK: "dify", + INPUT_VALUE: trace_info.workflow_run_inputs.get("sys.query", ""), + OUTPUT_VALUE: json.dumps(trace_info.workflow_run_outputs, ensure_ascii=False), + }, + status=status, + ) + self.trace_client.add_span(message_span) + + workflow_span = SpanData( + trace_id=trace_id, + parent_span_id=message_span_id, + span_id=workflow_span_id, + name="workflow", + start_time=convert_datetime_to_nanoseconds(trace_info.start_time), + end_time=convert_datetime_to_nanoseconds(trace_info.end_time), + attributes={ + GEN_AI_USER_ID: str(user_id), + GEN_AI_SPAN_KIND: GenAISpanKind.CHAIN.value, + GEN_AI_FRAMEWORK: "dify", + INPUT_VALUE: json.dumps(trace_info.workflow_run_inputs, ensure_ascii=False), + OUTPUT_VALUE: json.dumps(trace_info.workflow_run_outputs, ensure_ascii=False), + }, + status=status, + ) + self.trace_client.add_span(workflow_span) + + def suggested_question_trace(self, trace_info: SuggestedQuestionTraceInfo): + message_id = trace_info.message_id + status: Status = Status(StatusCode.OK) + if trace_info.error: + status = Status(StatusCode.ERROR, trace_info.error) + suggested_question_span = SpanData( + trace_id=convert_to_trace_id(message_id), + parent_span_id=convert_to_span_id(message_id, "message"), + span_id=convert_to_span_id(message_id, "suggested_question"), + name="suggested_question", + start_time=convert_datetime_to_nanoseconds(trace_info.start_time), + end_time=convert_datetime_to_nanoseconds(trace_info.end_time), + attributes={ + GEN_AI_SPAN_KIND: GenAISpanKind.LLM.value, + GEN_AI_FRAMEWORK: "dify", + GEN_AI_MODEL_NAME: trace_info.metadata.get("ls_model_name", ""), + GEN_AI_SYSTEM: trace_info.metadata.get("ls_provider", ""), + GEN_AI_PROMPT: json.dumps(trace_info.inputs, ensure_ascii=False), + GEN_AI_COMPLETION: json.dumps(trace_info.suggested_question, ensure_ascii=False), + INPUT_VALUE: json.dumps(trace_info.inputs, ensure_ascii=False), + OUTPUT_VALUE: json.dumps(trace_info.suggested_question, ensure_ascii=False), + }, + status=status, + ) + self.trace_client.add_span(suggested_question_span) + + +def extract_retrieval_documents(documents: list[Document]): + documents_data = [] + for document in documents: + document_data = { + "content": document.page_content, + "metadata": { + "dataset_id": document.metadata.get("dataset_id"), + "doc_id": document.metadata.get("doc_id"), + "document_id": document.metadata.get("document_id"), + }, + "score": document.metadata.get("score"), + } + documents_data.append(document_data) + return documents_data diff --git a/api/core/ops/aliyun_trace/data_exporter/__init__.py b/api/core/ops/aliyun_trace/data_exporter/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/core/ops/aliyun_trace/data_exporter/traceclient.py b/api/core/ops/aliyun_trace/data_exporter/traceclient.py new file mode 100644 index 0000000000..ba5ac3f420 --- /dev/null +++ b/api/core/ops/aliyun_trace/data_exporter/traceclient.py @@ -0,0 +1,200 @@ +import hashlib +import logging +import random +import socket +import threading +import uuid +from collections import deque +from collections.abc import Sequence +from datetime import datetime +from typing import Optional + +import requests +from opentelemetry import trace as trace_api +from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import ReadableSpan +from opentelemetry.sdk.util.instrumentation import InstrumentationScope +from opentelemetry.semconv.resource import ResourceAttributes + +from configs import dify_config +from core.ops.aliyun_trace.entities.aliyun_trace_entity import SpanData + +INVALID_SPAN_ID = 0x0000000000000000 +INVALID_TRACE_ID = 0x00000000000000000000000000000000 + +logger = logging.getLogger(__name__) + + +class TraceClient: + def __init__( + self, + service_name: str, + endpoint: str, + max_queue_size: int = 1000, + schedule_delay_sec: int = 5, + max_export_batch_size: int = 50, + ): + self.endpoint = endpoint + self.resource = Resource( + attributes={ + ResourceAttributes.SERVICE_NAME: service_name, + ResourceAttributes.SERVICE_VERSION: f"dify-{dify_config.project.version}-{dify_config.COMMIT_SHA}", + ResourceAttributes.DEPLOYMENT_ENVIRONMENT: f"{dify_config.DEPLOY_ENV}-{dify_config.EDITION}", + ResourceAttributes.HOST_NAME: socket.gethostname(), + } + ) + self.span_builder = SpanBuilder(self.resource) + self.exporter = OTLPSpanExporter(endpoint=endpoint) + + self.max_queue_size = max_queue_size + self.schedule_delay_sec = schedule_delay_sec + self.max_export_batch_size = max_export_batch_size + + self.queue: deque = deque(maxlen=max_queue_size) + self.condition = threading.Condition(threading.Lock()) + self.done = False + + self.worker_thread = threading.Thread(target=self._worker, daemon=True) + self.worker_thread.start() + + self._spans_dropped = False + + def export(self, spans: Sequence[ReadableSpan]): + self.exporter.export(spans) + + def api_check(self): + try: + response = requests.head(self.endpoint, timeout=5) + if response.status_code == 405: + return True + else: + logger.debug(f"AliyunTrace API check failed: Unexpected status code: {response.status_code}") + return False + except requests.exceptions.RequestException as e: + logger.debug(f"AliyunTrace API check failed: {str(e)}") + raise ValueError(f"AliyunTrace API check failed: {str(e)}") + + def get_project_url(self): + return "https://arms.console.aliyun.com/#/llm" + + def add_span(self, span_data: SpanData): + if span_data is None: + return + span: ReadableSpan = self.span_builder.build_span(span_data) + with self.condition: + if len(self.queue) == self.max_queue_size: + if not self._spans_dropped: + logger.warning("Queue is full, likely spans will be dropped.") + self._spans_dropped = True + + self.queue.appendleft(span) + if len(self.queue) >= self.max_export_batch_size: + self.condition.notify() + + def _worker(self): + while not self.done: + with self.condition: + if len(self.queue) < self.max_export_batch_size and not self.done: + self.condition.wait(timeout=self.schedule_delay_sec) + self._export_batch() + + def _export_batch(self): + spans_to_export: list[ReadableSpan] = [] + with self.condition: + while len(spans_to_export) < self.max_export_batch_size and self.queue: + spans_to_export.append(self.queue.pop()) + + if spans_to_export: + try: + self.exporter.export(spans_to_export) + except Exception as e: + logger.debug(f"Error exporting spans: {e}") + + def shutdown(self): + with self.condition: + self.done = True + self.condition.notify_all() + self.worker_thread.join() + self._export_batch() + self.exporter.shutdown() + + +class SpanBuilder: + def __init__(self, resource): + self.resource = resource + self.instrumentation_scope = InstrumentationScope( + __name__, + "", + None, + None, + ) + + def build_span(self, span_data: SpanData) -> ReadableSpan: + span_context = trace_api.SpanContext( + trace_id=span_data.trace_id, + span_id=span_data.span_id, + is_remote=False, + trace_flags=trace_api.TraceFlags(trace_api.TraceFlags.SAMPLED), + trace_state=None, + ) + + parent_span_context = None + if span_data.parent_span_id is not None: + parent_span_context = trace_api.SpanContext( + trace_id=span_data.trace_id, + span_id=span_data.parent_span_id, + is_remote=False, + trace_flags=trace_api.TraceFlags(trace_api.TraceFlags.SAMPLED), + trace_state=None, + ) + + span = ReadableSpan( + name=span_data.name, + context=span_context, + parent=parent_span_context, + resource=self.resource, + attributes=span_data.attributes, + events=span_data.events, + links=span_data.links, + kind=trace_api.SpanKind.INTERNAL, + status=span_data.status, + start_time=span_data.start_time, + end_time=span_data.end_time, + instrumentation_scope=self.instrumentation_scope, + ) + return span + + +def generate_span_id() -> int: + span_id = random.getrandbits(64) + while span_id == INVALID_SPAN_ID: + span_id = random.getrandbits(64) + return span_id + + +def convert_to_trace_id(uuid_v4: Optional[str]) -> int: + try: + uuid_obj = uuid.UUID(uuid_v4) + return uuid_obj.int + except Exception as e: + raise ValueError(f"Invalid UUID input: {e}") + + +def convert_to_span_id(uuid_v4: Optional[str], span_type: str) -> int: + try: + uuid_obj = uuid.UUID(uuid_v4) + except Exception as e: + raise ValueError(f"Invalid UUID input: {e}") + combined_key = f"{uuid_obj.hex}-{span_type}" + hash_bytes = hashlib.sha256(combined_key.encode("utf-8")).digest() + span_id = int.from_bytes(hash_bytes[:8], byteorder="big", signed=False) + return span_id + + +def convert_datetime_to_nanoseconds(start_time_a: Optional[datetime]) -> Optional[int]: + if start_time_a is None: + return None + timestamp_in_seconds = start_time_a.timestamp() + timestamp_in_nanoseconds = int(timestamp_in_seconds * 1e9) + return timestamp_in_nanoseconds diff --git a/api/core/ops/aliyun_trace/entities/__init__.py b/api/core/ops/aliyun_trace/entities/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/core/ops/aliyun_trace/entities/aliyun_trace_entity.py b/api/core/ops/aliyun_trace/entities/aliyun_trace_entity.py new file mode 100644 index 0000000000..1caa822cd0 --- /dev/null +++ b/api/core/ops/aliyun_trace/entities/aliyun_trace_entity.py @@ -0,0 +1,21 @@ +from collections.abc import Sequence +from typing import Optional + +from opentelemetry import trace as trace_api +from opentelemetry.sdk.trace import Event, Status, StatusCode +from pydantic import BaseModel, Field + + +class SpanData(BaseModel): + model_config = {"arbitrary_types_allowed": True} + + trace_id: int = Field(..., description="The unique identifier for the trace.") + parent_span_id: Optional[int] = Field(None, description="The ID of the parent span, if any.") + span_id: int = Field(..., description="The unique identifier for this span.") + name: str = Field(..., description="The name of the span.") + attributes: dict[str, str] = Field(default_factory=dict, description="Attributes associated with the span.") + events: Sequence[Event] = Field(default_factory=list, description="Events recorded in the span.") + links: Sequence[trace_api.Link] = Field(default_factory=list, description="Links to other spans.") + status: Status = Field(default=Status(StatusCode.UNSET), description="The status of the span.") + start_time: Optional[int] = Field(..., description="The start time of the span in nanoseconds.") + end_time: Optional[int] = Field(..., description="The end time of the span in nanoseconds.") diff --git a/api/core/ops/aliyun_trace/entities/semconv.py b/api/core/ops/aliyun_trace/entities/semconv.py new file mode 100644 index 0000000000..5d70264320 --- /dev/null +++ b/api/core/ops/aliyun_trace/entities/semconv.py @@ -0,0 +1,64 @@ +from enum import Enum + +# public +GEN_AI_SESSION_ID = "gen_ai.session.id" + +GEN_AI_USER_ID = "gen_ai.user.id" + +GEN_AI_USER_NAME = "gen_ai.user.name" + +GEN_AI_SPAN_KIND = "gen_ai.span.kind" + +GEN_AI_FRAMEWORK = "gen_ai.framework" + + +# Chain +INPUT_VALUE = "input.value" + +OUTPUT_VALUE = "output.value" + + +# Retriever +RETRIEVAL_QUERY = "retrieval.query" + +RETRIEVAL_DOCUMENT = "retrieval.document" + + +# LLM +GEN_AI_MODEL_NAME = "gen_ai.model_name" + +GEN_AI_SYSTEM = "gen_ai.system" + +GEN_AI_USAGE_INPUT_TOKENS = "gen_ai.usage.input_tokens" + +GEN_AI_USAGE_OUTPUT_TOKENS = "gen_ai.usage.output_tokens" + +GEN_AI_USAGE_TOTAL_TOKENS = "gen_ai.usage.total_tokens" + +GEN_AI_PROMPT_TEMPLATE_TEMPLATE = "gen_ai.prompt_template.template" + +GEN_AI_PROMPT_TEMPLATE_VARIABLE = "gen_ai.prompt_template.variable" + +GEN_AI_PROMPT = "gen_ai.prompt" + +GEN_AI_COMPLETION = "gen_ai.completion" + +GEN_AI_RESPONSE_FINISH_REASON = "gen_ai.response.finish_reason" + +# Tool +TOOL_NAME = "tool.name" + +TOOL_DESCRIPTION = "tool.description" + +TOOL_PARAMETERS = "tool.parameters" + + +class GenAISpanKind(Enum): + CHAIN = "CHAIN" + RETRIEVER = "RETRIEVER" + RERANKER = "RERANKER" + LLM = "LLM" + EMBEDDING = "EMBEDDING" + TOOL = "TOOL" + AGENT = "AGENT" + TASK = "TASK" diff --git a/api/core/ops/arize_phoenix_trace/__init__.py b/api/core/ops/arize_phoenix_trace/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/core/ops/arize_phoenix_trace/arize_phoenix_trace.py b/api/core/ops/arize_phoenix_trace/arize_phoenix_trace.py new file mode 100644 index 0000000000..ffda0885d4 --- /dev/null +++ b/api/core/ops/arize_phoenix_trace/arize_phoenix_trace.py @@ -0,0 +1,726 @@ +import hashlib +import json +import logging +import os +from datetime import datetime, timedelta +from typing import Optional, Union, cast + +from openinference.semconv.trace import OpenInferenceSpanKindValues, SpanAttributes +from opentelemetry import trace +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter as GrpcOTLPSpanExporter +from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter as HttpOTLPSpanExporter +from opentelemetry.sdk import trace as trace_sdk +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.id_generator import RandomIdGenerator +from opentelemetry.trace import SpanContext, TraceFlags, TraceState + +from core.ops.base_trace_instance import BaseTraceInstance +from core.ops.entities.config_entity import ArizeConfig, PhoenixConfig +from core.ops.entities.trace_entity import ( + BaseTraceInfo, + DatasetRetrievalTraceInfo, + GenerateNameTraceInfo, + MessageTraceInfo, + ModerationTraceInfo, + SuggestedQuestionTraceInfo, + ToolTraceInfo, + TraceTaskName, + WorkflowTraceInfo, +) +from extensions.ext_database import db +from models.model import EndUser, MessageFile +from models.workflow import WorkflowNodeExecutionModel + +logger = logging.getLogger(__name__) + + +def setup_tracer(arize_phoenix_config: ArizeConfig | PhoenixConfig) -> tuple[trace_sdk.Tracer, SimpleSpanProcessor]: + """Configure OpenTelemetry tracer with OTLP exporter for Arize/Phoenix.""" + try: + # Choose the appropriate exporter based on config type + exporter: Union[GrpcOTLPSpanExporter, HttpOTLPSpanExporter] + if isinstance(arize_phoenix_config, ArizeConfig): + arize_endpoint = f"{arize_phoenix_config.endpoint}/v1" + arize_headers = { + "api_key": arize_phoenix_config.api_key or "", + "space_id": arize_phoenix_config.space_id or "", + "authorization": f"Bearer {arize_phoenix_config.api_key or ''}", + } + exporter = GrpcOTLPSpanExporter( + endpoint=arize_endpoint, + headers=arize_headers, + timeout=30, + ) + else: + phoenix_endpoint = f"{arize_phoenix_config.endpoint}/v1/traces" + phoenix_headers = { + "api_key": arize_phoenix_config.api_key or "", + "authorization": f"Bearer {arize_phoenix_config.api_key or ''}", + } + exporter = HttpOTLPSpanExporter( + endpoint=phoenix_endpoint, + headers=phoenix_headers, + timeout=30, + ) + + attributes = { + "openinference.project.name": arize_phoenix_config.project or "", + "model_id": arize_phoenix_config.project or "", + } + resource = Resource(attributes=attributes) + provider = trace_sdk.TracerProvider(resource=resource) + processor = SimpleSpanProcessor( + exporter, + ) + provider.add_span_processor(processor) + + # Create a named tracer instead of setting the global provider + tracer_name = f"arize_phoenix_tracer_{arize_phoenix_config.project}" + logger.info(f"[Arize/Phoenix] Created tracer with name: {tracer_name}") + return cast(trace_sdk.Tracer, provider.get_tracer(tracer_name)), processor + except Exception as e: + logger.error(f"[Arize/Phoenix] Failed to setup the tracer: {str(e)}", exc_info=True) + raise + + +def datetime_to_nanos(dt: Optional[datetime]) -> int: + """Convert datetime to nanoseconds since epoch. If None, use current time.""" + if dt is None: + dt = datetime.now() + return int(dt.timestamp() * 1_000_000_000) + + +def uuid_to_trace_id(string: Optional[str]) -> int: + """Convert UUID string to a valid trace ID (16-byte integer).""" + if string is None: + string = "" + hash_object = hashlib.sha256(string.encode()) + + # Take the first 16 bytes (128 bits) of the hash + digest = hash_object.digest()[:16] + + # Convert to integer (128 bits) + return int.from_bytes(digest, byteorder="big") + + +class ArizePhoenixDataTrace(BaseTraceInstance): + def __init__( + self, + arize_phoenix_config: ArizeConfig | PhoenixConfig, + ): + super().__init__(arize_phoenix_config) + import logging + + logging.basicConfig() + logging.getLogger().setLevel(logging.DEBUG) + self.arize_phoenix_config = arize_phoenix_config + self.tracer, self.processor = setup_tracer(arize_phoenix_config) + self.project = arize_phoenix_config.project + self.file_base_url = os.getenv("FILES_URL", "http://127.0.0.1:5001") + + def trace(self, trace_info: BaseTraceInfo): + logger.info(f"[Arize/Phoenix] Trace: {trace_info}") + try: + if isinstance(trace_info, WorkflowTraceInfo): + self.workflow_trace(trace_info) + if isinstance(trace_info, MessageTraceInfo): + self.message_trace(trace_info) + if isinstance(trace_info, ModerationTraceInfo): + self.moderation_trace(trace_info) + if isinstance(trace_info, SuggestedQuestionTraceInfo): + self.suggested_question_trace(trace_info) + if isinstance(trace_info, DatasetRetrievalTraceInfo): + self.dataset_retrieval_trace(trace_info) + if isinstance(trace_info, ToolTraceInfo): + self.tool_trace(trace_info) + if isinstance(trace_info, GenerateNameTraceInfo): + self.generate_name_trace(trace_info) + + except Exception as e: + logger.error(f"[Arize/Phoenix] Error in the trace: {str(e)}", exc_info=True) + raise + + def workflow_trace(self, trace_info: WorkflowTraceInfo): + if trace_info.message_data is None: + return + + workflow_metadata = { + "workflow_id": trace_info.workflow_run_id or "", + "message_id": trace_info.message_id or "", + "workflow_app_log_id": trace_info.workflow_app_log_id or "", + "status": trace_info.workflow_run_status or "", + "status_message": trace_info.error or "", + "level": "ERROR" if trace_info.error else "DEFAULT", + "total_tokens": trace_info.total_tokens or 0, + } + workflow_metadata.update(trace_info.metadata) + + trace_id = uuid_to_trace_id(trace_info.message_id) + span_id = RandomIdGenerator().generate_span_id() + context = SpanContext( + trace_id=trace_id, + span_id=span_id, + is_remote=False, + trace_flags=TraceFlags(TraceFlags.SAMPLED), + trace_state=TraceState(), + ) + + workflow_span = self.tracer.start_span( + name=TraceTaskName.WORKFLOW_TRACE.value, + attributes={ + SpanAttributes.INPUT_VALUE: json.dumps(trace_info.workflow_run_inputs, ensure_ascii=False), + SpanAttributes.OUTPUT_VALUE: json.dumps(trace_info.workflow_run_outputs, ensure_ascii=False), + SpanAttributes.OPENINFERENCE_SPAN_KIND: OpenInferenceSpanKindValues.CHAIN.value, + SpanAttributes.METADATA: json.dumps(workflow_metadata, ensure_ascii=False), + SpanAttributes.SESSION_ID: trace_info.conversation_id or "", + }, + start_time=datetime_to_nanos(trace_info.start_time), + context=trace.set_span_in_context(trace.NonRecordingSpan(context)), + ) + + try: + # Process workflow nodes + for node_execution in self._get_workflow_nodes(trace_info.workflow_run_id): + created_at = node_execution.created_at or datetime.now() + elapsed_time = node_execution.elapsed_time + finished_at = created_at + timedelta(seconds=elapsed_time) + + process_data = json.loads(node_execution.process_data) if node_execution.process_data else {} + + node_metadata = { + "node_id": node_execution.id, + "node_type": node_execution.node_type, + "node_status": node_execution.status, + "tenant_id": node_execution.tenant_id, + "app_id": node_execution.app_id, + "app_name": node_execution.title, + "status": node_execution.status, + "level": "ERROR" if node_execution.status != "succeeded" else "DEFAULT", + } + + if node_execution.execution_metadata: + node_metadata.update(json.loads(node_execution.execution_metadata)) + + # Determine the correct span kind based on node type + span_kind = OpenInferenceSpanKindValues.CHAIN.value + if node_execution.node_type == "llm": + span_kind = OpenInferenceSpanKindValues.LLM.value + provider = process_data.get("model_provider") + model = process_data.get("model_name") + if provider: + node_metadata["ls_provider"] = provider + if model: + node_metadata["ls_model_name"] = model + + outputs = json.loads(node_execution.outputs).get("usage", {}) + usage_data = process_data.get("usage", {}) if "usage" in process_data else outputs.get("usage", {}) + if usage_data: + node_metadata["total_tokens"] = usage_data.get("total_tokens", 0) + node_metadata["prompt_tokens"] = usage_data.get("prompt_tokens", 0) + node_metadata["completion_tokens"] = usage_data.get("completion_tokens", 0) + elif node_execution.node_type == "dataset_retrieval": + span_kind = OpenInferenceSpanKindValues.RETRIEVER.value + elif node_execution.node_type == "tool": + span_kind = OpenInferenceSpanKindValues.TOOL.value + else: + span_kind = OpenInferenceSpanKindValues.CHAIN.value + + node_span = self.tracer.start_span( + name=node_execution.node_type, + attributes={ + SpanAttributes.INPUT_VALUE: node_execution.inputs or "{}", + SpanAttributes.OUTPUT_VALUE: node_execution.outputs or "{}", + SpanAttributes.OPENINFERENCE_SPAN_KIND: span_kind, + SpanAttributes.METADATA: json.dumps(node_metadata, ensure_ascii=False), + SpanAttributes.SESSION_ID: trace_info.conversation_id or "", + }, + start_time=datetime_to_nanos(created_at), + ) + + try: + if node_execution.node_type == "llm": + provider = process_data.get("model_provider") + model = process_data.get("model_name") + if provider: + node_span.set_attribute(SpanAttributes.LLM_PROVIDER, provider) + if model: + node_span.set_attribute(SpanAttributes.LLM_MODEL_NAME, model) + + outputs = json.loads(node_execution.outputs).get("usage", {}) + usage_data = ( + process_data.get("usage", {}) if "usage" in process_data else outputs.get("usage", {}) + ) + if usage_data: + node_span.set_attribute( + SpanAttributes.LLM_TOKEN_COUNT_TOTAL, usage_data.get("total_tokens", 0) + ) + node_span.set_attribute( + SpanAttributes.LLM_TOKEN_COUNT_PROMPT, usage_data.get("prompt_tokens", 0) + ) + node_span.set_attribute( + SpanAttributes.LLM_TOKEN_COUNT_COMPLETION, usage_data.get("completion_tokens", 0) + ) + finally: + node_span.end(end_time=datetime_to_nanos(finished_at)) + finally: + workflow_span.end(end_time=datetime_to_nanos(trace_info.end_time)) + + def message_trace(self, trace_info: MessageTraceInfo): + if trace_info.message_data is None: + return + + file_list = cast(list[str], trace_info.file_list) or [] + message_file_data: Optional[MessageFile] = trace_info.message_file_data + + if message_file_data is not None: + file_url = f"{self.file_base_url}/{message_file_data.url}" if message_file_data else "" + file_list.append(file_url) + + message_metadata = { + "message_id": trace_info.message_id or "", + "conversation_mode": str(trace_info.conversation_mode or ""), + "user_id": trace_info.message_data.from_account_id or "", + "file_list": json.dumps(file_list), + "status": trace_info.message_data.status or "", + "status_message": trace_info.error or "", + "level": "ERROR" if trace_info.error else "DEFAULT", + "total_tokens": trace_info.total_tokens or 0, + "prompt_tokens": trace_info.message_tokens or 0, + "completion_tokens": trace_info.answer_tokens or 0, + "ls_provider": trace_info.message_data.model_provider or "", + "ls_model_name": trace_info.message_data.model_id or "", + } + message_metadata.update(trace_info.metadata) + + # Add end user data if available + if trace_info.message_data.from_end_user_id: + end_user_data: Optional[EndUser] = ( + db.session.query(EndUser).filter(EndUser.id == trace_info.message_data.from_end_user_id).first() + ) + if end_user_data is not None: + message_metadata["end_user_id"] = end_user_data.session_id + + attributes = { + SpanAttributes.INPUT_VALUE: trace_info.message_data.query, + SpanAttributes.OUTPUT_VALUE: trace_info.message_data.answer, + SpanAttributes.OPENINFERENCE_SPAN_KIND: OpenInferenceSpanKindValues.CHAIN.value, + SpanAttributes.METADATA: json.dumps(message_metadata, ensure_ascii=False), + SpanAttributes.SESSION_ID: trace_info.message_data.conversation_id, + } + + trace_id = uuid_to_trace_id(trace_info.message_id) + message_span_id = RandomIdGenerator().generate_span_id() + span_context = SpanContext( + trace_id=trace_id, + span_id=message_span_id, + is_remote=False, + trace_flags=TraceFlags(TraceFlags.SAMPLED), + trace_state=TraceState(), + ) + + message_span = self.tracer.start_span( + name=TraceTaskName.MESSAGE_TRACE.value, + attributes=attributes, + start_time=datetime_to_nanos(trace_info.start_time), + context=trace.set_span_in_context(trace.NonRecordingSpan(span_context)), + ) + + try: + if trace_info.error: + message_span.add_event( + "exception", + attributes={ + "exception.message": trace_info.error, + "exception.type": "Error", + "exception.stacktrace": trace_info.error, + }, + ) + + # Convert outputs to string based on type + if isinstance(trace_info.outputs, dict | list): + outputs_str = json.dumps(trace_info.outputs, ensure_ascii=False) + elif isinstance(trace_info.outputs, str): + outputs_str = trace_info.outputs + else: + outputs_str = str(trace_info.outputs) + + llm_attributes = { + SpanAttributes.OPENINFERENCE_SPAN_KIND: OpenInferenceSpanKindValues.LLM.value, + SpanAttributes.INPUT_VALUE: json.dumps(trace_info.inputs, ensure_ascii=False), + SpanAttributes.OUTPUT_VALUE: outputs_str, + SpanAttributes.METADATA: json.dumps(message_metadata, ensure_ascii=False), + SpanAttributes.SESSION_ID: trace_info.message_data.conversation_id, + } + + if isinstance(trace_info.inputs, list): + for i, msg in enumerate(trace_info.inputs): + if isinstance(msg, dict): + llm_attributes[f"{SpanAttributes.LLM_INPUT_MESSAGES}.{i}.message.content"] = msg.get("text", "") + llm_attributes[f"{SpanAttributes.LLM_INPUT_MESSAGES}.{i}.message.role"] = msg.get( + "role", "user" + ) + # todo: handle assistant and tool role messages, as they don't always + # have a text field, but may have a tool_calls field instead + # e.g. 'tool_calls': [{'id': '98af3a29-b066-45a5-b4b1-46c74ddafc58', + # 'type': 'function', 'function': {'name': 'current_time', 'arguments': '{}'}}]} + elif isinstance(trace_info.inputs, dict): + llm_attributes[f"{SpanAttributes.LLM_INPUT_MESSAGES}.0.message.content"] = json.dumps(trace_info.inputs) + llm_attributes[f"{SpanAttributes.LLM_INPUT_MESSAGES}.0.message.role"] = "user" + elif isinstance(trace_info.inputs, str): + llm_attributes[f"{SpanAttributes.LLM_INPUT_MESSAGES}.0.message.content"] = trace_info.inputs + llm_attributes[f"{SpanAttributes.LLM_INPUT_MESSAGES}.0.message.role"] = "user" + + if trace_info.total_tokens is not None and trace_info.total_tokens > 0: + llm_attributes[SpanAttributes.LLM_TOKEN_COUNT_TOTAL] = trace_info.total_tokens + if trace_info.message_tokens is not None and trace_info.message_tokens > 0: + llm_attributes[SpanAttributes.LLM_TOKEN_COUNT_PROMPT] = trace_info.message_tokens + if trace_info.answer_tokens is not None and trace_info.answer_tokens > 0: + llm_attributes[SpanAttributes.LLM_TOKEN_COUNT_COMPLETION] = trace_info.answer_tokens + + if trace_info.message_data.model_id is not None: + llm_attributes[SpanAttributes.LLM_MODEL_NAME] = trace_info.message_data.model_id + if trace_info.message_data.model_provider is not None: + llm_attributes[SpanAttributes.LLM_PROVIDER] = trace_info.message_data.model_provider + + if trace_info.message_data and trace_info.message_data.message_metadata: + metadata_dict = json.loads(trace_info.message_data.message_metadata) + if model_params := metadata_dict.get("model_parameters"): + llm_attributes[SpanAttributes.LLM_INVOCATION_PARAMETERS] = json.dumps(model_params) + + llm_span = self.tracer.start_span( + name="llm", + attributes=llm_attributes, + start_time=datetime_to_nanos(trace_info.start_time), + context=trace.set_span_in_context(trace.NonRecordingSpan(span_context)), + ) + + try: + if trace_info.error: + llm_span.add_event( + "exception", + attributes={ + "exception.message": trace_info.error, + "exception.type": "Error", + "exception.stacktrace": trace_info.error, + }, + ) + finally: + llm_span.end(end_time=datetime_to_nanos(trace_info.end_time)) + finally: + message_span.end(end_time=datetime_to_nanos(trace_info.end_time)) + + def moderation_trace(self, trace_info: ModerationTraceInfo): + if trace_info.message_data is None: + return + + metadata = { + "message_id": trace_info.message_id, + "tool_name": "moderation", + "status": trace_info.message_data.status, + "status_message": trace_info.message_data.error or "", + "level": "ERROR" if trace_info.message_data.error else "DEFAULT", + } + metadata.update(trace_info.metadata) + + trace_id = uuid_to_trace_id(trace_info.message_id) + span_id = RandomIdGenerator().generate_span_id() + context = SpanContext( + trace_id=trace_id, + span_id=span_id, + is_remote=False, + trace_flags=TraceFlags(TraceFlags.SAMPLED), + trace_state=TraceState(), + ) + + span = self.tracer.start_span( + name=TraceTaskName.MODERATION_TRACE.value, + attributes={ + SpanAttributes.INPUT_VALUE: json.dumps(trace_info.inputs, ensure_ascii=False), + SpanAttributes.OUTPUT_VALUE: json.dumps( + { + "action": trace_info.action, + "flagged": trace_info.flagged, + "preset_response": trace_info.preset_response, + "inputs": trace_info.inputs, + }, + ensure_ascii=False, + ), + SpanAttributes.OPENINFERENCE_SPAN_KIND: OpenInferenceSpanKindValues.CHAIN.value, + SpanAttributes.METADATA: json.dumps(metadata, ensure_ascii=False), + }, + start_time=datetime_to_nanos(trace_info.start_time), + context=trace.set_span_in_context(trace.NonRecordingSpan(context)), + ) + + try: + if trace_info.message_data.error: + span.add_event( + "exception", + attributes={ + "exception.message": trace_info.message_data.error, + "exception.type": "Error", + "exception.stacktrace": trace_info.message_data.error, + }, + ) + finally: + span.end(end_time=datetime_to_nanos(trace_info.end_time)) + + def suggested_question_trace(self, trace_info: SuggestedQuestionTraceInfo): + if trace_info.message_data is None: + return + + start_time = trace_info.start_time or trace_info.message_data.created_at + end_time = trace_info.end_time or trace_info.message_data.updated_at + + metadata = { + "message_id": trace_info.message_id, + "tool_name": "suggested_question", + "status": trace_info.status, + "status_message": trace_info.error or "", + "level": "ERROR" if trace_info.error else "DEFAULT", + "total_tokens": trace_info.total_tokens, + "ls_provider": trace_info.model_provider or "", + "ls_model_name": trace_info.model_id or "", + } + metadata.update(trace_info.metadata) + + trace_id = uuid_to_trace_id(trace_info.message_id) + span_id = RandomIdGenerator().generate_span_id() + context = SpanContext( + trace_id=trace_id, + span_id=span_id, + is_remote=False, + trace_flags=TraceFlags(TraceFlags.SAMPLED), + trace_state=TraceState(), + ) + + span = self.tracer.start_span( + name=TraceTaskName.SUGGESTED_QUESTION_TRACE.value, + attributes={ + SpanAttributes.INPUT_VALUE: json.dumps(trace_info.inputs, ensure_ascii=False), + SpanAttributes.OUTPUT_VALUE: json.dumps(trace_info.suggested_question, ensure_ascii=False), + SpanAttributes.OPENINFERENCE_SPAN_KIND: OpenInferenceSpanKindValues.CHAIN.value, + SpanAttributes.METADATA: json.dumps(metadata, ensure_ascii=False), + }, + start_time=datetime_to_nanos(start_time), + context=trace.set_span_in_context(trace.NonRecordingSpan(context)), + ) + + try: + if trace_info.error: + span.add_event( + "exception", + attributes={ + "exception.message": trace_info.error, + "exception.type": "Error", + "exception.stacktrace": trace_info.error, + }, + ) + finally: + span.end(end_time=datetime_to_nanos(end_time)) + + def dataset_retrieval_trace(self, trace_info: DatasetRetrievalTraceInfo): + if trace_info.message_data is None: + return + + start_time = trace_info.start_time or trace_info.message_data.created_at + end_time = trace_info.end_time or trace_info.message_data.updated_at + + metadata = { + "message_id": trace_info.message_id, + "tool_name": "dataset_retrieval", + "status": trace_info.message_data.status, + "status_message": trace_info.message_data.error or "", + "level": "ERROR" if trace_info.message_data.error else "DEFAULT", + "ls_provider": trace_info.message_data.model_provider or "", + "ls_model_name": trace_info.message_data.model_id or "", + } + metadata.update(trace_info.metadata) + + trace_id = uuid_to_trace_id(trace_info.message_id) + span_id = RandomIdGenerator().generate_span_id() + context = SpanContext( + trace_id=trace_id, + span_id=span_id, + is_remote=False, + trace_flags=TraceFlags(TraceFlags.SAMPLED), + trace_state=TraceState(), + ) + + span = self.tracer.start_span( + name=TraceTaskName.DATASET_RETRIEVAL_TRACE.value, + attributes={ + SpanAttributes.INPUT_VALUE: json.dumps(trace_info.inputs, ensure_ascii=False), + SpanAttributes.OUTPUT_VALUE: json.dumps({"documents": trace_info.documents}, ensure_ascii=False), + SpanAttributes.OPENINFERENCE_SPAN_KIND: OpenInferenceSpanKindValues.RETRIEVER.value, + SpanAttributes.METADATA: json.dumps(metadata, ensure_ascii=False), + "start_time": start_time.isoformat() if start_time else "", + "end_time": end_time.isoformat() if end_time else "", + }, + start_time=datetime_to_nanos(start_time), + context=trace.set_span_in_context(trace.NonRecordingSpan(context)), + ) + + try: + if trace_info.message_data.error: + span.add_event( + "exception", + attributes={ + "exception.message": trace_info.message_data.error, + "exception.type": "Error", + "exception.stacktrace": trace_info.message_data.error, + }, + ) + finally: + span.end(end_time=datetime_to_nanos(end_time)) + + def tool_trace(self, trace_info: ToolTraceInfo): + if trace_info.message_data is None: + logger.warning("[Arize/Phoenix] Message data is None, skipping tool trace.") + return + + metadata = { + "message_id": trace_info.message_id, + "tool_config": json.dumps(trace_info.tool_config, ensure_ascii=False), + } + + trace_id = uuid_to_trace_id(trace_info.message_id) + tool_span_id = RandomIdGenerator().generate_span_id() + logger.info(f"[Arize/Phoenix] Creating tool trace with trace_id: {trace_id}, span_id: {tool_span_id}") + + # Create span context with the same trace_id as the parent + # todo: Create with the appropriate parent span context, so that the tool span is + # a child of the appropriate span (e.g. message span) + span_context = SpanContext( + trace_id=trace_id, + span_id=tool_span_id, + is_remote=False, + trace_flags=TraceFlags(TraceFlags.SAMPLED), + trace_state=TraceState(), + ) + + tool_params_str = ( + json.dumps(trace_info.tool_parameters, ensure_ascii=False) + if isinstance(trace_info.tool_parameters, dict) + else str(trace_info.tool_parameters) + ) + + span = self.tracer.start_span( + name=trace_info.tool_name, + attributes={ + SpanAttributes.INPUT_VALUE: json.dumps(trace_info.tool_inputs, ensure_ascii=False), + SpanAttributes.OUTPUT_VALUE: trace_info.tool_outputs, + SpanAttributes.OPENINFERENCE_SPAN_KIND: OpenInferenceSpanKindValues.TOOL.value, + SpanAttributes.METADATA: json.dumps(metadata, ensure_ascii=False), + SpanAttributes.TOOL_NAME: trace_info.tool_name, + SpanAttributes.TOOL_PARAMETERS: tool_params_str, + }, + start_time=datetime_to_nanos(trace_info.start_time), + context=trace.set_span_in_context(trace.NonRecordingSpan(span_context)), + ) + + try: + if trace_info.error: + span.add_event( + "exception", + attributes={ + "exception.message": trace_info.error, + "exception.type": "Error", + "exception.stacktrace": trace_info.error, + }, + ) + finally: + span.end(end_time=datetime_to_nanos(trace_info.end_time)) + + def generate_name_trace(self, trace_info: GenerateNameTraceInfo): + if trace_info.message_data is None: + return + + metadata = { + "project_name": self.project, + "message_id": trace_info.message_id, + "status": trace_info.message_data.status, + "status_message": trace_info.message_data.error or "", + "level": "ERROR" if trace_info.message_data.error else "DEFAULT", + } + metadata.update(trace_info.metadata) + + trace_id = uuid_to_trace_id(trace_info.message_id) + span_id = RandomIdGenerator().generate_span_id() + context = SpanContext( + trace_id=trace_id, + span_id=span_id, + is_remote=False, + trace_flags=TraceFlags(TraceFlags.SAMPLED), + trace_state=TraceState(), + ) + + span = self.tracer.start_span( + name=TraceTaskName.GENERATE_NAME_TRACE.value, + attributes={ + SpanAttributes.INPUT_VALUE: json.dumps(trace_info.inputs, ensure_ascii=False), + SpanAttributes.OUTPUT_VALUE: json.dumps(trace_info.outputs, ensure_ascii=False), + SpanAttributes.OPENINFERENCE_SPAN_KIND: OpenInferenceSpanKindValues.CHAIN.value, + SpanAttributes.METADATA: json.dumps(metadata, ensure_ascii=False), + SpanAttributes.SESSION_ID: trace_info.message_data.conversation_id, + "start_time": trace_info.start_time.isoformat() if trace_info.start_time else "", + "end_time": trace_info.end_time.isoformat() if trace_info.end_time else "", + }, + start_time=datetime_to_nanos(trace_info.start_time), + context=trace.set_span_in_context(trace.NonRecordingSpan(context)), + ) + + try: + if trace_info.message_data.error: + span.add_event( + "exception", + attributes={ + "exception.message": trace_info.message_data.error, + "exception.type": "Error", + "exception.stacktrace": trace_info.message_data.error, + }, + ) + finally: + span.end(end_time=datetime_to_nanos(trace_info.end_time)) + + def api_check(self): + try: + with self.tracer.start_span("api_check") as span: + span.set_attribute("test", "true") + return True + except Exception as e: + logger.info(f"[Arize/Phoenix] API check failed: {str(e)}", exc_info=True) + raise ValueError(f"[Arize/Phoenix] API check failed: {str(e)}") + + def get_project_url(self): + try: + if self.arize_phoenix_config.endpoint == "https://otlp.arize.com": + return "https://app.arize.com/" + else: + return f"{self.arize_phoenix_config.endpoint}/projects/" + except Exception as e: + logger.info(f"[Arize/Phoenix] Get run url failed: {str(e)}", exc_info=True) + raise ValueError(f"[Arize/Phoenix] Get run url failed: {str(e)}") + + def _get_workflow_nodes(self, workflow_run_id: str): + """Helper method to get workflow nodes""" + workflow_nodes = ( + db.session.query( + WorkflowNodeExecutionModel.id, + WorkflowNodeExecutionModel.tenant_id, + WorkflowNodeExecutionModel.app_id, + WorkflowNodeExecutionModel.title, + WorkflowNodeExecutionModel.node_type, + WorkflowNodeExecutionModel.status, + WorkflowNodeExecutionModel.inputs, + WorkflowNodeExecutionModel.outputs, + WorkflowNodeExecutionModel.created_at, + WorkflowNodeExecutionModel.elapsed_time, + WorkflowNodeExecutionModel.process_data, + WorkflowNodeExecutionModel.execution_metadata, + ) + .filter(WorkflowNodeExecutionModel.workflow_run_id == workflow_run_id) + .all() + ) + return workflow_nodes diff --git a/api/core/ops/entities/config_entity.py b/api/core/ops/entities/config_entity.py index c988bf48d1..89ff0cfded 100644 --- a/api/core/ops/entities/config_entity.py +++ b/api/core/ops/entities/config_entity.py @@ -2,20 +2,92 @@ from enum import StrEnum from pydantic import BaseModel, ValidationInfo, field_validator +from core.ops.utils import validate_project_name, validate_url, validate_url_with_path + class TracingProviderEnum(StrEnum): + ARIZE = "arize" + PHOENIX = "phoenix" LANGFUSE = "langfuse" LANGSMITH = "langsmith" OPIK = "opik" WEAVE = "weave" + ALIYUN = "aliyun" class BaseTracingConfig(BaseModel): """ - Base model class for tracing + Base model class for tracing configurations """ - ... + @classmethod + def validate_endpoint_url(cls, v: str, default_url: str) -> str: + """ + Common endpoint URL validation logic + + Args: + v: URL value to validate + default_url: Default URL to use if input is None or empty + + Returns: + Validated and normalized URL + """ + return validate_url(v, default_url) + + @classmethod + def validate_project_field(cls, v: str, default_name: str) -> str: + """ + Common project name validation logic + + Args: + v: Project name to validate + default_name: Default name to use if input is None or empty + + Returns: + Validated project name + """ + return validate_project_name(v, default_name) + + +class ArizeConfig(BaseTracingConfig): + """ + Model class for Arize tracing config. + """ + + api_key: str | None = None + space_id: str | None = None + project: str | None = None + endpoint: str = "https://otlp.arize.com" + + @field_validator("project") + @classmethod + def project_validator(cls, v, info: ValidationInfo): + return cls.validate_project_field(v, "default") + + @field_validator("endpoint") + @classmethod + def endpoint_validator(cls, v, info: ValidationInfo): + return cls.validate_endpoint_url(v, "https://otlp.arize.com") + + +class PhoenixConfig(BaseTracingConfig): + """ + Model class for Phoenix tracing config. + """ + + api_key: str | None = None + project: str | None = None + endpoint: str = "https://app.phoenix.arize.com" + + @field_validator("project") + @classmethod + def project_validator(cls, v, info: ValidationInfo): + return cls.validate_project_field(v, "default") + + @field_validator("endpoint") + @classmethod + def endpoint_validator(cls, v, info: ValidationInfo): + return cls.validate_endpoint_url(v, "https://app.phoenix.arize.com") class LangfuseConfig(BaseTracingConfig): @@ -29,13 +101,8 @@ class LangfuseConfig(BaseTracingConfig): @field_validator("host") @classmethod - def set_value(cls, v, info: ValidationInfo): - if v is None or v == "": - v = "https://api.langfuse.com" - if not v.startswith("https://") and not v.startswith("http://"): - raise ValueError("host must start with https:// or http://") - - return v + def host_validator(cls, v, info: ValidationInfo): + return cls.validate_endpoint_url(v, "https://api.langfuse.com") class LangSmithConfig(BaseTracingConfig): @@ -49,13 +116,9 @@ class LangSmithConfig(BaseTracingConfig): @field_validator("endpoint") @classmethod - def set_value(cls, v, info: ValidationInfo): - if v is None or v == "": - v = "https://api.smith.langchain.com" - if not v.startswith("https://"): - raise ValueError("endpoint must start with https://") - - return v + def endpoint_validator(cls, v, info: ValidationInfo): + # LangSmith only allows HTTPS + return validate_url(v, "https://api.smith.langchain.com", allowed_schemes=("https",)) class OpikConfig(BaseTracingConfig): @@ -71,22 +134,12 @@ class OpikConfig(BaseTracingConfig): @field_validator("project") @classmethod def project_validator(cls, v, info: ValidationInfo): - if v is None or v == "": - v = "Default Project" - - return v + return cls.validate_project_field(v, "Default Project") @field_validator("url") @classmethod def url_validator(cls, v, info: ValidationInfo): - if v is None or v == "": - v = "https://www.comet.com/opik/api/" - if not v.startswith(("https://", "http://")): - raise ValueError("url must start with https:// or http://") - if not v.endswith("/api/"): - raise ValueError("url should ends with /api/") - - return v + return validate_url_with_path(v, "https://www.comet.com/opik/api/", required_suffix="/api/") class WeaveConfig(BaseTracingConfig): @@ -102,22 +155,44 @@ class WeaveConfig(BaseTracingConfig): @field_validator("endpoint") @classmethod - def set_value(cls, v, info: ValidationInfo): - if v is None or v == "": - v = "https://trace.wandb.ai" - if not v.startswith("https://"): - raise ValueError("endpoint must start with https://") - - return v + def endpoint_validator(cls, v, info: ValidationInfo): + # Weave only allows HTTPS for endpoint + return validate_url(v, "https://trace.wandb.ai", allowed_schemes=("https",)) @field_validator("host") @classmethod - def validate_host(cls, v, info: ValidationInfo): - if v is not None and v != "": - if not v.startswith(("https://", "http://")): - raise ValueError("host must start with https:// or http://") + def host_validator(cls, v, info: ValidationInfo): + if v is not None and v.strip() != "": + return validate_url(v, v, allowed_schemes=("https", "http")) return v +class AliyunConfig(BaseTracingConfig): + """ + Model class for Aliyun tracing config. + """ + + app_name: str = "dify_app" + license_key: str + endpoint: str + + @field_validator("app_name") + @classmethod + def app_name_validator(cls, v, info: ValidationInfo): + return cls.validate_project_field(v, "dify_app") + + @field_validator("license_key") + @classmethod + def license_key_validator(cls, v, info: ValidationInfo): + if not v or v.strip() == "": + raise ValueError("License key cannot be empty") + return v + + @field_validator("endpoint") + @classmethod + def endpoint_validator(cls, v, info: ValidationInfo): + return cls.validate_endpoint_url(v, "https://tracing-analysis-dc-hz.aliyuncs.com") + + OPS_FILE_PATH = "ops_trace/" OPS_TRACE_FAILED_KEY = "FAILED_OPS_TRACE" diff --git a/api/core/ops/langfuse_trace/langfuse_trace.py b/api/core/ops/langfuse_trace/langfuse_trace.py index d37342012d..a3dbce0e59 100644 --- a/api/core/ops/langfuse_trace/langfuse_trace.py +++ b/api/core/ops/langfuse_trace/langfuse_trace.py @@ -32,6 +32,7 @@ from core.repositories import SQLAlchemyWorkflowNodeExecutionRepository from core.workflow.nodes.enums import NodeType from extensions.ext_database import db from models import EndUser, WorkflowNodeExecutionTriggeredFrom +from models.enums import MessageStatus logger = logging.getLogger(__name__) @@ -180,12 +181,9 @@ class LangFuseDataTrace(BaseTraceInstance): prompt_tokens = 0 completion_tokens = 0 try: - if outputs.get("usage"): - prompt_tokens = outputs.get("usage", {}).get("prompt_tokens", 0) - completion_tokens = outputs.get("usage", {}).get("completion_tokens", 0) - else: - prompt_tokens = process_data.get("usage", {}).get("prompt_tokens", 0) - completion_tokens = process_data.get("usage", {}).get("completion_tokens", 0) + usage_data = process_data.get("usage", {}) if "usage" in process_data else outputs.get("usage", {}) + prompt_tokens = usage_data.get("prompt_tokens", 0) + completion_tokens = usage_data.get("completion_tokens", 0) except Exception: logger.error("Failed to extract usage", exc_info=True) @@ -293,7 +291,7 @@ class LangFuseDataTrace(BaseTraceInstance): input=trace_info.inputs, output=message_data.answer, metadata=metadata, - level=(LevelEnum.DEFAULT if message_data.status != "error" else LevelEnum.ERROR), + level=(LevelEnum.DEFAULT if message_data.status != MessageStatus.ERROR else LevelEnum.ERROR), status_message=message_data.error or "", usage=generation_usage, ) @@ -339,7 +337,7 @@ class LangFuseDataTrace(BaseTraceInstance): start_time=trace_info.start_time, end_time=trace_info.end_time, metadata=trace_info.metadata, - level=(LevelEnum.DEFAULT if message_data.status != "error" else LevelEnum.ERROR), + level=(LevelEnum.DEFAULT if message_data.status != MessageStatus.ERROR else LevelEnum.ERROR), status_message=message_data.error or "", usage=generation_usage, ) diff --git a/api/core/ops/langsmith_trace/langsmith_trace.py b/api/core/ops/langsmith_trace/langsmith_trace.py index 8a392940db..f94e5e49d7 100644 --- a/api/core/ops/langsmith_trace/langsmith_trace.py +++ b/api/core/ops/langsmith_trace/langsmith_trace.py @@ -206,12 +206,9 @@ class LangSmithDataTrace(BaseTraceInstance): prompt_tokens = 0 completion_tokens = 0 try: - if outputs.get("usage"): - prompt_tokens = outputs.get("usage", {}).get("prompt_tokens", 0) - completion_tokens = outputs.get("usage", {}).get("completion_tokens", 0) - else: - prompt_tokens = process_data.get("usage", {}).get("prompt_tokens", 0) - completion_tokens = process_data.get("usage", {}).get("completion_tokens", 0) + usage_data = process_data.get("usage", {}) if "usage" in process_data else outputs.get("usage", {}) + prompt_tokens = usage_data.get("prompt_tokens", 0) + completion_tokens = usage_data.get("completion_tokens", 0) except Exception: logger.error("Failed to extract usage", exc_info=True) diff --git a/api/core/ops/opik_trace/opik_trace.py b/api/core/ops/opik_trace/opik_trace.py index f4d2760ba5..8bedea20fb 100644 --- a/api/core/ops/opik_trace/opik_trace.py +++ b/api/core/ops/opik_trace/opik_trace.py @@ -222,10 +222,10 @@ class OpikDataTrace(BaseTraceInstance): ) try: - if outputs.get("usage"): - total_tokens = outputs["usage"].get("total_tokens", 0) - prompt_tokens = outputs["usage"].get("prompt_tokens", 0) - completion_tokens = outputs["usage"].get("completion_tokens", 0) + usage_data = process_data.get("usage", {}) if "usage" in process_data else outputs.get("usage", {}) + total_tokens = usage_data.get("total_tokens", 0) + prompt_tokens = usage_data.get("prompt_tokens", 0) + completion_tokens = usage_data.get("completion_tokens", 0) except Exception: logger.error("Failed to extract usage", exc_info=True) diff --git a/api/core/ops/ops_trace_manager.py b/api/core/ops/ops_trace_manager.py index a98904102c..5c9b9d27b7 100644 --- a/api/core/ops/ops_trace_manager.py +++ b/api/core/ops/ops_trace_manager.py @@ -84,6 +84,36 @@ class OpsTraceProviderConfigMap(dict[str, dict[str, Any]]): "other_keys": ["project", "entity", "endpoint", "host"], "trace_instance": WeaveDataTrace, } + case TracingProviderEnum.ARIZE: + from core.ops.arize_phoenix_trace.arize_phoenix_trace import ArizePhoenixDataTrace + from core.ops.entities.config_entity import ArizeConfig + + return { + "config_class": ArizeConfig, + "secret_keys": ["api_key", "space_id"], + "other_keys": ["project", "endpoint"], + "trace_instance": ArizePhoenixDataTrace, + } + case TracingProviderEnum.PHOENIX: + from core.ops.arize_phoenix_trace.arize_phoenix_trace import ArizePhoenixDataTrace + from core.ops.entities.config_entity import PhoenixConfig + + return { + "config_class": PhoenixConfig, + "secret_keys": ["api_key"], + "other_keys": ["project", "endpoint"], + "trace_instance": ArizePhoenixDataTrace, + } + case TracingProviderEnum.ALIYUN: + from core.ops.aliyun_trace.aliyun_trace import AliyunDataTrace + from core.ops.entities.config_entity import AliyunConfig + + return { + "config_class": AliyunConfig, + "secret_keys": ["license_key"], + "other_keys": ["endpoint", "app_name"], + "trace_instance": AliyunDataTrace, + } case _: raise KeyError(f"Unsupported tracing provider: {provider}") diff --git a/api/core/ops/utils.py b/api/core/ops/utils.py index 8b06df1930..36d060afd2 100644 --- a/api/core/ops/utils.py +++ b/api/core/ops/utils.py @@ -1,6 +1,7 @@ from contextlib import contextmanager from datetime import datetime from typing import Optional, Union +from urllib.parse import urlparse from extensions.ext_database import db from models.model import Message @@ -60,3 +61,83 @@ def generate_dotted_order( return current_segment return f"{parent_dotted_order}.{current_segment}" + + +def validate_url(url: str, default_url: str, allowed_schemes: tuple = ("https", "http")) -> str: + """ + Validate and normalize URL with proper error handling + + Args: + url: The URL to validate + default_url: Default URL to use if input is None or empty + allowed_schemes: Tuple of allowed URL schemes (default: https, http) + + Returns: + Normalized URL string + + Raises: + ValueError: If URL format is invalid or scheme not allowed + """ + if not url or url.strip() == "": + return default_url + + # Parse URL to validate format + parsed = urlparse(url) + + # Check if scheme is allowed + if parsed.scheme not in allowed_schemes: + raise ValueError(f"URL scheme must be one of: {', '.join(allowed_schemes)}") + + # Reconstruct URL with only scheme, netloc (removing path, query, fragment) + normalized_url = f"{parsed.scheme}://{parsed.netloc}" + + return normalized_url + + +def validate_url_with_path(url: str, default_url: str, required_suffix: str | None = None) -> str: + """ + Validate URL that may include path components + + Args: + url: The URL to validate + default_url: Default URL to use if input is None or empty + required_suffix: Optional suffix that URL must end with + + Returns: + Validated URL string + + Raises: + ValueError: If URL format is invalid or doesn't match required suffix + """ + if not url or url.strip() == "": + return default_url + + # Parse URL to validate format + parsed = urlparse(url) + + # Check if scheme is allowed + if parsed.scheme not in ("https", "http"): + raise ValueError("URL must start with https:// or http://") + + # Check required suffix if specified + if required_suffix and not url.endswith(required_suffix): + raise ValueError(f"URL should end with {required_suffix}") + + return url + + +def validate_project_name(project: str, default_name: str) -> str: + """ + Validate and normalize project name + + Args: + project: Project name to validate + default_name: Default name to use if input is None or empty + + Returns: + Normalized project name + """ + if not project or project.strip() == "": + return default_name + + return project.strip() diff --git a/api/core/plugin/entities/parameters.py b/api/core/plugin/entities/parameters.py index 2b438a3c33..2be65d67a0 100644 --- a/api/core/plugin/entities/parameters.py +++ b/api/core/plugin/entities/parameters.py @@ -43,6 +43,19 @@ class PluginParameterType(enum.StrEnum): # deprecated, should not use. SYSTEM_FILES = CommonParameterType.SYSTEM_FILES.value + # MCP object and array type parameters + ARRAY = CommonParameterType.ARRAY.value + OBJECT = CommonParameterType.OBJECT.value + + +class MCPServerParameterType(enum.StrEnum): + """ + MCP server got complex parameter types + """ + + ARRAY = "array" + OBJECT = "object" + class PluginParameterAutoGenerate(BaseModel): class Type(enum.StrEnum): @@ -138,6 +151,34 @@ def cast_parameter_value(typ: enum.StrEnum, value: Any, /): if value and not isinstance(value, list): raise ValueError("The tools selector must be a list.") return value + case PluginParameterType.ARRAY: + if not isinstance(value, list): + # Try to parse JSON string for arrays + if isinstance(value, str): + try: + import json + + parsed_value = json.loads(value) + if isinstance(parsed_value, list): + return parsed_value + except (json.JSONDecodeError, ValueError): + pass + return [value] + return value + case PluginParameterType.OBJECT: + if not isinstance(value, dict): + # Try to parse JSON string for objects + if isinstance(value, str): + try: + import json + + parsed_value = json.loads(value) + if isinstance(parsed_value, dict): + return parsed_value + except (json.JSONDecodeError, ValueError): + pass + return {} + return value case _: return str(value) except ValueError: diff --git a/api/core/plugin/entities/plugin.py b/api/core/plugin/entities/plugin.py index bdf7d5ce1f..e5cf7ee03a 100644 --- a/api/core/plugin/entities/plugin.py +++ b/api/core/plugin/entities/plugin.py @@ -72,12 +72,14 @@ class PluginDeclaration(BaseModel): class Meta(BaseModel): minimum_dify_version: Optional[str] = Field(default=None, pattern=r"^\d{1,4}(\.\d{1,4}){1,3}(-\w{1,16})?$") + version: Optional[str] = Field(default=None) version: str = Field(..., pattern=r"^\d{1,4}(\.\d{1,4}){1,3}(-\w{1,16})?$") author: Optional[str] = Field(..., pattern=r"^[a-zA-Z0-9_-]{1,64}$") name: str = Field(..., pattern=r"^[a-z0-9_-]{1,128}$") description: I18nObject icon: str + icon_dark: Optional[str] = Field(default=None) label: I18nObject category: PluginCategory created_at: datetime.datetime diff --git a/api/core/plugin/entities/plugin_daemon.py b/api/core/plugin/entities/plugin_daemon.py index 592b42c0da..00253b8a11 100644 --- a/api/core/plugin/entities/plugin_daemon.py +++ b/api/core/plugin/entities/plugin_daemon.py @@ -53,6 +53,7 @@ class PluginAgentProviderEntity(BaseModel): plugin_unique_identifier: str plugin_id: str declaration: AgentProviderEntityWithPlugin + meta: PluginDeclaration.Meta class PluginBasicBooleanResponse(BaseModel): diff --git a/api/core/plugin/entities/request.py b/api/core/plugin/entities/request.py index f9c81ed4d5..89f595ec46 100644 --- a/api/core/plugin/entities/request.py +++ b/api/core/plugin/entities/request.py @@ -32,7 +32,7 @@ class RequestInvokeTool(BaseModel): Request to invoke a tool """ - tool_type: Literal["builtin", "workflow", "api"] + tool_type: Literal["builtin", "workflow", "api", "mcp"] provider: str tool: str tool_parameters: dict diff --git a/api/core/rag/datasource/vdb/qdrant/qdrant_vector.py b/api/core/rag/datasource/vdb/qdrant/qdrant_vector.py index 8ce194c683..05fa73011a 100644 --- a/api/core/rag/datasource/vdb/qdrant/qdrant_vector.py +++ b/api/core/rag/datasource/vdb/qdrant/qdrant_vector.py @@ -47,6 +47,7 @@ class QdrantConfig(BaseModel): grpc_port: int = 6334 prefer_grpc: bool = False replication_factor: int = 1 + write_consistency_factor: int = 1 def to_qdrant_params(self): if self.endpoint and self.endpoint.startswith("path:"): @@ -127,6 +128,7 @@ class QdrantVector(BaseVector): hnsw_config=hnsw_config, timeout=int(self._client_config.timeout), replication_factor=self._client_config.replication_factor, + write_consistency_factor=self._client_config.write_consistency_factor, ) # create group_id payload index diff --git a/api/core/rag/datasource/vdb/tencent/tencent_vector.py b/api/core/rag/datasource/vdb/tencent/tencent_vector.py index d2bf3eb92a..75afe0cdb8 100644 --- a/api/core/rag/datasource/vdb/tencent/tencent_vector.py +++ b/api/core/rag/datasource/vdb/tencent/tencent_vector.py @@ -122,7 +122,6 @@ class TencentVector(BaseVector): metric_type, params, ) - index_text = vdb_index.FilterIndex(self.field_text, enum.FieldType.String, enum.IndexType.FILTER) index_metadate = vdb_index.FilterIndex(self.field_metadata, enum.FieldType.Json, enum.IndexType.FILTER) index_sparse_vector = vdb_index.SparseIndex( name="sparse_vector", @@ -130,7 +129,7 @@ class TencentVector(BaseVector): index_type=enum.IndexType.SPARSE_INVERTED, metric_type=enum.MetricType.IP, ) - indexes = [index_id, index_vector, index_text, index_metadate] + indexes = [index_id, index_vector, index_metadate] if self._enable_hybrid_search: indexes.append(index_sparse_vector) try: @@ -149,7 +148,7 @@ class TencentVector(BaseVector): index_metadate = vdb_index.FilterIndex( self.field_metadata, enum.FieldType.String, enum.IndexType.FILTER ) - indexes = [index_id, index_vector, index_text, index_metadate] + indexes = [index_id, index_vector, index_metadate] if self._enable_hybrid_search: indexes.append(index_sparse_vector) self._client.create_collection( diff --git a/api/core/rag/datasource/vdb/vector_factory.py b/api/core/rag/datasource/vdb/vector_factory.py index 67a4a515b1..00080b0fae 100644 --- a/api/core/rag/datasource/vdb/vector_factory.py +++ b/api/core/rag/datasource/vdb/vector_factory.py @@ -1,3 +1,5 @@ +import logging +import time from abc import ABC, abstractmethod from typing import Any, Optional @@ -13,6 +15,8 @@ from extensions.ext_database import db from extensions.ext_redis import redis_client from models.dataset import Dataset, Whitelist +logger = logging.getLogger(__name__) + class AbstractVectorFactory(ABC): @abstractmethod @@ -173,8 +177,20 @@ class Vector: def create(self, texts: Optional[list] = None, **kwargs): if texts: - embeddings = self._embeddings.embed_documents([document.page_content for document in texts]) - self._vector_processor.create(texts=texts, embeddings=embeddings, **kwargs) + start = time.time() + logger.info(f"start embedding {len(texts)} texts {start}") + batch_size = 1000 + total_batches = len(texts) + batch_size - 1 + for i in range(0, len(texts), batch_size): + batch = texts[i : i + batch_size] + batch_start = time.time() + logger.info(f"Processing batch {i // batch_size + 1}/{total_batches} ({len(batch)} texts)") + batch_embeddings = self._embeddings.embed_documents([document.page_content for document in batch]) + logger.info( + f"Embedding batch {i // batch_size + 1}/{total_batches} took {time.time() - batch_start:.3f}s" + ) + self._vector_processor.create(texts=batch, embeddings=batch_embeddings, **kwargs) + logger.info(f"Embedding {len(texts)} texts took {time.time() - start:.3f}s") def add_texts(self, documents: list[Document], **kwargs): if kwargs.get("duplicate_check", False): diff --git a/api/core/rag/extractor/helpers.py b/api/core/rag/extractor/helpers.py index 69ca9d5d63..3d2fb55d9a 100644 --- a/api/core/rag/extractor/helpers.py +++ b/api/core/rag/extractor/helpers.py @@ -1,7 +1,6 @@ """Document loader helpers.""" import concurrent.futures -from pathlib import Path from typing import NamedTuple, Optional, cast @@ -16,7 +15,7 @@ class FileEncoding(NamedTuple): """The language of the file.""" -def detect_file_encodings(file_path: str, timeout: int = 5) -> list[FileEncoding]: +def detect_file_encodings(file_path: str, timeout: int = 5, sample_size: int = 1024 * 1024) -> list[FileEncoding]: """Try to detect the file encoding. Returns a list of `FileEncoding` tuples with the detected encodings ordered @@ -25,11 +24,16 @@ def detect_file_encodings(file_path: str, timeout: int = 5) -> list[FileEncoding Args: file_path: The path to the file to detect the encoding for. timeout: The timeout in seconds for the encoding detection. + sample_size: The number of bytes to read for encoding detection. Default is 1MB. + For large files, reading only a sample is sufficient and prevents timeout. """ import chardet def read_and_detect(file_path: str) -> list[dict]: - rawdata = Path(file_path).read_bytes() + with open(file_path, "rb") as f: + # Read only a sample of the file for encoding detection + # This prevents timeout on large files while still providing accurate encoding detection + rawdata = f.read(sample_size) return cast(list[dict], chardet.detect_all(rawdata)) with concurrent.futures.ThreadPoolExecutor() as executor: diff --git a/api/core/rag/extractor/text_extractor.py b/api/core/rag/extractor/text_extractor.py index b2b51d71d7..a00d328cb1 100644 --- a/api/core/rag/extractor/text_extractor.py +++ b/api/core/rag/extractor/text_extractor.py @@ -36,8 +36,12 @@ class TextExtractor(BaseExtractor): break except UnicodeDecodeError: continue + else: + raise RuntimeError( + f"Decode failed: {self._file_path}, all detected encodings failed. Original error: {e}" + ) else: - raise RuntimeError(f"Error loading {self._file_path}") from e + raise RuntimeError(f"Decode failed: {self._file_path}, specified encoding failed. Original error: {e}") except Exception as e: raise RuntimeError(f"Error loading {self._file_path}") from e diff --git a/api/core/repositories/sqlalchemy_workflow_execution_repository.py b/api/core/repositories/sqlalchemy_workflow_execution_repository.py index cdec92aee7..0b3e5eb424 100644 --- a/api/core/repositories/sqlalchemy_workflow_execution_repository.py +++ b/api/core/repositories/sqlalchemy_workflow_execution_repository.py @@ -17,6 +17,7 @@ from core.workflow.entities.workflow_execution import ( ) from core.workflow.repositories.workflow_execution_repository import WorkflowExecutionRepository from core.workflow.workflow_type_encoder import WorkflowRuntimeTypeConverter +from libs.helper import extract_tenant_id from models import ( Account, CreatorUserRole, @@ -67,7 +68,7 @@ class SQLAlchemyWorkflowExecutionRepository(WorkflowExecutionRepository): ) # Extract tenant_id from user - tenant_id: str | None = user.tenant_id if isinstance(user, EndUser) else user.current_tenant_id + tenant_id = extract_tenant_id(user) if not tenant_id: raise ValueError("User must have a tenant_id or current_tenant_id") self._tenant_id = tenant_id diff --git a/api/core/repositories/sqlalchemy_workflow_node_execution_repository.py b/api/core/repositories/sqlalchemy_workflow_node_execution_repository.py index 797cce9354..a5feeb0d7c 100644 --- a/api/core/repositories/sqlalchemy_workflow_node_execution_repository.py +++ b/api/core/repositories/sqlalchemy_workflow_node_execution_repository.py @@ -20,6 +20,7 @@ from core.workflow.entities.workflow_node_execution import ( from core.workflow.nodes.enums import NodeType from core.workflow.repositories.workflow_node_execution_repository import OrderConfig, WorkflowNodeExecutionRepository from core.workflow.workflow_type_encoder import WorkflowRuntimeTypeConverter +from libs.helper import extract_tenant_id from models import ( Account, CreatorUserRole, @@ -70,7 +71,7 @@ class SQLAlchemyWorkflowNodeExecutionRepository(WorkflowNodeExecutionRepository) ) # Extract tenant_id from user - tenant_id: str | None = user.tenant_id if isinstance(user, EndUser) else user.current_tenant_id + tenant_id = extract_tenant_id(user) if not tenant_id: raise ValueError("User must have a tenant_id or current_tenant_id") self._tenant_id = tenant_id diff --git a/api/core/tools/builtin_tool/providers/audio/tools/tts.py b/api/core/tools/builtin_tool/providers/audio/tools/tts.py index 9b104b00f5..f191968812 100644 --- a/api/core/tools/builtin_tool/providers/audio/tools/tts.py +++ b/api/core/tools/builtin_tool/providers/audio/tools/tts.py @@ -31,6 +31,14 @@ class TTSTool(BuiltinTool): model_type=ModelType.TTS, model=model, ) + if not voice: + voices = model_instance.get_tts_voices() + if voices: + voice = voices[0].get("value") + if not voice: + raise ValueError("Sorry, no voice available.") + else: + raise ValueError("Sorry, no voice available.") tts = model_instance.invoke_tts( content_text=tool_parameters.get("text"), # type: ignore user=user_id, diff --git a/api/core/tools/custom_tool/provider.py b/api/core/tools/custom_tool/provider.py index 3137d32013..fbe1d79137 100644 --- a/api/core/tools/custom_tool/provider.py +++ b/api/core/tools/custom_tool/provider.py @@ -39,19 +39,22 @@ class ApiToolProviderController(ToolProviderController): type=ProviderConfig.Type.SELECT, options=[ ProviderConfig.Option(value="none", label=I18nObject(en_US="None", zh_Hans="无")), - ProviderConfig.Option(value="api_key", label=I18nObject(en_US="api_key", zh_Hans="api_key")), + ProviderConfig.Option(value="api_key_header", label=I18nObject(en_US="Header", zh_Hans="请求头")), + ProviderConfig.Option( + value="api_key_query", label=I18nObject(en_US="Query Param", zh_Hans="查询参数") + ), ], default="none", help=I18nObject(en_US="The auth type of the api provider", zh_Hans="api provider 的认证类型"), ) ] - if auth_type == ApiProviderAuthType.API_KEY: + if auth_type == ApiProviderAuthType.API_KEY_HEADER: credentials_schema = [ *credentials_schema, ProviderConfig( name="api_key_header", required=False, - default="api_key", + default="Authorization", type=ProviderConfig.Type.TEXT_INPUT, help=I18nObject(en_US="The header name of the api key", zh_Hans="携带 api key 的 header 名称"), ), @@ -74,6 +77,25 @@ class ApiToolProviderController(ToolProviderController): ], ), ] + elif auth_type == ApiProviderAuthType.API_KEY_QUERY: + credentials_schema = [ + *credentials_schema, + ProviderConfig( + name="api_key_query_param", + required=False, + default="key", + type=ProviderConfig.Type.TEXT_INPUT, + help=I18nObject( + en_US="The query parameter name of the api key", zh_Hans="携带 api key 的查询参数名称" + ), + ), + ProviderConfig( + name="api_key_value", + required=True, + type=ProviderConfig.Type.SECRET_INPUT, + help=I18nObject(en_US="The api key", zh_Hans="api key 的值"), + ), + ] elif auth_type == ApiProviderAuthType.NONE: pass diff --git a/api/core/tools/custom_tool/tool.py b/api/core/tools/custom_tool/tool.py index 2f5cc6d4c0..10653b9948 100644 --- a/api/core/tools/custom_tool/tool.py +++ b/api/core/tools/custom_tool/tool.py @@ -78,8 +78,8 @@ class ApiTool(Tool): if "auth_type" not in credentials: raise ToolProviderCredentialValidationError("Missing auth_type") - if credentials["auth_type"] == "api_key": - api_key_header = "api_key" + if credentials["auth_type"] in ("api_key_header", "api_key"): # backward compatibility: + api_key_header = "Authorization" if "api_key_header" in credentials: api_key_header = credentials["api_key_header"] @@ -100,6 +100,11 @@ class ApiTool(Tool): headers[api_key_header] = credentials["api_key_value"] + elif credentials["auth_type"] == "api_key_query": + # For query parameter authentication, we don't add anything to headers + # The query parameter will be added in do_http_request method + pass + needed_parameters = [parameter for parameter in (self.api_bundle.parameters or []) if parameter.required] for parameter in needed_parameters: if parameter.required and parameter.name not in parameters: @@ -154,6 +159,15 @@ class ApiTool(Tool): cookies = {} files = [] + # Add API key to query parameters if auth_type is api_key_query + if self.runtime and self.runtime.credentials: + credentials = self.runtime.credentials + if credentials.get("auth_type") == "api_key_query": + api_key_query_param = credentials.get("api_key_query_param", "key") + api_key_value = credentials.get("api_key_value") + if api_key_value: + params[api_key_query_param] = api_key_value + # check parameters for parameter in self.api_bundle.openapi.get("parameters", []): value = self.get_parameter_value(parameter, parameters) @@ -213,7 +227,8 @@ class ApiTool(Tool): elif "default" in property: body[name] = property["default"] else: - body[name] = None + # omit optional parameters that weren't provided, instead of setting them to None + pass break # replace path parameters diff --git a/api/core/tools/entities/api_entities.py b/api/core/tools/entities/api_entities.py index 687883ce19..27ce96b90e 100644 --- a/api/core/tools/entities/api_entities.py +++ b/api/core/tools/entities/api_entities.py @@ -1,4 +1,5 @@ -from typing import Literal, Optional +from datetime import datetime +from typing import Any, Literal, Optional from pydantic import BaseModel, Field, field_validator @@ -18,7 +19,7 @@ class ToolApiEntity(BaseModel): output_schema: Optional[dict] = None -ToolProviderTypeApiLiteral = Optional[Literal["builtin", "api", "workflow"]] +ToolProviderTypeApiLiteral = Optional[Literal["builtin", "api", "workflow", "mcp"]] class ToolProviderApiEntity(BaseModel): @@ -27,6 +28,7 @@ class ToolProviderApiEntity(BaseModel): name: str # identifier description: I18nObject icon: str | dict + icon_dark: Optional[str | dict] = Field(default=None, description="The dark icon of the tool") label: I18nObject # label type: ToolProviderType masked_credentials: Optional[dict] = None @@ -37,6 +39,10 @@ class ToolProviderApiEntity(BaseModel): plugin_unique_identifier: Optional[str] = Field(default="", description="The unique identifier of the tool") tools: list[ToolApiEntity] = Field(default_factory=list) labels: list[str] = Field(default_factory=list) + # MCP + server_url: Optional[str] = Field(default="", description="The server url of the tool") + updated_at: int = Field(default_factory=lambda: int(datetime.now().timestamp())) + server_identifier: Optional[str] = Field(default="", description="The server identifier of the MCP tool") @field_validator("tools", mode="before") @classmethod @@ -52,8 +58,13 @@ class ToolProviderApiEntity(BaseModel): for parameter in tool.get("parameters"): if parameter.get("type") == ToolParameter.ToolParameterType.SYSTEM_FILES.value: parameter["type"] = "files" + if parameter.get("input_schema") is None: + parameter.pop("input_schema", None) # ------------- - + optional_fields = self.optional_field("server_url", self.server_url) + if self.type == ToolProviderType.MCP.value: + optional_fields.update(self.optional_field("updated_at", self.updated_at)) + optional_fields.update(self.optional_field("server_identifier", self.server_identifier)) return { "id": self.id, "author": self.author, @@ -62,6 +73,7 @@ class ToolProviderApiEntity(BaseModel): "plugin_unique_identifier": self.plugin_unique_identifier, "description": self.description.to_dict(), "icon": self.icon, + "icon_dark": self.icon_dark, "label": self.label.to_dict(), "type": self.type.value, "team_credentials": self.masked_credentials, @@ -69,8 +81,13 @@ class ToolProviderApiEntity(BaseModel): "allow_delete": self.allow_delete, "tools": tools, "labels": self.labels, + **optional_fields, } + def optional_field(self, key: str, value: Any) -> dict: + """Return dict with key-value if value is truthy, empty dict otherwise.""" + return {key: value} if value else {} + class ToolProviderCredentialApiEntity(BaseModel): id: str = Field(description="The unique id of the credential") diff --git a/api/core/tools/entities/tool_entities.py b/api/core/tools/entities/tool_entities.py index aad2320a25..2e53f1ae73 100644 --- a/api/core/tools/entities/tool_entities.py +++ b/api/core/tools/entities/tool_entities.py @@ -8,6 +8,7 @@ from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_seriali from core.entities.provider_entities import ProviderConfig from core.plugin.entities.parameters import ( + MCPServerParameterType, PluginParameter, PluginParameterOption, PluginParameterType, @@ -49,6 +50,7 @@ class ToolProviderType(enum.StrEnum): API = "api" APP = "app" DATASET_RETRIEVAL = "dataset-retrieval" + MCP = "mcp" @classmethod def value_of(cls, value: str) -> "ToolProviderType": @@ -94,7 +96,8 @@ class ApiProviderAuthType(Enum): """ NONE = "none" - API_KEY = "api_key" + API_KEY_HEADER = "api_key_header" + API_KEY_QUERY = "api_key_query" @classmethod def value_of(cls, value: str) -> "ApiProviderAuthType": @@ -242,6 +245,10 @@ class ToolParameter(PluginParameter): MODEL_SELECTOR = PluginParameterType.MODEL_SELECTOR.value DYNAMIC_SELECT = PluginParameterType.DYNAMIC_SELECT.value + # MCP object and array type parameters + ARRAY = MCPServerParameterType.ARRAY.value + OBJECT = MCPServerParameterType.OBJECT.value + # deprecated, should not use. SYSTEM_FILES = PluginParameterType.SYSTEM_FILES.value @@ -260,6 +267,8 @@ class ToolParameter(PluginParameter): human_description: Optional[I18nObject] = Field(default=None, description="The description presented to the user") form: ToolParameterForm = Field(..., description="The form of the parameter, schema/form/llm") llm_description: Optional[str] = None + # MCP object and array type parameters use this field to store the schema + input_schema: Optional[dict] = None @classmethod def get_simple_instance( @@ -309,6 +318,7 @@ class ToolProviderIdentity(BaseModel): name: str = Field(..., description="The name of the tool") description: I18nObject = Field(..., description="The description of the tool") icon: str = Field(..., description="The icon of the tool") + icon_dark: Optional[str] = Field(default=None, description="The dark icon of the tool") label: I18nObject = Field(..., description="The label of the tool") tags: Optional[list[ToolLabelEnum]] = Field( default=[], diff --git a/api/core/tools/mcp_tool/provider.py b/api/core/tools/mcp_tool/provider.py new file mode 100644 index 0000000000..93f003effe --- /dev/null +++ b/api/core/tools/mcp_tool/provider.py @@ -0,0 +1,130 @@ +import json +from typing import Any + +from core.mcp.types import Tool as RemoteMCPTool +from core.tools.__base.tool_provider import ToolProviderController +from core.tools.__base.tool_runtime import ToolRuntime +from core.tools.entities.common_entities import I18nObject +from core.tools.entities.tool_entities import ( + ToolDescription, + ToolEntity, + ToolIdentity, + ToolProviderEntityWithPlugin, + ToolProviderIdentity, + ToolProviderType, +) +from core.tools.mcp_tool.tool import MCPTool +from models.tools import MCPToolProvider +from services.tools.tools_transform_service import ToolTransformService + + +class MCPToolProviderController(ToolProviderController): + provider_id: str + entity: ToolProviderEntityWithPlugin + + def __init__(self, entity: ToolProviderEntityWithPlugin, provider_id: str, tenant_id: str, server_url: str) -> None: + super().__init__(entity) + self.entity = entity + self.tenant_id = tenant_id + self.provider_id = provider_id + self.server_url = server_url + + @property + def provider_type(self) -> ToolProviderType: + """ + returns the type of the provider + + :return: type of the provider + """ + return ToolProviderType.MCP + + @classmethod + def _from_db(cls, db_provider: MCPToolProvider) -> "MCPToolProviderController": + """ + from db provider + """ + tools = [] + tools_data = json.loads(db_provider.tools) + remote_mcp_tools = [RemoteMCPTool(**tool) for tool in tools_data] + user = db_provider.load_user() + tools = [ + ToolEntity( + identity=ToolIdentity( + author=user.name if user else "Anonymous", + name=remote_mcp_tool.name, + label=I18nObject(en_US=remote_mcp_tool.name, zh_Hans=remote_mcp_tool.name), + provider=db_provider.server_identifier, + icon=db_provider.icon, + ), + parameters=ToolTransformService.convert_mcp_schema_to_parameter(remote_mcp_tool.inputSchema), + description=ToolDescription( + human=I18nObject( + en_US=remote_mcp_tool.description or "", zh_Hans=remote_mcp_tool.description or "" + ), + llm=remote_mcp_tool.description or "", + ), + output_schema=None, + has_runtime_parameters=len(remote_mcp_tool.inputSchema) > 0, + ) + for remote_mcp_tool in remote_mcp_tools + ] + + return cls( + entity=ToolProviderEntityWithPlugin( + identity=ToolProviderIdentity( + author=user.name if user else "Anonymous", + name=db_provider.name, + label=I18nObject(en_US=db_provider.name, zh_Hans=db_provider.name), + description=I18nObject(en_US="", zh_Hans=""), + icon=db_provider.icon, + ), + plugin_id=None, + credentials_schema=[], + tools=tools, + ), + provider_id=db_provider.server_identifier or "", + tenant_id=db_provider.tenant_id or "", + server_url=db_provider.decrypted_server_url, + ) + + def _validate_credentials(self, user_id: str, credentials: dict[str, Any]) -> None: + """ + validate the credentials of the provider + """ + pass + + def get_tool(self, tool_name: str) -> MCPTool: # type: ignore + """ + return tool with given name + """ + tool_entity = next( + (tool_entity for tool_entity in self.entity.tools if tool_entity.identity.name == tool_name), None + ) + + if not tool_entity: + raise ValueError(f"Tool with name {tool_name} not found") + + return MCPTool( + entity=tool_entity, + runtime=ToolRuntime(tenant_id=self.tenant_id), + tenant_id=self.tenant_id, + icon=self.entity.identity.icon, + server_url=self.server_url, + provider_id=self.provider_id, + ) + + def get_tools(self) -> list[MCPTool]: # type: ignore + """ + get all tools + """ + return [ + MCPTool( + entity=tool_entity, + runtime=ToolRuntime(tenant_id=self.tenant_id), + tenant_id=self.tenant_id, + icon=self.entity.identity.icon, + server_url=self.server_url, + provider_id=self.provider_id, + ) + for tool_entity in self.entity.tools + ] diff --git a/api/core/tools/mcp_tool/tool.py b/api/core/tools/mcp_tool/tool.py new file mode 100644 index 0000000000..d1bacbc735 --- /dev/null +++ b/api/core/tools/mcp_tool/tool.py @@ -0,0 +1,92 @@ +import base64 +import json +from collections.abc import Generator +from typing import Any, Optional + +from core.mcp.error import MCPAuthError, MCPConnectionError +from core.mcp.mcp_client import MCPClient +from core.mcp.types import ImageContent, TextContent +from core.tools.__base.tool import Tool +from core.tools.__base.tool_runtime import ToolRuntime +from core.tools.entities.tool_entities import ToolEntity, ToolInvokeMessage, ToolParameter, ToolProviderType + + +class MCPTool(Tool): + tenant_id: str + icon: str + runtime_parameters: Optional[list[ToolParameter]] + server_url: str + provider_id: str + + def __init__( + self, entity: ToolEntity, runtime: ToolRuntime, tenant_id: str, icon: str, server_url: str, provider_id: str + ) -> None: + super().__init__(entity, runtime) + self.tenant_id = tenant_id + self.icon = icon + self.runtime_parameters = None + self.server_url = server_url + self.provider_id = provider_id + + def tool_provider_type(self) -> ToolProviderType: + return ToolProviderType.MCP + + def _invoke( + self, + user_id: str, + tool_parameters: dict[str, Any], + conversation_id: Optional[str] = None, + app_id: Optional[str] = None, + message_id: Optional[str] = None, + ) -> Generator[ToolInvokeMessage, None, None]: + from core.tools.errors import ToolInvokeError + + try: + with MCPClient(self.server_url, self.provider_id, self.tenant_id, authed=True) as mcp_client: + tool_parameters = self._handle_none_parameter(tool_parameters) + result = mcp_client.invoke_tool(tool_name=self.entity.identity.name, tool_args=tool_parameters) + except MCPAuthError as e: + raise ToolInvokeError("Please auth the tool first") from e + except MCPConnectionError as e: + raise ToolInvokeError(f"Failed to connect to MCP server: {e}") from e + except Exception as e: + raise ToolInvokeError(f"Failed to invoke tool: {e}") from e + + for content in result.content: + if isinstance(content, TextContent): + try: + content_json = json.loads(content.text) + if isinstance(content_json, dict): + yield self.create_json_message(content_json) + elif isinstance(content_json, list): + for item in content_json: + yield self.create_json_message(item) + else: + yield self.create_text_message(content.text) + except json.JSONDecodeError: + yield self.create_text_message(content.text) + + elif isinstance(content, ImageContent): + yield self.create_blob_message( + blob=base64.b64decode(content.data), meta={"mime_type": content.mimeType} + ) + + def fork_tool_runtime(self, runtime: ToolRuntime) -> "MCPTool": + return MCPTool( + entity=self.entity, + runtime=runtime, + tenant_id=self.tenant_id, + icon=self.icon, + server_url=self.server_url, + provider_id=self.provider_id, + ) + + def _handle_none_parameter(self, parameter: dict[str, Any]) -> dict[str, Any]: + """ + in mcp tool invoke, if the parameter is empty, it will be set to None + """ + return { + key: value + for key, value in parameter.items() + if value is not None and not (isinstance(value, str) and value.strip() == "") + } diff --git a/api/core/tools/signature.py b/api/core/tools/signature.py index e80005d7bf..5cdf473542 100644 --- a/api/core/tools/signature.py +++ b/api/core/tools/signature.py @@ -9,9 +9,10 @@ from configs import dify_config def sign_tool_file(tool_file_id: str, extension: str) -> str: """ - sign file to get a temporary url + sign file to get a temporary url for plugin access """ - base_url = dify_config.FILES_URL + # Use internal URL for plugin/tool file access in Docker environments + base_url = dify_config.INTERNAL_FILES_URL or dify_config.FILES_URL file_preview_url = f"{base_url}/files/tools/{tool_file_id}{extension}" timestamp = str(int(time.time())) diff --git a/api/core/tools/tool_file_manager.py b/api/core/tools/tool_file_manager.py index b849f51064..ece02f9d59 100644 --- a/api/core/tools/tool_file_manager.py +++ b/api/core/tools/tool_file_manager.py @@ -35,9 +35,10 @@ class ToolFileManager: @staticmethod def sign_file(tool_file_id: str, extension: str) -> str: """ - sign file to get a temporary url + sign file to get a temporary url for plugin access """ - base_url = dify_config.FILES_URL + # Use internal URL for plugin/tool file access in Docker environments + base_url = dify_config.INTERNAL_FILES_URL or dify_config.FILES_URL file_preview_url = f"{base_url}/files/tools/{tool_file_id}{extension}" timestamp = str(int(time.time())) diff --git a/api/core/tools/tool_manager.py b/api/core/tools/tool_manager.py index 9ed29da4e6..f96aac81bb 100644 --- a/api/core/tools/tool_manager.py +++ b/api/core/tools/tool_manager.py @@ -4,7 +4,7 @@ import mimetypes from collections.abc import Generator from os import listdir, path from threading import Lock -from typing import TYPE_CHECKING, Any, Optional, Union, cast +from typing import TYPE_CHECKING, Any, Literal, Optional, Union, cast from yarl import URL @@ -14,9 +14,13 @@ from core.plugin.entities.plugin import ToolProviderID from core.plugin.impl.tool import PluginToolManager from core.tools.__base.tool_provider import ToolProviderController from core.tools.__base.tool_runtime import ToolRuntime +from core.tools.mcp_tool.provider import MCPToolProviderController +from core.tools.mcp_tool.tool import MCPTool from core.tools.plugin_tool.provider import PluginToolProviderController from core.tools.plugin_tool.tool import PluginTool from core.tools.workflow_as_tool.provider import WorkflowToolProviderController +from core.workflow.entities.variable_pool import VariablePool +from services.tools.mcp_tools_mange_service import MCPToolManageService if TYPE_CHECKING: from core.workflow.nodes.tool.entities import ToolEntity @@ -42,7 +46,7 @@ from core.tools.entities.tool_entities import ( ToolParameter, ToolProviderType, ) -from core.tools.errors import ToolProviderNotFoundError +from core.tools.errors import ToolNotFoundError, ToolProviderNotFoundError from core.tools.tool_label_manager import ToolLabelManager from core.tools.utils.configuration import ( ToolParameterConfigurationManager, @@ -50,7 +54,7 @@ from core.tools.utils.configuration import ( from core.tools.utils.encryption import create_provider_encrypter, create_tool_provider_encrypter from core.tools.workflow_as_tool.tool import WorkflowTool from extensions.ext_database import db -from models.tools import ApiToolProvider, BuiltinToolProvider, WorkflowToolProvider +from models.tools import ApiToolProvider, BuiltinToolProvider, MCPToolProvider, WorkflowToolProvider from services.tools.tools_transform_service import ToolTransformService logger = logging.getLogger(__name__) @@ -148,7 +152,7 @@ class ToolManager: invoke_from: InvokeFrom = InvokeFrom.DEBUGGER, tool_invoke_from: ToolInvokeFrom = ToolInvokeFrom.AGENT, credential_id: Optional[str] = None, - ) -> Union[BuiltinTool, PluginTool, ApiTool, WorkflowTool]: + ) -> Union[BuiltinTool, PluginTool, ApiTool, WorkflowTool, MCPTool]: """ get the tool runtime @@ -293,6 +297,8 @@ class ToolManager: raise NotImplementedError("app provider not implemented") elif provider_type == ToolProviderType.PLUGIN: return cls.get_plugin_provider(provider_id, tenant_id).get_tool(tool_name) + elif provider_type == ToolProviderType.MCP: + return cls.get_mcp_provider_controller(tenant_id, provider_id).get_tool(tool_name) else: raise ToolProviderNotFoundError(f"provider type {provider_type.value} not found") @@ -303,6 +309,7 @@ class ToolManager: app_id: str, agent_tool: AgentToolEntity, invoke_from: InvokeFrom = InvokeFrom.DEBUGGER, + variable_pool: Optional[VariablePool] = None, ) -> Tool: """ get the agent tool runtime @@ -317,24 +324,9 @@ class ToolManager: ) runtime_parameters = {} parameters = tool_entity.get_merged_runtime_parameters() - for parameter in parameters: - # check file types - if ( - parameter.type - in { - ToolParameter.ToolParameterType.SYSTEM_FILES, - ToolParameter.ToolParameterType.FILE, - ToolParameter.ToolParameterType.FILES, - } - and parameter.required - ): - raise ValueError(f"file type parameter {parameter.name} not supported in agent") - - if parameter.form == ToolParameter.ToolParameterForm.FORM: - # save tool parameter to tool entity memory - value = parameter.init_frontend_parameter(agent_tool.tool_parameters.get(parameter.name)) - runtime_parameters[parameter.name] = value - + runtime_parameters = cls._convert_tool_parameters_type( + parameters, variable_pool, agent_tool.tool_parameters, typ="agent" + ) # decrypt runtime parameters encryption_manager = ToolParameterConfigurationManager( tenant_id=tenant_id, @@ -358,10 +350,12 @@ class ToolManager: node_id: str, workflow_tool: "ToolEntity", invoke_from: InvokeFrom = InvokeFrom.DEBUGGER, + variable_pool: Optional[VariablePool] = None, ) -> Tool: """ get the workflow tool runtime """ + tool_runtime = cls.get_tool_runtime( provider_type=workflow_tool.provider_type, provider_id=workflow_tool.provider_id, @@ -371,15 +365,11 @@ class ToolManager: tool_invoke_from=ToolInvokeFrom.WORKFLOW, credential_id=workflow_tool.credential_id, ) - runtime_parameters = {} + parameters = tool_runtime.get_merged_runtime_parameters() - - for parameter in parameters: - # save tool parameter to tool entity memory - if parameter.form == ToolParameter.ToolParameterForm.FORM: - value = parameter.init_frontend_parameter(workflow_tool.tool_configurations.get(parameter.name)) - runtime_parameters[parameter.name] = value - + runtime_parameters = cls._convert_tool_parameters_type( + parameters, variable_pool, workflow_tool.tool_configurations, typ="workflow" + ) # decrypt runtime parameters encryption_manager = ToolParameterConfigurationManager( tenant_id=tenant_id, @@ -587,7 +577,7 @@ class ToolManager: filters = [] if not typ: - filters.extend(["builtin", "api", "workflow"]) + filters.extend(["builtin", "api", "workflow", "mcp"]) else: filters.append(typ) @@ -671,6 +661,10 @@ class ToolManager: labels=labels.get(provider_controller.provider_id, []), ) result_providers[f"workflow_provider.{user_provider.name}"] = user_provider + if "mcp" in filters: + mcp_providers = MCPToolManageService.retrieve_mcp_tools(tenant_id, for_list=True) + for mcp_provider in mcp_providers: + result_providers[f"mcp_provider.{mcp_provider.name}"] = mcp_provider return BuiltinToolProviderSort.sort(list(result_providers.values())) @@ -698,14 +692,47 @@ class ToolManager: if provider is None: raise ToolProviderNotFoundError(f"api provider {provider_id} not found") + auth_type = ApiProviderAuthType.NONE + provider_auth_type = provider.credentials.get("auth_type") + if provider_auth_type in ("api_key_header", "api_key"): # backward compatibility + auth_type = ApiProviderAuthType.API_KEY_HEADER + elif provider_auth_type == "api_key_query": + auth_type = ApiProviderAuthType.API_KEY_QUERY + controller = ApiToolProviderController.from_db( provider, - ApiProviderAuthType.API_KEY if provider.credentials["auth_type"] == "api_key" else ApiProviderAuthType.NONE, + auth_type, ) controller.load_bundled_tools(provider.tools) return controller, provider.credentials + @classmethod + def get_mcp_provider_controller(cls, tenant_id: str, provider_id: str) -> MCPToolProviderController: + """ + get the api provider + + :param tenant_id: the id of the tenant + :param provider_id: the id of the provider + + :return: the provider controller, the credentials + """ + provider: MCPToolProvider | None = ( + db.session.query(MCPToolProvider) + .filter( + MCPToolProvider.server_identifier == provider_id, + MCPToolProvider.tenant_id == tenant_id, + ) + .first() + ) + + if provider is None: + raise ToolProviderNotFoundError(f"mcp provider {provider_id} not found") + + controller = MCPToolProviderController._from_db(provider) + + return controller + @classmethod def user_get_api_provider(cls, provider: str, tenant_id: str) -> dict: """ @@ -733,9 +760,16 @@ class ToolManager: credentials = {} # package tool provider controller + auth_type = ApiProviderAuthType.NONE + credentials_auth_type = credentials.get("auth_type") + if credentials_auth_type in ("api_key_header", "api_key"): # backward compatibility + auth_type = ApiProviderAuthType.API_KEY_HEADER + elif credentials_auth_type == "api_key_query": + auth_type = ApiProviderAuthType.API_KEY_QUERY + controller = ApiToolProviderController.from_db( provider_obj, - ApiProviderAuthType.API_KEY if credentials["auth_type"] == "api_key" else ApiProviderAuthType.NONE, + auth_type, ) # init tool configuration encrypter, _ = create_tool_provider_encrypter( @@ -831,6 +865,22 @@ class ToolManager: except Exception: return {"background": "#252525", "content": "\ud83d\ude01"} + @classmethod + def generate_mcp_tool_icon_url(cls, tenant_id: str, provider_id: str) -> dict[str, str] | str: + try: + mcp_provider: MCPToolProvider | None = ( + db.session.query(MCPToolProvider) + .filter(MCPToolProvider.tenant_id == tenant_id, MCPToolProvider.server_identifier == provider_id) + .first() + ) + + if mcp_provider is None: + raise ToolProviderNotFoundError(f"mcp provider {provider_id} not found") + + return mcp_provider.provider_icon + except Exception: + return {"background": "#252525", "content": "\ud83d\ude01"} + @classmethod def get_tool_icon( cls, @@ -868,8 +918,61 @@ class ToolManager: except Exception: return {"background": "#252525", "content": "\ud83d\ude01"} raise ValueError(f"plugin provider {provider_id} not found") + elif provider_type == ToolProviderType.MCP: + return cls.generate_mcp_tool_icon_url(tenant_id, provider_id) else: raise ValueError(f"provider type {provider_type} not found") + @classmethod + def _convert_tool_parameters_type( + cls, + parameters: list[ToolParameter], + variable_pool: Optional[VariablePool], + tool_configurations: dict[str, Any], + typ: Literal["agent", "workflow", "tool"] = "workflow", + ) -> dict[str, Any]: + """ + Convert tool parameters type + """ + from core.workflow.nodes.tool.entities import ToolNodeData + from core.workflow.nodes.tool.exc import ToolParameterError + + runtime_parameters = {} + for parameter in parameters: + if ( + parameter.type + in { + ToolParameter.ToolParameterType.SYSTEM_FILES, + ToolParameter.ToolParameterType.FILE, + ToolParameter.ToolParameterType.FILES, + } + and parameter.required + and typ == "agent" + ): + raise ValueError(f"file type parameter {parameter.name} not supported in agent") + # save tool parameter to tool entity memory + if parameter.form == ToolParameter.ToolParameterForm.FORM: + if variable_pool: + config = tool_configurations.get(parameter.name, {}) + if not (config and isinstance(config, dict) and config.get("value") is not None): + continue + tool_input = ToolNodeData.ToolInput(**tool_configurations.get(parameter.name, {})) + if tool_input.type == "variable": + variable = variable_pool.get(tool_input.value) + if variable is None: + raise ToolParameterError(f"Variable {tool_input.value} does not exist") + parameter_value = variable.value + elif tool_input.type in {"mixed", "constant"}: + segment_group = variable_pool.convert_template(str(tool_input.value)) + parameter_value = segment_group.text + else: + raise ToolParameterError(f"Unknown tool input type '{tool_input.type}'") + runtime_parameters[parameter.name] = parameter_value + + else: + value = parameter.init_frontend_parameter(tool_configurations.get(parameter.name)) + runtime_parameters[parameter.name] = value + return runtime_parameters + ToolManager.load_hardcoded_providers_cache() diff --git a/api/core/tools/workflow_as_tool/tool.py b/api/core/tools/workflow_as_tool/tool.py index 57c93d1d45..10bf8ca640 100644 --- a/api/core/tools/workflow_as_tool/tool.py +++ b/api/core/tools/workflow_as_tool/tool.py @@ -8,7 +8,12 @@ from flask_login import current_user from core.file import FILE_MODEL_IDENTITY, File, FileTransferMethod from core.tools.__base.tool import Tool from core.tools.__base.tool_runtime import ToolRuntime -from core.tools.entities.tool_entities import ToolEntity, ToolInvokeMessage, ToolParameter, ToolProviderType +from core.tools.entities.tool_entities import ( + ToolEntity, + ToolInvokeMessage, + ToolParameter, + ToolProviderType, +) from core.tools.errors import ToolInvokeError from extensions.ext_database import db from factories.file_factory import build_from_mapping diff --git a/api/core/workflow/callbacks/workflow_logging_callback.py b/api/core/workflow/callbacks/workflow_logging_callback.py index e6813a3997..12b5203ca3 100644 --- a/api/core/workflow/callbacks/workflow_logging_callback.py +++ b/api/core/workflow/callbacks/workflow_logging_callback.py @@ -232,14 +232,14 @@ class WorkflowLoggingCallback(WorkflowCallback): Publish loop started """ self.print_text("\n[LoopRunStartedEvent]", color="blue") - self.print_text(f"Loop Node ID: {event.loop_id}", color="blue") + self.print_text(f"Loop Node ID: {event.loop_node_id}", color="blue") def on_workflow_loop_next(self, event: LoopRunNextEvent) -> None: """ Publish loop next """ self.print_text("\n[LoopRunNextEvent]", color="blue") - self.print_text(f"Loop Node ID: {event.loop_id}", color="blue") + self.print_text(f"Loop Node ID: {event.loop_node_id}", color="blue") self.print_text(f"Loop Index: {event.index}", color="blue") def on_workflow_loop_completed(self, event: LoopRunSucceededEvent | LoopRunFailedEvent) -> None: @@ -250,7 +250,7 @@ class WorkflowLoggingCallback(WorkflowCallback): "\n[LoopRunSucceededEvent]" if isinstance(event, LoopRunSucceededEvent) else "\n[LoopRunFailedEvent]", color="blue", ) - self.print_text(f"Node ID: {event.loop_id}", color="blue") + self.print_text(f"Loop Node ID: {event.loop_node_id}", color="blue") def print_text(self, text: str, color: Optional[str] = None, end: str = "\n") -> None: """Print text with highlighting and no end characters.""" diff --git a/api/core/workflow/graph_engine/entities/graph.py b/api/core/workflow/graph_engine/entities/graph.py index 8e5b1e7142..362777a199 100644 --- a/api/core/workflow/graph_engine/entities/graph.py +++ b/api/core/workflow/graph_engine/entities/graph.py @@ -334,7 +334,7 @@ class Graph(BaseModel): parallel = GraphParallel( start_from_node_id=start_node_id, - parent_parallel_id=parent_parallel.id if parent_parallel else None, + parent_parallel_id=parent_parallel_id, parent_parallel_start_node_id=parent_parallel.start_from_node_id if parent_parallel else None, ) parallel_mapping[parallel.id] = parallel diff --git a/api/core/workflow/graph_engine/graph_engine.py b/api/core/workflow/graph_engine/graph_engine.py index 61a7a26652..5a2915e2d3 100644 --- a/api/core/workflow/graph_engine/graph_engine.py +++ b/api/core/workflow/graph_engine/graph_engine.py @@ -103,7 +103,7 @@ class GraphEngine: call_depth: int, graph: Graph, graph_config: Mapping[str, Any], - variable_pool: VariablePool, + graph_runtime_state: GraphRuntimeState, max_execution_steps: int, max_execution_time: int, thread_pool_id: Optional[str] = None, @@ -140,7 +140,7 @@ class GraphEngine: call_depth=call_depth, ) - self.graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter()) + self.graph_runtime_state = graph_runtime_state self.max_execution_steps = max_execution_steps self.max_execution_time = max_execution_time diff --git a/api/core/workflow/nodes/agent/agent_node.py b/api/core/workflow/nodes/agent/agent_node.py index 987f670acb..678b99d546 100644 --- a/api/core/workflow/nodes/agent/agent_node.py +++ b/api/core/workflow/nodes/agent/agent_node.py @@ -1,19 +1,22 @@ import json +import uuid from collections.abc import Generator, Mapping, Sequence from typing import Any, Optional, cast +from packaging.version import Version from sqlalchemy import select from sqlalchemy.orm import Session from core.agent.entities import AgentToolEntity from core.agent.plugin_entities import AgentStrategyParameter +from core.agent.strategy.plugin import PluginAgentStrategy from core.memory.token_buffer_memory import TokenBufferMemory from core.model_manager import ModelInstance, ModelManager from core.model_runtime.entities.model_entities import AIModelEntity, ModelType from core.plugin.impl.exc import PluginDaemonClientSideError from core.plugin.impl.plugin import PluginInstaller from core.provider_manager import ProviderManager -from core.tools.entities.tool_entities import ToolParameter, ToolProviderType +from core.tools.entities.tool_entities import ToolInvokeMessage, ToolParameter, ToolProviderType from core.tools.tool_manager import ToolManager from core.variables.segments import StringSegment from core.workflow.entities.node_entities import NodeRunResult @@ -72,12 +75,14 @@ class AgentNode(ToolNode): agent_parameters=agent_parameters, variable_pool=self.graph_runtime_state.variable_pool, node_data=node_data, + strategy=strategy, ) parameters_for_log = self._generate_agent_parameters( agent_parameters=agent_parameters, variable_pool=self.graph_runtime_state.variable_pool, node_data=node_data, for_log=True, + strategy=strategy, ) # get conversation id @@ -102,6 +107,32 @@ class AgentNode(ToolNode): try: # convert tool messages + agent_thoughts: list = [] + + thought_log_message = ToolInvokeMessage( + type=ToolInvokeMessage.MessageType.LOG, + message=ToolInvokeMessage.LogMessage( + id=str(uuid.uuid4()), + label=f"Agent Strategy: {cast(AgentNodeData, self.node_data).agent_strategy_name}", + parent_id=None, + error=None, + status=ToolInvokeMessage.LogMessage.LogStatus.START, + data={ + "strategy": cast(AgentNodeData, self.node_data).agent_strategy_name, + "parameters": parameters_for_log, + "thought_process": "Agent strategy execution started", + }, + metadata={ + "icon": self.agent_strategy_icon, + "agent_strategy": cast(AgentNodeData, self.node_data).agent_strategy_name, + }, + ), + ) + + def enhanced_message_stream(): + yield thought_log_message + + yield from message_stream yield from self._transform_message( message_stream, @@ -110,6 +141,7 @@ class AgentNode(ToolNode): "agent_strategy": cast(AgentNodeData, self.node_data).agent_strategy_name, }, parameters_for_log, + agent_thoughts, ) except PluginDaemonClientSideError as e: yield RunCompletedEvent( @@ -127,6 +159,7 @@ class AgentNode(ToolNode): variable_pool: VariablePool, node_data: AgentNodeData, for_log: bool = False, + strategy: PluginAgentStrategy, ) -> dict[str, Any]: """ Generate parameters based on the given tool parameters, variable pool, and node data. @@ -179,7 +212,7 @@ class AgentNode(ToolNode): if parameter.type == "array[tools]": value = cast(list[dict[str, Any]], value) value = [tool for tool in value if tool.get("enabled", False)] - + value = self._filter_mcp_type_tool(strategy, value) for tool in value: if "schemas" in tool: tool.pop("schemas") @@ -216,9 +249,9 @@ class AgentNode(ToolNode): ) extra = tool.get("extra", {}) - + runtime_variable_pool = variable_pool if self.node_data.version != "1" else None tool_runtime = ToolManager.get_agent_tool_runtime( - self.tenant_id, self.app_id, entity, self.invoke_from + self.tenant_id, self.app_id, entity, self.invoke_from, runtime_variable_pool ) if tool_runtime.entity.description: tool_runtime.entity.description.llm = ( @@ -370,3 +403,16 @@ class AgentNode(ToolNode): except ValueError: model_schema.features.remove(feature) return model_schema + + def _filter_mcp_type_tool(self, strategy: PluginAgentStrategy, tools: list[dict[str, Any]]) -> list[dict[str, Any]]: + """ + Filter MCP type tool + :param strategy: plugin agent strategy + :param tool: tool + :return: filtered tool dict + """ + meta_version = strategy.meta_version + if meta_version and Version(meta_version) > Version("0.0.1"): + return tools + else: + return [tool for tool in tools if tool.get("type") != ToolProviderType.MCP.value] diff --git a/api/core/workflow/nodes/http_request/executor.py b/api/core/workflow/nodes/http_request/executor.py index b0a14229c5..8ac1ae8526 100644 --- a/api/core/workflow/nodes/http_request/executor.py +++ b/api/core/workflow/nodes/http_request/executor.py @@ -8,6 +8,7 @@ from typing import Any, Literal from urllib.parse import urlencode, urlparse import httpx +from json_repair import repair_json from configs import dify_config from core.file import file_manager @@ -178,7 +179,8 @@ class Executor: raise RequestBodyError("json body type should have exactly one item") json_string = self.variable_pool.convert_template(data[0].value).text try: - json_object = json.loads(json_string, strict=False) + repaired = repair_json(json_string) + json_object = json.loads(repaired, strict=False) except json.JSONDecodeError as e: raise RequestBodyError(f"Failed to parse JSON: {json_string}") from e self.json = json_object diff --git a/api/core/workflow/nodes/iteration/iteration_node.py b/api/core/workflow/nodes/iteration/iteration_node.py index 151efc28ec..c447f433aa 100644 --- a/api/core/workflow/nodes/iteration/iteration_node.py +++ b/api/core/workflow/nodes/iteration/iteration_node.py @@ -1,5 +1,6 @@ import contextvars import logging +import time import uuid from collections.abc import Generator, Mapping, Sequence from concurrent.futures import Future, wait @@ -133,8 +134,11 @@ class IterationNode(BaseNode[IterationNodeData]): variable_pool.add([self.node_id, "item"], iterator_list_value[0]) # init graph engine + from core.workflow.graph_engine.entities.graph_runtime_state import GraphRuntimeState from core.workflow.graph_engine.graph_engine import GraphEngine, GraphEngineThreadPool + graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter()) + graph_engine = GraphEngine( tenant_id=self.tenant_id, app_id=self.app_id, @@ -146,7 +150,7 @@ class IterationNode(BaseNode[IterationNodeData]): call_depth=self.workflow_call_depth, graph=iteration_graph, graph_config=graph_config, - variable_pool=variable_pool, + graph_runtime_state=graph_runtime_state, max_execution_steps=dify_config.WORKFLOW_MAX_EXECUTION_STEPS, max_execution_time=dify_config.WORKFLOW_MAX_EXECUTION_TIME, thread_pool_id=self.thread_pool_id, diff --git a/api/core/workflow/nodes/llm/node.py b/api/core/workflow/nodes/llm/node.py index b5225ce548..9bfb402dc8 100644 --- a/api/core/workflow/nodes/llm/node.py +++ b/api/core/workflow/nodes/llm/node.py @@ -221,15 +221,6 @@ class LLMNode(BaseNode[LLMNodeData]): jinja2_variables=self.node_data.prompt_config.jinja2_variables, ) - process_data = { - "model_mode": model_config.mode, - "prompts": PromptMessageUtil.prompt_messages_to_prompt_for_saving( - model_mode=model_config.mode, prompt_messages=prompt_messages - ), - "model_provider": model_config.provider, - "model_name": model_config.model, - } - # handle invoke result generator = self._invoke_llm( node_data_model=self.node_data.model, @@ -253,6 +244,17 @@ class LLMNode(BaseNode[LLMNodeData]): elif isinstance(event, LLMStructuredOutput): structured_output = event + process_data = { + "model_mode": model_config.mode, + "prompts": PromptMessageUtil.prompt_messages_to_prompt_for_saving( + model_mode=model_config.mode, prompt_messages=prompt_messages + ), + "usage": jsonable_encoder(usage), + "finish_reason": finish_reason, + "model_provider": model_config.provider, + "model_name": model_config.model, + } + outputs = {"text": result_text, "usage": jsonable_encoder(usage), "finish_reason": finish_reason} if structured_output: outputs["structured_output"] = structured_output.structured_output diff --git a/api/core/workflow/nodes/loop/loop_node.py b/api/core/workflow/nodes/loop/loop_node.py index 368d662a75..11fd7b6c2d 100644 --- a/api/core/workflow/nodes/loop/loop_node.py +++ b/api/core/workflow/nodes/loop/loop_node.py @@ -1,5 +1,6 @@ import json import logging +import time from collections.abc import Generator, Mapping, Sequence from datetime import UTC, datetime from typing import TYPE_CHECKING, Any, Literal, cast @@ -101,8 +102,11 @@ class LoopNode(BaseNode[LoopNodeData]): loop_variable_selectors[loop_variable.label] = variable_selector inputs[loop_variable.label] = processed_segment.value + from core.workflow.graph_engine.entities.graph_runtime_state import GraphRuntimeState from core.workflow.graph_engine.graph_engine import GraphEngine + graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter()) + graph_engine = GraphEngine( tenant_id=self.tenant_id, app_id=self.app_id, @@ -114,7 +118,7 @@ class LoopNode(BaseNode[LoopNodeData]): call_depth=self.workflow_call_depth, graph=loop_graph, graph_config=self.graph_config, - variable_pool=variable_pool, + graph_runtime_state=graph_runtime_state, max_execution_steps=dify_config.WORKFLOW_MAX_EXECUTION_STEPS, max_execution_time=dify_config.WORKFLOW_MAX_EXECUTION_TIME, thread_pool_id=self.thread_pool_id, diff --git a/api/core/workflow/nodes/node_mapping.py b/api/core/workflow/nodes/node_mapping.py index 67cc884f20..ccfaec4a8c 100644 --- a/api/core/workflow/nodes/node_mapping.py +++ b/api/core/workflow/nodes/node_mapping.py @@ -73,6 +73,7 @@ NODE_TYPE_CLASSES_MAPPING: Mapping[NodeType, Mapping[str, type[BaseNode]]] = { }, NodeType.TOOL: { LATEST_VERSION: ToolNode, + "2": ToolNode, "1": ToolNode, }, NodeType.VARIABLE_AGGREGATOR: { @@ -122,6 +123,7 @@ NODE_TYPE_CLASSES_MAPPING: Mapping[NodeType, Mapping[str, type[BaseNode]]] = { }, NodeType.AGENT: { LATEST_VERSION: AgentNode, + "2": AgentNode, "1": AgentNode, }, } diff --git a/api/core/workflow/nodes/parameter_extractor/parameter_extractor_node.py b/api/core/workflow/nodes/parameter_extractor/parameter_extractor_node.py index 8d6c2d0a5c..25a534256b 100644 --- a/api/core/workflow/nodes/parameter_extractor/parameter_extractor_node.py +++ b/api/core/workflow/nodes/parameter_extractor/parameter_extractor_node.py @@ -253,7 +253,12 @@ class ParameterExtractorNode(BaseNode): status=WorkflowNodeExecutionStatus.SUCCEEDED, inputs=inputs, process_data=process_data, - outputs={"__is_success": 1 if not error else 0, "__reason": error, **result}, + outputs={ + "__is_success": 1 if not error else 0, + "__reason": error, + "__usage": jsonable_encoder(usage), + **result, + }, metadata={ WorkflowNodeExecutionMetadataKey.TOTAL_TOKENS: usage.total_tokens, WorkflowNodeExecutionMetadataKey.TOTAL_PRICE: usage.total_price, diff --git a/api/core/workflow/nodes/question_classifier/question_classifier_node.py b/api/core/workflow/nodes/question_classifier/question_classifier_node.py index a518167cc6..74024ed90c 100644 --- a/api/core/workflow/nodes/question_classifier/question_classifier_node.py +++ b/api/core/workflow/nodes/question_classifier/question_classifier_node.py @@ -145,7 +145,11 @@ class QuestionClassifierNode(LLMNode): "model_provider": model_config.provider, "model_name": model_config.model, } - outputs = {"class_name": category_name, "class_id": category_id} + outputs = { + "class_name": category_name, + "class_id": category_id, + "usage": jsonable_encoder(usage), + } return NodeRunResult( status=WorkflowNodeExecutionStatus.SUCCEEDED, diff --git a/api/core/workflow/nodes/tool/entities.py b/api/core/workflow/nodes/tool/entities.py index 2ce6ac3fc1..88c5160d14 100644 --- a/api/core/workflow/nodes/tool/entities.py +++ b/api/core/workflow/nodes/tool/entities.py @@ -42,6 +42,10 @@ class ToolNodeData(BaseNodeData, ToolEntity): def check_type(cls, value, validation_info: ValidationInfo): typ = value value = validation_info.data.get("value") + + if value is None: + return typ + if typ == "mixed" and not isinstance(value, str): raise ValueError("value must be a string") elif typ == "variable": @@ -55,3 +59,22 @@ class ToolNodeData(BaseNodeData, ToolEntity): return typ tool_parameters: dict[str, ToolInput] + + @field_validator("tool_parameters", mode="before") + @classmethod + def filter_none_tool_inputs(cls, value): + if not isinstance(value, dict): + return value + + return { + key: tool_input + for key, tool_input in value.items() + if tool_input is not None and cls._has_valid_value(tool_input) + } + + @staticmethod + def _has_valid_value(tool_input): + """Check if the value is valid""" + if isinstance(tool_input, dict): + return tool_input.get("value") is not None + return getattr(tool_input, "value", None) is not None diff --git a/api/core/workflow/nodes/tool/tool_node.py b/api/core/workflow/nodes/tool/tool_node.py index 4d15d78a95..48627a229d 100644 --- a/api/core/workflow/nodes/tool/tool_node.py +++ b/api/core/workflow/nodes/tool/tool_node.py @@ -1,11 +1,12 @@ from collections.abc import Generator, Mapping, Sequence -from typing import Any, cast +from typing import Any, Optional, cast from sqlalchemy import select from sqlalchemy.orm import Session from core.callback_handler.workflow_tool_callback_handler import DifyWorkflowCallbackHandler from core.file import File, FileTransferMethod +from core.model_runtime.entities.llm_entities import LLMUsage from core.plugin.impl.exc import PluginDaemonClientSideError from core.plugin.impl.plugin import PluginInstaller from core.tools.entities.tool_entities import ToolInvokeMessage, ToolParameter @@ -66,8 +67,9 @@ class ToolNode(BaseNode[ToolNodeData]): try: from core.tools.tool_manager import ToolManager + variable_pool = self.graph_runtime_state.variable_pool if self.node_data.version != "1" else None tool_runtime = ToolManager.get_workflow_tool_runtime( - self.tenant_id, self.app_id, self.node_id, self.node_data, self.invoke_from + self.tenant_id, self.app_id, self.node_id, self.node_data, self.invoke_from, variable_pool ) except ToolNodeError as e: yield RunCompletedEvent( @@ -94,7 +96,6 @@ class ToolNode(BaseNode[ToolNodeData]): node_data=self.node_data, for_log=True, ) - # get conversation id conversation_id = self.graph_runtime_state.variable_pool.get(["sys", SystemVariableKey.CONVERSATION_ID]) @@ -190,6 +191,7 @@ class ToolNode(BaseNode[ToolNodeData]): messages: Generator[ToolInvokeMessage, None, None], tool_info: Mapping[str, Any], parameters_for_log: dict[str, Any], + agent_thoughts: Optional[list] = None, ) -> Generator: """ Convert ToolInvokeMessages into tuple[plain_text, files] @@ -208,7 +210,7 @@ class ToolNode(BaseNode[ToolNodeData]): agent_logs: list[AgentLogEvent] = [] agent_execution_metadata: Mapping[WorkflowNodeExecutionMetadataKey, Any] = {} - + llm_usage: LLMUsage | None = None variables: dict[str, Any] = {} for message in message_stream: @@ -276,13 +278,15 @@ class ToolNode(BaseNode[ToolNodeData]): elif message.type == ToolInvokeMessage.MessageType.JSON: assert isinstance(message.message, ToolInvokeMessage.JsonMessage) if self.node_type == NodeType.AGENT: - msg_metadata = message.message.json_object.pop("execution_metadata", {}) + msg_metadata: dict[str, Any] = message.message.json_object.pop("execution_metadata", {}) + llm_usage = LLMUsage.from_metadata(msg_metadata) agent_execution_metadata = { - key: value + WorkflowNodeExecutionMetadataKey(key): value for key, value in msg_metadata.items() if key in WorkflowNodeExecutionMetadataKey.__members__.values() } - json.append(message.message.json_object) + if message.message.json_object is not None: + json.append(message.message.json_object) elif message.type == ToolInvokeMessage.MessageType.LINK: assert isinstance(message.message, ToolInvokeMessage.TextMessage) stream_text = f"Link: {message.message.text}\n" @@ -325,6 +329,7 @@ class ToolNode(BaseNode[ToolNodeData]): icon = current_plugin.declaration.icon except StopIteration: pass + icon_dark = None try: builtin_tool = next( provider @@ -335,10 +340,12 @@ class ToolNode(BaseNode[ToolNodeData]): if provider.name == dict_metadata["provider"] ) icon = builtin_tool.icon + icon_dark = builtin_tool.icon_dark except StopIteration: pass dict_metadata["icon"] = icon + dict_metadata["icon_dark"] = icon_dark message.message.metadata = dict_metadata agent_log = AgentLogEvent( id=message.message.id, @@ -367,16 +374,41 @@ class ToolNode(BaseNode[ToolNodeData]): yield agent_log + # Add agent_logs to outputs['json'] to ensure frontend can access thinking process + json_output: list[dict[str, Any]] = [] + + # Step 1: append each agent log as its own dict. + if agent_logs: + for log in agent_logs: + json_output.append( + { + "id": log.id, + "parent_id": log.parent_id, + "error": log.error, + "status": log.status, + "data": log.data, + "label": log.label, + "metadata": log.metadata, + "node_id": log.node_id, + } + ) + # Step 2: normalize JSON into {"data": [...]}.change json to list[dict] + if json: + json_output.extend(json) + else: + json_output.append({"data": []}) + yield RunCompletedEvent( run_result=NodeRunResult( status=WorkflowNodeExecutionStatus.SUCCEEDED, - outputs={"text": text, "files": ArrayFileSegment(value=files), "json": json, **variables}, + outputs={"text": text, "files": ArrayFileSegment(value=files), "json": json_output, **variables}, metadata={ **agent_execution_metadata, WorkflowNodeExecutionMetadataKey.TOOL_INFO: tool_info, WorkflowNodeExecutionMetadataKey.AGENT_LOG: agent_logs, }, inputs=parameters_for_log, + llm_usage=llm_usage, ) ) diff --git a/api/core/workflow/workflow_entry.py b/api/core/workflow/workflow_entry.py index c0e98db3db..2868dcb7de 100644 --- a/api/core/workflow/workflow_entry.py +++ b/api/core/workflow/workflow_entry.py @@ -69,6 +69,7 @@ class WorkflowEntry: raise ValueError("Max workflow call depth {} reached.".format(workflow_call_max_depth)) # init workflow run state + graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter()) self.graph_engine = GraphEngine( tenant_id=tenant_id, app_id=app_id, @@ -80,7 +81,7 @@ class WorkflowEntry: call_depth=call_depth, graph=graph, graph_config=graph_config, - variable_pool=variable_pool, + graph_runtime_state=graph_runtime_state, max_execution_steps=dify_config.WORKFLOW_MAX_EXECUTION_STEPS, max_execution_time=dify_config.WORKFLOW_MAX_EXECUTION_TIME, thread_pool_id=thread_pool_id, diff --git a/api/extensions/ext_blueprints.py b/api/extensions/ext_blueprints.py index 316be12f5c..a4d013ffc0 100644 --- a/api/extensions/ext_blueprints.py +++ b/api/extensions/ext_blueprints.py @@ -10,6 +10,7 @@ def init_app(app: DifyApp): from controllers.console import bp as console_app_bp from controllers.files import bp as files_bp from controllers.inner_api import bp as inner_api_bp + from controllers.mcp import bp as mcp_bp from controllers.service_api import bp as service_api_bp from controllers.web import bp as web_bp @@ -46,3 +47,4 @@ def init_app(app: DifyApp): app.register_blueprint(files_bp) app.register_blueprint(inner_api_bp) + app.register_blueprint(mcp_bp) diff --git a/api/extensions/ext_login.py b/api/extensions/ext_login.py index 3b4d787d01..11d1856ac4 100644 --- a/api/extensions/ext_login.py +++ b/api/extensions/ext_login.py @@ -10,7 +10,7 @@ from dify_app import DifyApp from extensions.ext_database import db from libs.passport import PassportService from models.account import Account, Tenant, TenantAccountJoin -from models.model import EndUser +from models.model import AppMCPServer, EndUser from services.account_service import AccountService login_manager = flask_login.LoginManager() @@ -74,6 +74,21 @@ def load_user_from_request(request_from_flask_login): if not end_user: raise NotFound("End user not found.") return end_user + elif request.blueprint == "mcp": + server_code = request.view_args.get("server_code") if request.view_args else None + if not server_code: + raise Unauthorized("Invalid Authorization token.") + app_mcp_server = db.session.query(AppMCPServer).filter(AppMCPServer.server_code == server_code).first() + if not app_mcp_server: + raise NotFound("App MCP server not found.") + end_user = ( + db.session.query(EndUser) + .filter(EndUser.external_user_id == app_mcp_server.id, EndUser.type == "mcp") + .first() + ) + if not end_user: + raise NotFound("End user not found.") + return end_user @user_logged_in.connect diff --git a/api/extensions/ext_otel.py b/api/extensions/ext_otel.py index 23cf4c5cab..b62b0b60d6 100644 --- a/api/extensions/ext_otel.py +++ b/api/extensions/ext_otel.py @@ -12,6 +12,7 @@ from flask_login import user_loaded_from_request, user_logged_in # type: ignore from configs import dify_config from dify_app import DifyApp +from libs.helper import extract_tenant_id from models import Account, EndUser @@ -24,11 +25,8 @@ def on_user_loaded(_sender, user: Union["Account", "EndUser"]): if user: try: current_span = get_current_span() - if isinstance(user, Account) and user.current_tenant_id: - tenant_id = user.current_tenant_id - elif isinstance(user, EndUser): - tenant_id = user.tenant_id - else: + tenant_id = extract_tenant_id(user) + if not tenant_id: return if current_span: current_span.set_attribute("service.tenant.id", tenant_id) diff --git a/api/extensions/ext_redis.py b/api/extensions/ext_redis.py index c283b1b7ca..be2f6115f7 100644 --- a/api/extensions/ext_redis.py +++ b/api/extensions/ext_redis.py @@ -1,6 +1,10 @@ +import functools +import logging +from collections.abc import Callable from typing import Any, Union import redis +from redis import RedisError from redis.cache import CacheConfig from redis.cluster import ClusterNode, RedisCluster from redis.connection import Connection, SSLConnection @@ -9,6 +13,8 @@ from redis.sentinel import Sentinel from configs import dify_config from dify_app import DifyApp +logger = logging.getLogger(__name__) + class RedisClientWrapper: """ @@ -115,3 +121,25 @@ def init_app(app: DifyApp): redis_client.initialize(redis.Redis(connection_pool=pool)) app.extensions["redis"] = redis_client + + +def redis_fallback(default_return: Any = None): + """ + decorator to handle Redis operation exceptions and return a default value when Redis is unavailable. + + Args: + default_return: The value to return when a Redis operation fails. Defaults to None. + """ + + def decorator(func: Callable): + @functools.wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except RedisError as e: + logger.warning(f"Redis operation failed in {func.__name__}: {str(e)}", exc_info=True) + return default_return + + return wrapper + + return decorator diff --git a/api/factories/agent_factory.py b/api/factories/agent_factory.py index 4b12afb528..2570bc22f1 100644 --- a/api/factories/agent_factory.py +++ b/api/factories/agent_factory.py @@ -10,6 +10,6 @@ def get_plugin_agent_strategy( agent_provider = manager.fetch_agent_strategy_provider(tenant_id, agent_strategy_provider_name) for agent_strategy in agent_provider.declaration.strategies: if agent_strategy.identity.name == agent_strategy_name: - return PluginAgentStrategy(tenant_id, agent_strategy) + return PluginAgentStrategy(tenant_id, agent_strategy, agent_provider.meta.version) raise ValueError(f"Agent strategy {agent_strategy_name} not found") diff --git a/api/fields/app_fields.py b/api/fields/app_fields.py index 500ca47c7e..73c224542a 100644 --- a/api/fields/app_fields.py +++ b/api/fields/app_fields.py @@ -1,8 +1,21 @@ +import json + from flask_restful import fields from fields.workflow_fields import workflow_partial_fields from libs.helper import AppIconUrlField, TimestampField + +class JsonStringField(fields.Raw): + def format(self, value): + if isinstance(value, str): + try: + return json.loads(value) + except (json.JSONDecodeError, TypeError): + return value + return value + + app_detail_kernel_fields = { "id": fields.String, "name": fields.String, @@ -218,3 +231,14 @@ app_import_fields = { app_import_check_dependencies_fields = { "leaked_dependencies": fields.List(fields.Nested(leaked_dependency_fields)), } + +app_server_fields = { + "id": fields.String, + "name": fields.String, + "server_code": fields.String, + "description": fields.String, + "status": fields.String, + "parameters": JsonStringField, + "created_at": TimestampField, + "updated_at": TimestampField, +} diff --git a/api/fields/workflow_fields.py b/api/fields/workflow_fields.py index 9f1bef3b36..f00ea71c54 100644 --- a/api/fields/workflow_fields.py +++ b/api/fields/workflow_fields.py @@ -17,6 +17,7 @@ class EnvironmentVariableField(fields.Raw): "name": value.name, "value": encrypter.obfuscated_token(value.value), "value_type": value.value_type.value, + "description": value.description, } if isinstance(value, Variable): return { @@ -24,6 +25,7 @@ class EnvironmentVariableField(fields.Raw): "name": value.name, "value": value.value, "value_type": value.value_type.value, + "description": value.description, } if isinstance(value, dict): value_type = value.get("value_type") diff --git a/api/libs/helper.py b/api/libs/helper.py index 3f2a630956..48126461a3 100644 --- a/api/libs/helper.py +++ b/api/libs/helper.py @@ -25,6 +25,31 @@ from extensions.ext_redis import redis_client if TYPE_CHECKING: from models.account import Account + from models.model import EndUser + + +def extract_tenant_id(user: Union["Account", "EndUser"]) -> str | None: + """ + Extract tenant_id from Account or EndUser object. + + Args: + user: Account or EndUser object + + Returns: + tenant_id string if available, None otherwise + + Raises: + ValueError: If user is neither Account nor EndUser + """ + from models.account import Account + from models.model import EndUser + + if isinstance(user, Account): + return user.current_tenant_id + elif isinstance(user, EndUser): + return user.tenant_id + else: + raise ValueError(f"Invalid user type: {type(user)}. Expected Account or EndUser.") def run(script): diff --git a/api/migrations/versions/2025_06_25_0936-58eb7bdb93fe_add_mcp_server_tool_and_app_server.py b/api/migrations/versions/2025_06_25_0936-58eb7bdb93fe_add_mcp_server_tool_and_app_server.py new file mode 100644 index 0000000000..0548bf05ef --- /dev/null +++ b/api/migrations/versions/2025_06_25_0936-58eb7bdb93fe_add_mcp_server_tool_and_app_server.py @@ -0,0 +1,64 @@ +"""add mcp server tool and app server + +Revision ID: 58eb7bdb93fe +Revises: 0ab65e1cc7fa +Create Date: 2025-06-25 09:36:07.510570 + +""" +from alembic import op +import models as models +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '58eb7bdb93fe' +down_revision = '0ab65e1cc7fa' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('app_mcp_servers', + sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('tenant_id', models.types.StringUUID(), nullable=False), + sa.Column('app_id', models.types.StringUUID(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('description', sa.String(length=255), nullable=False), + sa.Column('server_code', sa.String(length=255), nullable=False), + sa.Column('status', sa.String(length=255), server_default=sa.text("'normal'::character varying"), nullable=False), + sa.Column('parameters', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.PrimaryKeyConstraint('id', name='app_mcp_server_pkey'), + sa.UniqueConstraint('tenant_id', 'app_id', name='unique_app_mcp_server_tenant_app_id'), + sa.UniqueConstraint('server_code', name='unique_app_mcp_server_server_code') + ) + op.create_table('tool_mcp_providers', + sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('name', sa.String(length=40), nullable=False), + sa.Column('server_identifier', sa.String(length=24), nullable=False), + sa.Column('server_url', sa.Text(), nullable=False), + sa.Column('server_url_hash', sa.String(length=64), nullable=False), + sa.Column('icon', sa.String(length=255), nullable=True), + sa.Column('tenant_id', models.types.StringUUID(), nullable=False), + sa.Column('user_id', models.types.StringUUID(), nullable=False), + sa.Column('encrypted_credentials', sa.Text(), nullable=True), + sa.Column('authed', sa.Boolean(), nullable=False), + sa.Column('tools', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.PrimaryKeyConstraint('id', name='tool_mcp_provider_pkey'), + sa.UniqueConstraint('tenant_id', 'name', name='unique_mcp_provider_name'), + sa.UniqueConstraint('tenant_id', 'server_identifier', name='unique_mcp_provider_server_identifier'), + sa.UniqueConstraint('tenant_id', 'server_url_hash', name='unique_mcp_provider_server_url') + ) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('tool_mcp_providers') + op.drop_table('app_mcp_servers') + # ### end Alembic commands ### diff --git a/api/models/__init__.py b/api/models/__init__.py index 83b50eb099..1b4bdd32e4 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -34,6 +34,7 @@ from .model import ( App, AppAnnotationHitHistory, AppAnnotationSetting, + AppMCPServer, AppMode, AppModelConfig, Conversation, @@ -103,6 +104,7 @@ __all__ = [ "AppAnnotationHitHistory", "AppAnnotationSetting", "AppDatasetJoin", + "AppMCPServer", # Added "AppMode", "AppModelConfig", "BuiltinToolProvider", diff --git a/api/models/enums.py b/api/models/enums.py index 4434c3fec8..cc9f28a7bb 100644 --- a/api/models/enums.py +++ b/api/models/enums.py @@ -21,3 +21,12 @@ class DraftVariableType(StrEnum): NODE = "node" SYS = "sys" CONVERSATION = "conversation" + + +class MessageStatus(StrEnum): + """ + Message Status Enum + """ + + NORMAL = "normal" + ERROR = "error" diff --git a/api/models/model.py b/api/models/model.py index 93737043d5..b1007c4a79 100644 --- a/api/models/model.py +++ b/api/models/model.py @@ -1456,6 +1456,39 @@ class EndUser(Base, UserMixin): updated_at = db.Column(db.DateTime, nullable=False, server_default=func.current_timestamp()) +class AppMCPServer(Base): + __tablename__ = "app_mcp_servers" + __table_args__ = ( + db.PrimaryKeyConstraint("id", name="app_mcp_server_pkey"), + db.UniqueConstraint("tenant_id", "app_id", name="unique_app_mcp_server_tenant_app_id"), + db.UniqueConstraint("server_code", name="unique_app_mcp_server_server_code"), + ) + id = db.Column(StringUUID, server_default=db.text("uuid_generate_v4()")) + tenant_id = db.Column(StringUUID, nullable=False) + app_id = db.Column(StringUUID, nullable=False) + name = db.Column(db.String(255), nullable=False) + description = db.Column(db.String(255), nullable=False) + server_code = db.Column(db.String(255), nullable=False) + status = db.Column(db.String(255), nullable=False, server_default=db.text("'normal'::character varying")) + parameters = db.Column(db.Text, nullable=False) + + created_at = db.Column(db.DateTime, nullable=False, server_default=func.current_timestamp()) + updated_at = db.Column(db.DateTime, nullable=False, server_default=func.current_timestamp()) + + @staticmethod + def generate_server_code(n): + while True: + result = generate_string(n) + while db.session.query(AppMCPServer).filter(AppMCPServer.server_code == result).count() > 0: + result = generate_string(n) + + return result + + @property + def parameters_dict(self) -> dict[str, Any]: + return cast(dict[str, Any], json.loads(self.parameters)) + + class Site(Base): __tablename__ = "sites" __table_args__ = ( diff --git a/api/models/tools.py b/api/models/tools.py index ef2f7bcdde..05a4920a9c 100644 --- a/api/models/tools.py +++ b/api/models/tools.py @@ -1,12 +1,16 @@ import json from datetime import datetime from typing import Any, cast +from urllib.parse import urlparse import sqlalchemy as sa from deprecated import deprecated from sqlalchemy import ForeignKey, func from sqlalchemy.orm import Mapped, mapped_column +from core.file import helpers as file_helpers +from core.helper import encrypter +from core.mcp.types import Tool from core.tools.entities.common_entities import I18nObject from core.tools.entities.tool_bundle import ApiToolBundle from core.tools.entities.tool_entities import ApiProviderSchemaType, WorkflowToolParameterConfiguration @@ -234,6 +238,108 @@ class WorkflowToolProvider(Base): return db.session.query(App).filter(App.id == self.app_id).first() +class MCPToolProvider(Base): + """ + The table stores the mcp providers. + """ + + __tablename__ = "tool_mcp_providers" + __table_args__ = ( + db.PrimaryKeyConstraint("id", name="tool_mcp_provider_pkey"), + db.UniqueConstraint("tenant_id", "server_url_hash", name="unique_mcp_provider_server_url"), + db.UniqueConstraint("tenant_id", "name", name="unique_mcp_provider_name"), + db.UniqueConstraint("tenant_id", "server_identifier", name="unique_mcp_provider_server_identifier"), + ) + + id: Mapped[str] = mapped_column(StringUUID, server_default=db.text("uuid_generate_v4()")) + # name of the mcp provider + name: Mapped[str] = mapped_column(db.String(40), nullable=False) + # server identifier of the mcp provider + server_identifier: Mapped[str] = mapped_column(db.String(24), nullable=False) + # encrypted url of the mcp provider + server_url: Mapped[str] = mapped_column(db.Text, nullable=False) + # hash of server_url for uniqueness check + server_url_hash: Mapped[str] = mapped_column(db.String(64), nullable=False) + # icon of the mcp provider + icon: Mapped[str] = mapped_column(db.String(255), nullable=True) + # tenant id + tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + # who created this tool + user_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + # encrypted credentials + encrypted_credentials: Mapped[str] = mapped_column(db.Text, nullable=True) + # authed + authed: Mapped[bool] = mapped_column(db.Boolean, nullable=False, default=False) + # tools + tools: Mapped[str] = mapped_column(db.Text, nullable=False, default="[]") + created_at: Mapped[datetime] = mapped_column( + db.DateTime, nullable=False, server_default=db.text("CURRENT_TIMESTAMP(0)") + ) + updated_at: Mapped[datetime] = mapped_column( + db.DateTime, nullable=False, server_default=db.text("CURRENT_TIMESTAMP(0)") + ) + + def load_user(self) -> Account | None: + return db.session.query(Account).filter(Account.id == self.user_id).first() + + @property + def tenant(self) -> Tenant | None: + return db.session.query(Tenant).filter(Tenant.id == self.tenant_id).first() + + @property + def credentials(self) -> dict: + try: + return cast(dict, json.loads(self.encrypted_credentials)) or {} + except Exception: + return {} + + @property + def mcp_tools(self) -> list[Tool]: + return [Tool(**tool) for tool in json.loads(self.tools)] + + @property + def provider_icon(self) -> dict[str, str] | str: + try: + return cast(dict[str, str], json.loads(self.icon)) + except json.JSONDecodeError: + return file_helpers.get_signed_file_url(self.icon) + + @property + def decrypted_server_url(self) -> str: + return cast(str, encrypter.decrypt_token(self.tenant_id, self.server_url)) + + @property + def masked_server_url(self) -> str: + def mask_url(url: str, mask_char: str = "*") -> str: + """ + mask the url to a simple string + """ + parsed = urlparse(url) + base_url = f"{parsed.scheme}://{parsed.netloc}" + + if parsed.path and parsed.path != "/": + return f"{base_url}/{mask_char * 6}" + else: + return base_url + + return mask_url(self.decrypted_server_url) + + @property + def decrypted_credentials(self) -> dict: + from core.tools.mcp_tool.provider import MCPToolProviderController + from core.tools.utils.configuration import ProviderConfigEncrypter + + provider_controller = MCPToolProviderController._from_db(self) + + tool_configuration = ProviderConfigEncrypter( + tenant_id=self.tenant_id, + config=list(provider_controller.get_credentials_schema()), + provider_type=provider_controller.provider_type.value, + provider_identity=provider_controller.provider_id, + ) + return tool_configuration.decrypt(self.credentials, use_cache=False) + + class ToolModelInvoke(Base): """ store the invoke logs from tool invoke diff --git a/api/models/workflow.py b/api/models/workflow.py index 7f01135af3..77d48bec4f 100644 --- a/api/models/workflow.py +++ b/api/models/workflow.py @@ -15,6 +15,7 @@ from core.variables import utils as variable_utils from core.workflow.constants import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID from core.workflow.nodes.enums import NodeType from factories.variable_factory import TypeMismatchError, build_segment_with_type +from libs.helper import extract_tenant_id from ._workflow_exc import NodeNotFoundError, WorkflowDataError @@ -352,12 +353,7 @@ class Workflow(Base): self._environment_variables = "{}" # Get tenant_id from current_user (Account or EndUser) - if isinstance(current_user, Account): - # Account user - tenant_id = current_user.current_tenant_id - else: - # EndUser - tenant_id = current_user.tenant_id + tenant_id = extract_tenant_id(current_user) if not tenant_id: return [] @@ -384,12 +380,7 @@ class Workflow(Base): return # Get tenant_id from current_user (Account or EndUser) - if isinstance(current_user, Account): - # Account user - tenant_id = current_user.current_tenant_id - else: - # EndUser - tenant_id = current_user.tenant_id + tenant_id = extract_tenant_id(current_user) if not tenant_id: self._environment_variables = "{}" diff --git a/api/pyproject.toml b/api/pyproject.toml index b79b0fb236..420bc771b6 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -1,9 +1,10 @@ [project] name = "dify-api" -version = "1.5.1" +version = "1.6.0" requires-python = ">=3.11,<3.13" dependencies = [ + "arize-phoenix-otel~=0.9.2", "authlib==1.3.1", "azure-identity==1.16.1", "beautifulsoup4==4.12.2", @@ -81,6 +82,8 @@ dependencies = [ "weave~=0.51.0", "yarl~=1.18.3", "webvtt-py~=0.5.1", + "sseclient-py>=1.8.0", + "httpx-sse>=0.4.0", "sendgrid~=6.12.3", ] # Before adding new dependency, consider place it in diff --git a/api/services/account_service.py b/api/services/account_service.py index 3fdbda48a6..2ba6f4345b 100644 --- a/api/services/account_service.py +++ b/api/services/account_service.py @@ -16,7 +16,7 @@ from configs import dify_config from constants.languages import language_timezone_mapping, languages from events.tenant_event import tenant_was_created from extensions.ext_database import db -from extensions.ext_redis import redis_client +from extensions.ext_redis import redis_client, redis_fallback from libs.helper import RateLimiter, TokenManager from libs.passport import PassportService from libs.password import compare_password, hash_password, valid_password @@ -495,6 +495,7 @@ class AccountService: return account @staticmethod + @redis_fallback(default_return=None) def add_login_error_rate_limit(email: str) -> None: key = f"login_error_rate_limit:{email}" count = redis_client.get(key) @@ -504,6 +505,7 @@ class AccountService: redis_client.setex(key, dify_config.LOGIN_LOCKOUT_DURATION, count) @staticmethod + @redis_fallback(default_return=False) def is_login_error_rate_limit(email: str) -> bool: key = f"login_error_rate_limit:{email}" count = redis_client.get(key) @@ -516,11 +518,13 @@ class AccountService: return False @staticmethod + @redis_fallback(default_return=None) def reset_login_error_rate_limit(email: str): key = f"login_error_rate_limit:{email}" redis_client.delete(key) @staticmethod + @redis_fallback(default_return=None) def add_forgot_password_error_rate_limit(email: str) -> None: key = f"forgot_password_error_rate_limit:{email}" count = redis_client.get(key) @@ -530,6 +534,7 @@ class AccountService: redis_client.setex(key, dify_config.FORGOT_PASSWORD_LOCKOUT_DURATION, count) @staticmethod + @redis_fallback(default_return=False) def is_forgot_password_error_rate_limit(email: str) -> bool: key = f"forgot_password_error_rate_limit:{email}" count = redis_client.get(key) @@ -542,11 +547,13 @@ class AccountService: return False @staticmethod + @redis_fallback(default_return=None) def reset_forgot_password_error_rate_limit(email: str): key = f"forgot_password_error_rate_limit:{email}" redis_client.delete(key) @staticmethod + @redis_fallback(default_return=False) def is_email_send_ip_limit(ip_address: str): minute_key = f"email_send_ip_limit_minute:{ip_address}" freeze_key = f"email_send_ip_limit_freeze:{ip_address}" diff --git a/api/services/audio_service.py b/api/services/audio_service.py index a259f5a4c4..e8923eb51b 100644 --- a/api/services/audio_service.py +++ b/api/services/audio_service.py @@ -1,13 +1,17 @@ import io import logging import uuid +from collections.abc import Generator from typing import Optional +from flask import Response, stream_with_context from werkzeug.datastructures import FileStorage from constants import AUDIO_EXTENSIONS from core.model_manager import ModelManager from core.model_runtime.entities.model_entities import ModelType +from extensions.ext_database import db +from models.enums import MessageStatus from models.model import App, AppMode, AppModelConfig, Message from services.errors.audio import ( AudioTooLargeServiceError, @@ -16,6 +20,7 @@ from services.errors.audio import ( ProviderNotSupportTextToSpeechServiceError, UnsupportedAudioTypeServiceError, ) +from services.workflow_service import WorkflowService FILE_SIZE = 30 FILE_SIZE_LIMIT = FILE_SIZE * 1024 * 1024 @@ -74,35 +79,36 @@ class AudioService: voice: Optional[str] = None, end_user: Optional[str] = None, message_id: Optional[str] = None, + is_draft: bool = False, ): - from collections.abc import Generator - - from flask import Response, stream_with_context - from app import app - from extensions.ext_database import db - def invoke_tts(text_content: str, app_model: App, voice: Optional[str] = None): + def invoke_tts(text_content: str, app_model: App, voice: Optional[str] = None, is_draft: bool = False): with app.app_context(): - if app_model.mode in {AppMode.ADVANCED_CHAT.value, AppMode.WORKFLOW.value}: - workflow = app_model.workflow - if workflow is None: - raise ValueError("TTS is not enabled") + if voice is None: + if app_model.mode in {AppMode.ADVANCED_CHAT.value, AppMode.WORKFLOW.value}: + if is_draft: + workflow = WorkflowService().get_draft_workflow(app_model=app_model) + else: + workflow = app_model.workflow + if ( + workflow is None + or "text_to_speech" not in workflow.features_dict + or not workflow.features_dict["text_to_speech"].get("enabled") + ): + raise ValueError("TTS is not enabled") - features_dict = workflow.features_dict - if "text_to_speech" not in features_dict or not features_dict["text_to_speech"].get("enabled"): - raise ValueError("TTS is not enabled") + voice = workflow.features_dict["text_to_speech"].get("voice") + else: + if not is_draft: + if app_model.app_model_config is None: + raise ValueError("AppModelConfig not found") + text_to_speech_dict = app_model.app_model_config.text_to_speech_dict - voice = features_dict["text_to_speech"].get("voice") if voice is None else voice - else: - if app_model.app_model_config is None: - raise ValueError("AppModelConfig not found") - text_to_speech_dict = app_model.app_model_config.text_to_speech_dict + if not text_to_speech_dict.get("enabled"): + raise ValueError("TTS is not enabled") - if not text_to_speech_dict.get("enabled"): - raise ValueError("TTS is not enabled") - - voice = text_to_speech_dict.get("voice") if voice is None else voice + voice = text_to_speech_dict.get("voice") model_manager = ModelManager() model_instance = model_manager.get_default_model_instance( @@ -132,18 +138,18 @@ class AudioService: message = db.session.query(Message).filter(Message.id == message_id).first() if message is None: return None - if message.answer == "" and message.status == "normal": + if message.answer == "" and message.status == MessageStatus.NORMAL: return None else: - response = invoke_tts(message.answer, app_model=app_model, voice=voice) + response = invoke_tts(text_content=message.answer, app_model=app_model, voice=voice, is_draft=is_draft) if isinstance(response, Generator): return Response(stream_with_context(response), content_type="audio/mpeg") return response else: if text is None: raise ValueError("Text is required") - response = invoke_tts(text, app_model, voice) + response = invoke_tts(text_content=text, app_model=app_model, voice=voice, is_draft=is_draft) if isinstance(response, Generator): return Response(stream_with_context(response), content_type="audio/mpeg") return response diff --git a/api/services/file_service.py b/api/services/file_service.py index 2d68f30c5a..286535bd18 100644 --- a/api/services/file_service.py +++ b/api/services/file_service.py @@ -18,6 +18,7 @@ from core.file import helpers as file_helpers from core.rag.extractor.extract_processor import ExtractProcessor from extensions.ext_database import db from extensions.ext_storage import storage +from libs.helper import extract_tenant_id from models.account import Account from models.enums import CreatorUserRole from models.model import EndUser, UploadFile @@ -61,11 +62,7 @@ class FileService: # generate file key file_uuid = str(uuid.uuid4()) - if isinstance(user, Account): - current_tenant_id = user.current_tenant_id - else: - # end_user - current_tenant_id = user.tenant_id + current_tenant_id = extract_tenant_id(user) file_key = "upload_files/" + (current_tenant_id or "") + "/" + file_uuid + "." + extension diff --git a/api/services/ops_service.py b/api/services/ops_service.py index 792f50703e..dbeb4f1908 100644 --- a/api/services/ops_service.py +++ b/api/services/ops_service.py @@ -34,6 +34,24 @@ class OpsService: ) new_decrypt_tracing_config = OpsTraceManager.obfuscated_decrypt_token(tracing_provider, decrypt_tracing_config) + if tracing_provider == "arize" and ( + "project_url" not in decrypt_tracing_config or not decrypt_tracing_config.get("project_url") + ): + try: + project_url = OpsTraceManager.get_trace_config_project_url(decrypt_tracing_config, tracing_provider) + new_decrypt_tracing_config.update({"project_url": project_url}) + except Exception: + new_decrypt_tracing_config.update({"project_url": "https://app.arize.com/"}) + + if tracing_provider == "phoenix" and ( + "project_url" not in decrypt_tracing_config or not decrypt_tracing_config.get("project_url") + ): + try: + project_url = OpsTraceManager.get_trace_config_project_url(decrypt_tracing_config, tracing_provider) + new_decrypt_tracing_config.update({"project_url": project_url}) + except Exception: + new_decrypt_tracing_config.update({"project_url": "https://app.phoenix.arize.com/projects/"}) + if tracing_provider == "langfuse" and ( "project_key" not in decrypt_tracing_config or not decrypt_tracing_config.get("project_key") ): @@ -76,6 +94,16 @@ class OpsService: new_decrypt_tracing_config.update({"project_url": project_url}) except Exception: new_decrypt_tracing_config.update({"project_url": "https://wandb.ai/"}) + + if tracing_provider == "aliyun" and ( + "project_url" not in decrypt_tracing_config or not decrypt_tracing_config.get("project_url") + ): + try: + project_url = OpsTraceManager.get_trace_config_project_url(decrypt_tracing_config, tracing_provider) + new_decrypt_tracing_config.update({"project_url": project_url}) + except Exception: + new_decrypt_tracing_config.update({"project_url": "https://arms.console.aliyun.com/"}) + trace_config_data.tracing_config = new_decrypt_tracing_config return trace_config_data.to_dict() @@ -107,7 +135,9 @@ class OpsService: return {"error": "Invalid Credentials"} # get project url - if tracing_provider == "langfuse": + if tracing_provider in ("arize", "phoenix"): + project_url = OpsTraceManager.get_trace_config_project_url(tracing_config, tracing_provider) + elif tracing_provider == "langfuse": project_key = OpsTraceManager.get_trace_config_project_key(tracing_config, tracing_provider) project_url = "{host}/project/{key}".format(host=tracing_config.get("host"), key=project_key) elif tracing_provider in ("langsmith", "opik"): diff --git a/api/services/tools/mcp_tools_mange_service.py b/api/services/tools/mcp_tools_mange_service.py new file mode 100644 index 0000000000..7c23abda4b --- /dev/null +++ b/api/services/tools/mcp_tools_mange_service.py @@ -0,0 +1,231 @@ +import hashlib +import json +from datetime import datetime +from typing import Any + +from sqlalchemy import or_ +from sqlalchemy.exc import IntegrityError + +from core.helper import encrypter +from core.mcp.error import MCPAuthError, MCPError +from core.mcp.mcp_client import MCPClient +from core.tools.entities.api_entities import ToolProviderApiEntity +from core.tools.entities.common_entities import I18nObject +from core.tools.entities.tool_entities import ToolProviderType +from core.tools.mcp_tool.provider import MCPToolProviderController +from core.tools.utils.configuration import ProviderConfigEncrypter +from extensions.ext_database import db +from models.tools import MCPToolProvider +from services.tools.tools_transform_service import ToolTransformService + +UNCHANGED_SERVER_URL_PLACEHOLDER = "[__HIDDEN__]" + + +class MCPToolManageService: + """ + Service class for managing mcp tools. + """ + + @staticmethod + def get_mcp_provider_by_provider_id(provider_id: str, tenant_id: str) -> MCPToolProvider: + res = ( + db.session.query(MCPToolProvider) + .filter(MCPToolProvider.tenant_id == tenant_id, MCPToolProvider.id == provider_id) + .first() + ) + if not res: + raise ValueError("MCP tool not found") + return res + + @staticmethod + def get_mcp_provider_by_server_identifier(server_identifier: str, tenant_id: str) -> MCPToolProvider: + res = ( + db.session.query(MCPToolProvider) + .filter(MCPToolProvider.tenant_id == tenant_id, MCPToolProvider.server_identifier == server_identifier) + .first() + ) + if not res: + raise ValueError("MCP tool not found") + return res + + @staticmethod + def create_mcp_provider( + tenant_id: str, + name: str, + server_url: str, + user_id: str, + icon: str, + icon_type: str, + icon_background: str, + server_identifier: str, + ) -> ToolProviderApiEntity: + server_url_hash = hashlib.sha256(server_url.encode()).hexdigest() + existing_provider = ( + db.session.query(MCPToolProvider) + .filter( + MCPToolProvider.tenant_id == tenant_id, + or_( + MCPToolProvider.name == name, + MCPToolProvider.server_url_hash == server_url_hash, + MCPToolProvider.server_identifier == server_identifier, + ), + ) + .first() + ) + if existing_provider: + if existing_provider.name == name: + raise ValueError(f"MCP tool {name} already exists") + elif existing_provider.server_url_hash == server_url_hash: + raise ValueError(f"MCP tool {server_url} already exists") + elif existing_provider.server_identifier == server_identifier: + raise ValueError(f"MCP tool {server_identifier} already exists") + encrypted_server_url = encrypter.encrypt_token(tenant_id, server_url) + mcp_tool = MCPToolProvider( + tenant_id=tenant_id, + name=name, + server_url=encrypted_server_url, + server_url_hash=server_url_hash, + user_id=user_id, + authed=False, + tools="[]", + icon=json.dumps({"content": icon, "background": icon_background}) if icon_type == "emoji" else icon, + server_identifier=server_identifier, + ) + db.session.add(mcp_tool) + db.session.commit() + return ToolTransformService.mcp_provider_to_user_provider(mcp_tool, for_list=True) + + @staticmethod + def retrieve_mcp_tools(tenant_id: str, for_list: bool = False) -> list[ToolProviderApiEntity]: + mcp_providers = ( + db.session.query(MCPToolProvider) + .filter(MCPToolProvider.tenant_id == tenant_id) + .order_by(MCPToolProvider.name) + .all() + ) + return [ + ToolTransformService.mcp_provider_to_user_provider(mcp_provider, for_list=for_list) + for mcp_provider in mcp_providers + ] + + @classmethod + def list_mcp_tool_from_remote_server(cls, tenant_id: str, provider_id: str): + mcp_provider = cls.get_mcp_provider_by_provider_id(provider_id, tenant_id) + + try: + with MCPClient( + mcp_provider.decrypted_server_url, provider_id, tenant_id, authed=mcp_provider.authed, for_list=True + ) as mcp_client: + tools = mcp_client.list_tools() + except MCPAuthError as e: + raise ValueError("Please auth the tool first") + except MCPError as e: + raise ValueError(f"Failed to connect to MCP server: {e}") + mcp_provider.tools = json.dumps([tool.model_dump() for tool in tools]) + mcp_provider.authed = True + mcp_provider.updated_at = datetime.now() + db.session.commit() + user = mcp_provider.load_user() + return ToolProviderApiEntity( + id=mcp_provider.id, + name=mcp_provider.name, + tools=ToolTransformService.mcp_tool_to_user_tool(mcp_provider, tools), + type=ToolProviderType.MCP, + icon=mcp_provider.icon, + author=user.name if user else "Anonymous", + server_url=mcp_provider.masked_server_url, + updated_at=int(mcp_provider.updated_at.timestamp()), + description=I18nObject(en_US="", zh_Hans=""), + label=I18nObject(en_US=mcp_provider.name, zh_Hans=mcp_provider.name), + plugin_unique_identifier=mcp_provider.server_identifier, + ) + + @classmethod + def delete_mcp_tool(cls, tenant_id: str, provider_id: str): + mcp_tool = cls.get_mcp_provider_by_provider_id(provider_id, tenant_id) + + db.session.delete(mcp_tool) + db.session.commit() + + @classmethod + def update_mcp_provider( + cls, + tenant_id: str, + provider_id: str, + name: str, + server_url: str, + icon: str, + icon_type: str, + icon_background: str, + server_identifier: str, + ): + mcp_provider = cls.get_mcp_provider_by_provider_id(provider_id, tenant_id) + mcp_provider.updated_at = datetime.now() + mcp_provider.name = name + mcp_provider.icon = ( + json.dumps({"content": icon, "background": icon_background}) if icon_type == "emoji" else icon + ) + mcp_provider.server_identifier = server_identifier + + if UNCHANGED_SERVER_URL_PLACEHOLDER not in server_url: + encrypted_server_url = encrypter.encrypt_token(tenant_id, server_url) + mcp_provider.server_url = encrypted_server_url + server_url_hash = hashlib.sha256(server_url.encode()).hexdigest() + + if server_url_hash != mcp_provider.server_url_hash: + cls._re_connect_mcp_provider(mcp_provider, provider_id, tenant_id) + mcp_provider.server_url_hash = server_url_hash + try: + db.session.commit() + except IntegrityError as e: + db.session.rollback() + error_msg = str(e.orig) + if "unique_mcp_provider_name" in error_msg: + raise ValueError(f"MCP tool {name} already exists") + elif "unique_mcp_provider_server_url" in error_msg: + raise ValueError(f"MCP tool {server_url} already exists") + elif "unique_mcp_provider_server_identifier" in error_msg: + raise ValueError(f"MCP tool {server_identifier} already exists") + else: + raise + + @classmethod + def update_mcp_provider_credentials( + cls, mcp_provider: MCPToolProvider, credentials: dict[str, Any], authed: bool = False + ): + provider_controller = MCPToolProviderController._from_db(mcp_provider) + tool_configuration = ProviderConfigEncrypter( + tenant_id=mcp_provider.tenant_id, + config=list(provider_controller.get_credentials_schema()), + provider_type=provider_controller.provider_type.value, + provider_identity=provider_controller.provider_id, + ) + credentials = tool_configuration.encrypt(credentials) + mcp_provider.updated_at = datetime.now() + mcp_provider.encrypted_credentials = json.dumps({**mcp_provider.credentials, **credentials}) + mcp_provider.authed = authed + if not authed: + mcp_provider.tools = "[]" + db.session.commit() + + @classmethod + def _re_connect_mcp_provider(cls, mcp_provider: MCPToolProvider, provider_id: str, tenant_id: str): + """re-connect mcp provider""" + try: + with MCPClient( + mcp_provider.decrypted_server_url, + provider_id, + tenant_id, + authed=False, + for_list=True, + ) as mcp_client: + tools = mcp_client.list_tools() + mcp_provider.authed = True + mcp_provider.tools = json.dumps([tool.model_dump() for tool in tools]) + except MCPAuthError: + mcp_provider.authed = False + mcp_provider.tools = "[]" + except MCPError as e: + raise ValueError(f"Failed to re-connect MCP server: {e}") from e + # reset credentials + mcp_provider.encrypted_credentials = "{}" diff --git a/api/services/tools/tools_transform_service.py b/api/services/tools/tools_transform_service.py index cafcaecdf0..36b892e205 100644 --- a/api/services/tools/tools_transform_service.py +++ b/api/services/tools/tools_transform_service.py @@ -1,11 +1,12 @@ import json import logging -from typing import Optional, Union, cast +from typing import Any, Optional, Union, cast from yarl import URL from configs import dify_config from core.helper.provider_cache import ToolProviderCredentialsCache +from core.mcp.types import Tool as MCPTool from core.tools.__base.tool import Tool from core.tools.__base.tool_runtime import ToolRuntime from core.tools.builtin_tool.provider import BuiltinToolProviderController @@ -23,7 +24,7 @@ from core.tools.plugin_tool.provider import PluginToolProviderController from core.tools.utils.encryption import create_provider_encrypter, create_tool_provider_encrypter from core.tools.workflow_as_tool.provider import WorkflowToolProviderController from core.tools.workflow_as_tool.tool import WorkflowTool -from models.tools import ApiToolProvider, BuiltinToolProvider, WorkflowToolProvider +from models.tools import ApiToolProvider, BuiltinToolProvider, MCPToolProvider, WorkflowToolProvider logger = logging.getLogger(__name__) @@ -54,7 +55,8 @@ class ToolTransformService: return icon except Exception: return {"background": "#252525", "content": "\ud83d\ude01"} - + elif provider_type == ToolProviderType.MCP.value: + return icon return "" @staticmethod @@ -75,10 +77,18 @@ class ToolTransformService: provider.icon = ToolTransformService.get_plugin_icon_url( tenant_id=tenant_id, filename=provider.icon ) + if isinstance(provider.icon_dark, str) and provider.icon_dark: + provider.icon_dark = ToolTransformService.get_plugin_icon_url( + tenant_id=tenant_id, filename=provider.icon_dark + ) else: provider.icon = ToolTransformService.get_tool_provider_icon_url( provider_type=provider.type.value, provider_name=provider.name, icon=provider.icon ) + if provider.icon_dark: + provider.icon_dark = ToolTransformService.get_tool_provider_icon_url( + provider_type=provider.type.value, provider_name=provider.name, icon=provider.icon_dark + ) @classmethod def builtin_provider_to_user_provider( @@ -96,6 +106,7 @@ class ToolTransformService: name=provider_controller.entity.identity.name, description=provider_controller.entity.identity.description, icon=provider_controller.entity.identity.icon, + icon_dark=provider_controller.entity.identity.icon_dark, label=provider_controller.entity.identity.label, type=ToolProviderType.BUILT_IN, masked_credentials={}, @@ -163,11 +174,16 @@ class ToolTransformService: convert provider controller to user provider """ # package tool provider controller + auth_type = ApiProviderAuthType.NONE + credentials_auth_type = db_provider.credentials.get("auth_type") + if credentials_auth_type in ("api_key_header", "api_key"): # backward compatibility + auth_type = ApiProviderAuthType.API_KEY_HEADER + elif credentials_auth_type == "api_key_query": + auth_type = ApiProviderAuthType.API_KEY_QUERY + controller = ApiToolProviderController.from_db( db_provider=db_provider, - auth_type=ApiProviderAuthType.API_KEY - if db_provider.credentials["auth_type"] == "api_key" - else ApiProviderAuthType.NONE, + auth_type=auth_type, ) return controller @@ -192,6 +208,7 @@ class ToolTransformService: name=provider_controller.entity.identity.name, description=provider_controller.entity.identity.description, icon=provider_controller.entity.identity.icon, + icon_dark=provider_controller.entity.identity.icon_dark, label=provider_controller.entity.identity.label, type=ToolProviderType.WORKFLOW, masked_credentials={}, @@ -202,6 +219,41 @@ class ToolTransformService: labels=labels or [], ) + @staticmethod + def mcp_provider_to_user_provider(db_provider: MCPToolProvider, for_list: bool = False) -> ToolProviderApiEntity: + user = db_provider.load_user() + return ToolProviderApiEntity( + id=db_provider.server_identifier if not for_list else db_provider.id, + author=user.name if user else "Anonymous", + name=db_provider.name, + icon=db_provider.provider_icon, + type=ToolProviderType.MCP, + is_team_authorization=db_provider.authed, + server_url=db_provider.masked_server_url, + tools=ToolTransformService.mcp_tool_to_user_tool( + db_provider, [MCPTool(**tool) for tool in json.loads(db_provider.tools)] + ), + updated_at=int(db_provider.updated_at.timestamp()), + label=I18nObject(en_US=db_provider.name, zh_Hans=db_provider.name), + description=I18nObject(en_US="", zh_Hans=""), + server_identifier=db_provider.server_identifier, + ) + + @staticmethod + def mcp_tool_to_user_tool(mcp_provider: MCPToolProvider, tools: list[MCPTool]) -> list[ToolApiEntity]: + user = mcp_provider.load_user() + return [ + ToolApiEntity( + author=user.name if user else "Anonymous", + name=tool.name, + label=I18nObject(en_US=tool.name, zh_Hans=tool.name), + description=I18nObject(en_US=tool.description, zh_Hans=tool.description), + parameters=ToolTransformService.convert_mcp_schema_to_parameter(tool.inputSchema), + labels=[], + ) + for tool in tools + ] + @classmethod def api_provider_to_user_provider( cls, @@ -329,3 +381,53 @@ class ToolTransformService: is_default=provider.is_default, credentials=credentials, ) + + @staticmethod + def convert_mcp_schema_to_parameter(schema: dict) -> list["ToolParameter"]: + """ + Convert MCP JSON schema to tool parameters + + :param schema: JSON schema dictionary + :return: list of ToolParameter instances + """ + + def create_parameter( + name: str, description: str, param_type: str, required: bool, input_schema: dict | None = None + ) -> ToolParameter: + """Create a ToolParameter instance with given attributes""" + input_schema_dict: dict[str, Any] = {"input_schema": input_schema} if input_schema else {} + return ToolParameter( + name=name, + llm_description=description, + label=I18nObject(en_US=name), + form=ToolParameter.ToolParameterForm.LLM, + required=required, + type=ToolParameter.ToolParameterType(param_type), + human_description=I18nObject(en_US=description), + **input_schema_dict, + ) + + def process_properties(props: dict, required: list, prefix: str = "") -> list[ToolParameter]: + """Process properties recursively""" + TYPE_MAPPING = {"integer": "number", "float": "number"} + COMPLEX_TYPES = ["array", "object"] + + parameters = [] + for name, prop in props.items(): + current_description = prop.get("description", "") + prop_type = prop.get("type", "string") + + if isinstance(prop_type, list): + prop_type = prop_type[0] + if prop_type in TYPE_MAPPING: + prop_type = TYPE_MAPPING[prop_type] + input_schema = prop if prop_type in COMPLEX_TYPES else None + parameters.append( + create_parameter(name, current_description, prop_type, name in required, input_schema) + ) + + return parameters + + if schema.get("type") == "object" and "properties" in schema: + return process_properties(schema["properties"], schema.get("required", [])) + return [] diff --git a/api/tasks/clean_document_task.py b/api/tasks/clean_document_task.py index 5824121e8f..c72a3319c1 100644 --- a/api/tasks/clean_document_task.py +++ b/api/tasks/clean_document_task.py @@ -72,6 +72,7 @@ def clean_document_task(document_id: str, dataset_id: str, doc_form: str, file_i DatasetMetadataBinding.dataset_id == dataset_id, DatasetMetadataBinding.document_id == document_id, ).delete() + db.session.commit() end_at = time.perf_counter() logging.info( diff --git a/api/tasks/remove_app_and_related_data_task.py b/api/tasks/remove_app_and_related_data_task.py index d366efd6f2..4a62cb74b4 100644 --- a/api/tasks/remove_app_and_related_data_task.py +++ b/api/tasks/remove_app_and_related_data_task.py @@ -13,6 +13,7 @@ from models import ( AppAnnotationHitHistory, AppAnnotationSetting, AppDatasetJoin, + AppMCPServer, AppModelConfig, Conversation, EndUser, @@ -41,6 +42,7 @@ def remove_app_and_related_data_task(self, tenant_id: str, app_id: str): # Delete related data _delete_app_model_configs(tenant_id, app_id) _delete_app_site(tenant_id, app_id) + _delete_app_mcp_servers(tenant_id, app_id) _delete_app_api_tokens(tenant_id, app_id) _delete_installed_apps(tenant_id, app_id) _delete_recommended_apps(tenant_id, app_id) @@ -89,6 +91,18 @@ def _delete_app_site(tenant_id: str, app_id: str): _delete_records("""select id from sites where app_id=:app_id limit 1000""", {"app_id": app_id}, del_site, "site") +def _delete_app_mcp_servers(tenant_id: str, app_id: str): + def del_mcp_server(mcp_server_id: str): + db.session.query(AppMCPServer).filter(AppMCPServer.id == mcp_server_id).delete(synchronize_session=False) + + _delete_records( + """select id from app_mcp_servers where app_id=:app_id limit 1000""", + {"app_id": app_id}, + del_mcp_server, + "app mcp server", + ) + + def _delete_app_api_tokens(tenant_id: str, app_id: str): def del_api_token(api_token_id: str): db.session.query(ApiToken).filter(ApiToken.id == api_token_id).delete(synchronize_session=False) diff --git a/api/tests/integration_tests/vdb/couchbase/test_couchbase.py b/api/tests/integration_tests/vdb/couchbase/test_couchbase.py index d76c34ba0e..eef1ee4e75 100644 --- a/api/tests/integration_tests/vdb/couchbase/test_couchbase.py +++ b/api/tests/integration_tests/vdb/couchbase/test_couchbase.py @@ -4,7 +4,6 @@ import time from core.rag.datasource.vdb.couchbase.couchbase_vector import CouchbaseConfig, CouchbaseVector from tests.integration_tests.vdb.test_vector_store import ( AbstractVectorTest, - get_example_text, setup_mock_redis, ) diff --git a/api/tests/integration_tests/vdb/matrixone/test_matrixone.py b/api/tests/integration_tests/vdb/matrixone/test_matrixone.py index c8b19ef3ad..c4056db63e 100644 --- a/api/tests/integration_tests/vdb/matrixone/test_matrixone.py +++ b/api/tests/integration_tests/vdb/matrixone/test_matrixone.py @@ -1,7 +1,6 @@ from core.rag.datasource.vdb.matrixone.matrixone_vector import MatrixoneConfig, MatrixoneVector from tests.integration_tests.vdb.test_vector_store import ( AbstractVectorTest, - get_example_text, setup_mock_redis, ) diff --git a/api/tests/integration_tests/vdb/opengauss/test_opengauss.py b/api/tests/integration_tests/vdb/opengauss/test_opengauss.py index f2013848bf..2a1129493c 100644 --- a/api/tests/integration_tests/vdb/opengauss/test_opengauss.py +++ b/api/tests/integration_tests/vdb/opengauss/test_opengauss.py @@ -5,7 +5,6 @@ import psycopg2 # type: ignore from core.rag.datasource.vdb.opengauss.opengauss import OpenGauss, OpenGaussConfig from tests.integration_tests.vdb.test_vector_store import ( AbstractVectorTest, - get_example_text, setup_mock_redis, ) diff --git a/api/tests/integration_tests/vdb/pyvastbase/test_vastbase_vector.py b/api/tests/integration_tests/vdb/pyvastbase/test_vastbase_vector.py index 3d7873442b..02931fef5a 100644 --- a/api/tests/integration_tests/vdb/pyvastbase/test_vastbase_vector.py +++ b/api/tests/integration_tests/vdb/pyvastbase/test_vastbase_vector.py @@ -1,7 +1,6 @@ from core.rag.datasource.vdb.pyvastbase.vastbase_vector import VastbaseVector, VastbaseVectorConfig from tests.integration_tests.vdb.test_vector_store import ( AbstractVectorTest, - get_example_text, setup_mock_redis, ) diff --git a/api/tests/integration_tests/workflow/nodes/test_llm.py b/api/tests/integration_tests/workflow/nodes/test_llm.py index 389d1071f3..638323f850 100644 --- a/api/tests/integration_tests/workflow/nodes/test_llm.py +++ b/api/tests/integration_tests/workflow/nodes/test_llm.py @@ -1,5 +1,4 @@ import json -import os import time import uuid from collections.abc import Generator @@ -113,8 +112,6 @@ def test_execute_llm(flask_req_ctx): }, ) - credentials = {"openai_api_key": os.environ.get("OPENAI_API_KEY")} - # Create a proper LLM result with real entities mock_usage = LLMUsage( prompt_tokens=30, diff --git a/api/tests/unit_tests/configs/test_dify_config.py b/api/tests/unit_tests/configs/test_dify_config.py index cac0a688cd..b70c8830ed 100644 --- a/api/tests/unit_tests/configs/test_dify_config.py +++ b/api/tests/unit_tests/configs/test_dify_config.py @@ -1,6 +1,7 @@ import os from flask import Flask +from packaging.version import Version from yarl import URL from configs.app_config import DifyConfig @@ -40,6 +41,9 @@ def test_dify_config(monkeypatch): assert config.WORKFLOW_PARALLEL_DEPTH_LIMIT == 3 + # values from pyproject.toml + assert Version(config.project.version) >= Version("1.0.0") + # NOTE: If there is a `.env` file in your Workspace, this test might not succeed as expected. # This is due to `pymilvus` loading all the variables from the `.env` file into `os.environ`. diff --git a/api/tests/unit_tests/core/helper/test_encrypter.py b/api/tests/unit_tests/core/helper/test_encrypter.py new file mode 100644 index 0000000000..61cf8f255d --- /dev/null +++ b/api/tests/unit_tests/core/helper/test_encrypter.py @@ -0,0 +1,280 @@ +import base64 +import binascii +from unittest.mock import MagicMock, patch + +import pytest + +from core.helper.encrypter import ( + batch_decrypt_token, + decrypt_token, + encrypt_token, + get_decrypt_decoding, + obfuscated_token, +) +from libs.rsa import PrivkeyNotFoundError + + +class TestObfuscatedToken: + @pytest.mark.parametrize( + ("token", "expected"), + [ + ("", ""), # Empty token + ("1234567", "*" * 20), # Short token (<8 chars) + ("12345678", "*" * 20), # Boundary case (8 chars) + ("123456789abcdef", "123456" + "*" * 12 + "ef"), # Long token + ("abc!@#$%^&*()def", "abc!@#" + "*" * 12 + "ef"), # Special chars + ], + ) + def test_obfuscation_logic(self, token, expected): + """Test core obfuscation logic for various token lengths""" + assert obfuscated_token(token) == expected + + def test_sensitive_data_protection(self): + """Ensure obfuscation never reveals full sensitive data""" + token = "api_key_secret_12345" + obfuscated = obfuscated_token(token) + assert token not in obfuscated + assert "*" * 12 in obfuscated + + +class TestEncryptToken: + @patch("models.engine.db.session.query") + @patch("libs.rsa.encrypt") + def test_successful_encryption(self, mock_encrypt, mock_query): + """Test successful token encryption""" + mock_tenant = MagicMock() + mock_tenant.encrypt_public_key = "mock_public_key" + mock_query.return_value.filter.return_value.first.return_value = mock_tenant + mock_encrypt.return_value = b"encrypted_data" + + result = encrypt_token("tenant-123", "test_token") + + assert result == base64.b64encode(b"encrypted_data").decode() + mock_encrypt.assert_called_with("test_token", "mock_public_key") + + @patch("models.engine.db.session.query") + def test_tenant_not_found(self, mock_query): + """Test error when tenant doesn't exist""" + mock_query.return_value.filter.return_value.first.return_value = None + + with pytest.raises(ValueError) as exc_info: + encrypt_token("invalid-tenant", "test_token") + + assert "Tenant with id invalid-tenant not found" in str(exc_info.value) + + +class TestDecryptToken: + @patch("libs.rsa.decrypt") + def test_successful_decryption(self, mock_decrypt): + """Test successful token decryption""" + mock_decrypt.return_value = "decrypted_token" + encrypted_data = base64.b64encode(b"encrypted_data").decode() + + result = decrypt_token("tenant-123", encrypted_data) + + assert result == "decrypted_token" + mock_decrypt.assert_called_once_with(b"encrypted_data", "tenant-123") + + def test_invalid_base64(self): + """Test handling of invalid base64 input""" + with pytest.raises(binascii.Error): + decrypt_token("tenant-123", "invalid_base64!!!") + + +class TestBatchDecryptToken: + @patch("libs.rsa.get_decrypt_decoding") + @patch("libs.rsa.decrypt_token_with_decoding") + def test_batch_decryption(self, mock_decrypt_with_decoding, mock_get_decoding): + """Test batch decryption functionality""" + mock_rsa_key = MagicMock() + mock_cipher_rsa = MagicMock() + mock_get_decoding.return_value = (mock_rsa_key, mock_cipher_rsa) + + # Test multiple tokens + mock_decrypt_with_decoding.side_effect = ["token1", "token2", "token3"] + tokens = [ + base64.b64encode(b"encrypted1").decode(), + base64.b64encode(b"encrypted2").decode(), + base64.b64encode(b"encrypted3").decode(), + ] + result = batch_decrypt_token("tenant-123", tokens) + + assert result == ["token1", "token2", "token3"] + # Key should only be loaded once + mock_get_decoding.assert_called_once_with("tenant-123") + + +class TestGetDecryptDecoding: + @patch("extensions.ext_redis.redis_client.get") + @patch("extensions.ext_storage.storage.load") + def test_private_key_not_found(self, mock_storage_load, mock_redis_get): + """Test error when private key file doesn't exist""" + mock_redis_get.return_value = None + mock_storage_load.side_effect = FileNotFoundError() + + with pytest.raises(PrivkeyNotFoundError) as exc_info: + get_decrypt_decoding("tenant-123") + + assert "Private key not found, tenant_id: tenant-123" in str(exc_info.value) + + +class TestEncryptDecryptIntegration: + @patch("models.engine.db.session.query") + @patch("libs.rsa.encrypt") + @patch("libs.rsa.decrypt") + def test_should_encrypt_and_decrypt_consistently(self, mock_decrypt, mock_encrypt, mock_query): + """Test that encryption and decryption are consistent""" + # Setup mock tenant + mock_tenant = MagicMock() + mock_tenant.encrypt_public_key = "mock_public_key" + mock_query.return_value.filter.return_value.first.return_value = mock_tenant + + # Setup mock encryption/decryption + original_token = "test_token_123" + mock_encrypt.return_value = b"encrypted_data" + mock_decrypt.return_value = original_token + + # Test encryption + encrypted = encrypt_token("tenant-123", original_token) + + # Test decryption + decrypted = decrypt_token("tenant-123", encrypted) + + assert decrypted == original_token + + +class TestSecurity: + """Critical security tests for encryption system""" + + @patch("models.engine.db.session.query") + @patch("libs.rsa.encrypt") + def test_cross_tenant_isolation(self, mock_encrypt, mock_query): + """Ensure tokens encrypted for one tenant cannot be used by another""" + # Setup mock tenant + mock_tenant = MagicMock() + mock_tenant.encrypt_public_key = "tenant1_public_key" + mock_query.return_value.filter.return_value.first.return_value = mock_tenant + mock_encrypt.return_value = b"encrypted_for_tenant1" + + # Encrypt token for tenant1 + encrypted = encrypt_token("tenant-123", "sensitive_data") + + # Attempt to decrypt with different tenant should fail + with patch("libs.rsa.decrypt") as mock_decrypt: + mock_decrypt.side_effect = Exception("Invalid tenant key") + + with pytest.raises(Exception, match="Invalid tenant key"): + decrypt_token("different-tenant", encrypted) + + @patch("libs.rsa.decrypt") + def test_tampered_ciphertext_rejection(self, mock_decrypt): + """Detect and reject tampered ciphertext""" + valid_encrypted = base64.b64encode(b"valid_data").decode() + + # Tamper with ciphertext + tampered_bytes = bytearray(base64.b64decode(valid_encrypted)) + tampered_bytes[0] ^= 0xFF + tampered = base64.b64encode(bytes(tampered_bytes)).decode() + + mock_decrypt.side_effect = Exception("Decryption error") + + with pytest.raises(Exception, match="Decryption error"): + decrypt_token("tenant-123", tampered) + + @patch("models.engine.db.session.query") + @patch("libs.rsa.encrypt") + def test_encryption_randomness(self, mock_encrypt, mock_query): + """Ensure same plaintext produces different ciphertext""" + mock_tenant = MagicMock(encrypt_public_key="key") + mock_query.return_value.filter.return_value.first.return_value = mock_tenant + + # Different outputs for same input + mock_encrypt.side_effect = [b"enc1", b"enc2", b"enc3"] + + results = [encrypt_token("tenant-123", "token") for _ in range(3)] + + # All results should be different + assert len(set(results)) == 3 + + +class TestEdgeCases: + """Additional security-focused edge case tests""" + + def test_should_handle_empty_string_in_obfuscation(self): + """Test handling of empty string in obfuscation""" + # Test empty string (which is a valid str type) + assert obfuscated_token("") == "" + + @patch("models.engine.db.session.query") + @patch("libs.rsa.encrypt") + def test_should_handle_empty_token_encryption(self, mock_encrypt, mock_query): + """Test encryption of empty token""" + mock_tenant = MagicMock() + mock_tenant.encrypt_public_key = "mock_public_key" + mock_query.return_value.filter.return_value.first.return_value = mock_tenant + mock_encrypt.return_value = b"encrypted_empty" + + result = encrypt_token("tenant-123", "") + + assert result == base64.b64encode(b"encrypted_empty").decode() + mock_encrypt.assert_called_with("", "mock_public_key") + + @patch("models.engine.db.session.query") + @patch("libs.rsa.encrypt") + def test_should_handle_special_characters_in_token(self, mock_encrypt, mock_query): + """Test tokens containing special/unicode characters""" + mock_tenant = MagicMock() + mock_tenant.encrypt_public_key = "mock_public_key" + mock_query.return_value.filter.return_value.first.return_value = mock_tenant + mock_encrypt.return_value = b"encrypted_special" + + # Test various special characters + special_tokens = [ + "token\x00with\x00null", # Null bytes + "token_with_emoji_😀🎉", # Unicode emoji + "token\nwith\nnewlines", # Newlines + "token\twith\ttabs", # Tabs + "token_with_中文字符", # Chinese characters + ] + + for token in special_tokens: + result = encrypt_token("tenant-123", token) + assert result == base64.b64encode(b"encrypted_special").decode() + mock_encrypt.assert_called_with(token, "mock_public_key") + + @patch("models.engine.db.session.query") + @patch("libs.rsa.encrypt") + def test_should_handle_rsa_size_limits(self, mock_encrypt, mock_query): + """Test behavior when token exceeds RSA encryption limits""" + mock_tenant = MagicMock() + mock_tenant.encrypt_public_key = "mock_public_key" + mock_query.return_value.filter.return_value.first.return_value = mock_tenant + + # RSA 2048-bit can only encrypt ~245 bytes + # The actual limit depends on padding scheme + mock_encrypt.side_effect = ValueError("Message too long for RSA key size") + + # Create a token that would exceed RSA limits + long_token = "x" * 300 + + with pytest.raises(ValueError, match="Message too long for RSA key size"): + encrypt_token("tenant-123", long_token) + + @patch("libs.rsa.get_decrypt_decoding") + @patch("libs.rsa.decrypt_token_with_decoding") + def test_batch_decrypt_loads_key_only_once(self, mock_decrypt_with_decoding, mock_get_decoding): + """Verify batch decryption optimization - loads key only once""" + mock_rsa_key = MagicMock() + mock_cipher_rsa = MagicMock() + mock_get_decoding.return_value = (mock_rsa_key, mock_cipher_rsa) + + # Test with multiple tokens + mock_decrypt_with_decoding.side_effect = ["token1", "token2", "token3", "token4", "token5"] + tokens = [base64.b64encode(f"encrypted{i}".encode()).decode() for i in range(5)] + + result = batch_decrypt_token("tenant-123", tokens) + + assert result == ["token1", "token2", "token3", "token4", "token5"] + # Key should only be loaded once regardless of token count + mock_get_decoding.assert_called_once_with("tenant-123") + assert mock_decrypt_with_decoding.call_count == 5 diff --git a/api/tests/unit_tests/core/helper/test_url_signer.py b/api/tests/unit_tests/core/helper/test_url_signer.py new file mode 100644 index 0000000000..5af24777de --- /dev/null +++ b/api/tests/unit_tests/core/helper/test_url_signer.py @@ -0,0 +1,194 @@ +from unittest.mock import patch +from urllib.parse import parse_qs, urlparse + +import pytest + +from core.helper.url_signer import SignedUrlParams, UrlSigner + + +class TestUrlSigner: + """Test cases for UrlSigner class""" + + @patch("configs.dify_config.SECRET_KEY", "test-secret-key-12345") + def test_should_generate_signed_url_params(self): + """Test generation of signed URL parameters with all required fields""" + sign_key = "test-sign-key" + prefix = "test-prefix" + + params = UrlSigner.get_signed_url_params(sign_key, prefix) + + # Verify the returned object and required fields + assert isinstance(params, SignedUrlParams) + assert params.sign_key == sign_key + assert params.timestamp is not None + assert params.nonce is not None + assert params.sign is not None + + # Verify nonce format (32 character hex string) + assert len(params.nonce) == 32 + assert all(c in "0123456789abcdef" for c in params.nonce) + + @patch("configs.dify_config.SECRET_KEY", "test-secret-key-12345") + def test_should_generate_complete_signed_url(self): + """Test generation of complete signed URL with query parameters""" + base_url = "https://example.com/api/test" + sign_key = "test-sign-key" + prefix = "test-prefix" + + signed_url = UrlSigner.get_signed_url(base_url, sign_key, prefix) + + # Parse URL and verify structure + parsed = urlparse(signed_url) + assert f"{parsed.scheme}://{parsed.netloc}{parsed.path}" == base_url + + # Verify query parameters + query_params = parse_qs(parsed.query) + assert "timestamp" in query_params + assert "nonce" in query_params + assert "sign" in query_params + + # Verify each parameter has exactly one value + assert len(query_params["timestamp"]) == 1 + assert len(query_params["nonce"]) == 1 + assert len(query_params["sign"]) == 1 + + # Verify parameter values are not empty + assert query_params["timestamp"][0] + assert query_params["nonce"][0] + assert query_params["sign"][0] + + @patch("configs.dify_config.SECRET_KEY", "test-secret-key-12345") + def test_should_verify_valid_signature(self): + """Test verification of valid signature""" + sign_key = "test-sign-key" + prefix = "test-prefix" + + # Generate and verify signature + params = UrlSigner.get_signed_url_params(sign_key, prefix) + + is_valid = UrlSigner.verify( + sign_key=sign_key, timestamp=params.timestamp, nonce=params.nonce, sign=params.sign, prefix=prefix + ) + + assert is_valid is True + + @patch("configs.dify_config.SECRET_KEY", "test-secret-key-12345") + @pytest.mark.parametrize( + ("field", "modifier"), + [ + ("sign_key", lambda _: "wrong-sign-key"), + ("timestamp", lambda t: str(int(t) + 1000)), + ("nonce", lambda _: "different-nonce-123456789012345"), + ("prefix", lambda _: "wrong-prefix"), + ("sign", lambda s: s + "tampered"), + ], + ) + def test_should_reject_invalid_signature_params(self, field, modifier): + """Test signature verification rejects invalid parameters""" + sign_key = "test-sign-key" + prefix = "test-prefix" + + # Generate valid signed parameters + params = UrlSigner.get_signed_url_params(sign_key, prefix) + + # Prepare verification parameters + verify_params = { + "sign_key": sign_key, + "timestamp": params.timestamp, + "nonce": params.nonce, + "sign": params.sign, + "prefix": prefix, + } + + # Modify the specific field + verify_params[field] = modifier(verify_params[field]) + + # Verify should fail + is_valid = UrlSigner.verify(**verify_params) + assert is_valid is False + + @patch("configs.dify_config.SECRET_KEY", None) + def test_should_raise_error_without_secret_key(self): + """Test that signing fails when SECRET_KEY is not configured""" + with pytest.raises(Exception) as exc_info: + UrlSigner.get_signed_url_params("key", "prefix") + + assert "SECRET_KEY is not set" in str(exc_info.value) + + @patch("configs.dify_config.SECRET_KEY", "test-secret-key-12345") + def test_should_generate_unique_signatures(self): + """Test that different inputs produce different signatures""" + params1 = UrlSigner.get_signed_url_params("key1", "prefix1") + params2 = UrlSigner.get_signed_url_params("key2", "prefix2") + + # Different inputs should produce different signatures + assert params1.sign != params2.sign + assert params1.nonce != params2.nonce + + @patch("configs.dify_config.SECRET_KEY", "test-secret-key-12345") + def test_should_handle_special_characters(self): + """Test handling of special characters in parameters""" + special_cases = [ + "test with spaces", + "test/with/slashes", + "test中文字符", + ] + + for sign_key in special_cases: + params = UrlSigner.get_signed_url_params(sign_key, "prefix") + + # Should generate valid signature and verify correctly + is_valid = UrlSigner.verify( + sign_key=sign_key, timestamp=params.timestamp, nonce=params.nonce, sign=params.sign, prefix="prefix" + ) + assert is_valid is True + + @patch("configs.dify_config.SECRET_KEY", "test-secret-key-12345") + def test_should_ensure_nonce_randomness(self): + """Test that nonce is random for each generation - critical for security""" + sign_key = "test-sign-key" + prefix = "test-prefix" + + # Generate multiple nonces + nonces = set() + for _ in range(5): + params = UrlSigner.get_signed_url_params(sign_key, prefix) + nonces.add(params.nonce) + + # All nonces should be unique + assert len(nonces) == 5 + + @patch("configs.dify_config.SECRET_KEY", "test-secret-key-12345") + @patch("time.time", return_value=1234567890) + @patch("os.urandom", return_value=b"\xab\xcd\xef\x12\x34\x56\x78\x90\xab\xcd\xef\x12\x34\x56\x78\x90") + def test_should_produce_consistent_signatures(self, mock_urandom, mock_time): + """Test that same inputs produce same signature - ensures deterministic behavior""" + sign_key = "test-sign-key" + prefix = "test-prefix" + + # Generate signature multiple times with same inputs (time and nonce are mocked) + params1 = UrlSigner.get_signed_url_params(sign_key, prefix) + params2 = UrlSigner.get_signed_url_params(sign_key, prefix) + + # With mocked time and random, should produce identical results + assert params1.timestamp == params2.timestamp + assert params1.nonce == params2.nonce + assert params1.sign == params2.sign + + # Verify the signature is valid + assert UrlSigner.verify( + sign_key=sign_key, timestamp=params1.timestamp, nonce=params1.nonce, sign=params1.sign, prefix=prefix + ) + + @patch("configs.dify_config.SECRET_KEY", "test-secret-key-12345") + def test_should_handle_empty_strings(self): + """Test handling of empty string parameters - common edge case""" + # Empty sign_key and prefix should still work + params = UrlSigner.get_signed_url_params("", "") + assert params.sign is not None + + # Should verify correctly + is_valid = UrlSigner.verify( + sign_key="", timestamp=params.timestamp, nonce=params.nonce, sign=params.sign, prefix="" + ) + assert is_valid is True diff --git a/api/tests/unit_tests/core/mcp/client/test_session.py b/api/tests/unit_tests/core/mcp/client/test_session.py new file mode 100644 index 0000000000..c84169bf15 --- /dev/null +++ b/api/tests/unit_tests/core/mcp/client/test_session.py @@ -0,0 +1,471 @@ +import queue +import threading +from typing import Any + +from core.mcp import types +from core.mcp.entities import RequestContext +from core.mcp.session.base_session import RequestResponder +from core.mcp.session.client_session import DEFAULT_CLIENT_INFO, ClientSession +from core.mcp.types import ( + LATEST_PROTOCOL_VERSION, + ClientNotification, + ClientRequest, + Implementation, + InitializedNotification, + InitializeRequest, + InitializeResult, + JSONRPCMessage, + JSONRPCNotification, + JSONRPCRequest, + JSONRPCResponse, + ServerCapabilities, + ServerResult, + SessionMessage, +) + + +def test_client_session_initialize(): + # Create synchronous queues to replace async streams + client_to_server: queue.Queue[SessionMessage] = queue.Queue() + server_to_client: queue.Queue[SessionMessage] = queue.Queue() + + initialized_notification = None + + def mock_server(): + nonlocal initialized_notification + + # Receive initialization request + session_message = client_to_server.get(timeout=5.0) + jsonrpc_request = session_message.message + assert isinstance(jsonrpc_request.root, JSONRPCRequest) + request = ClientRequest.model_validate( + jsonrpc_request.root.model_dump(by_alias=True, mode="json", exclude_none=True) + ) + assert isinstance(request.root, InitializeRequest) + + # Create response + result = ServerResult( + InitializeResult( + protocolVersion=LATEST_PROTOCOL_VERSION, + capabilities=ServerCapabilities( + logging=None, + resources=None, + tools=None, + experimental=None, + prompts=None, + ), + serverInfo=Implementation(name="mock-server", version="0.1.0"), + instructions="The server instructions.", + ) + ) + + # Send response + server_to_client.put( + SessionMessage( + message=JSONRPCMessage( + JSONRPCResponse( + jsonrpc="2.0", + id=jsonrpc_request.root.id, + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), + ) + ) + ) + ) + + # Receive initialized notification + session_notification = client_to_server.get(timeout=5.0) + jsonrpc_notification = session_notification.message + assert isinstance(jsonrpc_notification.root, JSONRPCNotification) + initialized_notification = ClientNotification.model_validate( + jsonrpc_notification.root.model_dump(by_alias=True, mode="json", exclude_none=True) + ) + + # Create message handler + def message_handler( + message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception, + ) -> None: + if isinstance(message, Exception): + raise message + + # Start mock server thread + server_thread = threading.Thread(target=mock_server, daemon=True) + server_thread.start() + + # Create and use client session + with ClientSession( + server_to_client, + client_to_server, + message_handler=message_handler, + ) as session: + result = session.initialize() + + # Wait for server thread to complete + server_thread.join(timeout=10.0) + + # Assert results + assert isinstance(result, InitializeResult) + assert result.protocolVersion == LATEST_PROTOCOL_VERSION + assert isinstance(result.capabilities, ServerCapabilities) + assert result.serverInfo == Implementation(name="mock-server", version="0.1.0") + assert result.instructions == "The server instructions." + + # Check that client sent initialized notification + assert initialized_notification + assert isinstance(initialized_notification.root, InitializedNotification) + + +def test_client_session_custom_client_info(): + # Create synchronous queues to replace async streams + client_to_server: queue.Queue[SessionMessage] = queue.Queue() + server_to_client: queue.Queue[SessionMessage] = queue.Queue() + + custom_client_info = Implementation(name="test-client", version="1.2.3") + received_client_info = None + + def mock_server(): + nonlocal received_client_info + + session_message = client_to_server.get(timeout=5.0) + jsonrpc_request = session_message.message + assert isinstance(jsonrpc_request.root, JSONRPCRequest) + request = ClientRequest.model_validate( + jsonrpc_request.root.model_dump(by_alias=True, mode="json", exclude_none=True) + ) + assert isinstance(request.root, InitializeRequest) + received_client_info = request.root.params.clientInfo + + result = ServerResult( + InitializeResult( + protocolVersion=LATEST_PROTOCOL_VERSION, + capabilities=ServerCapabilities(), + serverInfo=Implementation(name="mock-server", version="0.1.0"), + ) + ) + + server_to_client.put( + SessionMessage( + message=JSONRPCMessage( + JSONRPCResponse( + jsonrpc="2.0", + id=jsonrpc_request.root.id, + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), + ) + ) + ) + ) + # Receive initialized notification + client_to_server.get(timeout=5.0) + + # Start mock server thread + server_thread = threading.Thread(target=mock_server, daemon=True) + server_thread.start() + + with ClientSession( + server_to_client, + client_to_server, + client_info=custom_client_info, + ) as session: + session.initialize() + + # Wait for server thread to complete + server_thread.join(timeout=10.0) + + # Assert that custom client info was sent + assert received_client_info == custom_client_info + + +def test_client_session_default_client_info(): + # Create synchronous queues to replace async streams + client_to_server: queue.Queue[SessionMessage] = queue.Queue() + server_to_client: queue.Queue[SessionMessage] = queue.Queue() + + received_client_info = None + + def mock_server(): + nonlocal received_client_info + + session_message = client_to_server.get(timeout=5.0) + jsonrpc_request = session_message.message + assert isinstance(jsonrpc_request.root, JSONRPCRequest) + request = ClientRequest.model_validate( + jsonrpc_request.root.model_dump(by_alias=True, mode="json", exclude_none=True) + ) + assert isinstance(request.root, InitializeRequest) + received_client_info = request.root.params.clientInfo + + result = ServerResult( + InitializeResult( + protocolVersion=LATEST_PROTOCOL_VERSION, + capabilities=ServerCapabilities(), + serverInfo=Implementation(name="mock-server", version="0.1.0"), + ) + ) + + server_to_client.put( + SessionMessage( + message=JSONRPCMessage( + JSONRPCResponse( + jsonrpc="2.0", + id=jsonrpc_request.root.id, + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), + ) + ) + ) + ) + # Receive initialized notification + client_to_server.get(timeout=5.0) + + # Start mock server thread + server_thread = threading.Thread(target=mock_server, daemon=True) + server_thread.start() + + with ClientSession( + server_to_client, + client_to_server, + ) as session: + session.initialize() + + # Wait for server thread to complete + server_thread.join(timeout=10.0) + + # Assert that default client info was used + assert received_client_info == DEFAULT_CLIENT_INFO + + +def test_client_session_version_negotiation_success(): + # Create synchronous queues to replace async streams + client_to_server: queue.Queue[SessionMessage] = queue.Queue() + server_to_client: queue.Queue[SessionMessage] = queue.Queue() + + def mock_server(): + session_message = client_to_server.get(timeout=5.0) + jsonrpc_request = session_message.message + assert isinstance(jsonrpc_request.root, JSONRPCRequest) + request = ClientRequest.model_validate( + jsonrpc_request.root.model_dump(by_alias=True, mode="json", exclude_none=True) + ) + assert isinstance(request.root, InitializeRequest) + + # Send supported protocol version + result = ServerResult( + InitializeResult( + protocolVersion=LATEST_PROTOCOL_VERSION, + capabilities=ServerCapabilities(), + serverInfo=Implementation(name="mock-server", version="0.1.0"), + ) + ) + + server_to_client.put( + SessionMessage( + message=JSONRPCMessage( + JSONRPCResponse( + jsonrpc="2.0", + id=jsonrpc_request.root.id, + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), + ) + ) + ) + ) + # Receive initialized notification + client_to_server.get(timeout=5.0) + + # Start mock server thread + server_thread = threading.Thread(target=mock_server, daemon=True) + server_thread.start() + + with ClientSession( + server_to_client, + client_to_server, + ) as session: + result = session.initialize() + + # Wait for server thread to complete + server_thread.join(timeout=10.0) + + # Should successfully initialize + assert isinstance(result, InitializeResult) + assert result.protocolVersion == LATEST_PROTOCOL_VERSION + + +def test_client_session_version_negotiation_failure(): + # Create synchronous queues to replace async streams + client_to_server: queue.Queue[SessionMessage] = queue.Queue() + server_to_client: queue.Queue[SessionMessage] = queue.Queue() + + def mock_server(): + session_message = client_to_server.get(timeout=5.0) + jsonrpc_request = session_message.message + assert isinstance(jsonrpc_request.root, JSONRPCRequest) + request = ClientRequest.model_validate( + jsonrpc_request.root.model_dump(by_alias=True, mode="json", exclude_none=True) + ) + assert isinstance(request.root, InitializeRequest) + + # Send unsupported protocol version + result = ServerResult( + InitializeResult( + protocolVersion="99.99.99", # Unsupported version + capabilities=ServerCapabilities(), + serverInfo=Implementation(name="mock-server", version="0.1.0"), + ) + ) + + server_to_client.put( + SessionMessage( + message=JSONRPCMessage( + JSONRPCResponse( + jsonrpc="2.0", + id=jsonrpc_request.root.id, + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), + ) + ) + ) + ) + + # Start mock server thread + server_thread = threading.Thread(target=mock_server, daemon=True) + server_thread.start() + + with ClientSession( + server_to_client, + client_to_server, + ) as session: + import pytest + + with pytest.raises(RuntimeError, match="Unsupported protocol version"): + session.initialize() + + # Wait for server thread to complete + server_thread.join(timeout=10.0) + + +def test_client_capabilities_default(): + # Create synchronous queues to replace async streams + client_to_server: queue.Queue[SessionMessage] = queue.Queue() + server_to_client: queue.Queue[SessionMessage] = queue.Queue() + + received_capabilities = None + + def mock_server(): + nonlocal received_capabilities + + session_message = client_to_server.get(timeout=5.0) + jsonrpc_request = session_message.message + assert isinstance(jsonrpc_request.root, JSONRPCRequest) + request = ClientRequest.model_validate( + jsonrpc_request.root.model_dump(by_alias=True, mode="json", exclude_none=True) + ) + assert isinstance(request.root, InitializeRequest) + received_capabilities = request.root.params.capabilities + + result = ServerResult( + InitializeResult( + protocolVersion=LATEST_PROTOCOL_VERSION, + capabilities=ServerCapabilities(), + serverInfo=Implementation(name="mock-server", version="0.1.0"), + ) + ) + + server_to_client.put( + SessionMessage( + message=JSONRPCMessage( + JSONRPCResponse( + jsonrpc="2.0", + id=jsonrpc_request.root.id, + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), + ) + ) + ) + ) + # Receive initialized notification + client_to_server.get(timeout=5.0) + + # Start mock server thread + server_thread = threading.Thread(target=mock_server, daemon=True) + server_thread.start() + + with ClientSession( + server_to_client, + client_to_server, + ) as session: + session.initialize() + + # Wait for server thread to complete + server_thread.join(timeout=10.0) + + # Assert default capabilities + assert received_capabilities is not None + assert received_capabilities.sampling is not None + assert received_capabilities.roots is not None + assert received_capabilities.roots.listChanged is True + + +def test_client_capabilities_with_custom_callbacks(): + # Create synchronous queues to replace async streams + client_to_server: queue.Queue[SessionMessage] = queue.Queue() + server_to_client: queue.Queue[SessionMessage] = queue.Queue() + + def custom_sampling_callback( + context: RequestContext["ClientSession", Any], + params: types.CreateMessageRequestParams, + ) -> types.CreateMessageResult | types.ErrorData: + return types.CreateMessageResult( + model="test-model", + role="assistant", + content=types.TextContent(type="text", text="Custom response"), + ) + + def custom_list_roots_callback( + context: RequestContext["ClientSession", Any], + ) -> types.ListRootsResult | types.ErrorData: + return types.ListRootsResult(roots=[]) + + def mock_server(): + session_message = client_to_server.get(timeout=5.0) + jsonrpc_request = session_message.message + assert isinstance(jsonrpc_request.root, JSONRPCRequest) + request = ClientRequest.model_validate( + jsonrpc_request.root.model_dump(by_alias=True, mode="json", exclude_none=True) + ) + assert isinstance(request.root, InitializeRequest) + + result = ServerResult( + InitializeResult( + protocolVersion=LATEST_PROTOCOL_VERSION, + capabilities=ServerCapabilities(), + serverInfo=Implementation(name="mock-server", version="0.1.0"), + ) + ) + + server_to_client.put( + SessionMessage( + message=JSONRPCMessage( + JSONRPCResponse( + jsonrpc="2.0", + id=jsonrpc_request.root.id, + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), + ) + ) + ) + ) + # Receive initialized notification + client_to_server.get(timeout=5.0) + + # Start mock server thread + server_thread = threading.Thread(target=mock_server, daemon=True) + server_thread.start() + + with ClientSession( + server_to_client, + client_to_server, + sampling_callback=custom_sampling_callback, + list_roots_callback=custom_list_roots_callback, + ) as session: + result = session.initialize() + + # Wait for server thread to complete + server_thread.join(timeout=10.0) + + # Verify initialization succeeded + assert isinstance(result, InitializeResult) + assert result.protocolVersion == LATEST_PROTOCOL_VERSION diff --git a/api/tests/unit_tests/core/mcp/client/test_sse.py b/api/tests/unit_tests/core/mcp/client/test_sse.py new file mode 100644 index 0000000000..8122cd08eb --- /dev/null +++ b/api/tests/unit_tests/core/mcp/client/test_sse.py @@ -0,0 +1,349 @@ +import json +import queue +import threading +import time +from typing import Any +from unittest.mock import Mock, patch + +import httpx +import pytest + +from core.mcp import types +from core.mcp.client.sse_client import sse_client +from core.mcp.error import MCPAuthError, MCPConnectionError + +SERVER_NAME = "test_server_for_SSE" + + +def test_sse_message_id_coercion(): + """Test that string message IDs that look like integers are parsed as integers. + + See for more details. + """ + json_message = '{"jsonrpc": "2.0", "id": "123", "method": "ping", "params": null}' + msg = types.JSONRPCMessage.model_validate_json(json_message) + expected = types.JSONRPCMessage(root=types.JSONRPCRequest(method="ping", jsonrpc="2.0", id=123)) + + # Check if both are JSONRPCRequest instances + assert isinstance(msg.root, types.JSONRPCRequest) + assert isinstance(expected.root, types.JSONRPCRequest) + + assert msg.root.id == expected.root.id + assert msg.root.method == expected.root.method + assert msg.root.jsonrpc == expected.root.jsonrpc + + +class MockSSEClient: + """Mock SSE client for testing.""" + + def __init__(self, url: str, headers: dict[str, Any] | None = None): + self.url = url + self.headers = headers or {} + self.connected = False + self.read_queue: queue.Queue = queue.Queue() + self.write_queue: queue.Queue = queue.Queue() + + def connect(self): + """Simulate connection establishment.""" + self.connected = True + + # Send endpoint event + endpoint_data = "/messages/?session_id=test-session-123" + self.read_queue.put(("endpoint", endpoint_data)) + + return self.read_queue, self.write_queue + + def send_initialize_response(self): + """Send a mock initialize response.""" + response = { + "jsonrpc": "2.0", + "id": 1, + "result": { + "protocolVersion": types.LATEST_PROTOCOL_VERSION, + "capabilities": { + "logging": None, + "resources": None, + "tools": None, + "experimental": None, + "prompts": None, + }, + "serverInfo": {"name": SERVER_NAME, "version": "0.1.0"}, + "instructions": "Test server instructions.", + }, + } + self.read_queue.put(("message", json.dumps(response))) + + +def test_sse_client_message_id_handling(): + """Test SSE client properly handles message ID coercion.""" + mock_client = MockSSEClient("http://test.example/sse") + read_queue, write_queue = mock_client.connect() + + # Send a message with string ID that should be coerced to int + message_data = { + "jsonrpc": "2.0", + "id": "456", # String ID + "result": {"test": "data"}, + } + read_queue.put(("message", json.dumps(message_data))) + read_queue.get(timeout=1.0) + # Get the message from queue + event_type, data = read_queue.get(timeout=1.0) + assert event_type == "message" + + # Parse the message + parsed_message = types.JSONRPCMessage.model_validate_json(data) + # Check that it's a JSONRPCResponse and verify the ID + assert isinstance(parsed_message.root, types.JSONRPCResponse) + assert parsed_message.root.id == 456 # Should be converted to int + + +def test_sse_client_connection_validation(): + """Test SSE client validates endpoint URLs properly.""" + test_url = "http://test.example/sse" + + with patch("core.mcp.client.sse_client.create_ssrf_proxy_mcp_http_client") as mock_client_factory: + with patch("core.mcp.client.sse_client.ssrf_proxy_sse_connect") as mock_sse_connect: + # Mock the HTTP client + mock_client = Mock() + mock_client_factory.return_value.__enter__.return_value = mock_client + + # Mock the SSE connection + mock_event_source = Mock() + mock_event_source.response.raise_for_status.return_value = None + mock_sse_connect.return_value.__enter__.return_value = mock_event_source + + # Mock SSE events + class MockSSEEvent: + def __init__(self, event_type: str, data: str): + self.event = event_type + self.data = data + + # Simulate endpoint event + endpoint_event = MockSSEEvent("endpoint", "/messages/?session_id=test-123") + mock_event_source.iter_sse.return_value = [endpoint_event] + + # Test connection + try: + with sse_client(test_url) as (read_queue, write_queue): + assert read_queue is not None + assert write_queue is not None + except Exception as e: + # Connection might fail due to mocking, but we're testing the validation logic + pass + + +def test_sse_client_error_handling(): + """Test SSE client properly handles various error conditions.""" + test_url = "http://test.example/sse" + + # Test 401 error handling + with patch("core.mcp.client.sse_client.create_ssrf_proxy_mcp_http_client") as mock_client_factory: + with patch("core.mcp.client.sse_client.ssrf_proxy_sse_connect") as mock_sse_connect: + # Mock 401 HTTP error + mock_error = httpx.HTTPStatusError("Unauthorized", request=Mock(), response=Mock(status_code=401)) + mock_sse_connect.side_effect = mock_error + + with pytest.raises(MCPAuthError): + with sse_client(test_url): + pass + + # Test other HTTP errors + with patch("core.mcp.client.sse_client.create_ssrf_proxy_mcp_http_client") as mock_client_factory: + with patch("core.mcp.client.sse_client.ssrf_proxy_sse_connect") as mock_sse_connect: + # Mock other HTTP error + mock_error = httpx.HTTPStatusError("Server Error", request=Mock(), response=Mock(status_code=500)) + mock_sse_connect.side_effect = mock_error + + with pytest.raises(MCPConnectionError): + with sse_client(test_url): + pass + + +def test_sse_client_timeout_configuration(): + """Test SSE client timeout configuration.""" + test_url = "http://test.example/sse" + custom_timeout = 10.0 + custom_sse_timeout = 300.0 + custom_headers = {"Authorization": "Bearer test-token"} + + with patch("core.mcp.client.sse_client.create_ssrf_proxy_mcp_http_client") as mock_client_factory: + with patch("core.mcp.client.sse_client.ssrf_proxy_sse_connect") as mock_sse_connect: + # Mock successful connection + mock_client = Mock() + mock_client_factory.return_value.__enter__.return_value = mock_client + + mock_event_source = Mock() + mock_event_source.response.raise_for_status.return_value = None + mock_event_source.iter_sse.return_value = [] + mock_sse_connect.return_value.__enter__.return_value = mock_event_source + + try: + with sse_client( + test_url, headers=custom_headers, timeout=custom_timeout, sse_read_timeout=custom_sse_timeout + ) as (read_queue, write_queue): + # Verify the configuration was passed correctly + mock_client_factory.assert_called_with(headers=custom_headers) + + # Check that timeout was configured + call_args = mock_sse_connect.call_args + assert call_args is not None + timeout_arg = call_args[1]["timeout"] + assert timeout_arg.read == custom_sse_timeout + except Exception: + # Connection might fail due to mocking, but we tested the configuration + pass + + +def test_sse_transport_endpoint_validation(): + """Test SSE transport validates endpoint URLs correctly.""" + from core.mcp.client.sse_client import SSETransport + + transport = SSETransport("http://example.com/sse") + + # Valid endpoint (same origin) + valid_endpoint = "http://example.com/messages/session123" + assert transport._validate_endpoint_url(valid_endpoint) == True + + # Invalid endpoint (different origin) + invalid_endpoint = "http://malicious.com/messages/session123" + assert transport._validate_endpoint_url(invalid_endpoint) == False + + # Invalid endpoint (different scheme) + invalid_scheme = "https://example.com/messages/session123" + assert transport._validate_endpoint_url(invalid_scheme) == False + + +def test_sse_transport_message_parsing(): + """Test SSE transport properly parses different message types.""" + from core.mcp.client.sse_client import SSETransport + + transport = SSETransport("http://example.com/sse") + read_queue: queue.Queue = queue.Queue() + + # Test valid JSON-RPC message + valid_message = '{"jsonrpc": "2.0", "id": 1, "method": "ping"}' + transport._handle_message_event(valid_message, read_queue) + + # Should have a SessionMessage in the queue + message = read_queue.get(timeout=1.0) + assert message is not None + assert hasattr(message, "message") + + # Test invalid JSON + invalid_json = '{"invalid": json}' + transport._handle_message_event(invalid_json, read_queue) + + # Should have an exception in the queue + error = read_queue.get(timeout=1.0) + assert isinstance(error, Exception) + + +def test_sse_client_queue_cleanup(): + """Test that SSE client properly cleans up queues on exit.""" + test_url = "http://test.example/sse" + + read_queue = None + write_queue = None + + with patch("core.mcp.client.sse_client.create_ssrf_proxy_mcp_http_client") as mock_client_factory: + with patch("core.mcp.client.sse_client.ssrf_proxy_sse_connect") as mock_sse_connect: + # Mock connection that raises an exception + mock_sse_connect.side_effect = Exception("Connection failed") + + try: + with sse_client(test_url) as (rq, wq): + read_queue = rq + write_queue = wq + except Exception: + pass # Expected to fail + + # Queues should be cleaned up even on exception + # Note: In real implementation, cleanup should put None to signal shutdown + + +def test_sse_client_url_processing(): + """Test SSE client URL processing functions.""" + from core.mcp.client.sse_client import remove_request_params + + # Test URL with parameters + url_with_params = "http://example.com/sse?param1=value1¶m2=value2" + cleaned_url = remove_request_params(url_with_params) + assert cleaned_url == "http://example.com/sse" + + # Test URL without parameters + url_without_params = "http://example.com/sse" + cleaned_url = remove_request_params(url_without_params) + assert cleaned_url == "http://example.com/sse" + + # Test URL with path and parameters + complex_url = "http://example.com/path/to/sse?session=123&token=abc" + cleaned_url = remove_request_params(complex_url) + assert cleaned_url == "http://example.com/path/to/sse" + + +def test_sse_client_headers_propagation(): + """Test that custom headers are properly propagated in SSE client.""" + test_url = "http://test.example/sse" + custom_headers = { + "Authorization": "Bearer test-token", + "X-Custom-Header": "test-value", + "User-Agent": "test-client/1.0", + } + + with patch("core.mcp.client.sse_client.create_ssrf_proxy_mcp_http_client") as mock_client_factory: + with patch("core.mcp.client.sse_client.ssrf_proxy_sse_connect") as mock_sse_connect: + # Mock the client factory to capture headers + mock_client = Mock() + mock_client_factory.return_value.__enter__.return_value = mock_client + + # Mock the SSE connection + mock_event_source = Mock() + mock_event_source.response.raise_for_status.return_value = None + mock_event_source.iter_sse.return_value = [] + mock_sse_connect.return_value.__enter__.return_value = mock_event_source + + try: + with sse_client(test_url, headers=custom_headers): + pass + except Exception: + pass # Expected due to mocking + + # Verify headers were passed to client factory + mock_client_factory.assert_called_with(headers=custom_headers) + + +def test_sse_client_concurrent_access(): + """Test SSE client behavior with concurrent queue access.""" + test_read_queue: queue.Queue = queue.Queue() + + # Simulate concurrent producers and consumers + def producer(): + for i in range(10): + test_read_queue.put(f"message_{i}") + time.sleep(0.01) # Small delay to simulate real conditions + + def consumer(): + received = [] + for _ in range(10): + try: + msg = test_read_queue.get(timeout=2.0) + received.append(msg) + except queue.Empty: + break + return received + + # Start producer in separate thread + producer_thread = threading.Thread(target=producer, daemon=True) + producer_thread.start() + + # Consume messages + received_messages = consumer() + + # Wait for producer to finish + producer_thread.join(timeout=5.0) + + # Verify all messages were received + assert len(received_messages) == 10 + for i in range(10): + assert f"message_{i}" in received_messages diff --git a/api/tests/unit_tests/core/mcp/client/test_streamable_http.py b/api/tests/unit_tests/core/mcp/client/test_streamable_http.py new file mode 100644 index 0000000000..9a30a35a49 --- /dev/null +++ b/api/tests/unit_tests/core/mcp/client/test_streamable_http.py @@ -0,0 +1,450 @@ +""" +Tests for the StreamableHTTP client transport. + +Contains tests for only the client side of the StreamableHTTP transport. +""" + +import queue +import threading +import time +from typing import Any +from unittest.mock import Mock, patch + +from core.mcp import types +from core.mcp.client.streamable_client import streamablehttp_client + +# Test constants +SERVER_NAME = "test_streamable_http_server" +TEST_SESSION_ID = "test-session-id-12345" +INIT_REQUEST = { + "jsonrpc": "2.0", + "method": "initialize", + "params": { + "clientInfo": {"name": "test-client", "version": "1.0"}, + "protocolVersion": "2025-03-26", + "capabilities": {}, + }, + "id": "init-1", +} + + +class MockStreamableHTTPClient: + """Mock StreamableHTTP client for testing.""" + + def __init__(self, url: str, headers: dict[str, Any] | None = None): + self.url = url + self.headers = headers or {} + self.connected = False + self.read_queue: queue.Queue = queue.Queue() + self.write_queue: queue.Queue = queue.Queue() + self.session_id = TEST_SESSION_ID + + def connect(self): + """Simulate connection establishment.""" + self.connected = True + return self.read_queue, self.write_queue, lambda: self.session_id + + def send_initialize_response(self): + """Send a mock initialize response.""" + session_message = types.SessionMessage( + message=types.JSONRPCMessage( + root=types.JSONRPCResponse( + jsonrpc="2.0", + id="init-1", + result={ + "protocolVersion": types.LATEST_PROTOCOL_VERSION, + "capabilities": { + "logging": None, + "resources": None, + "tools": None, + "experimental": None, + "prompts": None, + }, + "serverInfo": {"name": SERVER_NAME, "version": "0.1.0"}, + "instructions": "Test server instructions.", + }, + ) + ) + ) + self.read_queue.put(session_message) + + def send_tools_response(self): + """Send a mock tools list response.""" + session_message = types.SessionMessage( + message=types.JSONRPCMessage( + root=types.JSONRPCResponse( + jsonrpc="2.0", + id="tools-1", + result={ + "tools": [ + { + "name": "test_tool", + "description": "A test tool", + "inputSchema": {"type": "object", "properties": {}}, + } + ], + }, + ) + ) + ) + self.read_queue.put(session_message) + + +def test_streamablehttp_client_message_id_handling(): + """Test StreamableHTTP client properly handles message ID coercion.""" + mock_client = MockStreamableHTTPClient("http://test.example/mcp") + read_queue, write_queue, get_session_id = mock_client.connect() + + # Send a message with string ID that should be coerced to int + response_message = types.SessionMessage( + message=types.JSONRPCMessage(root=types.JSONRPCResponse(jsonrpc="2.0", id="789", result={"test": "data"})) + ) + read_queue.put(response_message) + + # Get the message from queue + message = read_queue.get(timeout=1.0) + assert message is not None + assert isinstance(message, types.SessionMessage) + + # Check that the ID was properly handled + assert isinstance(message.message.root, types.JSONRPCResponse) + assert message.message.root.id == 789 # ID should be coerced to int due to union_mode="left_to_right" + + +def test_streamablehttp_client_connection_validation(): + """Test StreamableHTTP client validates connections properly.""" + test_url = "http://test.example/mcp" + + with patch("core.mcp.client.streamable_client.create_ssrf_proxy_mcp_http_client") as mock_client_factory: + # Mock the HTTP client + mock_client = Mock() + mock_client_factory.return_value.__enter__.return_value = mock_client + + # Mock successful response + mock_response = Mock() + mock_response.status_code = 200 + mock_response.headers = {"content-type": "application/json"} + mock_response.raise_for_status.return_value = None + mock_client.post.return_value = mock_response + + # Test connection + try: + with streamablehttp_client(test_url) as (read_queue, write_queue, get_session_id): + assert read_queue is not None + assert write_queue is not None + assert get_session_id is not None + except Exception: + # Connection might fail due to mocking, but we're testing the validation logic + pass + + +def test_streamablehttp_client_timeout_configuration(): + """Test StreamableHTTP client timeout configuration.""" + test_url = "http://test.example/mcp" + custom_headers = {"Authorization": "Bearer test-token"} + + with patch("core.mcp.client.streamable_client.create_ssrf_proxy_mcp_http_client") as mock_client_factory: + # Mock successful connection + mock_client = Mock() + mock_client_factory.return_value.__enter__.return_value = mock_client + + mock_response = Mock() + mock_response.status_code = 200 + mock_response.headers = {"content-type": "application/json"} + mock_response.raise_for_status.return_value = None + mock_client.post.return_value = mock_response + + try: + with streamablehttp_client(test_url, headers=custom_headers) as (read_queue, write_queue, get_session_id): + # Verify the configuration was passed correctly + mock_client_factory.assert_called_with(headers=custom_headers) + except Exception: + # Connection might fail due to mocking, but we tested the configuration + pass + + +def test_streamablehttp_client_session_id_handling(): + """Test StreamableHTTP client properly handles session IDs.""" + mock_client = MockStreamableHTTPClient("http://test.example/mcp") + read_queue, write_queue, get_session_id = mock_client.connect() + + # Test that session ID is available + session_id = get_session_id() + assert session_id == TEST_SESSION_ID + + # Test that we can use the session ID in subsequent requests + assert session_id is not None + assert len(session_id) > 0 + + +def test_streamablehttp_client_message_parsing(): + """Test StreamableHTTP client properly parses different message types.""" + mock_client = MockStreamableHTTPClient("http://test.example/mcp") + read_queue, write_queue, get_session_id = mock_client.connect() + + # Test valid initialization response + mock_client.send_initialize_response() + + # Should have a SessionMessage in the queue + message = read_queue.get(timeout=1.0) + assert message is not None + assert isinstance(message, types.SessionMessage) + assert isinstance(message.message.root, types.JSONRPCResponse) + + # Test tools response + mock_client.send_tools_response() + + tools_message = read_queue.get(timeout=1.0) + assert tools_message is not None + assert isinstance(tools_message, types.SessionMessage) + + +def test_streamablehttp_client_queue_cleanup(): + """Test that StreamableHTTP client properly cleans up queues on exit.""" + test_url = "http://test.example/mcp" + + read_queue = None + write_queue = None + + with patch("core.mcp.client.streamable_client.create_ssrf_proxy_mcp_http_client") as mock_client_factory: + # Mock connection that raises an exception + mock_client_factory.side_effect = Exception("Connection failed") + + try: + with streamablehttp_client(test_url) as (rq, wq, get_session_id): + read_queue = rq + write_queue = wq + except Exception: + pass # Expected to fail + + # Queues should be cleaned up even on exception + # Note: In real implementation, cleanup should put None to signal shutdown + + +def test_streamablehttp_client_headers_propagation(): + """Test that custom headers are properly propagated in StreamableHTTP client.""" + test_url = "http://test.example/mcp" + custom_headers = { + "Authorization": "Bearer test-token", + "X-Custom-Header": "test-value", + "User-Agent": "test-client/1.0", + } + + with patch("core.mcp.client.streamable_client.create_ssrf_proxy_mcp_http_client") as mock_client_factory: + # Mock the client factory to capture headers + mock_client = Mock() + mock_client_factory.return_value.__enter__.return_value = mock_client + + mock_response = Mock() + mock_response.status_code = 200 + mock_response.headers = {"content-type": "application/json"} + mock_response.raise_for_status.return_value = None + mock_client.post.return_value = mock_response + + try: + with streamablehttp_client(test_url, headers=custom_headers): + pass + except Exception: + pass # Expected due to mocking + + # Verify headers were passed to client factory + # Check that the call was made with headers that include our custom headers + mock_client_factory.assert_called_once() + call_args = mock_client_factory.call_args + assert "headers" in call_args.kwargs + passed_headers = call_args.kwargs["headers"] + + # Verify all custom headers are present + for key, value in custom_headers.items(): + assert key in passed_headers + assert passed_headers[key] == value + + +def test_streamablehttp_client_concurrent_access(): + """Test StreamableHTTP client behavior with concurrent queue access.""" + test_read_queue: queue.Queue = queue.Queue() + test_write_queue: queue.Queue = queue.Queue() + + # Simulate concurrent producers and consumers + def producer(): + for i in range(10): + test_read_queue.put(f"message_{i}") + time.sleep(0.01) # Small delay to simulate real conditions + + def consumer(): + received = [] + for _ in range(10): + try: + msg = test_read_queue.get(timeout=2.0) + received.append(msg) + except queue.Empty: + break + return received + + # Start producer in separate thread + producer_thread = threading.Thread(target=producer, daemon=True) + producer_thread.start() + + # Consume messages + received_messages = consumer() + + # Wait for producer to finish + producer_thread.join(timeout=5.0) + + # Verify all messages were received + assert len(received_messages) == 10 + for i in range(10): + assert f"message_{i}" in received_messages + + +def test_streamablehttp_client_json_vs_sse_mode(): + """Test StreamableHTTP client handling of JSON vs SSE response modes.""" + test_url = "http://test.example/mcp" + + with patch("core.mcp.client.streamable_client.create_ssrf_proxy_mcp_http_client") as mock_client_factory: + mock_client = Mock() + mock_client_factory.return_value.__enter__.return_value = mock_client + + # Mock JSON response + mock_json_response = Mock() + mock_json_response.status_code = 200 + mock_json_response.headers = {"content-type": "application/json"} + mock_json_response.json.return_value = {"result": "json_mode"} + mock_json_response.raise_for_status.return_value = None + + # Mock SSE response + mock_sse_response = Mock() + mock_sse_response.status_code = 200 + mock_sse_response.headers = {"content-type": "text/event-stream"} + mock_sse_response.raise_for_status.return_value = None + + # Test JSON mode + mock_client.post.return_value = mock_json_response + + try: + with streamablehttp_client(test_url) as (read_queue, write_queue, get_session_id): + # Should handle JSON responses + assert read_queue is not None + assert write_queue is not None + except Exception: + pass # Expected due to mocking + + # Test SSE mode + mock_client.post.return_value = mock_sse_response + + try: + with streamablehttp_client(test_url) as (read_queue, write_queue, get_session_id): + # Should handle SSE responses + assert read_queue is not None + assert write_queue is not None + except Exception: + pass # Expected due to mocking + + +def test_streamablehttp_client_terminate_on_close(): + """Test StreamableHTTP client terminate_on_close parameter.""" + test_url = "http://test.example/mcp" + + with patch("core.mcp.client.streamable_client.create_ssrf_proxy_mcp_http_client") as mock_client_factory: + mock_client = Mock() + mock_client_factory.return_value.__enter__.return_value = mock_client + + mock_response = Mock() + mock_response.status_code = 200 + mock_response.headers = {"content-type": "application/json"} + mock_response.raise_for_status.return_value = None + mock_client.post.return_value = mock_response + mock_client.delete.return_value = mock_response + + # Test with terminate_on_close=True (default) + try: + with streamablehttp_client(test_url, terminate_on_close=True) as (read_queue, write_queue, get_session_id): + pass + except Exception: + pass # Expected due to mocking + + # Test with terminate_on_close=False + try: + with streamablehttp_client(test_url, terminate_on_close=False) as (read_queue, write_queue, get_session_id): + pass + except Exception: + pass # Expected due to mocking + + +def test_streamablehttp_client_protocol_version_handling(): + """Test StreamableHTTP client protocol version handling.""" + mock_client = MockStreamableHTTPClient("http://test.example/mcp") + read_queue, write_queue, get_session_id = mock_client.connect() + + # Send initialize response with specific protocol version + + session_message = types.SessionMessage( + message=types.JSONRPCMessage( + root=types.JSONRPCResponse( + jsonrpc="2.0", + id="init-1", + result={ + "protocolVersion": "2024-11-05", + "capabilities": {}, + "serverInfo": {"name": SERVER_NAME, "version": "0.1.0"}, + }, + ) + ) + ) + read_queue.put(session_message) + + # Get the message and verify protocol version + message = read_queue.get(timeout=1.0) + assert message is not None + assert isinstance(message.message.root, types.JSONRPCResponse) + result = message.message.root.result + assert result["protocolVersion"] == "2024-11-05" + + +def test_streamablehttp_client_error_response_handling(): + """Test StreamableHTTP client handling of error responses.""" + mock_client = MockStreamableHTTPClient("http://test.example/mcp") + read_queue, write_queue, get_session_id = mock_client.connect() + + # Send an error response + session_message = types.SessionMessage( + message=types.JSONRPCMessage( + root=types.JSONRPCError( + jsonrpc="2.0", + id="test-1", + error=types.ErrorData(code=-32601, message="Method not found", data=None), + ) + ) + ) + read_queue.put(session_message) + + # Get the error message + message = read_queue.get(timeout=1.0) + assert message is not None + assert isinstance(message.message.root, types.JSONRPCError) + assert message.message.root.error.code == -32601 + assert message.message.root.error.message == "Method not found" + + +def test_streamablehttp_client_resumption_token_handling(): + """Test StreamableHTTP client resumption token functionality.""" + test_url = "http://test.example/mcp" + test_resumption_token = "resume-token-123" + + with patch("core.mcp.client.streamable_client.create_ssrf_proxy_mcp_http_client") as mock_client_factory: + mock_client = Mock() + mock_client_factory.return_value.__enter__.return_value = mock_client + + mock_response = Mock() + mock_response.status_code = 200 + mock_response.headers = {"content-type": "application/json", "last-event-id": test_resumption_token} + mock_response.raise_for_status.return_value = None + mock_client.post.return_value = mock_response + + try: + with streamablehttp_client(test_url) as (read_queue, write_queue, get_session_id): + # Test that resumption token can be captured from headers + assert read_queue is not None + assert write_queue is not None + except Exception: + pass # Expected due to mocking diff --git a/api/tests/unit_tests/core/ops/__init__.py b/api/tests/unit_tests/core/ops/__init__.py new file mode 100644 index 0000000000..bb92ccdec7 --- /dev/null +++ b/api/tests/unit_tests/core/ops/__init__.py @@ -0,0 +1 @@ +# Unit tests for core ops module diff --git a/api/tests/unit_tests/core/ops/test_config_entity.py b/api/tests/unit_tests/core/ops/test_config_entity.py new file mode 100644 index 0000000000..81cb04548d --- /dev/null +++ b/api/tests/unit_tests/core/ops/test_config_entity.py @@ -0,0 +1,385 @@ +import pytest +from pydantic import ValidationError + +from core.ops.entities.config_entity import ( + AliyunConfig, + ArizeConfig, + LangfuseConfig, + LangSmithConfig, + OpikConfig, + PhoenixConfig, + TracingProviderEnum, + WeaveConfig, +) + + +class TestTracingProviderEnum: + """Test cases for TracingProviderEnum""" + + def test_enum_values(self): + """Test that all expected enum values are present""" + assert TracingProviderEnum.ARIZE == "arize" + assert TracingProviderEnum.PHOENIX == "phoenix" + assert TracingProviderEnum.LANGFUSE == "langfuse" + assert TracingProviderEnum.LANGSMITH == "langsmith" + assert TracingProviderEnum.OPIK == "opik" + assert TracingProviderEnum.WEAVE == "weave" + assert TracingProviderEnum.ALIYUN == "aliyun" + + +class TestArizeConfig: + """Test cases for ArizeConfig""" + + def test_valid_config(self): + """Test valid Arize configuration""" + config = ArizeConfig( + api_key="test_key", space_id="test_space", project="test_project", endpoint="https://custom.arize.com" + ) + assert config.api_key == "test_key" + assert config.space_id == "test_space" + assert config.project == "test_project" + assert config.endpoint == "https://custom.arize.com" + + def test_default_values(self): + """Test default values are set correctly""" + config = ArizeConfig() + assert config.api_key is None + assert config.space_id is None + assert config.project is None + assert config.endpoint == "https://otlp.arize.com" + + def test_project_validation_empty(self): + """Test project validation with empty value""" + config = ArizeConfig(project="") + assert config.project == "default" + + def test_project_validation_none(self): + """Test project validation with None value""" + config = ArizeConfig(project=None) + assert config.project == "default" + + def test_endpoint_validation_empty(self): + """Test endpoint validation with empty value""" + config = ArizeConfig(endpoint="") + assert config.endpoint == "https://otlp.arize.com" + + def test_endpoint_validation_with_path(self): + """Test endpoint validation normalizes URL by removing path""" + config = ArizeConfig(endpoint="https://custom.arize.com/api/v1") + assert config.endpoint == "https://custom.arize.com" + + def test_endpoint_validation_invalid_scheme(self): + """Test endpoint validation rejects invalid schemes""" + with pytest.raises(ValidationError, match="URL scheme must be one of"): + ArizeConfig(endpoint="ftp://invalid.com") + + def test_endpoint_validation_no_scheme(self): + """Test endpoint validation rejects URLs without scheme""" + with pytest.raises(ValidationError, match="URL scheme must be one of"): + ArizeConfig(endpoint="invalid.com") + + +class TestPhoenixConfig: + """Test cases for PhoenixConfig""" + + def test_valid_config(self): + """Test valid Phoenix configuration""" + config = PhoenixConfig(api_key="test_key", project="test_project", endpoint="https://custom.phoenix.com") + assert config.api_key == "test_key" + assert config.project == "test_project" + assert config.endpoint == "https://custom.phoenix.com" + + def test_default_values(self): + """Test default values are set correctly""" + config = PhoenixConfig() + assert config.api_key is None + assert config.project is None + assert config.endpoint == "https://app.phoenix.arize.com" + + def test_project_validation_empty(self): + """Test project validation with empty value""" + config = PhoenixConfig(project="") + assert config.project == "default" + + def test_endpoint_validation_with_path(self): + """Test endpoint validation normalizes URL by removing path""" + config = PhoenixConfig(endpoint="https://custom.phoenix.com/api/v1") + assert config.endpoint == "https://custom.phoenix.com" + + +class TestLangfuseConfig: + """Test cases for LangfuseConfig""" + + def test_valid_config(self): + """Test valid Langfuse configuration""" + config = LangfuseConfig(public_key="public_key", secret_key="secret_key", host="https://custom.langfuse.com") + assert config.public_key == "public_key" + assert config.secret_key == "secret_key" + assert config.host == "https://custom.langfuse.com" + + def test_default_values(self): + """Test default values are set correctly""" + config = LangfuseConfig(public_key="public", secret_key="secret") + assert config.host == "https://api.langfuse.com" + + def test_missing_required_fields(self): + """Test that required fields are enforced""" + with pytest.raises(ValidationError): + LangfuseConfig() + + with pytest.raises(ValidationError): + LangfuseConfig(public_key="public") + + with pytest.raises(ValidationError): + LangfuseConfig(secret_key="secret") + + def test_host_validation_empty(self): + """Test host validation with empty value""" + config = LangfuseConfig(public_key="public", secret_key="secret", host="") + assert config.host == "https://api.langfuse.com" + + +class TestLangSmithConfig: + """Test cases for LangSmithConfig""" + + def test_valid_config(self): + """Test valid LangSmith configuration""" + config = LangSmithConfig(api_key="test_key", project="test_project", endpoint="https://custom.smith.com") + assert config.api_key == "test_key" + assert config.project == "test_project" + assert config.endpoint == "https://custom.smith.com" + + def test_default_values(self): + """Test default values are set correctly""" + config = LangSmithConfig(api_key="key", project="project") + assert config.endpoint == "https://api.smith.langchain.com" + + def test_missing_required_fields(self): + """Test that required fields are enforced""" + with pytest.raises(ValidationError): + LangSmithConfig() + + with pytest.raises(ValidationError): + LangSmithConfig(api_key="key") + + with pytest.raises(ValidationError): + LangSmithConfig(project="project") + + def test_endpoint_validation_https_only(self): + """Test endpoint validation only allows HTTPS""" + with pytest.raises(ValidationError, match="URL scheme must be one of"): + LangSmithConfig(api_key="key", project="project", endpoint="http://insecure.com") + + +class TestOpikConfig: + """Test cases for OpikConfig""" + + def test_valid_config(self): + """Test valid Opik configuration""" + config = OpikConfig( + api_key="test_key", + project="test_project", + workspace="test_workspace", + url="https://custom.comet.com/opik/api/", + ) + assert config.api_key == "test_key" + assert config.project == "test_project" + assert config.workspace == "test_workspace" + assert config.url == "https://custom.comet.com/opik/api/" + + def test_default_values(self): + """Test default values are set correctly""" + config = OpikConfig() + assert config.api_key is None + assert config.project is None + assert config.workspace is None + assert config.url == "https://www.comet.com/opik/api/" + + def test_project_validation_empty(self): + """Test project validation with empty value""" + config = OpikConfig(project="") + assert config.project == "Default Project" + + def test_url_validation_empty(self): + """Test URL validation with empty value""" + config = OpikConfig(url="") + assert config.url == "https://www.comet.com/opik/api/" + + def test_url_validation_missing_suffix(self): + """Test URL validation requires /api/ suffix""" + with pytest.raises(ValidationError, match="URL should end with /api/"): + OpikConfig(url="https://custom.comet.com/opik/") + + def test_url_validation_invalid_scheme(self): + """Test URL validation rejects invalid schemes""" + with pytest.raises(ValidationError, match="URL must start with https:// or http://"): + OpikConfig(url="ftp://custom.comet.com/opik/api/") + + +class TestWeaveConfig: + """Test cases for WeaveConfig""" + + def test_valid_config(self): + """Test valid Weave configuration""" + config = WeaveConfig( + api_key="test_key", + entity="test_entity", + project="test_project", + endpoint="https://custom.wandb.ai", + host="https://custom.host.com", + ) + assert config.api_key == "test_key" + assert config.entity == "test_entity" + assert config.project == "test_project" + assert config.endpoint == "https://custom.wandb.ai" + assert config.host == "https://custom.host.com" + + def test_default_values(self): + """Test default values are set correctly""" + config = WeaveConfig(api_key="key", project="project") + assert config.entity is None + assert config.endpoint == "https://trace.wandb.ai" + assert config.host is None + + def test_missing_required_fields(self): + """Test that required fields are enforced""" + with pytest.raises(ValidationError): + WeaveConfig() + + with pytest.raises(ValidationError): + WeaveConfig(api_key="key") + + with pytest.raises(ValidationError): + WeaveConfig(project="project") + + def test_endpoint_validation_https_only(self): + """Test endpoint validation only allows HTTPS""" + with pytest.raises(ValidationError, match="URL scheme must be one of"): + WeaveConfig(api_key="key", project="project", endpoint="http://insecure.wandb.ai") + + def test_host_validation_optional(self): + """Test host validation is optional but validates when provided""" + config = WeaveConfig(api_key="key", project="project", host=None) + assert config.host is None + + config = WeaveConfig(api_key="key", project="project", host="") + assert config.host == "" + + config = WeaveConfig(api_key="key", project="project", host="https://valid.host.com") + assert config.host == "https://valid.host.com" + + def test_host_validation_invalid_scheme(self): + """Test host validation rejects invalid schemes when provided""" + with pytest.raises(ValidationError, match="URL scheme must be one of"): + WeaveConfig(api_key="key", project="project", host="ftp://invalid.host.com") + + +class TestAliyunConfig: + """Test cases for AliyunConfig""" + + def test_valid_config(self): + """Test valid Aliyun configuration""" + config = AliyunConfig( + app_name="test_app", + license_key="test_license_key", + endpoint="https://custom.tracing-analysis-dc-hz.aliyuncs.com", + ) + assert config.app_name == "test_app" + assert config.license_key == "test_license_key" + assert config.endpoint == "https://custom.tracing-analysis-dc-hz.aliyuncs.com" + + def test_default_values(self): + """Test default values are set correctly""" + config = AliyunConfig(license_key="test_license", endpoint="https://tracing-analysis-dc-hz.aliyuncs.com") + assert config.app_name == "dify_app" + + def test_missing_required_fields(self): + """Test that required fields are enforced""" + with pytest.raises(ValidationError): + AliyunConfig() + + with pytest.raises(ValidationError): + AliyunConfig(license_key="test_license") + + with pytest.raises(ValidationError): + AliyunConfig(endpoint="https://tracing-analysis-dc-hz.aliyuncs.com") + + def test_app_name_validation_empty(self): + """Test app_name validation with empty value""" + config = AliyunConfig( + license_key="test_license", endpoint="https://tracing-analysis-dc-hz.aliyuncs.com", app_name="" + ) + assert config.app_name == "dify_app" + + def test_endpoint_validation_empty(self): + """Test endpoint validation with empty value""" + config = AliyunConfig(license_key="test_license", endpoint="") + assert config.endpoint == "https://tracing-analysis-dc-hz.aliyuncs.com" + + def test_endpoint_validation_with_path(self): + """Test endpoint validation normalizes URL by removing path""" + config = AliyunConfig( + license_key="test_license", endpoint="https://tracing-analysis-dc-hz.aliyuncs.com/api/v1/traces" + ) + assert config.endpoint == "https://tracing-analysis-dc-hz.aliyuncs.com" + + def test_endpoint_validation_invalid_scheme(self): + """Test endpoint validation rejects invalid schemes""" + with pytest.raises(ValidationError, match="URL scheme must be one of"): + AliyunConfig(license_key="test_license", endpoint="ftp://invalid.tracing-analysis-dc-hz.aliyuncs.com") + + def test_endpoint_validation_no_scheme(self): + """Test endpoint validation rejects URLs without scheme""" + with pytest.raises(ValidationError, match="URL scheme must be one of"): + AliyunConfig(license_key="test_license", endpoint="invalid.tracing-analysis-dc-hz.aliyuncs.com") + + def test_license_key_required(self): + """Test that license_key is required and cannot be empty""" + with pytest.raises(ValidationError): + AliyunConfig(license_key="", endpoint="https://tracing-analysis-dc-hz.aliyuncs.com") + + +class TestConfigIntegration: + """Integration tests for configuration classes""" + + def test_all_configs_can_be_instantiated(self): + """Test that all config classes can be instantiated with valid data""" + configs = [ + ArizeConfig(api_key="key"), + PhoenixConfig(api_key="key"), + LangfuseConfig(public_key="public", secret_key="secret"), + LangSmithConfig(api_key="key", project="project"), + OpikConfig(api_key="key"), + WeaveConfig(api_key="key", project="project"), + AliyunConfig(license_key="test_license", endpoint="https://tracing-analysis-dc-hz.aliyuncs.com"), + ] + + for config in configs: + assert config is not None + + def test_url_normalization_consistency(self): + """Test that URL normalization works consistently across configs""" + # Test that paths are removed from endpoints + arize_config = ArizeConfig(endpoint="https://arize.com/api/v1/test") + phoenix_config = PhoenixConfig(endpoint="https://phoenix.com/api/v2/") + aliyun_config = AliyunConfig( + license_key="test_license", endpoint="https://tracing-analysis-dc-hz.aliyuncs.com/api/v1/traces" + ) + + assert arize_config.endpoint == "https://arize.com" + assert phoenix_config.endpoint == "https://phoenix.com" + assert aliyun_config.endpoint == "https://tracing-analysis-dc-hz.aliyuncs.com" + + def test_project_default_values(self): + """Test that project default values are set correctly""" + arize_config = ArizeConfig(project="") + phoenix_config = PhoenixConfig(project="") + opik_config = OpikConfig(project="") + aliyun_config = AliyunConfig( + license_key="test_license", endpoint="https://tracing-analysis-dc-hz.aliyuncs.com", app_name="" + ) + + assert arize_config.project == "default" + assert phoenix_config.project == "default" + assert opik_config.project == "Default Project" + assert aliyun_config.app_name == "dify_app" diff --git a/api/tests/unit_tests/core/ops/test_utils.py b/api/tests/unit_tests/core/ops/test_utils.py new file mode 100644 index 0000000000..7cc2772acf --- /dev/null +++ b/api/tests/unit_tests/core/ops/test_utils.py @@ -0,0 +1,138 @@ +import pytest + +from core.ops.utils import validate_project_name, validate_url, validate_url_with_path + + +class TestValidateUrl: + """Test cases for validate_url function""" + + def test_valid_https_url(self): + """Test valid HTTPS URL""" + result = validate_url("https://example.com", "https://default.com") + assert result == "https://example.com" + + def test_valid_http_url(self): + """Test valid HTTP URL""" + result = validate_url("http://example.com", "https://default.com") + assert result == "http://example.com" + + def test_url_with_path_removed(self): + """Test that URL path is removed during normalization""" + result = validate_url("https://example.com/api/v1/test", "https://default.com") + assert result == "https://example.com" + + def test_url_with_query_removed(self): + """Test that URL query parameters are removed""" + result = validate_url("https://example.com?param=value", "https://default.com") + assert result == "https://example.com" + + def test_url_with_fragment_removed(self): + """Test that URL fragments are removed""" + result = validate_url("https://example.com#section", "https://default.com") + assert result == "https://example.com" + + def test_empty_url_returns_default(self): + """Test empty URL returns default""" + result = validate_url("", "https://default.com") + assert result == "https://default.com" + + def test_none_url_returns_default(self): + """Test None URL returns default""" + result = validate_url(None, "https://default.com") + assert result == "https://default.com" + + def test_whitespace_url_returns_default(self): + """Test whitespace URL returns default""" + result = validate_url(" ", "https://default.com") + assert result == "https://default.com" + + def test_invalid_scheme_raises_error(self): + """Test invalid scheme raises ValueError""" + with pytest.raises(ValueError, match="URL scheme must be one of"): + validate_url("ftp://example.com", "https://default.com") + + def test_no_scheme_raises_error(self): + """Test URL without scheme raises ValueError""" + with pytest.raises(ValueError, match="URL scheme must be one of"): + validate_url("example.com", "https://default.com") + + def test_custom_allowed_schemes(self): + """Test custom allowed schemes""" + result = validate_url("https://example.com", "https://default.com", allowed_schemes=("https",)) + assert result == "https://example.com" + + with pytest.raises(ValueError, match="URL scheme must be one of"): + validate_url("http://example.com", "https://default.com", allowed_schemes=("https",)) + + +class TestValidateUrlWithPath: + """Test cases for validate_url_with_path function""" + + def test_valid_url_with_path(self): + """Test valid URL with path""" + result = validate_url_with_path("https://example.com/api/v1", "https://default.com") + assert result == "https://example.com/api/v1" + + def test_valid_url_with_required_suffix(self): + """Test valid URL with required suffix""" + result = validate_url_with_path("https://example.com/api/", "https://default.com", required_suffix="/api/") + assert result == "https://example.com/api/" + + def test_url_without_required_suffix_raises_error(self): + """Test URL without required suffix raises error""" + with pytest.raises(ValueError, match="URL should end with /api/"): + validate_url_with_path("https://example.com/api", "https://default.com", required_suffix="/api/") + + def test_empty_url_returns_default(self): + """Test empty URL returns default""" + result = validate_url_with_path("", "https://default.com") + assert result == "https://default.com" + + def test_none_url_returns_default(self): + """Test None URL returns default""" + result = validate_url_with_path(None, "https://default.com") + assert result == "https://default.com" + + def test_invalid_scheme_raises_error(self): + """Test invalid scheme raises ValueError""" + with pytest.raises(ValueError, match="URL must start with https:// or http://"): + validate_url_with_path("ftp://example.com", "https://default.com") + + def test_no_scheme_raises_error(self): + """Test URL without scheme raises ValueError""" + with pytest.raises(ValueError, match="URL must start with https:// or http://"): + validate_url_with_path("example.com", "https://default.com") + + +class TestValidateProjectName: + """Test cases for validate_project_name function""" + + def test_valid_project_name(self): + """Test valid project name""" + result = validate_project_name("my-project", "default") + assert result == "my-project" + + def test_empty_project_name_returns_default(self): + """Test empty project name returns default""" + result = validate_project_name("", "default") + assert result == "default" + + def test_none_project_name_returns_default(self): + """Test None project name returns default""" + result = validate_project_name(None, "default") + assert result == "default" + + def test_whitespace_project_name_returns_default(self): + """Test whitespace project name returns default""" + result = validate_project_name(" ", "default") + assert result == "default" + + def test_project_name_with_whitespace_trimmed(self): + """Test project name with whitespace is trimmed""" + result = validate_project_name(" my-project ", "default") + assert result == "my-project" + + def test_custom_default_name(self): + """Test custom default name""" + result = validate_project_name("", "Custom Default") + assert result == "Custom Default" diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_graph_engine.py b/api/tests/unit_tests/core/workflow/graph_engine/test_graph_engine.py index 7535ec4866..c288a5fa13 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/test_graph_engine.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/test_graph_engine.py @@ -1,3 +1,4 @@ +import time from unittest.mock import patch import pytest @@ -19,6 +20,7 @@ from core.workflow.graph_engine.entities.event import ( NodeRunSucceededEvent, ) from core.workflow.graph_engine.entities.graph import Graph +from core.workflow.graph_engine.entities.graph_runtime_state import GraphRuntimeState from core.workflow.graph_engine.entities.runtime_route_state import RouteNodeState from core.workflow.graph_engine.graph_engine import GraphEngine from core.workflow.nodes.code.code_node import CodeNode @@ -172,6 +174,7 @@ def test_run_parallel_in_workflow(mock_close, mock_remove): system_variables={SystemVariableKey.FILES: [], SystemVariableKey.USER_ID: "aaa"}, user_inputs={"query": "hi"} ) + graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter()) graph_engine = GraphEngine( tenant_id="111", app_id="222", @@ -183,7 +186,7 @@ def test_run_parallel_in_workflow(mock_close, mock_remove): invoke_from=InvokeFrom.WEB_APP, call_depth=0, graph=graph, - variable_pool=variable_pool, + graph_runtime_state=graph_runtime_state, max_execution_steps=500, max_execution_time=1200, ) @@ -299,6 +302,7 @@ def test_run_parallel_in_chatflow(mock_close, mock_remove): user_inputs={}, ) + graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter()) graph_engine = GraphEngine( tenant_id="111", app_id="222", @@ -310,7 +314,7 @@ def test_run_parallel_in_chatflow(mock_close, mock_remove): invoke_from=InvokeFrom.WEB_APP, call_depth=0, graph=graph, - variable_pool=variable_pool, + graph_runtime_state=graph_runtime_state, max_execution_steps=500, max_execution_time=1200, ) @@ -479,6 +483,7 @@ def test_run_branch(mock_close, mock_remove): user_inputs={"uid": "takato"}, ) + graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter()) graph_engine = GraphEngine( tenant_id="111", app_id="222", @@ -490,7 +495,7 @@ def test_run_branch(mock_close, mock_remove): invoke_from=InvokeFrom.WEB_APP, call_depth=0, graph=graph, - variable_pool=variable_pool, + graph_runtime_state=graph_runtime_state, max_execution_steps=500, max_execution_time=1200, ) @@ -813,6 +818,7 @@ def test_condition_parallel_correct_output(mock_close, mock_remove, app): system_variables={SystemVariableKey.FILES: [], SystemVariableKey.USER_ID: "aaa"}, user_inputs={"query": "hi"} ) + graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter()) graph_engine = GraphEngine( tenant_id="111", app_id="222", @@ -824,7 +830,7 @@ def test_condition_parallel_correct_output(mock_close, mock_remove, app): invoke_from=InvokeFrom.WEB_APP, call_depth=0, graph=graph, - variable_pool=variable_pool, + graph_runtime_state=graph_runtime_state, max_execution_steps=500, max_execution_time=1200, ) diff --git a/api/tests/unit_tests/core/workflow/nodes/test_continue_on_error.py b/api/tests/unit_tests/core/workflow/nodes/test_continue_on_error.py index ff60d5974b..a6c553faf0 100644 --- a/api/tests/unit_tests/core/workflow/nodes/test_continue_on_error.py +++ b/api/tests/unit_tests/core/workflow/nodes/test_continue_on_error.py @@ -1,7 +1,9 @@ +import time from unittest.mock import patch from core.app.entities.app_invoke_entities import InvokeFrom from core.workflow.entities.node_entities import NodeRunResult, WorkflowNodeExecutionMetadataKey +from core.workflow.entities.variable_pool import VariablePool from core.workflow.entities.workflow_node_execution import WorkflowNodeExecutionStatus from core.workflow.enums import SystemVariableKey from core.workflow.graph_engine.entities.event import ( @@ -11,6 +13,7 @@ from core.workflow.graph_engine.entities.event import ( NodeRunStreamChunkEvent, ) from core.workflow.graph_engine.entities.graph import Graph +from core.workflow.graph_engine.entities.graph_runtime_state import GraphRuntimeState from core.workflow.graph_engine.graph_engine import GraphEngine from core.workflow.nodes.event.event import RunCompletedEvent, RunStreamChunkEvent from core.workflow.nodes.llm.node import LLMNode @@ -163,15 +166,16 @@ class ContinueOnErrorTestHelper: def create_test_graph_engine(graph_config: dict, user_inputs: dict | None = None): """Helper method to create a graph engine instance for testing""" graph = Graph.init(graph_config=graph_config) - variable_pool = { - "system_variables": { + variable_pool = VariablePool( + system_variables={ SystemVariableKey.QUERY: "clear", SystemVariableKey.FILES: [], SystemVariableKey.CONVERSATION_ID: "abababa", SystemVariableKey.USER_ID: "aaa", }, - "user_inputs": user_inputs or {"uid": "takato"}, - } + user_inputs=user_inputs or {"uid": "takato"}, + ) + graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter()) return GraphEngine( tenant_id="111", @@ -184,7 +188,7 @@ class ContinueOnErrorTestHelper: invoke_from=InvokeFrom.WEB_APP, call_depth=0, graph=graph, - variable_pool=variable_pool, + graph_runtime_state=graph_runtime_state, max_execution_steps=500, max_execution_time=1200, ) diff --git a/api/tests/unit_tests/extensions/test_redis.py b/api/tests/unit_tests/extensions/test_redis.py new file mode 100644 index 0000000000..933fa32894 --- /dev/null +++ b/api/tests/unit_tests/extensions/test_redis.py @@ -0,0 +1,53 @@ +from redis import RedisError + +from extensions.ext_redis import redis_fallback + + +def test_redis_fallback_success(): + @redis_fallback(default_return=None) + def test_func(): + return "success" + + assert test_func() == "success" + + +def test_redis_fallback_error(): + @redis_fallback(default_return="fallback") + def test_func(): + raise RedisError("Redis error") + + assert test_func() == "fallback" + + +def test_redis_fallback_none_default(): + @redis_fallback() + def test_func(): + raise RedisError("Redis error") + + assert test_func() is None + + +def test_redis_fallback_with_args(): + @redis_fallback(default_return=0) + def test_func(x, y): + raise RedisError("Redis error") + + assert test_func(1, 2) == 0 + + +def test_redis_fallback_with_kwargs(): + @redis_fallback(default_return={}) + def test_func(x=None, y=None): + raise RedisError("Redis error") + + assert test_func(x=1, y=2) == {} + + +def test_redis_fallback_preserves_function_metadata(): + @redis_fallback(default_return=None) + def test_func(): + """Test function docstring""" + pass + + assert test_func.__name__ == "test_func" + assert test_func.__doc__ == "Test function docstring" diff --git a/api/tests/unit_tests/factories/test_variable_factory.py b/api/tests/unit_tests/factories/test_variable_factory.py index 481fbdc91a..edd4c5e93e 100644 --- a/api/tests/unit_tests/factories/test_variable_factory.py +++ b/api/tests/unit_tests/factories/test_variable_factory.py @@ -14,9 +14,7 @@ from core.variables import ( ArrayStringVariable, FloatVariable, IntegerVariable, - ObjectSegment, SecretVariable, - SegmentType, StringVariable, ) from core.variables.exc import VariableError @@ -418,8 +416,6 @@ def test_build_segment_file_array_with_different_file_types(): @st.composite def _generate_file(draw) -> File: - file_id = draw(st.text(min_size=1, max_size=10)) - tenant_id = draw(st.text(min_size=1, max_size=10)) file_type, mime_type, extension = draw( st.sampled_from( [ diff --git a/api/tests/unit_tests/libs/test_helper.py b/api/tests/unit_tests/libs/test_helper.py new file mode 100644 index 0000000000..b7701055f5 --- /dev/null +++ b/api/tests/unit_tests/libs/test_helper.py @@ -0,0 +1,65 @@ +import pytest + +from libs.helper import extract_tenant_id +from models.account import Account +from models.model import EndUser + + +class TestExtractTenantId: + """Test cases for the extract_tenant_id utility function.""" + + def test_extract_tenant_id_from_account_with_tenant(self): + """Test extracting tenant_id from Account with current_tenant_id.""" + # Create a mock Account object + account = Account() + # Mock the current_tenant_id property + account._current_tenant = type("MockTenant", (), {"id": "account-tenant-123"})() + + tenant_id = extract_tenant_id(account) + assert tenant_id == "account-tenant-123" + + def test_extract_tenant_id_from_account_without_tenant(self): + """Test extracting tenant_id from Account without current_tenant_id.""" + # Create a mock Account object + account = Account() + account._current_tenant = None + + tenant_id = extract_tenant_id(account) + assert tenant_id is None + + def test_extract_tenant_id_from_enduser_with_tenant(self): + """Test extracting tenant_id from EndUser with tenant_id.""" + # Create a mock EndUser object + end_user = EndUser() + end_user.tenant_id = "enduser-tenant-456" + + tenant_id = extract_tenant_id(end_user) + assert tenant_id == "enduser-tenant-456" + + def test_extract_tenant_id_from_enduser_without_tenant(self): + """Test extracting tenant_id from EndUser without tenant_id.""" + # Create a mock EndUser object + end_user = EndUser() + end_user.tenant_id = None + + tenant_id = extract_tenant_id(end_user) + assert tenant_id is None + + def test_extract_tenant_id_with_invalid_user_type(self): + """Test extracting tenant_id with invalid user type raises ValueError.""" + invalid_user = "not_a_user_object" + + with pytest.raises(ValueError, match="Invalid user type.*Expected Account or EndUser"): + extract_tenant_id(invalid_user) + + def test_extract_tenant_id_with_none_user(self): + """Test extracting tenant_id with None user raises ValueError.""" + with pytest.raises(ValueError, match="Invalid user type.*Expected Account or EndUser"): + extract_tenant_id(None) + + def test_extract_tenant_id_with_dict_user(self): + """Test extracting tenant_id with dict user raises ValueError.""" + dict_user = {"id": "123", "tenant_id": "456"} + + with pytest.raises(ValueError, match="Invalid user type.*Expected Account or EndUser"): + extract_tenant_id(dict_user) diff --git a/api/tests/unit_tests/libs/test_password.py b/api/tests/unit_tests/libs/test_password.py new file mode 100644 index 0000000000..79fc792cc5 --- /dev/null +++ b/api/tests/unit_tests/libs/test_password.py @@ -0,0 +1,74 @@ +import base64 +import binascii +import os + +import pytest + +from libs.password import compare_password, hash_password, valid_password + + +class TestValidPassword: + """Test password format validation""" + + def test_should_accept_valid_passwords(self): + """Test accepting valid password formats""" + assert valid_password("password123") == "password123" + assert valid_password("test1234") == "test1234" + assert valid_password("Test123456") == "Test123456" + + def test_should_reject_invalid_passwords(self): + """Test rejecting invalid password formats""" + # Too short + with pytest.raises(ValueError) as exc_info: + valid_password("abc123") + assert "Password must contain letters and numbers" in str(exc_info.value) + + # No numbers + with pytest.raises(ValueError): + valid_password("abcdefgh") + + # No letters + with pytest.raises(ValueError): + valid_password("12345678") + + # Empty + with pytest.raises(ValueError): + valid_password("") + + +class TestPasswordHashing: + """Test password hashing and comparison""" + + def setup_method(self): + """Setup test data""" + self.password = "test123password" + self.salt = os.urandom(16) + self.salt_base64 = base64.b64encode(self.salt).decode() + + password_hash = hash_password(self.password, self.salt) + self.password_hash_base64 = base64.b64encode(password_hash).decode() + + def test_should_verify_correct_password(self): + """Test correct password verification""" + result = compare_password(self.password, self.password_hash_base64, self.salt_base64) + assert result is True + + def test_should_reject_wrong_password(self): + """Test rejection of incorrect passwords""" + result = compare_password("wrongpassword", self.password_hash_base64, self.salt_base64) + assert result is False + + def test_should_handle_invalid_base64(self): + """Test handling of invalid base64 data""" + # Invalid base64 hash + with pytest.raises(binascii.Error): + compare_password(self.password, "invalid_base64!", self.salt_base64) + + # Invalid base64 salt + with pytest.raises(binascii.Error): + compare_password(self.password, self.password_hash_base64, "invalid_base64!") + + def test_should_be_case_sensitive(self): + """Test password case sensitivity""" + result = compare_password(self.password.upper(), self.password_hash_base64, self.salt_base64) + assert result is False diff --git a/api/tests/unit_tests/models/test_workflow.py b/api/tests/unit_tests/models/test_workflow.py index 69163d48bd..5bc77ad0ef 100644 --- a/api/tests/unit_tests/models/test_workflow.py +++ b/api/tests/unit_tests/models/test_workflow.py @@ -9,6 +9,7 @@ from core.file.models import File from core.variables import FloatVariable, IntegerVariable, SecretVariable, StringVariable from core.variables.segments import IntegerSegment, Segment from factories.variable_factory import build_segment +from models.model import EndUser from models.workflow import Workflow, WorkflowDraftVariable, WorkflowNodeExecutionModel, is_system_variable_editable @@ -43,7 +44,7 @@ def test_environment_variables(): ) # Mock current_user as an EndUser - mock_user = mock.Mock() + mock_user = mock.Mock(spec=EndUser) mock_user.tenant_id = "tenant_id" with ( @@ -90,7 +91,7 @@ def test_update_environment_variables(): ) # Mock current_user as an EndUser - mock_user = mock.Mock() + mock_user = mock.Mock(spec=EndUser) mock_user.tenant_id = "tenant_id" with ( @@ -136,7 +137,7 @@ def test_to_dict(): # Create some EnvironmentVariable instances # Mock current_user as an EndUser - mock_user = mock.Mock() + mock_user = mock.Mock(spec=EndUser) mock_user.tenant_id = "tenant_id" with ( diff --git a/api/tests/unit_tests/services/test_dataset_service_update_dataset.py b/api/tests/unit_tests/services/test_dataset_service_update_dataset.py index cdbb439c85..87b46f213b 100644 --- a/api/tests/unit_tests/services/test_dataset_service_update_dataset.py +++ b/api/tests/unit_tests/services/test_dataset_service_update_dataset.py @@ -10,7 +10,6 @@ from core.model_runtime.entities.model_entities import ModelType from models.dataset import Dataset, ExternalKnowledgeBindings from services.dataset_service import DatasetService from services.errors.account import NoPermissionError -from tests.unit_tests.conftest import redis_mock class DatasetUpdateTestDataFactory: diff --git a/api/tests/unit_tests/utils/structured_output_parser/__init__.py b/api/tests/unit_tests/utils/structured_output_parser/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/tests/unit_tests/utils/structured_output_parser/test_structured_output_parser.py b/api/tests/unit_tests/utils/structured_output_parser/test_structured_output_parser.py new file mode 100644 index 0000000000..728c58fc5b --- /dev/null +++ b/api/tests/unit_tests/utils/structured_output_parser/test_structured_output_parser.py @@ -0,0 +1,465 @@ +from decimal import Decimal +from unittest.mock import MagicMock, patch + +import pytest + +from core.llm_generator.output_parser.errors import OutputParserError +from core.llm_generator.output_parser.structured_output import invoke_llm_with_structured_output +from core.model_runtime.entities.llm_entities import ( + LLMResult, + LLMResultChunk, + LLMResultChunkDelta, + LLMResultChunkWithStructuredOutput, + LLMResultWithStructuredOutput, + LLMUsage, +) +from core.model_runtime.entities.message_entities import ( + AssistantPromptMessage, + SystemPromptMessage, + TextPromptMessageContent, + UserPromptMessage, +) +from core.model_runtime.entities.model_entities import AIModelEntity, ModelType + + +def create_mock_usage(prompt_tokens: int = 10, completion_tokens: int = 5) -> LLMUsage: + """Create a mock LLMUsage with all required fields""" + return LLMUsage( + prompt_tokens=prompt_tokens, + prompt_unit_price=Decimal("0.001"), + prompt_price_unit=Decimal("1"), + prompt_price=Decimal(str(prompt_tokens)) * Decimal("0.001"), + completion_tokens=completion_tokens, + completion_unit_price=Decimal("0.002"), + completion_price_unit=Decimal("1"), + completion_price=Decimal(str(completion_tokens)) * Decimal("0.002"), + total_tokens=prompt_tokens + completion_tokens, + total_price=Decimal(str(prompt_tokens)) * Decimal("0.001") + Decimal(str(completion_tokens)) * Decimal("0.002"), + currency="USD", + latency=1.5, + ) + + +def get_model_entity(provider: str, model_name: str, support_structure_output: bool = False) -> AIModelEntity: + """Create a mock AIModelEntity for testing""" + model_schema = MagicMock() + model_schema.model = model_name + model_schema.provider = provider + model_schema.model_type = ModelType.LLM + model_schema.model_provider = provider + model_schema.model_name = model_name + model_schema.support_structure_output = support_structure_output + model_schema.parameter_rules = [] + + return model_schema + + +def get_model_instance() -> MagicMock: + """Create a mock ModelInstance for testing""" + mock_instance = MagicMock() + mock_instance.provider = "openai" + mock_instance.credentials = {} + return mock_instance + + +def test_structured_output_parser(): + """Test cases for invoke_llm_with_structured_output function""" + + testcases = [ + # Test case 1: Model with native structured output support, non-streaming + { + "name": "native_structured_output_non_streaming", + "provider": "openai", + "model_name": "gpt-4o", + "support_structure_output": True, + "stream": False, + "json_schema": {"type": "object", "properties": {"name": {"type": "string"}}}, + "expected_llm_response": LLMResult( + model="gpt-4o", + message=AssistantPromptMessage(content='{"name": "test"}'), + usage=create_mock_usage(prompt_tokens=10, completion_tokens=5), + ), + "expected_result_type": LLMResultWithStructuredOutput, + "should_raise": False, + }, + # Test case 2: Model with native structured output support, streaming + { + "name": "native_structured_output_streaming", + "provider": "openai", + "model_name": "gpt-4o", + "support_structure_output": True, + "stream": True, + "json_schema": {"type": "object", "properties": {"name": {"type": "string"}}}, + "expected_llm_response": [ + LLMResultChunk( + model="gpt-4o", + prompt_messages=[UserPromptMessage(content="test")], + system_fingerprint="test", + delta=LLMResultChunkDelta( + index=0, + message=AssistantPromptMessage(content='{"name":'), + usage=create_mock_usage(prompt_tokens=10, completion_tokens=2), + ), + ), + LLMResultChunk( + model="gpt-4o", + prompt_messages=[UserPromptMessage(content="test")], + system_fingerprint="test", + delta=LLMResultChunkDelta( + index=0, + message=AssistantPromptMessage(content=' "test"}'), + usage=create_mock_usage(prompt_tokens=10, completion_tokens=3), + ), + ), + ], + "expected_result_type": "generator", + "should_raise": False, + }, + # Test case 3: Model without native structured output support, non-streaming + { + "name": "prompt_based_structured_output_non_streaming", + "provider": "anthropic", + "model_name": "claude-3-sonnet", + "support_structure_output": False, + "stream": False, + "json_schema": {"type": "object", "properties": {"answer": {"type": "string"}}}, + "expected_llm_response": LLMResult( + model="claude-3-sonnet", + message=AssistantPromptMessage(content='{"answer": "test response"}'), + usage=create_mock_usage(prompt_tokens=15, completion_tokens=8), + ), + "expected_result_type": LLMResultWithStructuredOutput, + "should_raise": False, + }, + # Test case 4: Model without native structured output support, streaming + { + "name": "prompt_based_structured_output_streaming", + "provider": "anthropic", + "model_name": "claude-3-sonnet", + "support_structure_output": False, + "stream": True, + "json_schema": {"type": "object", "properties": {"answer": {"type": "string"}}}, + "expected_llm_response": [ + LLMResultChunk( + model="claude-3-sonnet", + prompt_messages=[UserPromptMessage(content="test")], + system_fingerprint="test", + delta=LLMResultChunkDelta( + index=0, + message=AssistantPromptMessage(content='{"answer": "test'), + usage=create_mock_usage(prompt_tokens=15, completion_tokens=3), + ), + ), + LLMResultChunk( + model="claude-3-sonnet", + prompt_messages=[UserPromptMessage(content="test")], + system_fingerprint="test", + delta=LLMResultChunkDelta( + index=0, + message=AssistantPromptMessage(content=' response"}'), + usage=create_mock_usage(prompt_tokens=15, completion_tokens=5), + ), + ), + ], + "expected_result_type": "generator", + "should_raise": False, + }, + # Test case 5: Streaming with list content + { + "name": "streaming_with_list_content", + "provider": "openai", + "model_name": "gpt-4o", + "support_structure_output": True, + "stream": True, + "json_schema": {"type": "object", "properties": {"data": {"type": "string"}}}, + "expected_llm_response": [ + LLMResultChunk( + model="gpt-4o", + prompt_messages=[UserPromptMessage(content="test")], + system_fingerprint="test", + delta=LLMResultChunkDelta( + index=0, + message=AssistantPromptMessage( + content=[ + TextPromptMessageContent(data='{"data":'), + ] + ), + usage=create_mock_usage(prompt_tokens=10, completion_tokens=2), + ), + ), + LLMResultChunk( + model="gpt-4o", + prompt_messages=[UserPromptMessage(content="test")], + system_fingerprint="test", + delta=LLMResultChunkDelta( + index=0, + message=AssistantPromptMessage( + content=[ + TextPromptMessageContent(data=' "value"}'), + ] + ), + usage=create_mock_usage(prompt_tokens=10, completion_tokens=3), + ), + ), + ], + "expected_result_type": "generator", + "should_raise": False, + }, + # Test case 6: Error case - non-string LLM response content (non-streaming) + { + "name": "error_non_string_content_non_streaming", + "provider": "openai", + "model_name": "gpt-4o", + "support_structure_output": True, + "stream": False, + "json_schema": {"type": "object", "properties": {"name": {"type": "string"}}}, + "expected_llm_response": LLMResult( + model="gpt-4o", + message=AssistantPromptMessage(content=None), # Non-string content + usage=create_mock_usage(prompt_tokens=10, completion_tokens=5), + ), + "expected_result_type": None, + "should_raise": True, + "expected_error": OutputParserError, + }, + # Test case 7: JSON repair scenario + { + "name": "json_repair_scenario", + "provider": "openai", + "model_name": "gpt-4o", + "support_structure_output": True, + "stream": False, + "json_schema": {"type": "object", "properties": {"name": {"type": "string"}}}, + "expected_llm_response": LLMResult( + model="gpt-4o", + message=AssistantPromptMessage(content='{"name": "test"'), # Invalid JSON - missing closing brace + usage=create_mock_usage(prompt_tokens=10, completion_tokens=5), + ), + "expected_result_type": LLMResultWithStructuredOutput, + "should_raise": False, + }, + # Test case 8: Model with parameter rules for response format + { + "name": "model_with_parameter_rules", + "provider": "openai", + "model_name": "gpt-4o", + "support_structure_output": True, + "stream": False, + "json_schema": {"type": "object", "properties": {"result": {"type": "string"}}}, + "parameter_rules": [ + MagicMock(name="response_format", options=["json_schema"], required=False), + ], + "expected_llm_response": LLMResult( + model="gpt-4o", + message=AssistantPromptMessage(content='{"result": "success"}'), + usage=create_mock_usage(prompt_tokens=10, completion_tokens=5), + ), + "expected_result_type": LLMResultWithStructuredOutput, + "should_raise": False, + }, + # Test case 9: Model without native support but with JSON response format rules + { + "name": "non_native_with_json_rules", + "provider": "anthropic", + "model_name": "claude-3-sonnet", + "support_structure_output": False, + "stream": False, + "json_schema": {"type": "object", "properties": {"output": {"type": "string"}}}, + "parameter_rules": [ + MagicMock(name="response_format", options=["JSON"], required=False), + ], + "expected_llm_response": LLMResult( + model="claude-3-sonnet", + message=AssistantPromptMessage(content='{"output": "result"}'), + usage=create_mock_usage(prompt_tokens=15, completion_tokens=8), + ), + "expected_result_type": LLMResultWithStructuredOutput, + "should_raise": False, + }, + ] + + for case in testcases: + print(f"Running test case: {case['name']}") + + # Setup model entity + model_schema = get_model_entity(case["provider"], case["model_name"], case["support_structure_output"]) + + # Add parameter rules if specified + if "parameter_rules" in case: + model_schema.parameter_rules = case["parameter_rules"] + + # Setup model instance + model_instance = get_model_instance() + model_instance.invoke_llm.return_value = case["expected_llm_response"] + + # Setup prompt messages + prompt_messages = [ + SystemPromptMessage(content="You are a helpful assistant."), + UserPromptMessage(content="Generate a response according to the schema."), + ] + + if case["should_raise"]: + # Test error cases + with pytest.raises(case["expected_error"]): # noqa: PT012 + if case["stream"]: + result_generator = invoke_llm_with_structured_output( + provider=case["provider"], + model_schema=model_schema, + model_instance=model_instance, + prompt_messages=prompt_messages, + json_schema=case["json_schema"], + stream=case["stream"], + ) + # Consume the generator to trigger the error + list(result_generator) + else: + invoke_llm_with_structured_output( + provider=case["provider"], + model_schema=model_schema, + model_instance=model_instance, + prompt_messages=prompt_messages, + json_schema=case["json_schema"], + stream=case["stream"], + ) + else: + # Test successful cases + with patch("core.llm_generator.output_parser.structured_output.json_repair.loads") as mock_json_repair: + # Configure json_repair mock for cases that need it + if case["name"] == "json_repair_scenario": + mock_json_repair.return_value = {"name": "test"} + + result = invoke_llm_with_structured_output( + provider=case["provider"], + model_schema=model_schema, + model_instance=model_instance, + prompt_messages=prompt_messages, + json_schema=case["json_schema"], + stream=case["stream"], + model_parameters={"temperature": 0.7, "max_tokens": 100}, + user="test_user", + ) + + if case["expected_result_type"] == "generator": + # Test streaming results + assert hasattr(result, "__iter__") + chunks = list(result) + assert len(chunks) > 0 + + # Verify all chunks are LLMResultChunkWithStructuredOutput + for chunk in chunks[:-1]: # All except last + assert isinstance(chunk, LLMResultChunkWithStructuredOutput) + assert chunk.model == case["model_name"] + + # Last chunk should have structured output + last_chunk = chunks[-1] + assert isinstance(last_chunk, LLMResultChunkWithStructuredOutput) + assert last_chunk.structured_output is not None + assert isinstance(last_chunk.structured_output, dict) + else: + # Test non-streaming results + assert isinstance(result, case["expected_result_type"]) + assert result.model == case["model_name"] + assert result.structured_output is not None + assert isinstance(result.structured_output, dict) + + # Verify model_instance.invoke_llm was called with correct parameters + model_instance.invoke_llm.assert_called_once() + call_args = model_instance.invoke_llm.call_args + + assert call_args.kwargs["stream"] == case["stream"] + assert call_args.kwargs["user"] == "test_user" + assert "temperature" in call_args.kwargs["model_parameters"] + assert "max_tokens" in call_args.kwargs["model_parameters"] + + +def test_parse_structured_output_edge_cases(): + """Test edge cases for structured output parsing""" + + # Test case with list that contains dict (reasoning model scenario) + testcase_list_with_dict = { + "name": "list_with_dict_parsing", + "provider": "deepseek", + "model_name": "deepseek-r1", + "support_structure_output": False, + "stream": False, + "json_schema": {"type": "object", "properties": {"thought": {"type": "string"}}}, + "expected_llm_response": LLMResult( + model="deepseek-r1", + message=AssistantPromptMessage(content='[{"thought": "reasoning process"}, "other content"]'), + usage=create_mock_usage(prompt_tokens=10, completion_tokens=5), + ), + "expected_result_type": LLMResultWithStructuredOutput, + "should_raise": False, + } + + # Setup for list parsing test + model_schema = get_model_entity( + testcase_list_with_dict["provider"], + testcase_list_with_dict["model_name"], + testcase_list_with_dict["support_structure_output"], + ) + + model_instance = get_model_instance() + model_instance.invoke_llm.return_value = testcase_list_with_dict["expected_llm_response"] + + prompt_messages = [UserPromptMessage(content="Test reasoning")] + + with patch("core.llm_generator.output_parser.structured_output.json_repair.loads") as mock_json_repair: + # Mock json_repair to return a list with dict + mock_json_repair.return_value = [{"thought": "reasoning process"}, "other content"] + + result = invoke_llm_with_structured_output( + provider=testcase_list_with_dict["provider"], + model_schema=model_schema, + model_instance=model_instance, + prompt_messages=prompt_messages, + json_schema=testcase_list_with_dict["json_schema"], + stream=testcase_list_with_dict["stream"], + ) + + assert isinstance(result, LLMResultWithStructuredOutput) + assert result.structured_output == {"thought": "reasoning process"} + + +def test_model_specific_schema_preparation(): + """Test schema preparation for different model types""" + + # Test Gemini model + gemini_case = { + "provider": "google", + "model_name": "gemini-pro", + "support_structure_output": True, + "stream": False, + "json_schema": {"type": "object", "properties": {"result": {"type": "boolean"}}, "additionalProperties": False}, + } + + model_schema = get_model_entity( + gemini_case["provider"], gemini_case["model_name"], gemini_case["support_structure_output"] + ) + + model_instance = get_model_instance() + model_instance.invoke_llm.return_value = LLMResult( + model="gemini-pro", + message=AssistantPromptMessage(content='{"result": "true"}'), + usage=create_mock_usage(prompt_tokens=10, completion_tokens=5), + ) + + prompt_messages = [UserPromptMessage(content="Test")] + + result = invoke_llm_with_structured_output( + provider=gemini_case["provider"], + model_schema=model_schema, + model_instance=model_instance, + prompt_messages=prompt_messages, + json_schema=gemini_case["json_schema"], + stream=gemini_case["stream"], + ) + + assert isinstance(result, LLMResultWithStructuredOutput) + + # Verify model_instance.invoke_llm was called and check the schema preparation + model_instance.invoke_llm.assert_called_once() + call_args = model_instance.invoke_llm.call_args + + # For Gemini, the schema should not have additionalProperties and boolean should be converted to string + assert "json_schema" in call_args.kwargs["model_parameters"] diff --git a/api/uv.lock b/api/uv.lock index 05ffc1abcf..e108e0c445 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -99,28 +99,29 @@ wheels = [ [[package]] name = "aiosignal" -version = "1.3.2" +version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "frozenlist" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ba/b5/6d55e80f6d8a08ce22b982eafa278d823b541c925f11ee774b0b9c43473d/aiosignal-1.3.2.tar.gz", hash = "sha256:a8c255c66fafb1e499c9351d0bf32ff2d8a0321595ebac3b93713656d2436f54", size = 19424, upload-time = "2024-12-13T17:10:40.86Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/6a/bc7e17a3e87a2985d3e8f4da4cd0f481060eb78fb08596c42be62c90a4d9/aiosignal-1.3.2-py2.py3-none-any.whl", hash = "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5", size = 7597, upload-time = "2024-12-13T17:10:38.469Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] [[package]] name = "alembic" -version = "1.16.2" +version = "1.16.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mako" }, { name = "sqlalchemy" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9c/35/116797ff14635e496bbda0c168987f5326a6555b09312e9b817e360d1f56/alembic-1.16.2.tar.gz", hash = "sha256:e53c38ff88dadb92eb22f8b150708367db731d58ad7e9d417c9168ab516cbed8", size = 1963563, upload-time = "2025-06-16T18:05:08.566Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/40/28683414cc8711035a65256ca689e159471aa9ef08e8741ad1605bc01066/alembic-1.16.3.tar.gz", hash = "sha256:18ad13c1f40a5796deee4b2346d1a9c382f44b8af98053897484fa6cf88025e4", size = 1967462, upload-time = "2025-07-08T18:57:50.991Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dd/e2/88e425adac5ad887a087c38d04fe2030010572a3e0e627f8a6e8c33eeda8/alembic-1.16.2-py3-none-any.whl", hash = "sha256:5f42e9bd0afdbd1d5e3ad856c01754530367debdebf21ed6894e34af52b3bb03", size = 242717, upload-time = "2025-06-16T18:05:10.27Z" }, + { url = "https://files.pythonhosted.org/packages/e6/68/1dea77887af7304528ea944c355d769a7ccc4599d3a23bd39182486deb42/alembic-1.16.3-py3-none-any.whl", hash = "sha256:70a7c7829b792de52d08ca0e3aefaf060687cb8ed6bebfa557e597a1a5e5a481", size = 246933, upload-time = "2025-07-08T18:57:52.793Z" }, ] [[package]] @@ -243,7 +244,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/22/8a/ef8ddf5ee0350984c [[package]] name = "alibabacloud-tea-openapi" -version = "0.3.15" +version = "0.3.16" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "alibabacloud-credentials" }, @@ -252,7 +253,7 @@ dependencies = [ { name = "alibabacloud-tea-util" }, { name = "alibabacloud-tea-xml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/be/cb/f1b10b1da37e4c0de2aa9ca1e7153a6960a7f2dc496664e85fdc8b621f84/alibabacloud_tea_openapi-0.3.15.tar.gz", hash = "sha256:56a0aa6d51d8cf18c0cf3d219d861f4697f59d3e17fa6726b1101826d93988a2", size = 13021, upload-time = "2025-05-06T12:56:29.402Z" } +sdist = { url = "https://files.pythonhosted.org/packages/09/be/f594e79625e5ccfcfe7f12d7d70709a3c59e920878469c998886211c850d/alibabacloud_tea_openapi-0.3.16.tar.gz", hash = "sha256:6bffed8278597592e67860156f424bde4173a6599d7b6039fb640a3612bae292", size = 13087, upload-time = "2025-07-04T09:30:10.689Z" } [[package]] name = "alibabacloud-tea-util" @@ -265,12 +266,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/23/18/35be17103c8f40f9e [[package]] name = "alibabacloud-tea-xml" -version = "0.0.2" +version = "0.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "alibabacloud-tea" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3b/30/f934051b1f65525d450cb225e4ac81dc3b77d808b0f79d51059d2a7ad3d3/alibabacloud_tea_xml-0.0.2.tar.gz", hash = "sha256:f0135e8148fd7d9c1f029db161863f37f144f837c280cba16c2edeb2f9c549d8", size = 3378, upload-time = "2020-07-02T09:03:55.866Z" } +sdist = { url = "https://files.pythonhosted.org/packages/32/eb/5e82e419c3061823f3feae9b5681588762929dc4da0176667297c2784c1a/alibabacloud_tea_xml-0.0.3.tar.gz", hash = "sha256:979cb51fadf43de77f41c69fc69c12529728919f849723eb0cd24eb7b048a90c", size = 3466, upload-time = "2025-07-01T08:04:55.144Z" } [[package]] name = "aliyun-python-sdk-core" @@ -351,12 +352,30 @@ wheels = [ ] [[package]] -name = "asgiref" -version = "3.8.1" +name = "arize-phoenix-otel" +version = "0.9.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/29/38/b3395cc9ad1b56d2ddac9970bc8f4141312dbaec28bc7c218b0dfafd0f42/asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590", size = 35186, upload-time = "2024-03-22T14:39:36.863Z" } +dependencies = [ + { name = "openinference-instrumentation" }, + { name = "openinference-semantic-conventions" }, + { name = "opentelemetry-exporter-otlp" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/b9/8c89191eb46915e9ba7bdb473e2fb1c510b7db3635ae5ede5e65b2176b9d/arize_phoenix_otel-0.9.2.tar.gz", hash = "sha256:a48c7d41f3ac60dc75b037f036bf3306d2af4af371cdb55e247e67957749bc31", size = 11599, upload-time = "2025-04-14T22:05:28.637Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828, upload-time = "2024-03-22T14:39:34.521Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3d/f64136a758c649e883315939f30fe51ad0747024b0db05fd78450801a78d/arize_phoenix_otel-0.9.2-py3-none-any.whl", hash = "sha256:5286b33c58b596ef8edd9a4255ee00fd74f774b1e5dbd9393e77e87870a14d76", size = 12560, upload-time = "2025-04-14T22:05:27.162Z" }, +] + +[[package]] +name = "asgiref" +version = "3.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/61/0aa957eec22ff70b830b22ff91f825e70e1ef732c06666a805730f28b36b/asgiref-3.9.1.tar.gz", hash = "sha256:a5ab6582236218e5ef1648f242fd9f10626cfd4de8dc377db215d5d5098e3142", size = 36870, upload-time = "2025-07-08T09:07:43.344Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/3c/0464dcada90d5da0e71018c04a140ad6349558afb30b3051b4264cc5b965/asgiref-3.9.1-py3-none-any.whl", hash = "sha256:f3bba7092a48005b5f5bacd747d36ee4a5a61f4a269a6df590b43144355ebd2c", size = 23790, upload-time = "2025-07-08T09:07:41.548Z" }, ] [[package]] @@ -391,16 +410,16 @@ wheels = [ [[package]] name = "azure-core" -version = "1.34.0" +version = "1.35.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "requests" }, { name = "six" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/29/ff7a519a315e41c85bab92a7478c6acd1cf0b14353139a08caee4c691f77/azure_core-1.34.0.tar.gz", hash = "sha256:bdb544989f246a0ad1c85d72eeb45f2f835afdcbc5b45e43f0dbde7461c81ece", size = 297999, upload-time = "2025-05-01T23:17:27.59Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/89/f53968635b1b2e53e4aad2dd641488929fef4ca9dfb0b97927fa7697ddf3/azure_core-1.35.0.tar.gz", hash = "sha256:c0be528489485e9ede59b6971eb63c1eaacf83ef53001bfe3904e475e972be5c", size = 339689, upload-time = "2025-07-03T00:55:23.496Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/9e/5c87b49f65bb16571599bc789857d0ded2f53014d3392bc88a5d1f3ad779/azure_core-1.34.0-py3-none-any.whl", hash = "sha256:0615d3b756beccdb6624d1c0ae97284f38b78fb59a2a9839bf927c66fbbdddd6", size = 207409, upload-time = "2025-05-01T23:17:29.818Z" }, + { url = "https://files.pythonhosted.org/packages/d4/78/bf94897361fdd650850f0f2e405b2293e2f12808239046232bdedf554301/azure_core-1.35.0-py3-none-any.whl", hash = "sha256:8db78c72868a58f3de8991eb4d22c4d368fae226dac1002998d6c50437e7dad1", size = 210708, upload-time = "2025-07-03T00:55:25.238Z" }, ] [[package]] @@ -541,16 +560,16 @@ wheels = [ [[package]] name = "boto3-stubs" -version = "1.38.37" +version = "1.39.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore-stubs" }, { name = "types-s3transfer" }, { name = "typing-extensions", marker = "python_full_version < '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/31/3d/eab265de66aed281852e26d00d99a5367e4531cbf8c0a125ac7a5ff429a6/boto3_stubs-1.38.37.tar.gz", hash = "sha256:c98bf859009b14578ecd3bad495c728b79686874e31eb7efca16e557ddc4a326", size = 99162, upload-time = "2025-06-16T19:43:00.877Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/ea/85b9940d6eedc04d0c6febf24d27311b6ee54f85ccc37192eb4db0dff5d6/boto3_stubs-1.39.3.tar.gz", hash = "sha256:9aad443b1d690951fd9ccb6fa20ad387bd0b1054c704566ff65dd0043a63fc26", size = 99947, upload-time = "2025-07-03T19:28:15.602Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/7b/3960cb23b08ab51e46f0c51d96fe5e97f343b9fc264f6e0951bf789ea4e0/boto3_stubs-1.38.37-py3-none-any.whl", hash = "sha256:b7f4c60a549e9a34aaeb4ddf9fb4ef8eee9fd6b3503ee82e874f046a5cebe017", size = 68732, upload-time = "2025-06-16T19:42:54.504Z" }, + { url = "https://files.pythonhosted.org/packages/be/b8/0c56297e5f290de17e838c7e4ff338f5b94351c6566aed70ee197a671dc5/boto3_stubs-1.39.3-py3-none-any.whl", hash = "sha256:4daddb19374efa6d1bef7aded9cede0075f380722a9e60ab129ebba14ae66b69", size = 69196, upload-time = "2025-07-03T19:28:09.4Z" }, ] [package.optional-dependencies] @@ -574,14 +593,14 @@ wheels = [ [[package]] name = "botocore-stubs" -version = "1.38.30" +version = "1.38.46" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "types-awscrt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b2/df/9bf9f6346daf1cbcb736a12417efe025ed63d72a015799f4e8f26a823b93/botocore_stubs-1.38.30.tar.gz", hash = "sha256:291d7bf39a316c00a8a55b7255489b02c0cea1a343482e7784e8d1e235bae995", size = 42299, upload-time = "2025-06-04T20:14:50.799Z" } +sdist = { url = "https://files.pythonhosted.org/packages/05/45/27cabc7c3022dcb12de5098cc646b374065f5e72fae13600ff1756f365ee/botocore_stubs-1.38.46.tar.gz", hash = "sha256:a04e69766ab8bae338911c1897492f88d05cd489cd75f06e6eb4f135f9da8c7b", size = 42299, upload-time = "2025-06-29T22:58:24.765Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/71/cc9bc544489160cbacc8472b70ba0f9b93385628ed8bd0f673855b7ceeb7/botocore_stubs-1.38.30-py3-none-any.whl", hash = "sha256:2efb8bdf36504aff596c670d875d8f7dd15205277c15c4cea54afdba8200c266", size = 65628, upload-time = "2025-06-04T20:14:48.089Z" }, + { url = "https://files.pythonhosted.org/packages/cc/84/06490071e26bab22ac79a684e98445df118adcf80c58c33ba5af184030f2/botocore_stubs-1.38.46-py3-none-any.whl", hash = "sha256:cc21d9a7dd994bdd90872db4664d817c4719b51cda8004fd507a4bf65b085a75", size = 66083, upload-time = "2025-06-29T22:58:22.234Z" }, ] [[package]] @@ -906,14 +925,14 @@ wheels = [ [[package]] name = "click-plugins" -version = "1.1.1" +version = "1.1.1.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5f/1d/45434f64ed749540af821fd7e42b8e4d23ac04b1eda7c26613288d6cd8a8/click-plugins-1.1.1.tar.gz", hash = "sha256:46ab999744a9d831159c3411bb0c79346d94a444df9a3a3742e9ed63645f264b", size = 8164, upload-time = "2019-04-04T04:27:04.82Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/a4/34847b59150da33690a36da3681d6bbc2ec14ee9a846bc30a6746e5984e4/click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261", size = 8343, upload-time = "2025-06-25T00:47:37.555Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/da/824b92d9942f4e472702488857914bdd50f73021efea15b4cad9aca8ecef/click_plugins-1.1.1-py2.py3-none-any.whl", hash = "sha256:5d262006d3222f5057fd81e1623d4443e41dcda5dc815c06b442aa3c02889fc8", size = 7497, upload-time = "2019-04-04T04:27:03.36Z" }, + { url = "https://files.pythonhosted.org/packages/3d/9a/2abecb28ae875e39c8cad711eb1186d8d14eab564705325e77e4e6ab9ae5/click_plugins-1.1.1.2-py2.py3-none-any.whl", hash = "sha256:008d65743833ffc1f5417bf0e78e8d2c23aab04d9745ba817bd3e71b0feb6aa6", size = 11051, upload-time = "2025-06-25T00:47:36.731Z" }, ] [[package]] @@ -1102,43 +1121,43 @@ sdist = { url = "https://files.pythonhosted.org/packages/6b/b0/e595ce2a2527e169c [[package]] name = "cryptography" -version = "45.0.4" +version = "45.0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fe/c8/a2a376a8711c1e11708b9c9972e0c3223f5fc682552c82d8db844393d6ce/cryptography-45.0.4.tar.gz", hash = "sha256:7405ade85c83c37682c8fe65554759800a4a8c54b2d96e0f8ad114d31b808d57", size = 744890, upload-time = "2025-06-10T00:03:51.297Z" } +sdist = { url = "https://files.pythonhosted.org/packages/95/1e/49527ac611af559665f71cbb8f92b332b5ec9c6fbc4e88b0f8e92f5e85df/cryptography-45.0.5.tar.gz", hash = "sha256:72e76caa004ab63accdf26023fccd1d087f6d90ec6048ff33ad0445abf7f605a", size = 744903, upload-time = "2025-07-02T13:06:25.941Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/1c/92637793de053832523b410dbe016d3f5c11b41d0cf6eef8787aabb51d41/cryptography-45.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:425a9a6ac2823ee6e46a76a21a4e8342d8fa5c01e08b823c1f19a8b74f096069", size = 7055712, upload-time = "2025-06-10T00:02:38.826Z" }, - { url = "https://files.pythonhosted.org/packages/ba/14/93b69f2af9ba832ad6618a03f8a034a5851dc9a3314336a3d71c252467e1/cryptography-45.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:680806cf63baa0039b920f4976f5f31b10e772de42f16310a6839d9f21a26b0d", size = 4205335, upload-time = "2025-06-10T00:02:41.64Z" }, - { url = "https://files.pythonhosted.org/packages/67/30/fae1000228634bf0b647fca80403db5ca9e3933b91dd060570689f0bd0f7/cryptography-45.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4ca0f52170e821bc8da6fc0cc565b7bb8ff8d90d36b5e9fdd68e8a86bdf72036", size = 4431487, upload-time = "2025-06-10T00:02:43.696Z" }, - { url = "https://files.pythonhosted.org/packages/6d/5a/7dffcf8cdf0cb3c2430de7404b327e3db64735747d641fc492539978caeb/cryptography-45.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f3fe7a5ae34d5a414957cc7f457e2b92076e72938423ac64d215722f6cf49a9e", size = 4208922, upload-time = "2025-06-10T00:02:45.334Z" }, - { url = "https://files.pythonhosted.org/packages/c6/f3/528729726eb6c3060fa3637253430547fbaaea95ab0535ea41baa4a6fbd8/cryptography-45.0.4-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:25eb4d4d3e54595dc8adebc6bbd5623588991d86591a78c2548ffb64797341e2", size = 3900433, upload-time = "2025-06-10T00:02:47.359Z" }, - { url = "https://files.pythonhosted.org/packages/d9/4a/67ba2e40f619e04d83c32f7e1d484c1538c0800a17c56a22ff07d092ccc1/cryptography-45.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ce1678a2ccbe696cf3af15a75bb72ee008d7ff183c9228592ede9db467e64f1b", size = 4464163, upload-time = "2025-06-10T00:02:49.412Z" }, - { url = "https://files.pythonhosted.org/packages/7e/9a/b4d5aa83661483ac372464809c4b49b5022dbfe36b12fe9e323ca8512420/cryptography-45.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:49fe9155ab32721b9122975e168a6760d8ce4cffe423bcd7ca269ba41b5dfac1", size = 4208687, upload-time = "2025-06-10T00:02:50.976Z" }, - { url = "https://files.pythonhosted.org/packages/db/b7/a84bdcd19d9c02ec5807f2ec2d1456fd8451592c5ee353816c09250e3561/cryptography-45.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2882338b2a6e0bd337052e8b9007ced85c637da19ef9ecaf437744495c8c2999", size = 4463623, upload-time = "2025-06-10T00:02:52.542Z" }, - { url = "https://files.pythonhosted.org/packages/d8/84/69707d502d4d905021cac3fb59a316344e9f078b1da7fb43ecde5e10840a/cryptography-45.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:23b9c3ea30c3ed4db59e7b9619272e94891f8a3a5591d0b656a7582631ccf750", size = 4332447, upload-time = "2025-06-10T00:02:54.63Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ee/d4f2ab688e057e90ded24384e34838086a9b09963389a5ba6854b5876598/cryptography-45.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0a97c927497e3bc36b33987abb99bf17a9a175a19af38a892dc4bbb844d7ee2", size = 4572830, upload-time = "2025-06-10T00:02:56.689Z" }, - { url = "https://files.pythonhosted.org/packages/70/d4/994773a261d7ff98034f72c0e8251fe2755eac45e2265db4c866c1c6829c/cryptography-45.0.4-cp311-abi3-win32.whl", hash = "sha256:e00a6c10a5c53979d6242f123c0a97cff9f3abed7f064fc412c36dc521b5f257", size = 2932769, upload-time = "2025-06-10T00:02:58.467Z" }, - { url = "https://files.pythonhosted.org/packages/5a/42/c80bd0b67e9b769b364963b5252b17778a397cefdd36fa9aa4a5f34c599a/cryptography-45.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:817ee05c6c9f7a69a16200f0c90ab26d23a87701e2a284bd15156783e46dbcc8", size = 3410441, upload-time = "2025-06-10T00:03:00.14Z" }, - { url = "https://files.pythonhosted.org/packages/ce/0b/2488c89f3a30bc821c9d96eeacfcab6ff3accc08a9601ba03339c0fd05e5/cryptography-45.0.4-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:964bcc28d867e0f5491a564b7debb3ffdd8717928d315d12e0d7defa9e43b723", size = 7031836, upload-time = "2025-06-10T00:03:01.726Z" }, - { url = "https://files.pythonhosted.org/packages/fe/51/8c584ed426093aac257462ae62d26ad61ef1cbf5b58d8b67e6e13c39960e/cryptography-45.0.4-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6a5bf57554e80f75a7db3d4b1dacaa2764611ae166ab42ea9a72bcdb5d577637", size = 4195746, upload-time = "2025-06-10T00:03:03.94Z" }, - { url = "https://files.pythonhosted.org/packages/5c/7d/4b0ca4d7af95a704eef2f8f80a8199ed236aaf185d55385ae1d1610c03c2/cryptography-45.0.4-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:46cf7088bf91bdc9b26f9c55636492c1cce3e7aaf8041bbf0243f5e5325cfb2d", size = 4424456, upload-time = "2025-06-10T00:03:05.589Z" }, - { url = "https://files.pythonhosted.org/packages/1d/45/5fabacbc6e76ff056f84d9f60eeac18819badf0cefc1b6612ee03d4ab678/cryptography-45.0.4-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7bedbe4cc930fa4b100fc845ea1ea5788fcd7ae9562e669989c11618ae8d76ee", size = 4198495, upload-time = "2025-06-10T00:03:09.172Z" }, - { url = "https://files.pythonhosted.org/packages/55/b7/ffc9945b290eb0a5d4dab9b7636706e3b5b92f14ee5d9d4449409d010d54/cryptography-45.0.4-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:eaa3e28ea2235b33220b949c5a0d6cf79baa80eab2eb5607ca8ab7525331b9ff", size = 3885540, upload-time = "2025-06-10T00:03:10.835Z" }, - { url = "https://files.pythonhosted.org/packages/7f/e3/57b010282346980475e77d414080acdcb3dab9a0be63071efc2041a2c6bd/cryptography-45.0.4-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:7ef2dde4fa9408475038fc9aadfc1fb2676b174e68356359632e980c661ec8f6", size = 4452052, upload-time = "2025-06-10T00:03:12.448Z" }, - { url = "https://files.pythonhosted.org/packages/37/e6/ddc4ac2558bf2ef517a358df26f45bc774a99bf4653e7ee34b5e749c03e3/cryptography-45.0.4-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6a3511ae33f09094185d111160fd192c67aa0a2a8d19b54d36e4c78f651dc5ad", size = 4198024, upload-time = "2025-06-10T00:03:13.976Z" }, - { url = "https://files.pythonhosted.org/packages/3a/c0/85fa358ddb063ec588aed4a6ea1df57dc3e3bc1712d87c8fa162d02a65fc/cryptography-45.0.4-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:06509dc70dd71fa56eaa138336244e2fbaf2ac164fc9b5e66828fccfd2b680d6", size = 4451442, upload-time = "2025-06-10T00:03:16.248Z" }, - { url = "https://files.pythonhosted.org/packages/33/67/362d6ec1492596e73da24e669a7fbbaeb1c428d6bf49a29f7a12acffd5dc/cryptography-45.0.4-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5f31e6b0a5a253f6aa49be67279be4a7e5a4ef259a9f33c69f7d1b1191939872", size = 4325038, upload-time = "2025-06-10T00:03:18.4Z" }, - { url = "https://files.pythonhosted.org/packages/53/75/82a14bf047a96a1b13ebb47fb9811c4f73096cfa2e2b17c86879687f9027/cryptography-45.0.4-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:944e9ccf67a9594137f942d5b52c8d238b1b4e46c7a0c2891b7ae6e01e7c80a4", size = 4560964, upload-time = "2025-06-10T00:03:20.06Z" }, - { url = "https://files.pythonhosted.org/packages/cd/37/1a3cba4c5a468ebf9b95523a5ef5651244693dc712001e276682c278fc00/cryptography-45.0.4-cp37-abi3-win32.whl", hash = "sha256:c22fe01e53dc65edd1945a2e6f0015e887f84ced233acecb64b4daadb32f5c97", size = 2924557, upload-time = "2025-06-10T00:03:22.563Z" }, - { url = "https://files.pythonhosted.org/packages/2a/4b/3256759723b7e66380397d958ca07c59cfc3fb5c794fb5516758afd05d41/cryptography-45.0.4-cp37-abi3-win_amd64.whl", hash = "sha256:627ba1bc94f6adf0b0a2e35d87020285ead22d9f648c7e75bb64f367375f3b22", size = 3395508, upload-time = "2025-06-10T00:03:24.586Z" }, - { url = "https://files.pythonhosted.org/packages/ea/ba/cf442ae99ef363855ed84b39e0fb3c106ac66b7a7703f3c9c9cfe05412cb/cryptography-45.0.4-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4828190fb6c4bcb6ebc6331f01fe66ae838bb3bd58e753b59d4b22eb444b996c", size = 3590512, upload-time = "2025-06-10T00:03:36.982Z" }, - { url = "https://files.pythonhosted.org/packages/28/9a/a7d5bb87d149eb99a5abdc69a41e4e47b8001d767e5f403f78bfaafc7aa7/cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:03dbff8411206713185b8cebe31bc5c0eb544799a50c09035733716b386e61a4", size = 4146899, upload-time = "2025-06-10T00:03:38.659Z" }, - { url = "https://files.pythonhosted.org/packages/17/11/9361c2c71c42cc5c465cf294c8030e72fb0c87752bacbd7a3675245e3db3/cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:51dfbd4d26172d31150d84c19bbe06c68ea4b7f11bbc7b3a5e146b367c311349", size = 4388900, upload-time = "2025-06-10T00:03:40.233Z" }, - { url = "https://files.pythonhosted.org/packages/c0/76/f95b83359012ee0e670da3e41c164a0c256aeedd81886f878911581d852f/cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:0339a692de47084969500ee455e42c58e449461e0ec845a34a6a9b9bf7df7fb8", size = 4146422, upload-time = "2025-06-10T00:03:41.827Z" }, - { url = "https://files.pythonhosted.org/packages/09/ad/5429fcc4def93e577a5407988f89cf15305e64920203d4ac14601a9dc876/cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:0cf13c77d710131d33e63626bd55ae7c0efb701ebdc2b3a7952b9b23a0412862", size = 4388475, upload-time = "2025-06-10T00:03:43.493Z" }, - { url = "https://files.pythonhosted.org/packages/99/49/0ab9774f64555a1b50102757811508f5ace451cf5dc0a2d074a4b9deca6a/cryptography-45.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bbc505d1dc469ac12a0a064214879eac6294038d6b24ae9f71faae1448a9608d", size = 3337594, upload-time = "2025-06-10T00:03:45.523Z" }, + { url = "https://files.pythonhosted.org/packages/f0/fb/09e28bc0c46d2c547085e60897fea96310574c70fb21cd58a730a45f3403/cryptography-45.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:101ee65078f6dd3e5a028d4f19c07ffa4dd22cce6a20eaa160f8b5219911e7d8", size = 7043092, upload-time = "2025-07-02T13:05:01.514Z" }, + { url = "https://files.pythonhosted.org/packages/b1/05/2194432935e29b91fb649f6149c1a4f9e6d3d9fc880919f4ad1bcc22641e/cryptography-45.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3a264aae5f7fbb089dbc01e0242d3b67dffe3e6292e1f5182122bdf58e65215d", size = 4205926, upload-time = "2025-07-02T13:05:04.741Z" }, + { url = "https://files.pythonhosted.org/packages/07/8b/9ef5da82350175e32de245646b1884fc01124f53eb31164c77f95a08d682/cryptography-45.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e74d30ec9c7cb2f404af331d5b4099a9b322a8a6b25c4632755c8757345baac5", size = 4429235, upload-time = "2025-07-02T13:05:07.084Z" }, + { url = "https://files.pythonhosted.org/packages/7c/e1/c809f398adde1994ee53438912192d92a1d0fc0f2d7582659d9ef4c28b0c/cryptography-45.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3af26738f2db354aafe492fb3869e955b12b2ef2e16908c8b9cb928128d42c57", size = 4209785, upload-time = "2025-07-02T13:05:09.321Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8b/07eb6bd5acff58406c5e806eff34a124936f41a4fb52909ffa4d00815f8c/cryptography-45.0.5-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e6c00130ed423201c5bc5544c23359141660b07999ad82e34e7bb8f882bb78e0", size = 3893050, upload-time = "2025-07-02T13:05:11.069Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ef/3333295ed58d900a13c92806b67e62f27876845a9a908c939f040887cca9/cryptography-45.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:dd420e577921c8c2d31289536c386aaa30140b473835e97f83bc71ea9d2baf2d", size = 4457379, upload-time = "2025-07-02T13:05:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9d/44080674dee514dbb82b21d6fa5d1055368f208304e2ab1828d85c9de8f4/cryptography-45.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d05a38884db2ba215218745f0781775806bde4f32e07b135348355fe8e4991d9", size = 4209355, upload-time = "2025-07-02T13:05:15.017Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d8/0749f7d39f53f8258e5c18a93131919ac465ee1f9dccaf1b3f420235e0b5/cryptography-45.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ad0caded895a00261a5b4aa9af828baede54638754b51955a0ac75576b831b27", size = 4456087, upload-time = "2025-07-02T13:05:16.945Z" }, + { url = "https://files.pythonhosted.org/packages/09/d7/92acac187387bf08902b0bf0699816f08553927bdd6ba3654da0010289b4/cryptography-45.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9024beb59aca9d31d36fcdc1604dd9bbeed0a55bface9f1908df19178e2f116e", size = 4332873, upload-time = "2025-07-02T13:05:18.743Z" }, + { url = "https://files.pythonhosted.org/packages/03/c2/840e0710da5106a7c3d4153c7215b2736151bba60bf4491bdb421df5056d/cryptography-45.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:91098f02ca81579c85f66df8a588c78f331ca19089763d733e34ad359f474174", size = 4564651, upload-time = "2025-07-02T13:05:21.382Z" }, + { url = "https://files.pythonhosted.org/packages/2e/92/cc723dd6d71e9747a887b94eb3827825c6c24b9e6ce2bb33b847d31d5eaa/cryptography-45.0.5-cp311-abi3-win32.whl", hash = "sha256:926c3ea71a6043921050eaa639137e13dbe7b4ab25800932a8498364fc1abec9", size = 2929050, upload-time = "2025-07-02T13:05:23.39Z" }, + { url = "https://files.pythonhosted.org/packages/1f/10/197da38a5911a48dd5389c043de4aec4b3c94cb836299b01253940788d78/cryptography-45.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:b85980d1e345fe769cfc57c57db2b59cff5464ee0c045d52c0df087e926fbe63", size = 3403224, upload-time = "2025-07-02T13:05:25.202Z" }, + { url = "https://files.pythonhosted.org/packages/fe/2b/160ce8c2765e7a481ce57d55eba1546148583e7b6f85514472b1d151711d/cryptography-45.0.5-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:f3562c2f23c612f2e4a6964a61d942f891d29ee320edb62ff48ffb99f3de9ae8", size = 7017143, upload-time = "2025-07-02T13:05:27.229Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e7/2187be2f871c0221a81f55ee3105d3cf3e273c0a0853651d7011eada0d7e/cryptography-45.0.5-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3fcfbefc4a7f332dece7272a88e410f611e79458fab97b5efe14e54fe476f4fd", size = 4197780, upload-time = "2025-07-02T13:05:29.299Z" }, + { url = "https://files.pythonhosted.org/packages/b9/cf/84210c447c06104e6be9122661159ad4ce7a8190011669afceeaea150524/cryptography-45.0.5-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:460f8c39ba66af7db0545a8c6f2eabcbc5a5528fc1cf6c3fa9a1e44cec33385e", size = 4420091, upload-time = "2025-07-02T13:05:31.221Z" }, + { url = "https://files.pythonhosted.org/packages/3e/6a/cb8b5c8bb82fafffa23aeff8d3a39822593cee6e2f16c5ca5c2ecca344f7/cryptography-45.0.5-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9b4cf6318915dccfe218e69bbec417fdd7c7185aa7aab139a2c0beb7468c89f0", size = 4198711, upload-time = "2025-07-02T13:05:33.062Z" }, + { url = "https://files.pythonhosted.org/packages/04/f7/36d2d69df69c94cbb2473871926daf0f01ad8e00fe3986ac3c1e8c4ca4b3/cryptography-45.0.5-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2089cc8f70a6e454601525e5bf2779e665d7865af002a5dec8d14e561002e135", size = 3883299, upload-time = "2025-07-02T13:05:34.94Z" }, + { url = "https://files.pythonhosted.org/packages/82/c7/f0ea40f016de72f81288e9fe8d1f6748036cb5ba6118774317a3ffc6022d/cryptography-45.0.5-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0027d566d65a38497bc37e0dd7c2f8ceda73597d2ac9ba93810204f56f52ebc7", size = 4450558, upload-time = "2025-07-02T13:05:37.288Z" }, + { url = "https://files.pythonhosted.org/packages/06/ae/94b504dc1a3cdf642d710407c62e86296f7da9e66f27ab12a1ee6fdf005b/cryptography-45.0.5-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:be97d3a19c16a9be00edf79dca949c8fa7eff621763666a145f9f9535a5d7f42", size = 4198020, upload-time = "2025-07-02T13:05:39.102Z" }, + { url = "https://files.pythonhosted.org/packages/05/2b/aaf0adb845d5dabb43480f18f7ca72e94f92c280aa983ddbd0bcd6ecd037/cryptography-45.0.5-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:7760c1c2e1a7084153a0f68fab76e754083b126a47d0117c9ed15e69e2103492", size = 4449759, upload-time = "2025-07-02T13:05:41.398Z" }, + { url = "https://files.pythonhosted.org/packages/91/e4/f17e02066de63e0100a3a01b56f8f1016973a1d67551beaf585157a86b3f/cryptography-45.0.5-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6ff8728d8d890b3dda5765276d1bc6fb099252915a2cd3aff960c4c195745dd0", size = 4319991, upload-time = "2025-07-02T13:05:43.64Z" }, + { url = "https://files.pythonhosted.org/packages/f2/2e/e2dbd629481b499b14516eed933f3276eb3239f7cee2dcfa4ee6b44d4711/cryptography-45.0.5-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7259038202a47fdecee7e62e0fd0b0738b6daa335354396c6ddebdbe1206af2a", size = 4554189, upload-time = "2025-07-02T13:05:46.045Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ea/a78a0c38f4c8736287b71c2ea3799d173d5ce778c7d6e3c163a95a05ad2a/cryptography-45.0.5-cp37-abi3-win32.whl", hash = "sha256:1e1da5accc0c750056c556a93c3e9cb828970206c68867712ca5805e46dc806f", size = 2911769, upload-time = "2025-07-02T13:05:48.329Z" }, + { url = "https://files.pythonhosted.org/packages/79/b3/28ac139109d9005ad3f6b6f8976ffede6706a6478e21c889ce36c840918e/cryptography-45.0.5-cp37-abi3-win_amd64.whl", hash = "sha256:90cb0a7bb35959f37e23303b7eed0a32280510030daba3f7fdfbb65defde6a97", size = 3390016, upload-time = "2025-07-02T13:05:50.811Z" }, + { url = "https://files.pythonhosted.org/packages/c0/71/9bdbcfd58d6ff5084687fe722c58ac718ebedbc98b9f8f93781354e6d286/cryptography-45.0.5-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:8c4a6ff8a30e9e3d38ac0539e9a9e02540ab3f827a3394f8852432f6b0ea152e", size = 3587878, upload-time = "2025-07-02T13:06:06.339Z" }, + { url = "https://files.pythonhosted.org/packages/f0/63/83516cfb87f4a8756eaa4203f93b283fda23d210fc14e1e594bd5f20edb6/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bd4c45986472694e5121084c6ebbd112aa919a25e783b87eb95953c9573906d6", size = 4152447, upload-time = "2025-07-02T13:06:08.345Z" }, + { url = "https://files.pythonhosted.org/packages/22/11/d2823d2a5a0bd5802b3565437add16f5c8ce1f0778bf3822f89ad2740a38/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:982518cd64c54fcada9d7e5cf28eabd3ee76bd03ab18e08a48cad7e8b6f31b18", size = 4386778, upload-time = "2025-07-02T13:06:10.263Z" }, + { url = "https://files.pythonhosted.org/packages/5f/38/6bf177ca6bce4fe14704ab3e93627c5b0ca05242261a2e43ef3168472540/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:12e55281d993a793b0e883066f590c1ae1e802e3acb67f8b442e721e475e6463", size = 4151627, upload-time = "2025-07-02T13:06:13.097Z" }, + { url = "https://files.pythonhosted.org/packages/38/6a/69fc67e5266bff68a91bcb81dff8fb0aba4d79a78521a08812048913e16f/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:5aa1e32983d4443e310f726ee4b071ab7569f58eedfdd65e9675484a4eb67bd1", size = 4385593, upload-time = "2025-07-02T13:06:15.689Z" }, + { url = "https://files.pythonhosted.org/packages/f6/34/31a1604c9a9ade0fdab61eb48570e09a796f4d9836121266447b0eaf7feb/cryptography-45.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:e357286c1b76403dd384d938f93c46b2b058ed4dfcdce64a770f0537ed3feb6f", size = 3331106, upload-time = "2025-07-02T13:06:18.058Z" }, ] [[package]] @@ -1198,9 +1217,10 @@ wheels = [ [[package]] name = "dify-api" -version = "1.5.1" +version = "1.6.0" source = { virtual = "." } dependencies = [ + { name = "arize-phoenix-otel" }, { name = "authlib" }, { name = "azure-identity" }, { name = "beautifulsoup4" }, @@ -1226,6 +1246,7 @@ dependencies = [ { name = "googleapis-common-protos" }, { name = "gunicorn" }, { name = "httpx", extra = ["socks"] }, + { name = "httpx-sse" }, { name = "jieba" }, { name = "json-repair" }, { name = "langfuse" }, @@ -1270,6 +1291,7 @@ dependencies = [ { name = "sendgrid" }, { name = "sentry-sdk", extra = ["flask"] }, { name = "sqlalchemy" }, + { name = "sseclient-py" }, { name = "starlette" }, { name = "tiktoken" }, { name = "transformers" }, @@ -1380,6 +1402,7 @@ vdb = [ [package.metadata] requires-dist = [ + { name = "arize-phoenix-otel", specifier = "~=0.9.2" }, { name = "authlib", specifier = "==1.3.1" }, { name = "azure-identity", specifier = "==1.16.1" }, { name = "beautifulsoup4", specifier = "==4.12.2" }, @@ -1405,6 +1428,7 @@ requires-dist = [ { name = "googleapis-common-protos", specifier = "==1.63.0" }, { name = "gunicorn", specifier = "~=23.0.0" }, { name = "httpx", extras = ["socks"], specifier = "~=0.27.0" }, + { name = "httpx-sse", specifier = ">=0.4.0" }, { name = "jieba", specifier = "==0.42.1" }, { name = "json-repair", specifier = ">=0.41.1" }, { name = "langfuse", specifier = "~=2.51.3" }, @@ -1449,6 +1473,7 @@ requires-dist = [ { name = "sendgrid", specifier = "~=6.12.3" }, { name = "sentry-sdk", extras = ["flask"], specifier = "~=2.28.0" }, { name = "sqlalchemy", specifier = "~=2.0.29" }, + { name = "sseclient-py", specifier = ">=1.8.0" }, { name = "starlette", specifier = "==0.41.0" }, { name = "tiktoken", specifier = "~=0.9.0" }, { name = "transformers", specifier = "~=4.51.0" }, @@ -1688,16 +1713,16 @@ wheels = [ [[package]] name = "fastapi" -version = "0.115.12" +version = "0.116.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "starlette" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f4/55/ae499352d82338331ca1e28c7f4a63bfd09479b16395dce38cf50a39e2c2/fastapi-0.115.12.tar.gz", hash = "sha256:1e2c2a2646905f9e83d32f04a3f86aff4a286669c6c950ca95b5fd68c2602681", size = 295236, upload-time = "2025-03-23T22:55:43.822Z" } +sdist = { url = "https://files.pythonhosted.org/packages/20/38/e1da78736143fd885c36213a3ccc493c384ae8fea6a0f0bc272ef42ebea8/fastapi-0.116.0.tar.gz", hash = "sha256:80dc0794627af0390353a6d1171618276616310d37d24faba6648398e57d687a", size = 296518, upload-time = "2025-07-07T15:09:27.82Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/50/b3/b51f09c2ba432a576fe63758bddc81f78f0c6309d9e5c10d194313bf021e/fastapi-0.115.12-py3-none-any.whl", hash = "sha256:e94613d6c05e27be7ffebdd6ea5f388112e5e430c8f7d6494a9d1d88d43e814d", size = 95164, upload-time = "2025-03-23T22:55:42.101Z" }, + { url = "https://files.pythonhosted.org/packages/2f/68/d80347fe2360445b5f58cf290e588a4729746e7501080947e6cdae114b1f/fastapi-0.116.0-py3-none-any.whl", hash = "sha256:fdcc9ed272eaef038952923bef2b735c02372402d1203ee1210af4eea7a78d2b", size = 95625, upload-time = "2025-07-07T15:09:26.348Z" }, ] [[package]] @@ -2368,17 +2393,17 @@ wheels = [ [[package]] name = "hf-xet" -version = "1.1.4" +version = "1.1.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8d/11/b480bb7515db97d5b2b703927a59bbdd3f87e68d47dff5591aada467b4a9/hf_xet-1.1.4.tar.gz", hash = "sha256:875158df90cb13547752532ed73cad9dfaad3b29e203143838f67178418d08a4", size = 492082, upload-time = "2025-06-16T21:20:51.375Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/d4/7685999e85945ed0d7f0762b686ae7015035390de1161dcea9d5276c134c/hf_xet-1.1.5.tar.gz", hash = "sha256:69ebbcfd9ec44fdc2af73441619eeb06b94ee34511bbcf57cd423820090f5694", size = 495969, upload-time = "2025-06-20T21:48:38.007Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/62/3b41a7439930996530c64955874445012fd9044c82c60b34c5891c34fec6/hf_xet-1.1.4-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:6591ab9f61ea82d261107ed90237e2ece972f6a7577d96f5f071208bbf255d1c", size = 2643151, upload-time = "2025-06-16T21:20:45.656Z" }, - { url = "https://files.pythonhosted.org/packages/9b/9f/1744fb1d79e0ac147578b193ce29208ebb9f4636e8cdf505638f6f0a6874/hf_xet-1.1.4-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:071b0b4d4698990f746edd666c7cc42555833d22035d88db0df936677fb57d29", size = 2510687, upload-time = "2025-06-16T21:20:43.754Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a8/49a81d4f81b0d21cc758b6fca3880a85ca0d209e8425c8b3a6ef694881ca/hf_xet-1.1.4-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5b610831e92e41182d4c028653978b844d332d492cdcba1b920d3aca4a0207e", size = 3057631, upload-time = "2025-06-16T21:20:42.006Z" }, - { url = "https://files.pythonhosted.org/packages/bf/8b/65fa08273789dafbc38d0f0bdd20df56b63ebc6566981bbaa255d9d84a33/hf_xet-1.1.4-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f6578bcd71393abfd60395279cc160ca808b61f5f9d535b922fcdcd3f77a708d", size = 2949250, upload-time = "2025-06-16T21:20:39.914Z" }, - { url = "https://files.pythonhosted.org/packages/8b/4b/224340bb1d5c63b6e03e04095b4e42230848454bf4293c45cd7bdaa0c208/hf_xet-1.1.4-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fb2bbfa2aae0e4f0baca988e7ba8d8c1a39a25adf5317461eb7069ad00505b3e", size = 3124670, upload-time = "2025-06-16T21:20:47.688Z" }, - { url = "https://files.pythonhosted.org/packages/4a/b7/4be010014de6585401c32a04c46b09a4a842d66bd16ed549a401e973b74b/hf_xet-1.1.4-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:73346ba3e2e15ea8909a26b0862b458f15b003e6277935e3fba5bf273508d698", size = 3234131, upload-time = "2025-06-16T21:20:49.535Z" }, - { url = "https://files.pythonhosted.org/packages/c2/2d/cf148d532f741fbf93f380ff038a33c1309d1e24ea629dc39d11dca08c92/hf_xet-1.1.4-cp37-abi3-win_amd64.whl", hash = "sha256:52e8f8bc2029d8b911493f43cea131ac3fa1f0dc6a13c50b593c4516f02c6fc3", size = 2695589, upload-time = "2025-06-16T21:20:53.151Z" }, + { url = "https://files.pythonhosted.org/packages/00/89/a1119eebe2836cb25758e7661d6410d3eae982e2b5e974bcc4d250be9012/hf_xet-1.1.5-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:f52c2fa3635b8c37c7764d8796dfa72706cc4eded19d638331161e82b0792e23", size = 2687929, upload-time = "2025-06-20T21:48:32.284Z" }, + { url = "https://files.pythonhosted.org/packages/de/5f/2c78e28f309396e71ec8e4e9304a6483dcbc36172b5cea8f291994163425/hf_xet-1.1.5-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:9fa6e3ee5d61912c4a113e0708eaaef987047616465ac7aa30f7121a48fc1af8", size = 2556338, upload-time = "2025-06-20T21:48:30.079Z" }, + { url = "https://files.pythonhosted.org/packages/6d/2f/6cad7b5fe86b7652579346cb7f85156c11761df26435651cbba89376cd2c/hf_xet-1.1.5-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc874b5c843e642f45fd85cda1ce599e123308ad2901ead23d3510a47ff506d1", size = 3102894, upload-time = "2025-06-20T21:48:28.114Z" }, + { url = "https://files.pythonhosted.org/packages/d0/54/0fcf2b619720a26fbb6cc941e89f2472a522cd963a776c089b189559447f/hf_xet-1.1.5-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dbba1660e5d810bd0ea77c511a99e9242d920790d0e63c0e4673ed36c4022d18", size = 3002134, upload-time = "2025-06-20T21:48:25.906Z" }, + { url = "https://files.pythonhosted.org/packages/f3/92/1d351ac6cef7c4ba8c85744d37ffbfac2d53d0a6c04d2cabeba614640a78/hf_xet-1.1.5-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ab34c4c3104133c495785d5d8bba3b1efc99de52c02e759cf711a91fd39d3a14", size = 3171009, upload-time = "2025-06-20T21:48:33.987Z" }, + { url = "https://files.pythonhosted.org/packages/c9/65/4b2ddb0e3e983f2508528eb4501288ae2f84963586fbdfae596836d5e57a/hf_xet-1.1.5-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:83088ecea236d5113de478acb2339f92c95b4fb0462acaa30621fac02f5a534a", size = 3279245, upload-time = "2025-06-20T21:48:36.051Z" }, + { url = "https://files.pythonhosted.org/packages/f0/55/ef77a85ee443ae05a9e9cba1c9f0dd9241eb42da2aeba1dc50f51154c81a/hf_xet-1.1.5-cp37-abi3-win_amd64.whl", hash = "sha256:73e167d9807d166596b4b2f0b585c6d5bd84a26dea32843665a8b58f6edba245", size = 2738931, upload-time = "2025-06-20T21:48:39.482Z" }, ] [[package]] @@ -2512,9 +2537,18 @@ socks = [ { name = "socksio" }, ] +[[package]] +name = "httpx-sse" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/fa/66bd985dd0b7c109a3bcb89272ee0bfb7e2b4d06309ad7b38ff866734b2a/httpx_sse-0.4.1.tar.gz", hash = "sha256:8f44d34414bc7b21bf3602713005c5df4917884f76072479b21f68befa4ea26e", size = 12998, upload-time = "2025-06-24T13:21:05.71Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/0a/6269e3473b09aed2dab8aa1a600c70f31f00ae1349bee30658f7e358a159/httpx_sse-0.4.1-py3-none-any.whl", hash = "sha256:cba42174344c3a5b06f255ce65b350880f962d99ead85e776f23c6618a377a37", size = 8054, upload-time = "2025-06-24T13:21:04.772Z" }, +] + [[package]] name = "huggingface-hub" -version = "0.33.0" +version = "0.33.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, @@ -2526,9 +2560,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/91/8a/1362d565fefabaa4185cf3ae842a98dbc5b35146f5694f7080f043a6952f/huggingface_hub-0.33.0.tar.gz", hash = "sha256:aa31f70d29439d00ff7a33837c03f1f9dd83971ce4e29ad664d63ffb17d3bb97", size = 426179, upload-time = "2025-06-11T17:08:07.913Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/42/8a95c5632080ae312c0498744b2b852195e10b05a20b1be11c5141092f4c/huggingface_hub-0.33.2.tar.gz", hash = "sha256:84221defaec8fa09c090390cd68c78b88e3c4c2b7befba68d3dc5aacbc3c2c5f", size = 426637, upload-time = "2025-07-02T06:26:05.156Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/33/fb/53587a89fbc00799e4179796f51b3ad713c5de6bb680b2becb6d37c94649/huggingface_hub-0.33.0-py3-none-any.whl", hash = "sha256:e8668875b40c68f9929150d99727d39e5ebb8a05a98e4191b908dc7ded9074b3", size = 514799, upload-time = "2025-06-11T17:08:05.757Z" }, + { url = "https://files.pythonhosted.org/packages/44/f4/5f3f22e762ad1965f01122b42dae5bf0e009286e2dba601ce1d0dba72424/huggingface_hub-0.33.2-py3-none-any.whl", hash = "sha256:3749498bfa91e8cde2ddc2c1db92c79981f40e66434c20133b39e5928ac9bcc5", size = 515373, upload-time = "2025-07-02T06:26:03.072Z" }, ] [[package]] @@ -2554,15 +2588,15 @@ wheels = [ [[package]] name = "hypothesis" -version = "6.131.15" +version = "6.135.26" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "sortedcontainers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f1/6f/1e291f80627f3e043b19a86f9f6b172b910e3575577917d3122a6558410d/hypothesis-6.131.15.tar.gz", hash = "sha256:11849998ae5eecc8c586c6c98e47677fcc02d97475065f62768cfffbcc15ef7a", size = 436596, upload-time = "2025-05-07T23:04:25.127Z" } +sdist = { url = "https://files.pythonhosted.org/packages/da/83/15c4e30561a0d8c8d076c88cb159187823d877118f34c851ada3b9b02a7b/hypothesis-6.135.26.tar.gz", hash = "sha256:73af0e46cd5039c6806f514fed6a3c185d91ef88b5a1577477099ddbd1a2e300", size = 454523, upload-time = "2025-07-05T04:59:45.443Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b6/c7/78597bcec48e1585ea9029deb2bf2341516e90dd615a3db498413d68a4cc/hypothesis-6.131.15-py3-none-any.whl", hash = "sha256:e02e67e9f3cfd4cd4a67ccc03bf7431beccc1a084c5e90029799ddd36ce006d7", size = 501128, upload-time = "2025-05-07T23:04:22.045Z" }, + { url = "https://files.pythonhosted.org/packages/3c/78/db4fdc464219455f8dde90074660c3faf8429101b2d1299cac7d219e3176/hypothesis-6.135.26-py3-none-any.whl", hash = "sha256:fa237cbe2ae2c31d65f7230dcb866139ace635dcfec6c30dddf25974dd8ff4b9", size = 521517, upload-time = "2025-07-05T04:59:42.061Z" }, ] [[package]] @@ -2692,11 +2726,11 @@ wheels = [ [[package]] name = "json-repair" -version = "0.46.2" +version = "0.47.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cb/74/f8e4eb4ce31be034c08fd3da37328c9ab7a7503831cf6f41d2121699cc88/json_repair-0.46.2.tar.gz", hash = "sha256:4c81154d61c028ca3750b451472dbb33978f2ee6f44be84c42b444b03d9f4b16", size = 33605, upload-time = "2025-06-06T08:05:48.46Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/9e/e8bcda4fd47b16fcd4f545af258d56ba337fa43b847beb213818d7641515/json_repair-0.47.6.tar.gz", hash = "sha256:4af5a14b9291d4d005a11537bae5a6b7912376d7584795f0ac1b23724b999620", size = 34400, upload-time = "2025-07-01T15:42:07.458Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/d7/5f31df5ad00474f3005bbbac5f3a1e8d36535b40f1d352e6a5bd9880bf1f/json_repair-0.46.2-py3-none-any.whl", hash = "sha256:21fb339de583ab68db4272f984ec6fca9cc453d8117d9870e83c28b6b56c20e6", size = 22326, upload-time = "2025-06-06T08:05:47.064Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f8/f464ce2afc4be5decf53d0171c2d399d9ee6cd70d2273b8e85e7c6d00324/json_repair-0.47.6-py3-none-any.whl", hash = "sha256:1c9da58fb6240f99b8405f63534e08f8402793f09074dea25800a0b232d4fb19", size = 25754, upload-time = "2025-07-01T15:42:06.418Z" }, ] [[package]] @@ -2848,44 +2882,40 @@ wheels = [ [[package]] name = "lxml" -version = "5.4.0" +version = "6.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/3d/14e82fc7c8fb1b7761f7e748fd47e2ec8276d137b6acfe5a4bb73853e08f/lxml-5.4.0.tar.gz", hash = "sha256:d12832e1dbea4be280b22fd0ea7c9b87f0d8fc51ba06e92dc62d52f804f78ebd", size = 3679479, upload-time = "2025-04-23T01:50:29.322Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c5/ed/60eb6fa2923602fba988d9ca7c5cdbd7cf25faa795162ed538b527a35411/lxml-6.0.0.tar.gz", hash = "sha256:032e65120339d44cdc3efc326c9f660f5f7205f3a535c1fdbf898b29ea01fb72", size = 4096938, upload-time = "2025-06-26T16:28:19.373Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/2d/67693cc8a605a12e5975380d7ff83020dcc759351b5a066e1cced04f797b/lxml-5.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:98a3912194c079ef37e716ed228ae0dcb960992100461b704aea4e93af6b0bb9", size = 8083240, upload-time = "2025-04-23T01:45:18.566Z" }, - { url = "https://files.pythonhosted.org/packages/73/53/b5a05ab300a808b72e848efd152fe9c022c0181b0a70b8bca1199f1bed26/lxml-5.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0ea0252b51d296a75f6118ed0d8696888e7403408ad42345d7dfd0d1e93309a7", size = 4387685, upload-time = "2025-04-23T01:45:21.387Z" }, - { url = "https://files.pythonhosted.org/packages/d8/cb/1a3879c5f512bdcd32995c301886fe082b2edd83c87d41b6d42d89b4ea4d/lxml-5.4.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b92b69441d1bd39f4940f9eadfa417a25862242ca2c396b406f9272ef09cdcaa", size = 4991164, upload-time = "2025-04-23T01:45:23.849Z" }, - { url = "https://files.pythonhosted.org/packages/f9/94/bbc66e42559f9d04857071e3b3d0c9abd88579367fd2588a4042f641f57e/lxml-5.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20e16c08254b9b6466526bc1828d9370ee6c0d60a4b64836bc3ac2917d1e16df", size = 4746206, upload-time = "2025-04-23T01:45:26.361Z" }, - { url = "https://files.pythonhosted.org/packages/66/95/34b0679bee435da2d7cae895731700e519a8dfcab499c21662ebe671603e/lxml-5.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7605c1c32c3d6e8c990dd28a0970a3cbbf1429d5b92279e37fda05fb0c92190e", size = 5342144, upload-time = "2025-04-23T01:45:28.939Z" }, - { url = "https://files.pythonhosted.org/packages/e0/5d/abfcc6ab2fa0be72b2ba938abdae1f7cad4c632f8d552683ea295d55adfb/lxml-5.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ecf4c4b83f1ab3d5a7ace10bafcb6f11df6156857a3c418244cef41ca9fa3e44", size = 4825124, upload-time = "2025-04-23T01:45:31.361Z" }, - { url = "https://files.pythonhosted.org/packages/5a/78/6bd33186c8863b36e084f294fc0a5e5eefe77af95f0663ef33809cc1c8aa/lxml-5.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0cef4feae82709eed352cd7e97ae062ef6ae9c7b5dbe3663f104cd2c0e8d94ba", size = 4876520, upload-time = "2025-04-23T01:45:34.191Z" }, - { url = "https://files.pythonhosted.org/packages/3b/74/4d7ad4839bd0fc64e3d12da74fc9a193febb0fae0ba6ebd5149d4c23176a/lxml-5.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:df53330a3bff250f10472ce96a9af28628ff1f4efc51ccba351a8820bca2a8ba", size = 4765016, upload-time = "2025-04-23T01:45:36.7Z" }, - { url = "https://files.pythonhosted.org/packages/24/0d/0a98ed1f2471911dadfc541003ac6dd6879fc87b15e1143743ca20f3e973/lxml-5.4.0-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:aefe1a7cb852fa61150fcb21a8c8fcea7b58c4cb11fbe59c97a0a4b31cae3c8c", size = 5362884, upload-time = "2025-04-23T01:45:39.291Z" }, - { url = "https://files.pythonhosted.org/packages/48/de/d4f7e4c39740a6610f0f6959052b547478107967362e8424e1163ec37ae8/lxml-5.4.0-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:ef5a7178fcc73b7d8c07229e89f8eb45b2908a9238eb90dcfc46571ccf0383b8", size = 4902690, upload-time = "2025-04-23T01:45:42.386Z" }, - { url = "https://files.pythonhosted.org/packages/07/8c/61763abd242af84f355ca4ef1ee096d3c1b7514819564cce70fd18c22e9a/lxml-5.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:d2ed1b3cb9ff1c10e6e8b00941bb2e5bb568b307bfc6b17dffbbe8be5eecba86", size = 4944418, upload-time = "2025-04-23T01:45:46.051Z" }, - { url = "https://files.pythonhosted.org/packages/f9/c5/6d7e3b63e7e282619193961a570c0a4c8a57fe820f07ca3fe2f6bd86608a/lxml-5.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:72ac9762a9f8ce74c9eed4a4e74306f2f18613a6b71fa065495a67ac227b3056", size = 4827092, upload-time = "2025-04-23T01:45:48.943Z" }, - { url = "https://files.pythonhosted.org/packages/71/4a/e60a306df54680b103348545706a98a7514a42c8b4fbfdcaa608567bb065/lxml-5.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f5cb182f6396706dc6cc1896dd02b1c889d644c081b0cdec38747573db88a7d7", size = 5418231, upload-time = "2025-04-23T01:45:51.481Z" }, - { url = "https://files.pythonhosted.org/packages/27/f2/9754aacd6016c930875854f08ac4b192a47fe19565f776a64004aa167521/lxml-5.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:3a3178b4873df8ef9457a4875703488eb1622632a9cee6d76464b60e90adbfcd", size = 5261798, upload-time = "2025-04-23T01:45:54.146Z" }, - { url = "https://files.pythonhosted.org/packages/38/a2/0c49ec6941428b1bd4f280650d7b11a0f91ace9db7de32eb7aa23bcb39ff/lxml-5.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e094ec83694b59d263802ed03a8384594fcce477ce484b0cbcd0008a211ca751", size = 4988195, upload-time = "2025-04-23T01:45:56.685Z" }, - { url = "https://files.pythonhosted.org/packages/7a/75/87a3963a08eafc46a86c1131c6e28a4de103ba30b5ae903114177352a3d7/lxml-5.4.0-cp311-cp311-win32.whl", hash = "sha256:4329422de653cdb2b72afa39b0aa04252fca9071550044904b2e7036d9d97fe4", size = 3474243, upload-time = "2025-04-23T01:45:58.863Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f9/1f0964c4f6c2be861c50db380c554fb8befbea98c6404744ce243a3c87ef/lxml-5.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:fd3be6481ef54b8cfd0e1e953323b7aa9d9789b94842d0e5b142ef4bb7999539", size = 3815197, upload-time = "2025-04-23T01:46:01.096Z" }, - { url = "https://files.pythonhosted.org/packages/f8/4c/d101ace719ca6a4ec043eb516fcfcb1b396a9fccc4fcd9ef593df34ba0d5/lxml-5.4.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b5aff6f3e818e6bdbbb38e5967520f174b18f539c2b9de867b1e7fde6f8d95a4", size = 8127392, upload-time = "2025-04-23T01:46:04.09Z" }, - { url = "https://files.pythonhosted.org/packages/11/84/beddae0cec4dd9ddf46abf156f0af451c13019a0fa25d7445b655ba5ccb7/lxml-5.4.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:942a5d73f739ad7c452bf739a62a0f83e2578afd6b8e5406308731f4ce78b16d", size = 4415103, upload-time = "2025-04-23T01:46:07.227Z" }, - { url = "https://files.pythonhosted.org/packages/d0/25/d0d93a4e763f0462cccd2b8a665bf1e4343dd788c76dcfefa289d46a38a9/lxml-5.4.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:460508a4b07364d6abf53acaa0a90b6d370fafde5693ef37602566613a9b0779", size = 5024224, upload-time = "2025-04-23T01:46:10.237Z" }, - { url = "https://files.pythonhosted.org/packages/31/ce/1df18fb8f7946e7f3388af378b1f34fcf253b94b9feedb2cec5969da8012/lxml-5.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:529024ab3a505fed78fe3cc5ddc079464e709f6c892733e3f5842007cec8ac6e", size = 4769913, upload-time = "2025-04-23T01:46:12.757Z" }, - { url = "https://files.pythonhosted.org/packages/4e/62/f4a6c60ae7c40d43657f552f3045df05118636be1165b906d3423790447f/lxml-5.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ca56ebc2c474e8f3d5761debfd9283b8b18c76c4fc0967b74aeafba1f5647f9", size = 5290441, upload-time = "2025-04-23T01:46:16.037Z" }, - { url = "https://files.pythonhosted.org/packages/9e/aa/04f00009e1e3a77838c7fc948f161b5d2d5de1136b2b81c712a263829ea4/lxml-5.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a81e1196f0a5b4167a8dafe3a66aa67c4addac1b22dc47947abd5d5c7a3f24b5", size = 4820165, upload-time = "2025-04-23T01:46:19.137Z" }, - { url = "https://files.pythonhosted.org/packages/c9/1f/e0b2f61fa2404bf0f1fdf1898377e5bd1b74cc9b2cf2c6ba8509b8f27990/lxml-5.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00b8686694423ddae324cf614e1b9659c2edb754de617703c3d29ff568448df5", size = 4932580, upload-time = "2025-04-23T01:46:21.963Z" }, - { url = "https://files.pythonhosted.org/packages/24/a2/8263f351b4ffe0ed3e32ea7b7830f845c795349034f912f490180d88a877/lxml-5.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:c5681160758d3f6ac5b4fea370495c48aac0989d6a0f01bb9a72ad8ef5ab75c4", size = 4759493, upload-time = "2025-04-23T01:46:24.316Z" }, - { url = "https://files.pythonhosted.org/packages/05/00/41db052f279995c0e35c79d0f0fc9f8122d5b5e9630139c592a0b58c71b4/lxml-5.4.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:2dc191e60425ad70e75a68c9fd90ab284df64d9cd410ba8d2b641c0c45bc006e", size = 5324679, upload-time = "2025-04-23T01:46:27.097Z" }, - { url = "https://files.pythonhosted.org/packages/1d/be/ee99e6314cdef4587617d3b3b745f9356d9b7dd12a9663c5f3b5734b64ba/lxml-5.4.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:67f779374c6b9753ae0a0195a892a1c234ce8416e4448fe1e9f34746482070a7", size = 4890691, upload-time = "2025-04-23T01:46:30.009Z" }, - { url = "https://files.pythonhosted.org/packages/ad/36/239820114bf1d71f38f12208b9c58dec033cbcf80101cde006b9bde5cffd/lxml-5.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:79d5bfa9c1b455336f52343130b2067164040604e41f6dc4d8313867ed540079", size = 4955075, upload-time = "2025-04-23T01:46:32.33Z" }, - { url = "https://files.pythonhosted.org/packages/d4/e1/1b795cc0b174efc9e13dbd078a9ff79a58728a033142bc6d70a1ee8fc34d/lxml-5.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3d3c30ba1c9b48c68489dc1829a6eede9873f52edca1dda900066542528d6b20", size = 4838680, upload-time = "2025-04-23T01:46:34.852Z" }, - { url = "https://files.pythonhosted.org/packages/72/48/3c198455ca108cec5ae3662ae8acd7fd99476812fd712bb17f1b39a0b589/lxml-5.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1af80c6316ae68aded77e91cd9d80648f7dd40406cef73df841aa3c36f6907c8", size = 5391253, upload-time = "2025-04-23T01:46:37.608Z" }, - { url = "https://files.pythonhosted.org/packages/d6/10/5bf51858971c51ec96cfc13e800a9951f3fd501686f4c18d7d84fe2d6352/lxml-5.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4d885698f5019abe0de3d352caf9466d5de2baded00a06ef3f1216c1a58ae78f", size = 5261651, upload-time = "2025-04-23T01:46:40.183Z" }, - { url = "https://files.pythonhosted.org/packages/2b/11/06710dd809205377da380546f91d2ac94bad9ff735a72b64ec029f706c85/lxml-5.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aea53d51859b6c64e7c51d522c03cc2c48b9b5d6172126854cc7f01aa11f52bc", size = 5024315, upload-time = "2025-04-23T01:46:43.333Z" }, - { url = "https://files.pythonhosted.org/packages/f5/b0/15b6217834b5e3a59ebf7f53125e08e318030e8cc0d7310355e6edac98ef/lxml-5.4.0-cp312-cp312-win32.whl", hash = "sha256:d90b729fd2732df28130c064aac9bb8aff14ba20baa4aee7bd0795ff1187545f", size = 3486149, upload-time = "2025-04-23T01:46:45.684Z" }, - { url = "https://files.pythonhosted.org/packages/91/1e/05ddcb57ad2f3069101611bd5f5084157d90861a2ef460bf42f45cced944/lxml-5.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1dc4ca99e89c335a7ed47d38964abcb36c5910790f9bd106f2a8fa2ee0b909d2", size = 3817095, upload-time = "2025-04-23T01:46:48.521Z" }, + { url = "https://files.pythonhosted.org/packages/7c/23/828d4cc7da96c611ec0ce6147bbcea2fdbde023dc995a165afa512399bbf/lxml-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4ee56288d0df919e4aac43b539dd0e34bb55d6a12a6562038e8d6f3ed07f9e36", size = 8438217, upload-time = "2025-06-26T16:25:34.349Z" }, + { url = "https://files.pythonhosted.org/packages/f1/33/5ac521212c5bcb097d573145d54b2b4a3c9766cda88af5a0e91f66037c6e/lxml-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b8dd6dd0e9c1992613ccda2bcb74fc9d49159dbe0f0ca4753f37527749885c25", size = 4590317, upload-time = "2025-06-26T16:25:38.103Z" }, + { url = "https://files.pythonhosted.org/packages/2b/2e/45b7ca8bee304c07f54933c37afe7dd4d39ff61ba2757f519dcc71bc5d44/lxml-6.0.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:d7ae472f74afcc47320238b5dbfd363aba111a525943c8a34a1b657c6be934c3", size = 5221628, upload-time = "2025-06-26T16:25:40.878Z" }, + { url = "https://files.pythonhosted.org/packages/32/23/526d19f7eb2b85da1f62cffb2556f647b049ebe2a5aa8d4d41b1fb2c7d36/lxml-6.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5592401cdf3dc682194727c1ddaa8aa0f3ddc57ca64fd03226a430b955eab6f6", size = 4949429, upload-time = "2025-06-28T18:47:20.046Z" }, + { url = "https://files.pythonhosted.org/packages/ac/cc/f6be27a5c656a43a5344e064d9ae004d4dcb1d3c9d4f323c8189ddfe4d13/lxml-6.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58ffd35bd5425c3c3b9692d078bf7ab851441434531a7e517c4984d5634cd65b", size = 5087909, upload-time = "2025-06-28T18:47:22.834Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e6/8ec91b5bfbe6972458bc105aeb42088e50e4b23777170404aab5dfb0c62d/lxml-6.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f720a14aa102a38907c6d5030e3d66b3b680c3e6f6bc95473931ea3c00c59967", size = 5031713, upload-time = "2025-06-26T16:25:43.226Z" }, + { url = "https://files.pythonhosted.org/packages/33/cf/05e78e613840a40e5be3e40d892c48ad3e475804db23d4bad751b8cadb9b/lxml-6.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2a5e8d207311a0170aca0eb6b160af91adc29ec121832e4ac151a57743a1e1e", size = 5232417, upload-time = "2025-06-26T16:25:46.111Z" }, + { url = "https://files.pythonhosted.org/packages/ac/8c/6b306b3e35c59d5f0b32e3b9b6b3b0739b32c0dc42a295415ba111e76495/lxml-6.0.0-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:2dd1cc3ea7e60bfb31ff32cafe07e24839df573a5e7c2d33304082a5019bcd58", size = 4681443, upload-time = "2025-06-26T16:25:48.837Z" }, + { url = "https://files.pythonhosted.org/packages/59/43/0bd96bece5f7eea14b7220476835a60d2b27f8e9ca99c175f37c085cb154/lxml-6.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2cfcf84f1defed7e5798ef4f88aa25fcc52d279be731ce904789aa7ccfb7e8d2", size = 5074542, upload-time = "2025-06-26T16:25:51.65Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3d/32103036287a8ca012d8518071f8852c68f2b3bfe048cef2a0202eb05910/lxml-6.0.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:a52a4704811e2623b0324a18d41ad4b9fabf43ce5ff99b14e40a520e2190c851", size = 4729471, upload-time = "2025-06-26T16:25:54.571Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a8/7be5d17df12d637d81854bd8648cd329f29640a61e9a72a3f77add4a311b/lxml-6.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c16304bba98f48a28ae10e32a8e75c349dd742c45156f297e16eeb1ba9287a1f", size = 5256285, upload-time = "2025-06-26T16:25:56.997Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d0/6cb96174c25e0d749932557c8d51d60c6e292c877b46fae616afa23ed31a/lxml-6.0.0-cp311-cp311-win32.whl", hash = "sha256:f8d19565ae3eb956d84da3ef367aa7def14a2735d05bd275cd54c0301f0d0d6c", size = 3612004, upload-time = "2025-06-26T16:25:59.11Z" }, + { url = "https://files.pythonhosted.org/packages/ca/77/6ad43b165dfc6dead001410adeb45e88597b25185f4479b7ca3b16a5808f/lxml-6.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:b2d71cdefda9424adff9a3607ba5bbfc60ee972d73c21c7e3c19e71037574816", size = 4003470, upload-time = "2025-06-26T16:26:01.655Z" }, + { url = "https://files.pythonhosted.org/packages/a0/bc/4c50ec0eb14f932a18efc34fc86ee936a66c0eb5f2fe065744a2da8a68b2/lxml-6.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:8a2e76efbf8772add72d002d67a4c3d0958638696f541734304c7f28217a9cab", size = 3682477, upload-time = "2025-06-26T16:26:03.808Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/d01d735c298d7e0ddcedf6f028bf556577e5ab4f4da45175ecd909c79378/lxml-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78718d8454a6e928470d511bf8ac93f469283a45c354995f7d19e77292f26108", size = 8429515, upload-time = "2025-06-26T16:26:06.776Z" }, + { url = "https://files.pythonhosted.org/packages/06/37/0e3eae3043d366b73da55a86274a590bae76dc45aa004b7042e6f97803b1/lxml-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:84ef591495ffd3f9dcabffd6391db7bb70d7230b5c35ef5148354a134f56f2be", size = 4601387, upload-time = "2025-06-26T16:26:09.511Z" }, + { url = "https://files.pythonhosted.org/packages/a3/28/e1a9a881e6d6e29dda13d633885d13acb0058f65e95da67841c8dd02b4a8/lxml-6.0.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:2930aa001a3776c3e2601cb8e0a15d21b8270528d89cc308be4843ade546b9ab", size = 5228928, upload-time = "2025-06-26T16:26:12.337Z" }, + { url = "https://files.pythonhosted.org/packages/9a/55/2cb24ea48aa30c99f805921c1c7860c1f45c0e811e44ee4e6a155668de06/lxml-6.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:219e0431ea8006e15005767f0351e3f7f9143e793e58519dc97fe9e07fae5563", size = 4952289, upload-time = "2025-06-28T18:47:25.602Z" }, + { url = "https://files.pythonhosted.org/packages/31/c0/b25d9528df296b9a3306ba21ff982fc5b698c45ab78b94d18c2d6ae71fd9/lxml-6.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bd5913b4972681ffc9718bc2d4c53cde39ef81415e1671ff93e9aa30b46595e7", size = 5111310, upload-time = "2025-06-28T18:47:28.136Z" }, + { url = "https://files.pythonhosted.org/packages/e9/af/681a8b3e4f668bea6e6514cbcb297beb6de2b641e70f09d3d78655f4f44c/lxml-6.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:390240baeb9f415a82eefc2e13285016f9c8b5ad71ec80574ae8fa9605093cd7", size = 5025457, upload-time = "2025-06-26T16:26:15.068Z" }, + { url = "https://files.pythonhosted.org/packages/99/b6/3a7971aa05b7be7dfebc7ab57262ec527775c2c3c5b2f43675cac0458cad/lxml-6.0.0-cp312-cp312-manylinux_2_27_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d6e200909a119626744dd81bae409fc44134389e03fbf1d68ed2a55a2fb10991", size = 5657016, upload-time = "2025-07-03T19:19:06.008Z" }, + { url = "https://files.pythonhosted.org/packages/69/f8/693b1a10a891197143c0673fcce5b75fc69132afa81a36e4568c12c8faba/lxml-6.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ca50bd612438258a91b5b3788c6621c1f05c8c478e7951899f492be42defc0da", size = 5257565, upload-time = "2025-06-26T16:26:17.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/96/e08ff98f2c6426c98c8964513c5dab8d6eb81dadcd0af6f0c538ada78d33/lxml-6.0.0-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:c24b8efd9c0f62bad0439283c2c795ef916c5a6b75f03c17799775c7ae3c0c9e", size = 4713390, upload-time = "2025-06-26T16:26:20.292Z" }, + { url = "https://files.pythonhosted.org/packages/a8/83/6184aba6cc94d7413959f6f8f54807dc318fdcd4985c347fe3ea6937f772/lxml-6.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:afd27d8629ae94c5d863e32ab0e1d5590371d296b87dae0a751fb22bf3685741", size = 5066103, upload-time = "2025-06-26T16:26:22.765Z" }, + { url = "https://files.pythonhosted.org/packages/ee/01/8bf1f4035852d0ff2e36a4d9aacdbcc57e93a6cd35a54e05fa984cdf73ab/lxml-6.0.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:54c4855eabd9fc29707d30141be99e5cd1102e7d2258d2892314cf4c110726c3", size = 4791428, upload-time = "2025-06-26T16:26:26.461Z" }, + { url = "https://files.pythonhosted.org/packages/29/31/c0267d03b16954a85ed6b065116b621d37f559553d9339c7dcc4943a76f1/lxml-6.0.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c907516d49f77f6cd8ead1322198bdfd902003c3c330c77a1c5f3cc32a0e4d16", size = 5678523, upload-time = "2025-07-03T19:19:09.837Z" }, + { url = "https://files.pythonhosted.org/packages/5c/f7/5495829a864bc5f8b0798d2b52a807c89966523140f3d6fa3a58ab6720ea/lxml-6.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:36531f81c8214e293097cd2b7873f178997dae33d3667caaae8bdfb9666b76c0", size = 5281290, upload-time = "2025-06-26T16:26:29.406Z" }, + { url = "https://files.pythonhosted.org/packages/79/56/6b8edb79d9ed294ccc4e881f4db1023af56ba451909b9ce79f2a2cd7c532/lxml-6.0.0-cp312-cp312-win32.whl", hash = "sha256:690b20e3388a7ec98e899fd54c924e50ba6693874aa65ef9cb53de7f7de9d64a", size = 3613495, upload-time = "2025-06-26T16:26:31.588Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1e/cc32034b40ad6af80b6fd9b66301fc0f180f300002e5c3eb5a6110a93317/lxml-6.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:310b719b695b3dd442cdfbbe64936b2f2e231bb91d998e99e6f0daf991a3eba3", size = 4014711, upload-time = "2025-06-26T16:26:33.723Z" }, + { url = "https://files.pythonhosted.org/packages/55/10/dc8e5290ae4c94bdc1a4c55865be7e1f31dfd857a88b21cbba68b5fea61b/lxml-6.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:8cb26f51c82d77483cdcd2b4a53cda55bbee29b3c2f3ddeb47182a2a9064e4eb", size = 3674431, upload-time = "2025-06-26T16:26:35.959Z" }, ] [[package]] @@ -3020,16 +3050,16 @@ wheels = [ [[package]] name = "milvus-lite" -version = "2.4.12" +version = "2.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "tqdm" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/64/3a/110e46db650ced604f97307e48e353726cfa6d26b1bf72acb81bbf07ecbd/milvus_lite-2.4.12-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:e8d4f7cdd5f731efd6faeee3715d280fd91a5f9b4d89312664d56401f65b1473", size = 19843871, upload-time = "2025-03-21T06:20:26.141Z" }, - { url = "https://files.pythonhosted.org/packages/a5/a7/11c21f2d6f3299ad07af8142b007e4297ff12d4bdc53e1e1ba48f661954b/milvus_lite-2.4.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:20087663e7b4385050b7ad08f1f03404426d4c87b1ff91d5a8723eee7fd49e88", size = 17411635, upload-time = "2025-03-21T06:20:43.548Z" }, - { url = "https://files.pythonhosted.org/packages/a8/cc/b6f465e984439adf24da0a8ff3035d5c9ece30b6ff19f9a53f73f9ef901a/milvus_lite-2.4.12-py3-none-manylinux2014_aarch64.whl", hash = "sha256:a0f3a5ddbfd19f4a6b842b2fd3445693c796cde272b701a1646a94c1ac45d3d7", size = 35693118, upload-time = "2025-03-21T06:21:14.921Z" }, - { url = "https://files.pythonhosted.org/packages/44/43/b3f6e9defd1f3927b972beac7abe3d5b4a3bdb287e3bad69618e2e76cf0a/milvus_lite-2.4.12-py3-none-manylinux2014_x86_64.whl", hash = "sha256:334037ebbab60243b5d8b43d54ca2f835d81d48c3cda0c6a462605e588deb05d", size = 45182549, upload-time = "2025-03-21T06:21:45.425Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b2/acc5024c8e8b6a0b034670b8e8af306ebd633ede777dcbf557eac4785937/milvus_lite-2.5.1-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:6b014453200ba977be37ba660cb2d021030375fa6a35bc53c2e1d92980a0c512", size = 27934713, upload-time = "2025-06-30T04:23:37.028Z" }, + { url = "https://files.pythonhosted.org/packages/9b/2e/746f5bb1d6facd1e73eb4af6dd5efda11125b0f29d7908a097485ca6cad9/milvus_lite-2.5.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a2e031088bf308afe5f8567850412d618cfb05a65238ed1a6117f60decccc95a", size = 24421451, upload-time = "2025-06-30T04:23:51.747Z" }, + { url = "https://files.pythonhosted.org/packages/2e/cf/3d1fee5c16c7661cf53977067a34820f7269ed8ba99fe9cf35efc1700866/milvus_lite-2.5.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:a13277e9bacc6933dea172e42231f7e6135bd3bdb073dd2688ee180418abd8d9", size = 45337093, upload-time = "2025-06-30T04:24:06.706Z" }, + { url = "https://files.pythonhosted.org/packages/d3/82/41d9b80f09b82e066894d9b508af07b7b0fa325ce0322980674de49106a0/milvus_lite-2.5.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:25ce13f4b8d46876dd2b7ac8563d7d8306da7ff3999bb0d14b116b30f71d706c", size = 55263911, upload-time = "2025-06-30T04:24:19.434Z" }, ] [[package]] @@ -3139,45 +3169,47 @@ wheels = [ [[package]] name = "multidict" -version = "6.4.4" +version = "6.6.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/91/2f/a3470242707058fe856fe59241eee5635d79087100b7042a867368863a27/multidict-6.4.4.tar.gz", hash = "sha256:69ee9e6ba214b5245031b76233dd95408a0fd57fdb019ddcc1ead4790932a8e8", size = 90183, upload-time = "2025-05-19T14:16:37.381Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/2c/5dad12e82fbdf7470f29bff2171484bf07cb3b16ada60a6589af8f376440/multidict-6.6.3.tar.gz", hash = "sha256:798a9eb12dab0a6c2e29c1de6f3468af5cb2da6053a20dfa3344907eed0937cc", size = 101006, upload-time = "2025-06-30T15:53:46.929Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/1b/4c6e638195851524a63972c5773c7737bea7e47b1ba402186a37773acee2/multidict-6.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4f5f29794ac0e73d2a06ac03fd18870adc0135a9d384f4a306a951188ed02f95", size = 65515, upload-time = "2025-05-19T14:14:19.767Z" }, - { url = "https://files.pythonhosted.org/packages/25/d5/10e6bca9a44b8af3c7f920743e5fc0c2bcf8c11bf7a295d4cfe00b08fb46/multidict-6.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c04157266344158ebd57b7120d9b0b35812285d26d0e78193e17ef57bfe2979a", size = 38609, upload-time = "2025-05-19T14:14:21.538Z" }, - { url = "https://files.pythonhosted.org/packages/26/b4/91fead447ccff56247edc7f0535fbf140733ae25187a33621771ee598a18/multidict-6.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bb61ffd3ab8310d93427e460f565322c44ef12769f51f77277b4abad7b6f7223", size = 37871, upload-time = "2025-05-19T14:14:22.666Z" }, - { url = "https://files.pythonhosted.org/packages/3b/37/cbc977cae59277e99d15bbda84cc53b5e0c4929ffd91d958347200a42ad0/multidict-6.4.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e0ba18a9afd495f17c351d08ebbc4284e9c9f7971d715f196b79636a4d0de44", size = 226661, upload-time = "2025-05-19T14:14:24.124Z" }, - { url = "https://files.pythonhosted.org/packages/15/cd/7e0b57fbd4dc2fc105169c4ecce5be1a63970f23bb4ec8c721b67e11953d/multidict-6.4.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9faf1b1dcaadf9f900d23a0e6d6c8eadd6a95795a0e57fcca73acce0eb912065", size = 223422, upload-time = "2025-05-19T14:14:25.437Z" }, - { url = "https://files.pythonhosted.org/packages/f1/01/1de268da121bac9f93242e30cd3286f6a819e5f0b8896511162d6ed4bf8d/multidict-6.4.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a4d1cb1327c6082c4fce4e2a438483390964c02213bc6b8d782cf782c9b1471f", size = 235447, upload-time = "2025-05-19T14:14:26.793Z" }, - { url = "https://files.pythonhosted.org/packages/d2/8c/8b9a5e4aaaf4f2de14e86181a3a3d7b105077f668b6a06f043ec794f684c/multidict-6.4.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:941f1bec2f5dbd51feeb40aea654c2747f811ab01bdd3422a48a4e4576b7d76a", size = 231455, upload-time = "2025-05-19T14:14:28.149Z" }, - { url = "https://files.pythonhosted.org/packages/35/db/e1817dcbaa10b319c412769cf999b1016890849245d38905b73e9c286862/multidict-6.4.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5f8a146184da7ea12910a4cec51ef85e44f6268467fb489c3caf0cd512f29c2", size = 223666, upload-time = "2025-05-19T14:14:29.584Z" }, - { url = "https://files.pythonhosted.org/packages/4a/e1/66e8579290ade8a00e0126b3d9a93029033ffd84f0e697d457ed1814d0fc/multidict-6.4.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:232b7237e57ec3c09be97206bfb83a0aa1c5d7d377faa019c68a210fa35831f1", size = 217392, upload-time = "2025-05-19T14:14:30.961Z" }, - { url = "https://files.pythonhosted.org/packages/7b/6f/f8639326069c24a48c7747c2a5485d37847e142a3f741ff3340c88060a9a/multidict-6.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:55ae0721c1513e5e3210bca4fc98456b980b0c2c016679d3d723119b6b202c42", size = 228969, upload-time = "2025-05-19T14:14:32.672Z" }, - { url = "https://files.pythonhosted.org/packages/d2/c3/3d58182f76b960eeade51c89fcdce450f93379340457a328e132e2f8f9ed/multidict-6.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:51d662c072579f63137919d7bb8fc250655ce79f00c82ecf11cab678f335062e", size = 217433, upload-time = "2025-05-19T14:14:34.016Z" }, - { url = "https://files.pythonhosted.org/packages/e1/4b/f31a562906f3bd375f3d0e83ce314e4a660c01b16c2923e8229b53fba5d7/multidict-6.4.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0e05c39962baa0bb19a6b210e9b1422c35c093b651d64246b6c2e1a7e242d9fd", size = 225418, upload-time = "2025-05-19T14:14:35.376Z" }, - { url = "https://files.pythonhosted.org/packages/99/89/78bb95c89c496d64b5798434a3deee21996114d4d2c28dd65850bf3a691e/multidict-6.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5b1cc3ab8c31d9ebf0faa6e3540fb91257590da330ffe6d2393d4208e638925", size = 235042, upload-time = "2025-05-19T14:14:36.723Z" }, - { url = "https://files.pythonhosted.org/packages/74/91/8780a6e5885a8770442a8f80db86a0887c4becca0e5a2282ba2cae702bc4/multidict-6.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:93ec84488a384cd7b8a29c2c7f467137d8a73f6fe38bb810ecf29d1ade011a7c", size = 230280, upload-time = "2025-05-19T14:14:38.194Z" }, - { url = "https://files.pythonhosted.org/packages/68/c1/fcf69cabd542eb6f4b892469e033567ee6991d361d77abdc55e3a0f48349/multidict-6.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b308402608493638763abc95f9dc0030bbd6ac6aff784512e8ac3da73a88af08", size = 223322, upload-time = "2025-05-19T14:14:40.015Z" }, - { url = "https://files.pythonhosted.org/packages/b8/85/5b80bf4b83d8141bd763e1d99142a9cdfd0db83f0739b4797172a4508014/multidict-6.4.4-cp311-cp311-win32.whl", hash = "sha256:343892a27d1a04d6ae455ecece12904d242d299ada01633d94c4f431d68a8c49", size = 35070, upload-time = "2025-05-19T14:14:41.904Z" }, - { url = "https://files.pythonhosted.org/packages/09/66/0bed198ffd590ab86e001f7fa46b740d58cf8ff98c2f254e4a36bf8861ad/multidict-6.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:73484a94f55359780c0f458bbd3c39cb9cf9c182552177d2136e828269dee529", size = 38667, upload-time = "2025-05-19T14:14:43.534Z" }, - { url = "https://files.pythonhosted.org/packages/d2/b5/5675377da23d60875fe7dae6be841787755878e315e2f517235f22f59e18/multidict-6.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:dc388f75a1c00000824bf28b7633e40854f4127ede80512b44c3cfeeea1839a2", size = 64293, upload-time = "2025-05-19T14:14:44.724Z" }, - { url = "https://files.pythonhosted.org/packages/34/a7/be384a482754bb8c95d2bbe91717bf7ccce6dc38c18569997a11f95aa554/multidict-6.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:98af87593a666f739d9dba5d0ae86e01b0e1a9cfcd2e30d2d361fbbbd1a9162d", size = 38096, upload-time = "2025-05-19T14:14:45.95Z" }, - { url = "https://files.pythonhosted.org/packages/66/6d/d59854bb4352306145bdfd1704d210731c1bb2c890bfee31fb7bbc1c4c7f/multidict-6.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:aff4cafea2d120327d55eadd6b7f1136a8e5a0ecf6fb3b6863e8aca32cd8e50a", size = 37214, upload-time = "2025-05-19T14:14:47.158Z" }, - { url = "https://files.pythonhosted.org/packages/99/e0/c29d9d462d7cfc5fc8f9bf24f9c6843b40e953c0b55e04eba2ad2cf54fba/multidict-6.4.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:169c4ba7858176b797fe551d6e99040c531c775d2d57b31bcf4de6d7a669847f", size = 224686, upload-time = "2025-05-19T14:14:48.366Z" }, - { url = "https://files.pythonhosted.org/packages/dc/4a/da99398d7fd8210d9de068f9a1b5f96dfaf67d51e3f2521f17cba4ee1012/multidict-6.4.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b9eb4c59c54421a32b3273d4239865cb14ead53a606db066d7130ac80cc8ec93", size = 231061, upload-time = "2025-05-19T14:14:49.952Z" }, - { url = "https://files.pythonhosted.org/packages/21/f5/ac11add39a0f447ac89353e6ca46666847051103649831c08a2800a14455/multidict-6.4.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7cf3bd54c56aa16fdb40028d545eaa8d051402b61533c21e84046e05513d5780", size = 232412, upload-time = "2025-05-19T14:14:51.812Z" }, - { url = "https://files.pythonhosted.org/packages/d9/11/4b551e2110cded705a3c13a1d4b6a11f73891eb5a1c449f1b2b6259e58a6/multidict-6.4.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f682c42003c7264134bfe886376299db4cc0c6cd06a3295b41b347044bcb5482", size = 231563, upload-time = "2025-05-19T14:14:53.262Z" }, - { url = "https://files.pythonhosted.org/packages/4c/02/751530c19e78fe73b24c3da66618eda0aa0d7f6e7aa512e46483de6be210/multidict-6.4.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a920f9cf2abdf6e493c519492d892c362007f113c94da4c239ae88429835bad1", size = 223811, upload-time = "2025-05-19T14:14:55.232Z" }, - { url = "https://files.pythonhosted.org/packages/c7/cb/2be8a214643056289e51ca356026c7b2ce7225373e7a1f8c8715efee8988/multidict-6.4.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:530d86827a2df6504526106b4c104ba19044594f8722d3e87714e847c74a0275", size = 216524, upload-time = "2025-05-19T14:14:57.226Z" }, - { url = "https://files.pythonhosted.org/packages/19/f3/6d5011ec375c09081f5250af58de85f172bfcaafebff286d8089243c4bd4/multidict-6.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ecde56ea2439b96ed8a8d826b50c57364612ddac0438c39e473fafad7ae1c23b", size = 229012, upload-time = "2025-05-19T14:14:58.597Z" }, - { url = "https://files.pythonhosted.org/packages/67/9c/ca510785df5cf0eaf5b2a8132d7d04c1ce058dcf2c16233e596ce37a7f8e/multidict-6.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:dc8c9736d8574b560634775ac0def6bdc1661fc63fa27ffdfc7264c565bcb4f2", size = 226765, upload-time = "2025-05-19T14:15:00.048Z" }, - { url = "https://files.pythonhosted.org/packages/36/c8/ca86019994e92a0f11e642bda31265854e6ea7b235642f0477e8c2e25c1f/multidict-6.4.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7f3d3b3c34867579ea47cbd6c1f2ce23fbfd20a273b6f9e3177e256584f1eacc", size = 222888, upload-time = "2025-05-19T14:15:01.568Z" }, - { url = "https://files.pythonhosted.org/packages/c6/67/bc25a8e8bd522935379066950ec4e2277f9b236162a73548a2576d4b9587/multidict-6.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:87a728af265e08f96b6318ebe3c0f68b9335131f461efab2fc64cc84a44aa6ed", size = 234041, upload-time = "2025-05-19T14:15:03.759Z" }, - { url = "https://files.pythonhosted.org/packages/f1/a0/70c4c2d12857fccbe607b334b7ee28b6b5326c322ca8f73ee54e70d76484/multidict-6.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9f193eeda1857f8e8d3079a4abd258f42ef4a4bc87388452ed1e1c4d2b0c8740", size = 231046, upload-time = "2025-05-19T14:15:05.698Z" }, - { url = "https://files.pythonhosted.org/packages/c1/0f/52954601d02d39742aab01d6b92f53c1dd38b2392248154c50797b4df7f1/multidict-6.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be06e73c06415199200e9a2324a11252a3d62030319919cde5e6950ffeccf72e", size = 227106, upload-time = "2025-05-19T14:15:07.124Z" }, - { url = "https://files.pythonhosted.org/packages/af/24/679d83ec4379402d28721790dce818e5d6b9f94ce1323a556fb17fa9996c/multidict-6.4.4-cp312-cp312-win32.whl", hash = "sha256:622f26ea6a7e19b7c48dd9228071f571b2fbbd57a8cd71c061e848f281550e6b", size = 35351, upload-time = "2025-05-19T14:15:08.556Z" }, - { url = "https://files.pythonhosted.org/packages/52/ef/40d98bc5f986f61565f9b345f102409534e29da86a6454eb6b7c00225a13/multidict-6.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:5e2bcda30d5009996ff439e02a9f2b5c3d64a20151d34898c000a6281faa3781", size = 38791, upload-time = "2025-05-19T14:15:09.825Z" }, - { url = "https://files.pythonhosted.org/packages/84/5d/e17845bb0fa76334477d5de38654d27946d5b5d3695443987a094a71b440/multidict-6.4.4-py3-none-any.whl", hash = "sha256:bd4557071b561a8b3b6075c3ce93cf9bfb6182cb241805c3d66ced3b75eff4ac", size = 10481, upload-time = "2025-05-19T14:16:36.024Z" }, + { url = "https://files.pythonhosted.org/packages/08/f0/1a39863ced51f639c81a5463fbfa9eb4df59c20d1a8769ab9ef4ca57ae04/multidict-6.6.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:18f4eba0cbac3546b8ae31e0bbc55b02c801ae3cbaf80c247fcdd89b456ff58c", size = 76445, upload-time = "2025-06-30T15:51:24.01Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0e/a7cfa451c7b0365cd844e90b41e21fab32edaa1e42fc0c9f68461ce44ed7/multidict-6.6.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef43b5dd842382329e4797c46f10748d8c2b6e0614f46b4afe4aee9ac33159df", size = 44610, upload-time = "2025-06-30T15:51:25.158Z" }, + { url = "https://files.pythonhosted.org/packages/c6/bb/a14a4efc5ee748cc1904b0748be278c31b9295ce5f4d2ef66526f410b94d/multidict-6.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf9bd1fd5eec01494e0f2e8e446a74a85d5e49afb63d75a9934e4a5423dba21d", size = 44267, upload-time = "2025-06-30T15:51:26.326Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f8/410677d563c2d55e063ef74fe578f9d53fe6b0a51649597a5861f83ffa15/multidict-6.6.3-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5bd8d6f793a787153956cd35e24f60485bf0651c238e207b9a54f7458b16d539", size = 230004, upload-time = "2025-06-30T15:51:27.491Z" }, + { url = "https://files.pythonhosted.org/packages/fd/df/2b787f80059314a98e1ec6a4cc7576244986df3e56b3c755e6fc7c99e038/multidict-6.6.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bf99b4daf908c73856bd87ee0a2499c3c9a3d19bb04b9c6025e66af3fd07462", size = 247196, upload-time = "2025-06-30T15:51:28.762Z" }, + { url = "https://files.pythonhosted.org/packages/05/f2/f9117089151b9a8ab39f9019620d10d9718eec2ac89e7ca9d30f3ec78e96/multidict-6.6.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b9e59946b49dafaf990fd9c17ceafa62976e8471a14952163d10a7a630413a9", size = 225337, upload-time = "2025-06-30T15:51:30.025Z" }, + { url = "https://files.pythonhosted.org/packages/93/2d/7115300ec5b699faa152c56799b089a53ed69e399c3c2d528251f0aeda1a/multidict-6.6.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e2db616467070d0533832d204c54eea6836a5e628f2cb1e6dfd8cd6ba7277cb7", size = 257079, upload-time = "2025-06-30T15:51:31.716Z" }, + { url = "https://files.pythonhosted.org/packages/15/ea/ff4bab367623e39c20d3b07637225c7688d79e4f3cc1f3b9f89867677f9a/multidict-6.6.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7394888236621f61dcdd25189b2768ae5cc280f041029a5bcf1122ac63df79f9", size = 255461, upload-time = "2025-06-30T15:51:33.029Z" }, + { url = "https://files.pythonhosted.org/packages/74/07/2c9246cda322dfe08be85f1b8739646f2c4c5113a1422d7a407763422ec4/multidict-6.6.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f114d8478733ca7388e7c7e0ab34b72547476b97009d643644ac33d4d3fe1821", size = 246611, upload-time = "2025-06-30T15:51:34.47Z" }, + { url = "https://files.pythonhosted.org/packages/a8/62/279c13d584207d5697a752a66ffc9bb19355a95f7659140cb1b3cf82180e/multidict-6.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cdf22e4db76d323bcdc733514bf732e9fb349707c98d341d40ebcc6e9318ef3d", size = 243102, upload-time = "2025-06-30T15:51:36.525Z" }, + { url = "https://files.pythonhosted.org/packages/69/cc/e06636f48c6d51e724a8bc8d9e1db5f136fe1df066d7cafe37ef4000f86a/multidict-6.6.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e995a34c3d44ab511bfc11aa26869b9d66c2d8c799fa0e74b28a473a692532d6", size = 238693, upload-time = "2025-06-30T15:51:38.278Z" }, + { url = "https://files.pythonhosted.org/packages/89/a4/66c9d8fb9acf3b226cdd468ed009537ac65b520aebdc1703dd6908b19d33/multidict-6.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:766a4a5996f54361d8d5a9050140aa5362fe48ce51c755a50c0bc3706460c430", size = 246582, upload-time = "2025-06-30T15:51:39.709Z" }, + { url = "https://files.pythonhosted.org/packages/cf/01/c69e0317be556e46257826d5449feb4e6aa0d18573e567a48a2c14156f1f/multidict-6.6.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:3893a0d7d28a7fe6ca7a1f760593bc13038d1d35daf52199d431b61d2660602b", size = 253355, upload-time = "2025-06-30T15:51:41.013Z" }, + { url = "https://files.pythonhosted.org/packages/c0/da/9cc1da0299762d20e626fe0042e71b5694f9f72d7d3f9678397cbaa71b2b/multidict-6.6.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:934796c81ea996e61914ba58064920d6cad5d99140ac3167901eb932150e2e56", size = 247774, upload-time = "2025-06-30T15:51:42.291Z" }, + { url = "https://files.pythonhosted.org/packages/e6/91/b22756afec99cc31105ddd4a52f95ab32b1a4a58f4d417979c570c4a922e/multidict-6.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9ed948328aec2072bc00f05d961ceadfd3e9bfc2966c1319aeaf7b7c21219183", size = 242275, upload-time = "2025-06-30T15:51:43.642Z" }, + { url = "https://files.pythonhosted.org/packages/be/f1/adcc185b878036a20399d5be5228f3cbe7f823d78985d101d425af35c800/multidict-6.6.3-cp311-cp311-win32.whl", hash = "sha256:9f5b28c074c76afc3e4c610c488e3493976fe0e596dd3db6c8ddfbb0134dcac5", size = 41290, upload-time = "2025-06-30T15:51:45.264Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d4/27652c1c6526ea6b4f5ddd397e93f4232ff5de42bea71d339bc6a6cc497f/multidict-6.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:bc7f6fbc61b1c16050a389c630da0b32fc6d4a3d191394ab78972bf5edc568c2", size = 45942, upload-time = "2025-06-30T15:51:46.377Z" }, + { url = "https://files.pythonhosted.org/packages/16/18/23f4932019804e56d3c2413e237f866444b774b0263bcb81df2fdecaf593/multidict-6.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:d4e47d8faffaae822fb5cba20937c048d4f734f43572e7079298a6c39fb172cb", size = 42880, upload-time = "2025-06-30T15:51:47.561Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a0/6b57988ea102da0623ea814160ed78d45a2645e4bbb499c2896d12833a70/multidict-6.6.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:056bebbeda16b2e38642d75e9e5310c484b7c24e3841dc0fb943206a72ec89d6", size = 76514, upload-time = "2025-06-30T15:51:48.728Z" }, + { url = "https://files.pythonhosted.org/packages/07/7a/d1e92665b0850c6c0508f101f9cf0410c1afa24973e1115fe9c6a185ebf7/multidict-6.6.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e5f481cccb3c5c5e5de5d00b5141dc589c1047e60d07e85bbd7dea3d4580d63f", size = 45394, upload-time = "2025-06-30T15:51:49.986Z" }, + { url = "https://files.pythonhosted.org/packages/52/6f/dd104490e01be6ef8bf9573705d8572f8c2d2c561f06e3826b081d9e6591/multidict-6.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:10bea2ee839a759ee368b5a6e47787f399b41e70cf0c20d90dfaf4158dfb4e55", size = 43590, upload-time = "2025-06-30T15:51:51.331Z" }, + { url = "https://files.pythonhosted.org/packages/44/fe/06e0e01b1b0611e6581b7fd5a85b43dacc08b6cea3034f902f383b0873e5/multidict-6.6.3-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:2334cfb0fa9549d6ce2c21af2bfbcd3ac4ec3646b1b1581c88e3e2b1779ec92b", size = 237292, upload-time = "2025-06-30T15:51:52.584Z" }, + { url = "https://files.pythonhosted.org/packages/ce/71/4f0e558fb77696b89c233c1ee2d92f3e1d5459070a0e89153c9e9e804186/multidict-6.6.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8fee016722550a2276ca2cb5bb624480e0ed2bd49125b2b73b7010b9090e888", size = 258385, upload-time = "2025-06-30T15:51:53.913Z" }, + { url = "https://files.pythonhosted.org/packages/e3/25/cca0e68228addad24903801ed1ab42e21307a1b4b6dd2cf63da5d3ae082a/multidict-6.6.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5511cb35f5c50a2db21047c875eb42f308c5583edf96bd8ebf7d770a9d68f6d", size = 242328, upload-time = "2025-06-30T15:51:55.672Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a3/46f2d420d86bbcb8fe660b26a10a219871a0fbf4d43cb846a4031533f3e0/multidict-6.6.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:712b348f7f449948e0a6c4564a21c7db965af900973a67db432d724619b3c680", size = 268057, upload-time = "2025-06-30T15:51:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/9e/73/1c743542fe00794a2ec7466abd3f312ccb8fad8dff9f36d42e18fb1ec33e/multidict-6.6.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e4e15d2138ee2694e038e33b7c3da70e6b0ad8868b9f8094a72e1414aeda9c1a", size = 269341, upload-time = "2025-06-30T15:51:59.111Z" }, + { url = "https://files.pythonhosted.org/packages/a4/11/6ec9dcbe2264b92778eeb85407d1df18812248bf3506a5a1754bc035db0c/multidict-6.6.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8df25594989aebff8a130f7899fa03cbfcc5d2b5f4a461cf2518236fe6f15961", size = 256081, upload-time = "2025-06-30T15:52:00.533Z" }, + { url = "https://files.pythonhosted.org/packages/9b/2b/631b1e2afeb5f1696846d747d36cda075bfdc0bc7245d6ba5c319278d6c4/multidict-6.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:159ca68bfd284a8860f8d8112cf0521113bffd9c17568579e4d13d1f1dc76b65", size = 253581, upload-time = "2025-06-30T15:52:02.43Z" }, + { url = "https://files.pythonhosted.org/packages/bf/0e/7e3b93f79efeb6111d3bf9a1a69e555ba1d07ad1c11bceb56b7310d0d7ee/multidict-6.6.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e098c17856a8c9ade81b4810888c5ad1914099657226283cab3062c0540b0643", size = 250750, upload-time = "2025-06-30T15:52:04.26Z" }, + { url = "https://files.pythonhosted.org/packages/ad/9e/086846c1d6601948e7de556ee464a2d4c85e33883e749f46b9547d7b0704/multidict-6.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:67c92ed673049dec52d7ed39f8cf9ebbadf5032c774058b4406d18c8f8fe7063", size = 251548, upload-time = "2025-06-30T15:52:06.002Z" }, + { url = "https://files.pythonhosted.org/packages/8c/7b/86ec260118e522f1a31550e87b23542294880c97cfbf6fb18cc67b044c66/multidict-6.6.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:bd0578596e3a835ef451784053cfd327d607fc39ea1a14812139339a18a0dbc3", size = 262718, upload-time = "2025-06-30T15:52:07.707Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bd/22ce8f47abb0be04692c9fc4638508b8340987b18691aa7775d927b73f72/multidict-6.6.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:346055630a2df2115cd23ae271910b4cae40f4e336773550dca4889b12916e75", size = 259603, upload-time = "2025-06-30T15:52:09.58Z" }, + { url = "https://files.pythonhosted.org/packages/07/9c/91b7ac1691be95cd1f4a26e36a74b97cda6aa9820632d31aab4410f46ebd/multidict-6.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:555ff55a359302b79de97e0468e9ee80637b0de1fce77721639f7cd9440b3a10", size = 251351, upload-time = "2025-06-30T15:52:10.947Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5c/4d7adc739884f7a9fbe00d1eac8c034023ef8bad71f2ebe12823ca2e3649/multidict-6.6.3-cp312-cp312-win32.whl", hash = "sha256:73ab034fb8d58ff85c2bcbadc470efc3fafeea8affcf8722855fb94557f14cc5", size = 41860, upload-time = "2025-06-30T15:52:12.334Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a3/0fbc7afdf7cb1aa12a086b02959307848eb6bcc8f66fcb66c0cb57e2a2c1/multidict-6.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:04cbcce84f63b9af41bad04a54d4cc4e60e90c35b9e6ccb130be2d75b71f8c17", size = 45982, upload-time = "2025-06-30T15:52:13.6Z" }, + { url = "https://files.pythonhosted.org/packages/b8/95/8c825bd70ff9b02462dc18d1295dd08d3e9e4eb66856d292ffa62cfe1920/multidict-6.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:0f1130b896ecb52d2a1e615260f3ea2af55fa7dc3d7c3003ba0c3121a759b18b", size = 43210, upload-time = "2025-06-30T15:52:14.893Z" }, + { url = "https://files.pythonhosted.org/packages/d8/30/9aec301e9772b098c1f5c0ca0279237c9766d94b97802e9888010c64b0ed/multidict-6.6.3-py3-none-any.whl", hash = "sha256:8db10f29c7541fc5da4defd8cd697e1ca429db743fa716325f236079b96f775a", size = 12313, upload-time = "2025-06-30T15:53:45.437Z" }, ] [[package]] @@ -3208,14 +3240,14 @@ wheels = [ [[package]] name = "mypy-boto3-bedrock-runtime" -version = "1.38.4" +version = "1.39.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/55/56ce6f23d7fb98ce5b8a4261f089890bc94250666ea7089539dab55f6c25/mypy_boto3_bedrock_runtime-1.38.4.tar.gz", hash = "sha256:315a5f84c014c54e5406fdb80b030aba5cc79eb27982aff3d09ef331fb2cdd4d", size = 26169, upload-time = "2025-04-28T19:26:13.437Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/6d/65c684441a91cd16f00e442a7ebb34bba5ee335ba8bb9ec5ad8f08e71e27/mypy_boto3_bedrock_runtime-1.39.0.tar.gz", hash = "sha256:f3eb0972bd3801013470cffd9dd094ff93ddcd6fae7ca17ec5bad1e357ab8117", size = 26901, upload-time = "2025-06-30T19:34:15.089Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/43/eb/3015c6504540ca4888789ee14f47590c0340b748a33b059eeb6a48b406bb/mypy_boto3_bedrock_runtime-1.38.4-py3-none-any.whl", hash = "sha256:af14320532e9b798095129a3307f4b186ba80258917bb31410cda7f423592d72", size = 31858, upload-time = "2025-04-28T19:26:09.667Z" }, + { url = "https://files.pythonhosted.org/packages/05/92/ed01279bf155a1afe78a57d8e34f22604be66f59cb2b7c2f26e73715ced5/mypy_boto3_bedrock_runtime-1.39.0-py3-none-any.whl", hash = "sha256:2925d76b72ec77a7dc2169a0483c36567078de74cf2fcfff084e87b0e2c5ca8b", size = 32623, upload-time = "2025-06-30T19:34:13.663Z" }, ] [[package]] @@ -3322,11 +3354,11 @@ wheels = [ [[package]] name = "oauthlib" -version = "3.2.2" +version = "3.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6d/fa/fbf4001037904031639e6bfbfc02badfc7e12f137a8afa254df6c4c8a670/oauthlib-3.2.2.tar.gz", hash = "sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918", size = 177352, upload-time = "2022-10-17T20:04:27.471Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/80/cab10959dc1faead58dc8384a781dfbf93cb4d33d50988f7a69f1b7c9bbe/oauthlib-3.2.2-py3-none-any.whl", hash = "sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca", size = 151688, upload-time = "2022-10-17T20:04:24.037Z" }, + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, ] [[package]] @@ -3405,6 +3437,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/74/83/cc7c6de29b0a7585cd445258d174ca204d37729c3874ad08e515b0bf331c/opendal-0.45.20-cp311-abi3-win_amd64.whl", hash = "sha256:145efd56aa33b493d5b652c3e4f5ae5097ab69d38c132d80f108e9f5c1e4d863", size = 14929888, upload-time = "2025-05-26T07:01:46.929Z" }, ] +[[package]] +name = "openinference-instrumentation" +version = "0.1.34" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openinference-semantic-conventions" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/18/d074b45b04ba69bd03260d2dc0a034e5d586d8854e957695f40569278136/openinference_instrumentation-0.1.34.tar.gz", hash = "sha256:fa0328e8b92fc3e22e150c46f108794946ce39fe13670aed15f23ba0105f72ab", size = 22373, upload-time = "2025-06-17T16:47:22.641Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ad/1a0a5c0a755918269f71fbca225fd70759dd79dd5bffc4723e44f0d87240/openinference_instrumentation-0.1.34-py3-none-any.whl", hash = "sha256:0fff1cc6d9b86f3450fc1c88347c51c5467855992b75e7addb85bf09fd048d2d", size = 28137, upload-time = "2025-06-17T16:47:21.658Z" }, +] + +[[package]] +name = "openinference-semantic-conventions" +version = "0.1.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/0f/b794eb009846d4b10af50e205a323ca359f284563ef4d1778f35a80522ac/openinference_semantic_conventions-0.1.21.tar.gz", hash = "sha256:328405b9f79ff72a659c7712b8429c0d7ea68c6a4a1679e3eb44372aa228119b", size = 12534, upload-time = "2025-06-13T05:22:18.982Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/4d/092766f8e610f2c513e483c4adc892eea1634945022a73371fe01f621165/openinference_semantic_conventions-0.1.21-py3-none-any.whl", hash = "sha256:acde8282c20da1de900cdc0d6258a793ec3eb8031bfc496bd823dae17d32e326", size = 10167, upload-time = "2025-06-13T05:22:18.118Z" }, +] + [[package]] name = "openpyxl" version = "3.1.5" @@ -3693,7 +3748,7 @@ wheels = [ [[package]] name = "opik" -version = "1.7.34" +version = "1.7.43" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "boto3-stubs", extra = ["bedrock-runtime"] }, @@ -3712,9 +3767,9 @@ dependencies = [ { name = "tqdm" }, { name = "uuid6" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/14/c3/c11c1c5db253e6cfd36198e7c11b7383d8e90c5a2a74a177e6ced3ab8b9f/opik-1.7.34.tar.gz", hash = "sha256:c9e4de44c46e276e3abe18b48d01aa5f0826dd0578dc20361ed56ec5802d481c", size = 305179, upload-time = "2025-06-12T13:59:21.379Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/52/cea0317bc3207bc967b48932781995d9cdb2c490e7e05caa00ff660f7205/opik-1.7.43.tar.gz", hash = "sha256:0b02522b0b74d0a67b141939deda01f8bb69690eda6b04a7cecb1c7f0649ccd0", size = 326886, upload-time = "2025-07-07T10:30:07.715Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/7f/c2e06f62e541a8c32cfc570b63a8b2467f2f227d122b9ed6acbc0492ad88/opik-1.7.34-py3-none-any.whl", hash = "sha256:487530f3fc32748c666c2371ed8296a5c901631071c47fa20b578bd755b43048", size = 573502, upload-time = "2025-06-12T13:59:19.65Z" }, + { url = "https://files.pythonhosted.org/packages/76/ae/f3566bdc3c49a1a8f795b1b6e726ef211c87e31f92d870ca6d63999c9bbf/opik-1.7.43-py3-none-any.whl", hash = "sha256:a66395c8b5ea7c24846f72dafc70c74d5b8f24ffbc4c8a1b3a7f9456e550568d", size = 625356, upload-time = "2025-07-07T10:30:06.389Z" }, ] [[package]] @@ -3930,39 +3985,39 @@ wheels = [ [[package]] name = "pillow" -version = "11.2.1" +version = "11.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/cb/bb5c01fcd2a69335b86c22142b2bccfc3464087efb7fd382eee5ffc7fdf7/pillow-11.2.1.tar.gz", hash = "sha256:a64dd61998416367b7ef979b73d3a85853ba9bec4c2925f74e588879a58716b6", size = 47026707, upload-time = "2025-04-12T17:50:03.289Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/68/08/3fbf4b98924c73037a8e8b4c2c774784805e0fb4ebca6c5bb60795c40125/pillow-11.2.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:35ca289f712ccfc699508c4658a1d14652e8033e9b69839edf83cbdd0ba39e70", size = 3198450, upload-time = "2025-04-12T17:47:37.135Z" }, - { url = "https://files.pythonhosted.org/packages/84/92/6505b1af3d2849d5e714fc75ba9e69b7255c05ee42383a35a4d58f576b16/pillow-11.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0409af9f829f87a2dfb7e259f78f317a5351f2045158be321fd135973fff7bf", size = 3030550, upload-time = "2025-04-12T17:47:39.345Z" }, - { url = "https://files.pythonhosted.org/packages/3c/8c/ac2f99d2a70ff966bc7eb13dacacfaab57c0549b2ffb351b6537c7840b12/pillow-11.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4e5c5edee874dce4f653dbe59db7c73a600119fbea8d31f53423586ee2aafd7", size = 4415018, upload-time = "2025-04-12T17:47:41.128Z" }, - { url = "https://files.pythonhosted.org/packages/1f/e3/0a58b5d838687f40891fff9cbaf8669f90c96b64dc8f91f87894413856c6/pillow-11.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b93a07e76d13bff9444f1a029e0af2964e654bfc2e2c2d46bfd080df5ad5f3d8", size = 4498006, upload-time = "2025-04-12T17:47:42.912Z" }, - { url = "https://files.pythonhosted.org/packages/21/f5/6ba14718135f08fbfa33308efe027dd02b781d3f1d5c471444a395933aac/pillow-11.2.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:e6def7eed9e7fa90fde255afaf08060dc4b343bbe524a8f69bdd2a2f0018f600", size = 4517773, upload-time = "2025-04-12T17:47:44.611Z" }, - { url = "https://files.pythonhosted.org/packages/20/f2/805ad600fc59ebe4f1ba6129cd3a75fb0da126975c8579b8f57abeb61e80/pillow-11.2.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:8f4f3724c068be008c08257207210c138d5f3731af6c155a81c2b09a9eb3a788", size = 4607069, upload-time = "2025-04-12T17:47:46.46Z" }, - { url = "https://files.pythonhosted.org/packages/71/6b/4ef8a288b4bb2e0180cba13ca0a519fa27aa982875882392b65131401099/pillow-11.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a0a6709b47019dff32e678bc12c63008311b82b9327613f534e496dacaefb71e", size = 4583460, upload-time = "2025-04-12T17:47:49.255Z" }, - { url = "https://files.pythonhosted.org/packages/62/ae/f29c705a09cbc9e2a456590816e5c234382ae5d32584f451c3eb41a62062/pillow-11.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f6b0c664ccb879109ee3ca702a9272d877f4fcd21e5eb63c26422fd6e415365e", size = 4661304, upload-time = "2025-04-12T17:47:51.067Z" }, - { url = "https://files.pythonhosted.org/packages/6e/1a/c8217b6f2f73794a5e219fbad087701f412337ae6dbb956db37d69a9bc43/pillow-11.2.1-cp311-cp311-win32.whl", hash = "sha256:cc5d875d56e49f112b6def6813c4e3d3036d269c008bf8aef72cd08d20ca6df6", size = 2331809, upload-time = "2025-04-12T17:47:54.425Z" }, - { url = "https://files.pythonhosted.org/packages/e2/72/25a8f40170dc262e86e90f37cb72cb3de5e307f75bf4b02535a61afcd519/pillow-11.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:0f5c7eda47bf8e3c8a283762cab94e496ba977a420868cb819159980b6709193", size = 2676338, upload-time = "2025-04-12T17:47:56.535Z" }, - { url = "https://files.pythonhosted.org/packages/06/9e/76825e39efee61efea258b479391ca77d64dbd9e5804e4ad0fa453b4ba55/pillow-11.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:4d375eb838755f2528ac8cbc926c3e31cc49ca4ad0cf79cff48b20e30634a4a7", size = 2414918, upload-time = "2025-04-12T17:47:58.217Z" }, - { url = "https://files.pythonhosted.org/packages/c7/40/052610b15a1b8961f52537cc8326ca6a881408bc2bdad0d852edeb6ed33b/pillow-11.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:78afba22027b4accef10dbd5eed84425930ba41b3ea0a86fa8d20baaf19d807f", size = 3190185, upload-time = "2025-04-12T17:48:00.417Z" }, - { url = "https://files.pythonhosted.org/packages/e5/7e/b86dbd35a5f938632093dc40d1682874c33dcfe832558fc80ca56bfcb774/pillow-11.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78092232a4ab376a35d68c4e6d5e00dfd73454bd12b230420025fbe178ee3b0b", size = 3030306, upload-time = "2025-04-12T17:48:02.391Z" }, - { url = "https://files.pythonhosted.org/packages/a4/5c/467a161f9ed53e5eab51a42923c33051bf8d1a2af4626ac04f5166e58e0c/pillow-11.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25a5f306095c6780c52e6bbb6109624b95c5b18e40aab1c3041da3e9e0cd3e2d", size = 4416121, upload-time = "2025-04-12T17:48:04.554Z" }, - { url = "https://files.pythonhosted.org/packages/62/73/972b7742e38ae0e2ac76ab137ca6005dcf877480da0d9d61d93b613065b4/pillow-11.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c7b29dbd4281923a2bfe562acb734cee96bbb129e96e6972d315ed9f232bef4", size = 4501707, upload-time = "2025-04-12T17:48:06.831Z" }, - { url = "https://files.pythonhosted.org/packages/e4/3a/427e4cb0b9e177efbc1a84798ed20498c4f233abde003c06d2650a6d60cb/pillow-11.2.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3e645b020f3209a0181a418bffe7b4a93171eef6c4ef6cc20980b30bebf17b7d", size = 4522921, upload-time = "2025-04-12T17:48:09.229Z" }, - { url = "https://files.pythonhosted.org/packages/fe/7c/d8b1330458e4d2f3f45d9508796d7caf0c0d3764c00c823d10f6f1a3b76d/pillow-11.2.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b2dbea1012ccb784a65349f57bbc93730b96e85b42e9bf7b01ef40443db720b4", size = 4612523, upload-time = "2025-04-12T17:48:11.631Z" }, - { url = "https://files.pythonhosted.org/packages/b3/2f/65738384e0b1acf451de5a573d8153fe84103772d139e1e0bdf1596be2ea/pillow-11.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:da3104c57bbd72948d75f6a9389e6727d2ab6333c3617f0a89d72d4940aa0443", size = 4587836, upload-time = "2025-04-12T17:48:13.592Z" }, - { url = "https://files.pythonhosted.org/packages/6a/c5/e795c9f2ddf3debb2dedd0df889f2fe4b053308bb59a3cc02a0cd144d641/pillow-11.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:598174aef4589af795f66f9caab87ba4ff860ce08cd5bb447c6fc553ffee603c", size = 4669390, upload-time = "2025-04-12T17:48:15.938Z" }, - { url = "https://files.pythonhosted.org/packages/96/ae/ca0099a3995976a9fce2f423166f7bff9b12244afdc7520f6ed38911539a/pillow-11.2.1-cp312-cp312-win32.whl", hash = "sha256:1d535df14716e7f8776b9e7fee118576d65572b4aad3ed639be9e4fa88a1cad3", size = 2332309, upload-time = "2025-04-12T17:48:17.885Z" }, - { url = "https://files.pythonhosted.org/packages/7c/18/24bff2ad716257fc03da964c5e8f05d9790a779a8895d6566e493ccf0189/pillow-11.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:14e33b28bf17c7a38eede290f77db7c664e4eb01f7869e37fa98a5aa95978941", size = 2676768, upload-time = "2025-04-12T17:48:19.655Z" }, - { url = "https://files.pythonhosted.org/packages/da/bb/e8d656c9543276517ee40184aaa39dcb41e683bca121022f9323ae11b39d/pillow-11.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:21e1470ac9e5739ff880c211fc3af01e3ae505859392bf65458c224d0bf283eb", size = 2415087, upload-time = "2025-04-12T17:48:21.991Z" }, - { url = "https://files.pythonhosted.org/packages/a4/ad/2613c04633c7257d9481ab21d6b5364b59fc5d75faafd7cb8693523945a3/pillow-11.2.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:80f1df8dbe9572b4b7abdfa17eb5d78dd620b1d55d9e25f834efdbee872d3aed", size = 3181734, upload-time = "2025-04-12T17:49:46.789Z" }, - { url = "https://files.pythonhosted.org/packages/a4/fd/dcdda4471ed667de57bb5405bb42d751e6cfdd4011a12c248b455c778e03/pillow-11.2.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ea926cfbc3957090becbcbbb65ad177161a2ff2ad578b5a6ec9bb1e1cd78753c", size = 2999841, upload-time = "2025-04-12T17:49:48.812Z" }, - { url = "https://files.pythonhosted.org/packages/ac/89/8a2536e95e77432833f0db6fd72a8d310c8e4272a04461fb833eb021bf94/pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:738db0e0941ca0376804d4de6a782c005245264edaa253ffce24e5a15cbdc7bd", size = 3437470, upload-time = "2025-04-12T17:49:50.831Z" }, - { url = "https://files.pythonhosted.org/packages/9d/8f/abd47b73c60712f88e9eda32baced7bfc3e9bd6a7619bb64b93acff28c3e/pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db98ab6565c69082ec9b0d4e40dd9f6181dab0dd236d26f7a50b8b9bfbd5076", size = 3460013, upload-time = "2025-04-12T17:49:53.278Z" }, - { url = "https://files.pythonhosted.org/packages/f6/20/5c0a0aa83b213b7a07ec01e71a3d6ea2cf4ad1d2c686cc0168173b6089e7/pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:036e53f4170e270ddb8797d4c590e6dd14d28e15c7da375c18978045f7e6c37b", size = 3527165, upload-time = "2025-04-12T17:49:55.164Z" }, - { url = "https://files.pythonhosted.org/packages/58/0e/2abab98a72202d91146abc839e10c14f7cf36166f12838ea0c4db3ca6ecb/pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:14f73f7c291279bd65fda51ee87affd7c1e097709f7fdd0188957a16c264601f", size = 3571586, upload-time = "2025-04-12T17:49:57.171Z" }, - { url = "https://files.pythonhosted.org/packages/21/2c/5e05f58658cf49b6667762cca03d6e7d85cededde2caf2ab37b81f80e574/pillow-11.2.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:208653868d5c9ecc2b327f9b9ef34e0e42a4cdd172c2988fd81d62d2bc9bc044", size = 2674751, upload-time = "2025-04-12T17:49:59.628Z" }, + { url = "https://files.pythonhosted.org/packages/db/26/77f8ed17ca4ffd60e1dcd220a6ec6d71210ba398cfa33a13a1cd614c5613/pillow-11.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722", size = 5316531, upload-time = "2025-07-01T09:13:59.203Z" }, + { url = "https://files.pythonhosted.org/packages/cb/39/ee475903197ce709322a17a866892efb560f57900d9af2e55f86db51b0a5/pillow-11.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288", size = 4686560, upload-time = "2025-07-01T09:14:01.101Z" }, + { url = "https://files.pythonhosted.org/packages/d5/90/442068a160fd179938ba55ec8c97050a612426fae5ec0a764e345839f76d/pillow-11.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d", size = 5870978, upload-time = "2025-07-03T13:09:55.638Z" }, + { url = "https://files.pythonhosted.org/packages/13/92/dcdd147ab02daf405387f0218dcf792dc6dd5b14d2573d40b4caeef01059/pillow-11.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:91da1d88226663594e3f6b4b8c3c8d85bd504117d043740a8e0ec449087cc494", size = 7641168, upload-time = "2025-07-03T13:10:00.37Z" }, + { url = "https://files.pythonhosted.org/packages/6e/db/839d6ba7fd38b51af641aa904e2960e7a5644d60ec754c046b7d2aee00e5/pillow-11.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58", size = 5973053, upload-time = "2025-07-01T09:14:04.491Z" }, + { url = "https://files.pythonhosted.org/packages/f2/2f/d7675ecae6c43e9f12aa8d58b6012683b20b6edfbdac7abcb4e6af7a3784/pillow-11.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f", size = 6640273, upload-time = "2025-07-01T09:14:06.235Z" }, + { url = "https://files.pythonhosted.org/packages/45/ad/931694675ede172e15b2ff03c8144a0ddaea1d87adb72bb07655eaffb654/pillow-11.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e", size = 6082043, upload-time = "2025-07-01T09:14:07.978Z" }, + { url = "https://files.pythonhosted.org/packages/3a/04/ba8f2b11fc80d2dd462d7abec16351b45ec99cbbaea4387648a44190351a/pillow-11.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94", size = 6715516, upload-time = "2025-07-01T09:14:10.233Z" }, + { url = "https://files.pythonhosted.org/packages/48/59/8cd06d7f3944cc7d892e8533c56b0acb68399f640786313275faec1e3b6f/pillow-11.3.0-cp311-cp311-win32.whl", hash = "sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0", size = 6274768, upload-time = "2025-07-01T09:14:11.921Z" }, + { url = "https://files.pythonhosted.org/packages/f1/cc/29c0f5d64ab8eae20f3232da8f8571660aa0ab4b8f1331da5c2f5f9a938e/pillow-11.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac", size = 6986055, upload-time = "2025-07-01T09:14:13.623Z" }, + { url = "https://files.pythonhosted.org/packages/c6/df/90bd886fabd544c25addd63e5ca6932c86f2b701d5da6c7839387a076b4a/pillow-11.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd", size = 2423079, upload-time = "2025-07-01T09:14:15.268Z" }, + { url = "https://files.pythonhosted.org/packages/40/fe/1bc9b3ee13f68487a99ac9529968035cca2f0a51ec36892060edcc51d06a/pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4", size = 5278800, upload-time = "2025-07-01T09:14:17.648Z" }, + { url = "https://files.pythonhosted.org/packages/2c/32/7e2ac19b5713657384cec55f89065fb306b06af008cfd87e572035b27119/pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69", size = 4686296, upload-time = "2025-07-01T09:14:19.828Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1e/b9e12bbe6e4c2220effebc09ea0923a07a6da1e1f1bfbc8d7d29a01ce32b/pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d", size = 5871726, upload-time = "2025-07-03T13:10:04.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/33/e9200d2bd7ba00dc3ddb78df1198a6e80d7669cce6c2bdbeb2530a74ec58/pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6", size = 7644652, upload-time = "2025-07-03T13:10:10.391Z" }, + { url = "https://files.pythonhosted.org/packages/41/f1/6f2427a26fc683e00d985bc391bdd76d8dd4e92fac33d841127eb8fb2313/pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7", size = 5977787, upload-time = "2025-07-01T09:14:21.63Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024", size = 6645236, upload-time = "2025-07-01T09:14:23.321Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/848f69fb79843b3d91241bad658e9c14f39a32f71a301bcd1d139416d1be/pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809", size = 6086950, upload-time = "2025-07-01T09:14:25.237Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1a/7cff92e695a2a29ac1958c2a0fe4c0b2393b60aac13b04a4fe2735cad52d/pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d", size = 6723358, upload-time = "2025-07-01T09:14:27.053Z" }, + { url = "https://files.pythonhosted.org/packages/26/7d/73699ad77895f69edff76b0f332acc3d497f22f5d75e5360f78cbcaff248/pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149", size = 6275079, upload-time = "2025-07-01T09:14:30.104Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ce/e7dfc873bdd9828f3b6e5c2bbb74e47a98ec23cc5c74fc4e54462f0d9204/pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d", size = 6986324, upload-time = "2025-07-01T09:14:31.899Z" }, + { url = "https://files.pythonhosted.org/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542", size = 2423067, upload-time = "2025-07-01T09:14:33.709Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e3/6fa84033758276fb31da12e5fb66ad747ae83b93c67af17f8c6ff4cc8f34/pillow-11.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6", size = 5270566, upload-time = "2025-07-01T09:16:19.801Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ee/e8d2e1ab4892970b561e1ba96cbd59c0d28cf66737fc44abb2aec3795a4e/pillow-11.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438", size = 4654618, upload-time = "2025-07-01T09:16:21.818Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6d/17f80f4e1f0761f02160fc433abd4109fa1548dcfdca46cfdadaf9efa565/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3", size = 4874248, upload-time = "2025-07-03T13:11:20.738Z" }, + { url = "https://files.pythonhosted.org/packages/de/5f/c22340acd61cef960130585bbe2120e2fd8434c214802f07e8c03596b17e/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:465b9e8844e3c3519a983d58b80be3f668e2a7a5db97f2784e7079fbc9f9822c", size = 6583963, upload-time = "2025-07-03T13:11:26.283Z" }, + { url = "https://files.pythonhosted.org/packages/31/5e/03966aedfbfcbb4d5f8aa042452d3361f325b963ebbadddac05b122e47dd/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361", size = 4957170, upload-time = "2025-07-01T09:16:23.762Z" }, + { url = "https://files.pythonhosted.org/packages/cc/2d/e082982aacc927fc2cab48e1e731bdb1643a1406acace8bed0900a61464e/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7", size = 5581505, upload-time = "2025-07-01T09:16:25.593Z" }, + { url = "https://files.pythonhosted.org/packages/34/e7/ae39f538fd6844e982063c3a5e4598b8ced43b9633baa3a85ef33af8c05c/pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8", size = 6984598, upload-time = "2025-07-01T09:16:27.732Z" }, ] [[package]] @@ -4032,7 +4087,7 @@ wheels = [ [[package]] name = "posthog" -version = "5.0.0" +version = "6.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backoff" }, @@ -4040,10 +4095,11 @@ dependencies = [ { name = "python-dateutil" }, { name = "requests" }, { name = "six" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/27/68/c71abb7d3df9f52875a393c648814feb64f97a0b2b116e1146f78b0c9651/posthog-5.0.0.tar.gz", hash = "sha256:9f6a5eb650c19473b20085665f53ef762ba2a8558754eefc20843b07ac5be80e", size = 82927, upload-time = "2025-06-16T15:39:19.362Z" } +sdist = { url = "https://files.pythonhosted.org/packages/39/a2/1b68562124b0d0e615fa8431cc88c84b3db6526275c2c19a419579a49277/posthog-6.0.3.tar.gz", hash = "sha256:9005abb341af8fedd9d82ca0359b3d35a9537555cdc9881bfb469f7c0b4b0ec5", size = 91861, upload-time = "2025-07-07T07:14:08.21Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/82/d6/1123e623d3a4b657a4c670827bd81c0f65ed5a1a62f056e6c48a174cdc63/posthog-5.0.0-py3-none-any.whl", hash = "sha256:ac87c4bd1549a780045cfba1097352fc3c933b66b3be3e926915519ce1eabe44", size = 100042, upload-time = "2025-06-16T15:39:17.867Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f1/a8d86245d41c8686f7d828a4959bdf483e8ac331b249b48b8c61fc884a1c/posthog-6.0.3-py3-none-any.whl", hash = "sha256:4b808c907f3623216a9362d91fdafce8e2f57a8387fb3020475c62ec809be56d", size = 108978, upload-time = "2025-07-07T07:14:06.451Z" }, ] [[package]] @@ -4336,11 +4392,11 @@ wheels = [ [[package]] name = "pygments" -version = "2.19.1" +version = "2.19.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581, upload-time = "2025-01-06T17:26:30.443Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" }, + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] [[package]] @@ -4359,7 +4415,7 @@ crypto = [ [[package]] name = "pymilvus" -version = "2.5.11" +version = "2.5.12" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "grpcio" }, @@ -4370,9 +4426,9 @@ dependencies = [ { name = "setuptools" }, { name = "ujson" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e4/d9/3a76b1f5014a20efcfe1bb0aa46423d9cf1df5ab2ce8b1479248b943692a/pymilvus-2.5.11.tar.gz", hash = "sha256:cb1c291c659da73c58f2f5c2bd5bcbb87feb76f720afd72b9e7ace813d384c83", size = 1262466, upload-time = "2025-06-10T00:41:54.295Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/53/4af820a37163225a76656222ee43a0eb8f1bd2ceec063315680a585435da/pymilvus-2.5.12.tar.gz", hash = "sha256:79ec7dc0616c2484f77abe98bca8deafb613645b5703c492b51961afd4f985d8", size = 1265893, upload-time = "2025-07-02T15:34:00.385Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/2c/a9f2c2daff511e127616a4294e597bf4c7626d49865f62865432698c7ba9/pymilvus-2.5.11-py3-none-any.whl", hash = "sha256:20417ea0f364cd8e9d3783b432ad25c32cff8f3ceb40cdfdf54f8bbcf052cd7e", size = 228115, upload-time = "2025-06-10T00:41:52.354Z" }, + { url = "https://files.pythonhosted.org/packages/68/4f/80a4940f2772d10272c3292444af767a5aa1a5bbb631874568713ca01d54/pymilvus-2.5.12-py3-none-any.whl", hash = "sha256:ef77a4a0076469a30b05f0bb23b5a058acfbdca83d82af9574ca651764017f44", size = 231425, upload-time = "2025-07-02T15:33:58.938Z" }, ] [[package]] @@ -4433,11 +4489,11 @@ wheels = [ [[package]] name = "pypdf" -version = "5.6.0" +version = "5.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/40/46/67de1d7a65412aa1c896e6b280829b70b57d203fadae6859b690006b8e0a/pypdf-5.6.0.tar.gz", hash = "sha256:a4b6538b77fc796622000db7127e4e58039ec5e6afd292f8e9bf42e2e985a749", size = 5023749, upload-time = "2025-06-01T12:19:40.101Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/42/fbc37af367b20fa6c53da81b1780025f6046a0fac8cbf0663a17e743b033/pypdf-5.7.0.tar.gz", hash = "sha256:68c92f2e1aae878bab1150e74447f31ab3848b1c0a6f8becae9f0b1904460b6f", size = 5026120, upload-time = "2025-06-29T08:49:48.305Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/71/8b/dc3a72d98c22be7a4cbd664ad14c5a3e6295c2dbdf572865ed61e24b5e38/pypdf-5.6.0-py3-none-any.whl", hash = "sha256:ca6bf446bfb0a2d8d71d6d6bb860798d864c36a29b3d9ae8d7fc7958c59f88e7", size = 304208, upload-time = "2025-06-01T12:19:38.003Z" }, + { url = "https://files.pythonhosted.org/packages/73/9f/78d096ef795a813fa0e1cb9b33fa574b205f2b563d9c1e9366c854cf0364/pypdf-5.7.0-py3-none-any.whl", hash = "sha256:203379453439f5b68b7a1cd43cdf4c5f7a02b84810cefa7f93a47b350aaaba48", size = 305524, upload-time = "2025-06-29T08:49:46.16Z" }, ] [[package]] @@ -4551,39 +4607,39 @@ wheels = [ [[package]] name = "python-calamine" -version = "0.3.2" +version = "0.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6b/21/387b92059909e741af7837194d84250335d2a057f614752b6364aaaa2f56/python_calamine-0.3.2.tar.gz", hash = "sha256:5cf12f2086373047cdea681711857b672cba77a34a66dd3755d60686fc974e06", size = 117336, upload-time = "2025-04-02T10:06:23.14Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/03/269f96535705b2f18c8977fa58e76763b4e4727a9b3ae277a9468c8ffe05/python_calamine-0.4.0.tar.gz", hash = "sha256:94afcbae3fec36d2d7475095a59d4dc6fae45829968c743cb799ebae269d7bbf", size = 127737, upload-time = "2025-07-04T06:05:28.626Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/b7/d59863ebe319150739d0c352c6dea2710a2f90254ed32304d52e8349edce/python_calamine-0.3.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:5251746816069c38eafdd1e4eb7b83870e1fe0ff6191ce9a809b187ffba8ce93", size = 830854, upload-time = "2025-04-02T10:04:14.673Z" }, - { url = "https://files.pythonhosted.org/packages/d3/01/b48c6f2c2e530a1a031199c5c5bf35f7c2cf7f16f3989263e616e3bc86ce/python_calamine-0.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9775dbc93bc635d48f45433f8869a546cca28c2a86512581a05333f97a18337b", size = 809411, upload-time = "2025-04-02T10:04:16.067Z" }, - { url = "https://files.pythonhosted.org/packages/fe/6d/69c53ffb11b3ee1bf5bd945cc2514848adea492c879a50f38e2ed4424727/python_calamine-0.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ff4318b72ba78e8a04fb4c45342cfa23eab6f81ecdb85548cdab9f2db8ac9c7", size = 872905, upload-time = "2025-04-02T10:04:17.487Z" }, - { url = "https://files.pythonhosted.org/packages/be/ec/b02c4bc04c426d153af1f5ff07e797dd81ada6f47c170e0207d07c90b53a/python_calamine-0.3.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0cd8eb1ef8644da71788a33d3de602d1c08ff1c4136942d87e25f09580b512ef", size = 876464, upload-time = "2025-04-02T10:04:19.53Z" }, - { url = "https://files.pythonhosted.org/packages/46/ef/8403ee595207de5bd277279b56384b31390987df8a61c280b4176802481a/python_calamine-0.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9dcfd560d8f88f39d23b829f666ebae4bd8daeec7ed57adfb9313543f3c5fa35", size = 942289, upload-time = "2025-04-02T10:04:20.902Z" }, - { url = "https://files.pythonhosted.org/packages/89/97/b4e5b77c70b36613c10f2dbeece75b5d43727335a33bf5176792ec83c3fc/python_calamine-0.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5e79b9eae4b30c82d045f9952314137c7089c88274e1802947f9e3adb778a59", size = 978699, upload-time = "2025-04-02T10:04:22.263Z" }, - { url = "https://files.pythonhosted.org/packages/5f/e9/03bbafd6b11cdf70c004f2e856978fc252ec5ea7e77529f14f969134c7a8/python_calamine-0.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce5e8cc518c8e3e5988c5c658f9dcd8229f5541ca63353175bb15b6ad8c456d0", size = 886008, upload-time = "2025-04-02T10:04:23.754Z" }, - { url = "https://files.pythonhosted.org/packages/7b/20/e18f534e49b403ba0b979a4dfead146001d867f5be846b91f81ed5377972/python_calamine-0.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2a0e596b1346c28b2de15c9f86186cceefa4accb8882992aa0b7499c593446ed", size = 925104, upload-time = "2025-04-02T10:04:25.255Z" }, - { url = "https://files.pythonhosted.org/packages/54/4c/58933e69a0a7871487d10b958c1f83384bc430d53efbbfbf1dea141a0d85/python_calamine-0.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f521de16a9f3e951ec2e5e35d76752fe004088dbac4cdbf4dd62d0ad2bbf650f", size = 1050448, upload-time = "2025-04-02T10:04:26.649Z" }, - { url = "https://files.pythonhosted.org/packages/83/95/5c96d093eaaa2d15c63b43bcf8c87708eaab8428c72b6ebdcafc2604aa47/python_calamine-0.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:417d6825a36bba526ae17bed1b6ca576fbb54e23dc60c97eeb536c622e77c62f", size = 1056840, upload-time = "2025-04-02T10:04:28.18Z" }, - { url = "https://files.pythonhosted.org/packages/23/e0/b03cc3ad4f40fd3be0ebac0b71d273864ddf2bf0e611ec309328fdedded9/python_calamine-0.3.2-cp311-cp311-win32.whl", hash = "sha256:cd3ea1ca768139753633f9f0b16997648db5919894579f363d71f914f85f7ade", size = 663268, upload-time = "2025-04-02T10:04:29.659Z" }, - { url = "https://files.pythonhosted.org/packages/6b/bd/550da64770257fc70a185482f6353c0654a11f381227e146bb0170db040f/python_calamine-0.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:4560100412d8727c49048cca102eadeb004f91cfb9c99ae63cd7d4dc0a61333a", size = 692393, upload-time = "2025-04-02T10:04:31.534Z" }, - { url = "https://files.pythonhosted.org/packages/be/2e/0b4b7a146c3bb41116fe8e59a2f616340786db12aed51c7a9e75817cfa03/python_calamine-0.3.2-cp311-cp311-win_arm64.whl", hash = "sha256:a2526e6ba79087b1634f49064800339edb7316780dd7e1e86d10a0ca9de4e90f", size = 667312, upload-time = "2025-04-02T10:04:32.911Z" }, - { url = "https://files.pythonhosted.org/packages/f2/0f/c2e3e3bae774dae47cba6ffa640ff95525bd6a10a13d3cd998f33aeafc7f/python_calamine-0.3.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:7c063b1f783352d6c6792305b2b0123784882e2436b638a9b9a1e97f6d74fa51", size = 825179, upload-time = "2025-04-02T10:04:34.377Z" }, - { url = "https://files.pythonhosted.org/packages/c7/81/a05285f06d71ea38ab99b09f3119f93f575487c9d24d7a1bab65657b258b/python_calamine-0.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:85016728937e8f5d1810ff3c9603ffd2458d66e34d495202d7759fa8219871cd", size = 804036, upload-time = "2025-04-02T10:04:35.938Z" }, - { url = "https://files.pythonhosted.org/packages/24/b5/320f366ffd91ee5d5f0f77817d4fb684f62a5a68e438dcdb90e4f5f35137/python_calamine-0.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81f243323bf712bb0b2baf0b938a2e6d6c9fa3b9902a44c0654474d04f999fac", size = 871527, upload-time = "2025-04-02T10:04:38.272Z" }, - { url = "https://files.pythonhosted.org/packages/13/19/063afced19620b829697b90329c62ad73274cc38faaa91d9ee41047f5f8c/python_calamine-0.3.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0b719dd2b10237b0cfb2062e3eaf199f220918a5623197e8449f37c8de845a7c", size = 875411, upload-time = "2025-04-02T10:04:39.647Z" }, - { url = "https://files.pythonhosted.org/packages/d7/6a/c93c52414ec62cc51c4820aff434f03c4a1c69ced15cec3e4b93885e4012/python_calamine-0.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d5158310b9140e8ee8665c9541a11030901e7275eb036988150c93f01c5133bf", size = 943525, upload-time = "2025-04-02T10:04:41.025Z" }, - { url = "https://files.pythonhosted.org/packages/0a/0a/5bdecee03d235e8d111b1e8ee3ea0c0ed4ae43a402f75cebbe719930cf04/python_calamine-0.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2c1b248e8bf10194c449cb57e6ccb3f2fe3dc86975a6d746908cf2d37b048cc", size = 976332, upload-time = "2025-04-02T10:04:42.454Z" }, - { url = "https://files.pythonhosted.org/packages/05/ad/43ff92366856ee34f958e9cf4f5b98e63b0dc219e06ccba4ad6f63463756/python_calamine-0.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3a13ad8e5b6843a73933b8d1710bc4df39a9152cb57c11227ad51f47b5838a4", size = 885549, upload-time = "2025-04-02T10:04:43.869Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b9/76afb867e2bb4bfc296446b741cee01ae4ce6a094b43f4ed4eaed5189de4/python_calamine-0.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe950975a5758423c982ce1e2fdcb5c9c664d1a20b41ea21e619e5003bb4f96b", size = 926005, upload-time = "2025-04-02T10:04:45.884Z" }, - { url = "https://files.pythonhosted.org/packages/23/cf/5252b237b0e70c263f86741aea02e8e57aedb2bce9898468be1d9d55b9da/python_calamine-0.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:8707622ba816d6c26e36f1506ecda66a6a6cf43e55a43a8ef4c3bf8a805d3cfb", size = 1049380, upload-time = "2025-04-02T10:04:49.202Z" }, - { url = "https://files.pythonhosted.org/packages/1a/4d/f151e8923e53457ca49ceeaa3a34cb23afee7d7b46e6546ab2a29adc9125/python_calamine-0.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e6eac46475c26e162a037f6711b663767f61f8fca3daffeb35aa3fc7ee6267cc", size = 1056720, upload-time = "2025-04-02T10:04:51.002Z" }, - { url = "https://files.pythonhosted.org/packages/f5/cb/1b5db3e4a8bbaaaa7706b270570d4a65133618fa0ca7efafe5ce680f6cee/python_calamine-0.3.2-cp312-cp312-win32.whl", hash = "sha256:0dee82aedef3db27368a388d6741d69334c1d4d7a8087ddd33f1912166e17e37", size = 663502, upload-time = "2025-04-02T10:04:52.402Z" }, - { url = "https://files.pythonhosted.org/packages/5a/53/920fa8e7b570647c08da0f1158d781db2e318918b06cb28fe0363c3398ac/python_calamine-0.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:ae09b779718809d31ca5d722464be2776b7d79278b1da56e159bbbe11880eecf", size = 692660, upload-time = "2025-04-02T10:04:53.721Z" }, - { url = "https://files.pythonhosted.org/packages/a5/ea/5d0ecf5c345c4d78964a5f97e61848bc912965b276a54fb8ae698a9419a8/python_calamine-0.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:435546e401a5821fa70048b6c03a70db3b27d00037e2c4999c2126d8c40b51df", size = 666205, upload-time = "2025-04-02T10:04:56.377Z" }, + { url = "https://files.pythonhosted.org/packages/d4/a5/bcd82326d0ff1ab5889e7a5e13c868b483fc56398e143aae8e93149ba43b/python_calamine-0.4.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d1687f8c4d7852920c7b4e398072f183f88dd273baf5153391edc88b7454b8c0", size = 833019, upload-time = "2025-07-04T06:03:32.214Z" }, + { url = "https://files.pythonhosted.org/packages/f6/1a/a681f1d2f28164552e91ef47bcde6708098aa64a5f5fe3952f22362d340a/python_calamine-0.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:258d04230bebbbafa370a15838049d912d6a0a2c4da128943d8160ca4b6db58e", size = 812268, upload-time = "2025-07-04T06:03:33.855Z" }, + { url = "https://files.pythonhosted.org/packages/3d/92/2fc911431733739d4e7a633cefa903fa49a6b7a61e8765bad29a4a7c47b1/python_calamine-0.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c686e491634934f059553d55f77ac67ca4c235452d5b444f98fe79b3579f1ea5", size = 875733, upload-time = "2025-07-04T06:03:35.154Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/48bfae6802eb360028ca6c15e9edf42243aadd0006b6ac3e9edb41a57119/python_calamine-0.4.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4480af7babcc2f919c638a554b06b7b145d9ab3da47fd696d68c2fc6f67f9541", size = 878325, upload-time = "2025-07-04T06:03:36.638Z" }, + { url = "https://files.pythonhosted.org/packages/a4/dc/f8c956e15bac9d5d1e05cd1b907ae780e40522d2fd103c8c6e2f21dff4ed/python_calamine-0.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e405b87a8cd1e90a994e570705898634f105442029f25bab7da658ee9cbaa771", size = 1015038, upload-time = "2025-07-04T06:03:37.971Z" }, + { url = "https://files.pythonhosted.org/packages/54/3f/e69ab97c7734fb850fba2f506b775912fd59f04e17488582c8fbf52dbc72/python_calamine-0.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a831345ee42615f0dfcb0ed60a3b1601d2f946d4166edae64fd9a6f9bbd57fc1", size = 924969, upload-time = "2025-07-04T06:03:39.253Z" }, + { url = "https://files.pythonhosted.org/packages/79/03/b4c056b468908d87a3de94389166e0f4dba725a70bc39e03bc039ba96f6b/python_calamine-0.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9951b8e4cafb3e1623bb5dfc31a18d38ef43589275f9657e99dfcbe4c8c4b33e", size = 888020, upload-time = "2025-07-04T06:03:41.099Z" }, + { url = "https://files.pythonhosted.org/packages/86/4f/b9092f7c970894054083656953184e44cb2dadff8852425e950d4ca419af/python_calamine-0.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a6619fe3b5c9633ed8b178684605f8076c9d8d85b29ade15f7a7713fcfdee2d0", size = 930337, upload-time = "2025-07-04T06:03:42.89Z" }, + { url = "https://files.pythonhosted.org/packages/64/da/137239027bf253aabe7063450950085ec9abd827d0cbc5170f585f38f464/python_calamine-0.4.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:2cc45b8e76ee331f6ea88ca23677be0b7a05b502cd4423ba2c2bc8dad53af1be", size = 1054568, upload-time = "2025-07-04T06:03:44.153Z" }, + { url = "https://files.pythonhosted.org/packages/80/96/74c38bcf6b6825d5180c0e147b85be8c52dbfba11848b1e98ba358e32a64/python_calamine-0.4.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1b2cfb7ced1a7c80befa0cfddfe4aae65663eb4d63c4ae484b9b7a80ebe1b528", size = 1058317, upload-time = "2025-07-04T06:03:45.873Z" }, + { url = "https://files.pythonhosted.org/packages/33/95/9d7b8fe8b32d99a6c79534df3132cfe40e9df4a0f5204048bf5e66ddbd93/python_calamine-0.4.0-cp311-cp311-win32.whl", hash = "sha256:04f4e32ee16814fc1fafc49300be8eeb280d94878461634768b51497e1444bd6", size = 663934, upload-time = "2025-07-04T06:03:47.407Z" }, + { url = "https://files.pythonhosted.org/packages/7c/e3/1c6cd9fd499083bea6ff1c30033ee8215b9f64e862babf5be170cacae190/python_calamine-0.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:a8543f69afac2213c0257bb56215b03dadd11763064a9d6b19786f27d1bef586", size = 692535, upload-time = "2025-07-04T06:03:48.699Z" }, + { url = "https://files.pythonhosted.org/packages/94/1c/3105d19fbab6b66874ce8831652caedd73b23b72e88ce18addf8ceca8c12/python_calamine-0.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:54622e35ec7c3b6f07d119da49aa821731c185e951918f152c2dbf3bec1e15d6", size = 671751, upload-time = "2025-07-04T06:03:49.979Z" }, + { url = "https://files.pythonhosted.org/packages/63/60/f951513aaaa470b3a38a87d65eca45e0a02bc329b47864f5a17db563f746/python_calamine-0.4.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:74bca5d44a73acf3dcfa5370820797fcfd225c8c71abcddea987c5b4f5077e98", size = 826603, upload-time = "2025-07-04T06:03:51.245Z" }, + { url = "https://files.pythonhosted.org/packages/76/3f/789955bbc77831c639890758f945eb2b25d6358065edf00da6751226cf31/python_calamine-0.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cf80178f5d1b0ee2ccfffb8549c50855f6249e930664adc5807f4d0d6c2b269c", size = 805826, upload-time = "2025-07-04T06:03:52.482Z" }, + { url = "https://files.pythonhosted.org/packages/00/4c/f87d17d996f647030a40bfd124fe45fe893c002bee35ae6aca9910a923ae/python_calamine-0.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:65cfef345386ae86f7720f1be93495a40fd7e7feabb8caa1df5025d7fbc58a1f", size = 874989, upload-time = "2025-07-04T06:03:53.794Z" }, + { url = "https://files.pythonhosted.org/packages/47/d2/3269367303f6c0488cf1bfebded3f9fe968d118a988222e04c9b2636bf2e/python_calamine-0.4.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f23e6214dbf9b29065a5dcfd6a6c674dd0e251407298c9138611c907d53423ff", size = 877504, upload-time = "2025-07-04T06:03:55.095Z" }, + { url = "https://files.pythonhosted.org/packages/f9/6d/c7ac35f5c7125e8bd07eb36773f300fda20dd2da635eae78a8cebb0b6ab7/python_calamine-0.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d792d304ee232ab01598e1d3ab22e074a32c2511476b5fb4f16f4222d9c2a265", size = 1014171, upload-time = "2025-07-04T06:03:56.777Z" }, + { url = "https://files.pythonhosted.org/packages/f0/81/5ea8792a2e9ab5e2a05872db3a4d3ed3538ad5af1861282c789e2f13a8cf/python_calamine-0.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bf813425918fd68f3e991ef7c4b5015be0a1a95fc4a8ab7e73c016ef1b881bb4", size = 926737, upload-time = "2025-07-04T06:03:58.024Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6e/989e56e6f073fc0981a74ba7a393881eb351bb143e5486aa629b5e5d6a8b/python_calamine-0.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbe2a0ccb4d003635888eea83a995ff56b0748c8c76fc71923544f5a4a7d4cd7", size = 887032, upload-time = "2025-07-04T06:03:59.298Z" }, + { url = "https://files.pythonhosted.org/packages/5d/92/2c9bd64277c6fe4be695d7d5a803b38d953ec8565037486be7506642c27c/python_calamine-0.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a7b3bb5f0d910b9b03c240987560f843256626fd443279759df4e91b717826d2", size = 929700, upload-time = "2025-07-04T06:04:01.388Z" }, + { url = "https://files.pythonhosted.org/packages/64/fa/fc758ca37701d354a6bc7d63118699f1c73788a1f2e1b44d720824992764/python_calamine-0.4.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bd2c0fc2b5eabd08ceac8a2935bffa88dbc6116db971aa8c3f244bad3fd0f644", size = 1053971, upload-time = "2025-07-04T06:04:02.704Z" }, + { url = "https://files.pythonhosted.org/packages/65/52/40d7e08ae0ddba331cdc9f7fb3e92972f8f38d7afbd00228158ff6d1fceb/python_calamine-0.4.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:85b547cb1c5b692a0c2406678d666dbc1cec65a714046104683fe4f504a1721d", size = 1057057, upload-time = "2025-07-04T06:04:04.014Z" }, + { url = "https://files.pythonhosted.org/packages/16/de/e8a071c0adfda73285d891898a24f6e99338328c404f497ff5b0e6bc3d45/python_calamine-0.4.0-cp312-cp312-win32.whl", hash = "sha256:4c2a1e3a0db4d6de4587999a21cc35845648c84fba81c03dd6f3072c690888e4", size = 665540, upload-time = "2025-07-04T06:04:05.679Z" }, + { url = "https://files.pythonhosted.org/packages/5e/f2/7fdfada13f80db12356853cf08697ff4e38800a1809c2bdd26ee60962e7a/python_calamine-0.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b193c89ffcc146019475cd121c552b23348411e19c04dedf5c766a20db64399a", size = 695366, upload-time = "2025-07-04T06:04:06.977Z" }, + { url = "https://files.pythonhosted.org/packages/20/66/d37412ad854480ce32f50d9f74f2a2f88b1b8a6fbc32f70aabf3211ae89e/python_calamine-0.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:43a0f15e0b60c75a71b21a012b911d5d6f5fa052afad2a8edbc728af43af0fcf", size = 670740, upload-time = "2025-07-04T06:04:08.656Z" }, ] [[package]] @@ -4812,17 +4868,15 @@ wheels = [ [[package]] name = "realtime" -version = "2.5.0" +version = "2.5.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiohttp" }, - { name = "python-dateutil" }, { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/47/9e/d6e478ccc23869a450a5d0dd9ab0c8a4e37fee7aec43c925d89bb09fcaf5/realtime-2.5.0.tar.gz", hash = "sha256:03d744dedc823de019a7f9917c1a6509fb6e98d357adf7fd7f4015618dac7ecd", size = 18865, upload-time = "2025-05-15T12:40:14.868Z" } +sdist = { url = "https://files.pythonhosted.org/packages/48/94/3cf962b814303a1688eece56a94b25a7bd423d60705f1124cba0896c9c07/realtime-2.5.3.tar.gz", hash = "sha256:0587594f3bc1c84bf007ff625075b86db6528843e03250dc84f4f2808be3d99a", size = 18527, upload-time = "2025-06-26T22:39:01.59Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/52/09/a9ede9afa4a536ac84ad365bfa26116ab17463c8353f471b2396dc0e44d0/realtime-2.5.0-py3-none-any.whl", hash = "sha256:a54274a6cdc03c3eda61fbfec1d277e4a28e3aa9526d24b5c187385bb8a7e85a", size = 22086, upload-time = "2025-05-15T12:40:13.092Z" }, + { url = "https://files.pythonhosted.org/packages/fe/2a/f69c156a58d44b7b9ca22dab181b91e4d93d074f99923c75907bf3953d40/realtime-2.5.3-py3-none-any.whl", hash = "sha256:eb0994636946eff04c4c7f044f980c8c633c7eb632994f549f61053a474ac970", size = 21784, upload-time = "2025-06-26T22:38:59.98Z" }, ] [[package]] @@ -4975,49 +5029,49 @@ wheels = [ [[package]] name = "rpds-py" -version = "0.25.1" +version = "0.26.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/a6/60184b7fc00dd3ca80ac635dd5b8577d444c57e8e8742cecabfacb829921/rpds_py-0.25.1.tar.gz", hash = "sha256:8960b6dac09b62dac26e75d7e2c4a22efb835d827a7278c34f72b2b84fa160e3", size = 27304, upload-time = "2025-05-21T12:46:12.502Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/aa/4456d84bbb54adc6a916fb10c9b374f78ac840337644e4a5eda229c81275/rpds_py-0.26.0.tar.gz", hash = "sha256:20dae58a859b0906f0685642e591056f1e787f3a8b39c8e8749a45dc7d26bdb0", size = 27385, upload-time = "2025-07-01T15:57:13.958Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/95/e1/df13fe3ddbbea43567e07437f097863b20c99318ae1f58a0fe389f763738/rpds_py-0.25.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:5f048bbf18b1f9120685c6d6bb70cc1a52c8cc11bdd04e643d28d3be0baf666d", size = 373341, upload-time = "2025-05-21T12:43:02.978Z" }, - { url = "https://files.pythonhosted.org/packages/7a/58/deef4d30fcbcbfef3b6d82d17c64490d5c94585a2310544ce8e2d3024f83/rpds_py-0.25.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4fbb0dbba559959fcb5d0735a0f87cdbca9e95dac87982e9b95c0f8f7ad10255", size = 359111, upload-time = "2025-05-21T12:43:05.128Z" }, - { url = "https://files.pythonhosted.org/packages/bb/7e/39f1f4431b03e96ebaf159e29a0f82a77259d8f38b2dd474721eb3a8ac9b/rpds_py-0.25.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4ca54b9cf9d80b4016a67a0193ebe0bcf29f6b0a96f09db942087e294d3d4c2", size = 386112, upload-time = "2025-05-21T12:43:07.13Z" }, - { url = "https://files.pythonhosted.org/packages/db/e7/847068a48d63aec2ae695a1646089620b3b03f8ccf9f02c122ebaf778f3c/rpds_py-0.25.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ee3e26eb83d39b886d2cb6e06ea701bba82ef30a0de044d34626ede51ec98b0", size = 400362, upload-time = "2025-05-21T12:43:08.693Z" }, - { url = "https://files.pythonhosted.org/packages/3b/3d/9441d5db4343d0cee759a7ab4d67420a476cebb032081763de934719727b/rpds_py-0.25.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89706d0683c73a26f76a5315d893c051324d771196ae8b13e6ffa1ffaf5e574f", size = 522214, upload-time = "2025-05-21T12:43:10.694Z" }, - { url = "https://files.pythonhosted.org/packages/a2/ec/2cc5b30d95f9f1a432c79c7a2f65d85e52812a8f6cbf8768724571710786/rpds_py-0.25.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2013ee878c76269c7b557a9a9c042335d732e89d482606990b70a839635feb7", size = 411491, upload-time = "2025-05-21T12:43:12.739Z" }, - { url = "https://files.pythonhosted.org/packages/dc/6c/44695c1f035077a017dd472b6a3253553780837af2fac9b6ac25f6a5cb4d/rpds_py-0.25.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45e484db65e5380804afbec784522de84fa95e6bb92ef1bd3325d33d13efaebd", size = 386978, upload-time = "2025-05-21T12:43:14.25Z" }, - { url = "https://files.pythonhosted.org/packages/b1/74/b4357090bb1096db5392157b4e7ed8bb2417dc7799200fcbaee633a032c9/rpds_py-0.25.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:48d64155d02127c249695abb87d39f0faf410733428d499867606be138161d65", size = 420662, upload-time = "2025-05-21T12:43:15.8Z" }, - { url = "https://files.pythonhosted.org/packages/26/dd/8cadbebf47b96e59dfe8b35868e5c38a42272699324e95ed522da09d3a40/rpds_py-0.25.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:048893e902132fd6548a2e661fb38bf4896a89eea95ac5816cf443524a85556f", size = 563385, upload-time = "2025-05-21T12:43:17.78Z" }, - { url = "https://files.pythonhosted.org/packages/c3/ea/92960bb7f0e7a57a5ab233662f12152085c7dc0d5468534c65991a3d48c9/rpds_py-0.25.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0317177b1e8691ab5879f4f33f4b6dc55ad3b344399e23df2e499de7b10a548d", size = 592047, upload-time = "2025-05-21T12:43:19.457Z" }, - { url = "https://files.pythonhosted.org/packages/61/ad/71aabc93df0d05dabcb4b0c749277881f8e74548582d96aa1bf24379493a/rpds_py-0.25.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bffcf57826d77a4151962bf1701374e0fc87f536e56ec46f1abdd6a903354042", size = 557863, upload-time = "2025-05-21T12:43:21.69Z" }, - { url = "https://files.pythonhosted.org/packages/93/0f/89df0067c41f122b90b76f3660028a466eb287cbe38efec3ea70e637ca78/rpds_py-0.25.1-cp311-cp311-win32.whl", hash = "sha256:cda776f1967cb304816173b30994faaf2fd5bcb37e73118a47964a02c348e1bc", size = 219627, upload-time = "2025-05-21T12:43:23.311Z" }, - { url = "https://files.pythonhosted.org/packages/7c/8d/93b1a4c1baa903d0229374d9e7aa3466d751f1d65e268c52e6039c6e338e/rpds_py-0.25.1-cp311-cp311-win_amd64.whl", hash = "sha256:dc3c1ff0abc91444cd20ec643d0f805df9a3661fcacf9c95000329f3ddf268a4", size = 231603, upload-time = "2025-05-21T12:43:25.145Z" }, - { url = "https://files.pythonhosted.org/packages/cb/11/392605e5247bead2f23e6888e77229fbd714ac241ebbebb39a1e822c8815/rpds_py-0.25.1-cp311-cp311-win_arm64.whl", hash = "sha256:5a3ddb74b0985c4387719fc536faced33cadf2172769540c62e2a94b7b9be1c4", size = 223967, upload-time = "2025-05-21T12:43:26.566Z" }, - { url = "https://files.pythonhosted.org/packages/7f/81/28ab0408391b1dc57393653b6a0cf2014cc282cc2909e4615e63e58262be/rpds_py-0.25.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b5ffe453cde61f73fea9430223c81d29e2fbf412a6073951102146c84e19e34c", size = 364647, upload-time = "2025-05-21T12:43:28.559Z" }, - { url = "https://files.pythonhosted.org/packages/2c/9a/7797f04cad0d5e56310e1238434f71fc6939d0bc517192a18bb99a72a95f/rpds_py-0.25.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:115874ae5e2fdcfc16b2aedc95b5eef4aebe91b28e7e21951eda8a5dc0d3461b", size = 350454, upload-time = "2025-05-21T12:43:30.615Z" }, - { url = "https://files.pythonhosted.org/packages/69/3c/93d2ef941b04898011e5d6eaa56a1acf46a3b4c9f4b3ad1bbcbafa0bee1f/rpds_py-0.25.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a714bf6e5e81b0e570d01f56e0c89c6375101b8463999ead3a93a5d2a4af91fa", size = 389665, upload-time = "2025-05-21T12:43:32.629Z" }, - { url = "https://files.pythonhosted.org/packages/c1/57/ad0e31e928751dde8903a11102559628d24173428a0f85e25e187defb2c1/rpds_py-0.25.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:35634369325906bcd01577da4c19e3b9541a15e99f31e91a02d010816b49bfda", size = 403873, upload-time = "2025-05-21T12:43:34.576Z" }, - { url = "https://files.pythonhosted.org/packages/16/ad/c0c652fa9bba778b4f54980a02962748479dc09632e1fd34e5282cf2556c/rpds_py-0.25.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d4cb2b3ddc16710548801c6fcc0cfcdeeff9dafbc983f77265877793f2660309", size = 525866, upload-time = "2025-05-21T12:43:36.123Z" }, - { url = "https://files.pythonhosted.org/packages/2a/39/3e1839bc527e6fcf48d5fec4770070f872cdee6c6fbc9b259932f4e88a38/rpds_py-0.25.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9ceca1cf097ed77e1a51f1dbc8d174d10cb5931c188a4505ff9f3e119dfe519b", size = 416886, upload-time = "2025-05-21T12:43:38.034Z" }, - { url = "https://files.pythonhosted.org/packages/7a/95/dd6b91cd4560da41df9d7030a038298a67d24f8ca38e150562644c829c48/rpds_py-0.25.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c2cd1a4b0c2b8c5e31ffff50d09f39906fe351389ba143c195566056c13a7ea", size = 390666, upload-time = "2025-05-21T12:43:40.065Z" }, - { url = "https://files.pythonhosted.org/packages/64/48/1be88a820e7494ce0a15c2d390ccb7c52212370badabf128e6a7bb4cb802/rpds_py-0.25.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1de336a4b164c9188cb23f3703adb74a7623ab32d20090d0e9bf499a2203ad65", size = 425109, upload-time = "2025-05-21T12:43:42.263Z" }, - { url = "https://files.pythonhosted.org/packages/cf/07/3e2a17927ef6d7720b9949ec1b37d1e963b829ad0387f7af18d923d5cfa5/rpds_py-0.25.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9fca84a15333e925dd59ce01da0ffe2ffe0d6e5d29a9eeba2148916d1824948c", size = 567244, upload-time = "2025-05-21T12:43:43.846Z" }, - { url = "https://files.pythonhosted.org/packages/d2/e5/76cf010998deccc4f95305d827847e2eae9c568099c06b405cf96384762b/rpds_py-0.25.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:88ec04afe0c59fa64e2f6ea0dd9657e04fc83e38de90f6de201954b4d4eb59bd", size = 596023, upload-time = "2025-05-21T12:43:45.932Z" }, - { url = "https://files.pythonhosted.org/packages/52/9a/df55efd84403736ba37a5a6377b70aad0fd1cb469a9109ee8a1e21299a1c/rpds_py-0.25.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a8bd2f19e312ce3e1d2c635618e8a8d8132892bb746a7cf74780a489f0f6cdcb", size = 561634, upload-time = "2025-05-21T12:43:48.263Z" }, - { url = "https://files.pythonhosted.org/packages/ab/aa/dc3620dd8db84454aaf9374bd318f1aa02578bba5e567f5bf6b79492aca4/rpds_py-0.25.1-cp312-cp312-win32.whl", hash = "sha256:e5e2f7280d8d0d3ef06f3ec1b4fd598d386cc6f0721e54f09109a8132182fbfe", size = 222713, upload-time = "2025-05-21T12:43:49.897Z" }, - { url = "https://files.pythonhosted.org/packages/a3/7f/7cef485269a50ed5b4e9bae145f512d2a111ca638ae70cc101f661b4defd/rpds_py-0.25.1-cp312-cp312-win_amd64.whl", hash = "sha256:db58483f71c5db67d643857404da360dce3573031586034b7d59f245144cc192", size = 235280, upload-time = "2025-05-21T12:43:51.893Z" }, - { url = "https://files.pythonhosted.org/packages/99/f2/c2d64f6564f32af913bf5f3f7ae41c7c263c5ae4c4e8f1a17af8af66cd46/rpds_py-0.25.1-cp312-cp312-win_arm64.whl", hash = "sha256:6d50841c425d16faf3206ddbba44c21aa3310a0cebc3c1cdfc3e3f4f9f6f5728", size = 225399, upload-time = "2025-05-21T12:43:53.351Z" }, - { url = "https://files.pythonhosted.org/packages/49/74/48f3df0715a585cbf5d34919c9c757a4c92c1a9eba059f2d334e72471f70/rpds_py-0.25.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ee86d81551ec68a5c25373c5643d343150cc54672b5e9a0cafc93c1870a53954", size = 374208, upload-time = "2025-05-21T12:45:26.306Z" }, - { url = "https://files.pythonhosted.org/packages/55/b0/9b01bb11ce01ec03d05e627249cc2c06039d6aa24ea5a22a39c312167c10/rpds_py-0.25.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89c24300cd4a8e4a51e55c31a8ff3918e6651b241ee8876a42cc2b2a078533ba", size = 359262, upload-time = "2025-05-21T12:45:28.322Z" }, - { url = "https://files.pythonhosted.org/packages/a9/eb/5395621618f723ebd5116c53282052943a726dba111b49cd2071f785b665/rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:771c16060ff4e79584dc48902a91ba79fd93eade3aa3a12d6d2a4aadaf7d542b", size = 387366, upload-time = "2025-05-21T12:45:30.42Z" }, - { url = "https://files.pythonhosted.org/packages/68/73/3d51442bdb246db619d75039a50ea1cf8b5b4ee250c3e5cd5c3af5981cd4/rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:785ffacd0ee61c3e60bdfde93baa6d7c10d86f15655bd706c89da08068dc5038", size = 400759, upload-time = "2025-05-21T12:45:32.516Z" }, - { url = "https://files.pythonhosted.org/packages/b7/4c/3a32d5955d7e6cb117314597bc0f2224efc798428318b13073efe306512a/rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2a40046a529cc15cef88ac5ab589f83f739e2d332cb4d7399072242400ed68c9", size = 523128, upload-time = "2025-05-21T12:45:34.396Z" }, - { url = "https://files.pythonhosted.org/packages/be/95/1ffccd3b0bb901ae60b1dd4b1be2ab98bb4eb834cd9b15199888f5702f7b/rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:85fc223d9c76cabe5d0bff82214459189720dc135db45f9f66aa7cffbf9ff6c1", size = 411597, upload-time = "2025-05-21T12:45:36.164Z" }, - { url = "https://files.pythonhosted.org/packages/ef/6d/6e6cd310180689db8b0d2de7f7d1eabf3fb013f239e156ae0d5a1a85c27f/rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b0be9965f93c222fb9b4cc254235b3b2b215796c03ef5ee64f995b1b69af0762", size = 388053, upload-time = "2025-05-21T12:45:38.45Z" }, - { url = "https://files.pythonhosted.org/packages/4a/87/ec4186b1fe6365ced6fa470960e68fc7804bafbe7c0cf5a36237aa240efa/rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8378fa4a940f3fb509c081e06cb7f7f2adae8cf46ef258b0e0ed7519facd573e", size = 421821, upload-time = "2025-05-21T12:45:40.732Z" }, - { url = "https://files.pythonhosted.org/packages/7a/60/84f821f6bf4e0e710acc5039d91f8f594fae0d93fc368704920d8971680d/rpds_py-0.25.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:33358883a4490287e67a2c391dfaea4d9359860281db3292b6886bf0be3d8692", size = 564534, upload-time = "2025-05-21T12:45:42.672Z" }, - { url = "https://files.pythonhosted.org/packages/41/3a/bc654eb15d3b38f9330fe0f545016ba154d89cdabc6177b0295910cd0ebe/rpds_py-0.25.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1d1fadd539298e70cac2f2cb36f5b8a65f742b9b9f1014dd4ea1f7785e2470bf", size = 592674, upload-time = "2025-05-21T12:45:44.533Z" }, - { url = "https://files.pythonhosted.org/packages/2e/ba/31239736f29e4dfc7a58a45955c5db852864c306131fd6320aea214d5437/rpds_py-0.25.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:9a46c2fb2545e21181445515960006e85d22025bd2fe6db23e76daec6eb689fe", size = 558781, upload-time = "2025-05-21T12:45:46.281Z" }, + { url = "https://files.pythonhosted.org/packages/09/4c/4ee8f7e512030ff79fda1df3243c88d70fc874634e2dbe5df13ba4210078/rpds_py-0.26.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:9e8cb77286025bdb21be2941d64ac6ca016130bfdcd228739e8ab137eb4406ed", size = 372610, upload-time = "2025-07-01T15:53:58.844Z" }, + { url = "https://files.pythonhosted.org/packages/fa/9d/3dc16be00f14fc1f03c71b1d67c8df98263ab2710a2fbd65a6193214a527/rpds_py-0.26.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5e09330b21d98adc8ccb2dbb9fc6cb434e8908d4c119aeaa772cb1caab5440a0", size = 358032, upload-time = "2025-07-01T15:53:59.985Z" }, + { url = "https://files.pythonhosted.org/packages/e7/5a/7f1bf8f045da2866324a08ae80af63e64e7bfaf83bd31f865a7b91a58601/rpds_py-0.26.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c9c1b92b774b2e68d11193dc39620d62fd8ab33f0a3c77ecdabe19c179cdbc1", size = 381525, upload-time = "2025-07-01T15:54:01.162Z" }, + { url = "https://files.pythonhosted.org/packages/45/8a/04479398c755a066ace10e3d158866beb600867cacae194c50ffa783abd0/rpds_py-0.26.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:824e6d3503ab990d7090768e4dfd9e840837bae057f212ff9f4f05ec6d1975e7", size = 397089, upload-time = "2025-07-01T15:54:02.319Z" }, + { url = "https://files.pythonhosted.org/packages/72/88/9203f47268db488a1b6d469d69c12201ede776bb728b9d9f29dbfd7df406/rpds_py-0.26.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8ad7fd2258228bf288f2331f0a6148ad0186b2e3643055ed0db30990e59817a6", size = 514255, upload-time = "2025-07-01T15:54:03.38Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b4/01ce5d1e853ddf81fbbd4311ab1eff0b3cf162d559288d10fd127e2588b5/rpds_py-0.26.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0dc23bbb3e06ec1ea72d515fb572c1fea59695aefbffb106501138762e1e915e", size = 402283, upload-time = "2025-07-01T15:54:04.923Z" }, + { url = "https://files.pythonhosted.org/packages/34/a2/004c99936997bfc644d590a9defd9e9c93f8286568f9c16cdaf3e14429a7/rpds_py-0.26.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d80bf832ac7b1920ee29a426cdca335f96a2b5caa839811803e999b41ba9030d", size = 383881, upload-time = "2025-07-01T15:54:06.482Z" }, + { url = "https://files.pythonhosted.org/packages/05/1b/ef5fba4a8f81ce04c427bfd96223f92f05e6cd72291ce9d7523db3b03a6c/rpds_py-0.26.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0919f38f5542c0a87e7b4afcafab6fd2c15386632d249e9a087498571250abe3", size = 415822, upload-time = "2025-07-01T15:54:07.605Z" }, + { url = "https://files.pythonhosted.org/packages/16/80/5c54195aec456b292f7bd8aa61741c8232964063fd8a75fdde9c1e982328/rpds_py-0.26.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d422b945683e409000c888e384546dbab9009bb92f7c0b456e217988cf316107", size = 558347, upload-time = "2025-07-01T15:54:08.591Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1c/1845c1b1fd6d827187c43afe1841d91678d7241cbdb5420a4c6de180a538/rpds_py-0.26.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:77a7711fa562ba2da1aa757e11024ad6d93bad6ad7ede5afb9af144623e5f76a", size = 587956, upload-time = "2025-07-01T15:54:09.963Z" }, + { url = "https://files.pythonhosted.org/packages/2e/ff/9e979329dd131aa73a438c077252ddabd7df6d1a7ad7b9aacf6261f10faa/rpds_py-0.26.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:238e8c8610cb7c29460e37184f6799547f7e09e6a9bdbdab4e8edb90986a2318", size = 554363, upload-time = "2025-07-01T15:54:11.073Z" }, + { url = "https://files.pythonhosted.org/packages/00/8b/d78cfe034b71ffbe72873a136e71acc7a831a03e37771cfe59f33f6de8a2/rpds_py-0.26.0-cp311-cp311-win32.whl", hash = "sha256:893b022bfbdf26d7bedb083efeea624e8550ca6eb98bf7fea30211ce95b9201a", size = 220123, upload-time = "2025-07-01T15:54:12.382Z" }, + { url = "https://files.pythonhosted.org/packages/94/c1/3c8c94c7dd3905dbfde768381ce98778500a80db9924731d87ddcdb117e9/rpds_py-0.26.0-cp311-cp311-win_amd64.whl", hash = "sha256:87a5531de9f71aceb8af041d72fc4cab4943648d91875ed56d2e629bef6d4c03", size = 231732, upload-time = "2025-07-01T15:54:13.434Z" }, + { url = "https://files.pythonhosted.org/packages/67/93/e936fbed1b734eabf36ccb5d93c6a2e9246fbb13c1da011624b7286fae3e/rpds_py-0.26.0-cp311-cp311-win_arm64.whl", hash = "sha256:de2713f48c1ad57f89ac25b3cb7daed2156d8e822cf0eca9b96a6f990718cc41", size = 221917, upload-time = "2025-07-01T15:54:14.559Z" }, + { url = "https://files.pythonhosted.org/packages/ea/86/90eb87c6f87085868bd077c7a9938006eb1ce19ed4d06944a90d3560fce2/rpds_py-0.26.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:894514d47e012e794f1350f076c427d2347ebf82f9b958d554d12819849a369d", size = 363933, upload-time = "2025-07-01T15:54:15.734Z" }, + { url = "https://files.pythonhosted.org/packages/63/78/4469f24d34636242c924626082b9586f064ada0b5dbb1e9d096ee7a8e0c6/rpds_py-0.26.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc921b96fa95a097add244da36a1d9e4f3039160d1d30f1b35837bf108c21136", size = 350447, upload-time = "2025-07-01T15:54:16.922Z" }, + { url = "https://files.pythonhosted.org/packages/ad/91/c448ed45efdfdade82348d5e7995e15612754826ea640afc20915119734f/rpds_py-0.26.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e1157659470aa42a75448b6e943c895be8c70531c43cb78b9ba990778955582", size = 384711, upload-time = "2025-07-01T15:54:18.101Z" }, + { url = "https://files.pythonhosted.org/packages/ec/43/e5c86fef4be7f49828bdd4ecc8931f0287b1152c0bb0163049b3218740e7/rpds_py-0.26.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:521ccf56f45bb3a791182dc6b88ae5f8fa079dd705ee42138c76deb1238e554e", size = 400865, upload-time = "2025-07-01T15:54:19.295Z" }, + { url = "https://files.pythonhosted.org/packages/55/34/e00f726a4d44f22d5c5fe2e5ddd3ac3d7fd3f74a175607781fbdd06fe375/rpds_py-0.26.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9def736773fd56b305c0eef698be5192c77bfa30d55a0e5885f80126c4831a15", size = 517763, upload-time = "2025-07-01T15:54:20.858Z" }, + { url = "https://files.pythonhosted.org/packages/52/1c/52dc20c31b147af724b16104500fba13e60123ea0334beba7b40e33354b4/rpds_py-0.26.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdad4ea3b4513b475e027be79e5a0ceac8ee1c113a1a11e5edc3c30c29f964d8", size = 406651, upload-time = "2025-07-01T15:54:22.508Z" }, + { url = "https://files.pythonhosted.org/packages/2e/77/87d7bfabfc4e821caa35481a2ff6ae0b73e6a391bb6b343db2c91c2b9844/rpds_py-0.26.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82b165b07f416bdccf5c84546a484cc8f15137ca38325403864bfdf2b5b72f6a", size = 386079, upload-time = "2025-07-01T15:54:23.987Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d4/7f2200c2d3ee145b65b3cddc4310d51f7da6a26634f3ac87125fd789152a/rpds_py-0.26.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d04cab0a54b9dba4d278fe955a1390da3cf71f57feb78ddc7cb67cbe0bd30323", size = 421379, upload-time = "2025-07-01T15:54:25.073Z" }, + { url = "https://files.pythonhosted.org/packages/ae/13/9fdd428b9c820869924ab62236b8688b122baa22d23efdd1c566938a39ba/rpds_py-0.26.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:79061ba1a11b6a12743a2b0f72a46aa2758613d454aa6ba4f5a265cc48850158", size = 562033, upload-time = "2025-07-01T15:54:26.225Z" }, + { url = "https://files.pythonhosted.org/packages/f3/e1/b69686c3bcbe775abac3a4c1c30a164a2076d28df7926041f6c0eb5e8d28/rpds_py-0.26.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f405c93675d8d4c5ac87364bb38d06c988e11028a64b52a47158a355079661f3", size = 591639, upload-time = "2025-07-01T15:54:27.424Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c9/1e3d8c8863c84a90197ac577bbc3d796a92502124c27092413426f670990/rpds_py-0.26.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dafd4c44b74aa4bed4b250f1aed165b8ef5de743bcca3b88fc9619b6087093d2", size = 557105, upload-time = "2025-07-01T15:54:29.93Z" }, + { url = "https://files.pythonhosted.org/packages/9f/c5/90c569649057622959f6dcc40f7b516539608a414dfd54b8d77e3b201ac0/rpds_py-0.26.0-cp312-cp312-win32.whl", hash = "sha256:3da5852aad63fa0c6f836f3359647870e21ea96cf433eb393ffa45263a170d44", size = 223272, upload-time = "2025-07-01T15:54:31.128Z" }, + { url = "https://files.pythonhosted.org/packages/7d/16/19f5d9f2a556cfed454eebe4d354c38d51c20f3db69e7b4ce6cff904905d/rpds_py-0.26.0-cp312-cp312-win_amd64.whl", hash = "sha256:cf47cfdabc2194a669dcf7a8dbba62e37a04c5041d2125fae0233b720da6f05c", size = 234995, upload-time = "2025-07-01T15:54:32.195Z" }, + { url = "https://files.pythonhosted.org/packages/83/f0/7935e40b529c0e752dfaa7880224771b51175fce08b41ab4a92eb2fbdc7f/rpds_py-0.26.0-cp312-cp312-win_arm64.whl", hash = "sha256:20ab1ae4fa534f73647aad289003f1104092890849e0266271351922ed5574f8", size = 223198, upload-time = "2025-07-01T15:54:33.271Z" }, + { url = "https://files.pythonhosted.org/packages/51/f2/b5c85b758a00c513bb0389f8fc8e61eb5423050c91c958cdd21843faa3e6/rpds_py-0.26.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f61a9326f80ca59214d1cceb0a09bb2ece5b2563d4e0cd37bfd5515c28510674", size = 373505, upload-time = "2025-07-01T15:56:34.716Z" }, + { url = "https://files.pythonhosted.org/packages/23/e0/25db45e391251118e915e541995bb5f5ac5691a3b98fb233020ba53afc9b/rpds_py-0.26.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:183f857a53bcf4b1b42ef0f57ca553ab56bdd170e49d8091e96c51c3d69ca696", size = 359468, upload-time = "2025-07-01T15:56:36.219Z" }, + { url = "https://files.pythonhosted.org/packages/0b/73/dd5ee6075bb6491be3a646b301dfd814f9486d924137a5098e61f0487e16/rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:941c1cfdf4799d623cf3aa1d326a6b4fdb7a5799ee2687f3516738216d2262fb", size = 382680, upload-time = "2025-07-01T15:56:37.644Z" }, + { url = "https://files.pythonhosted.org/packages/2f/10/84b522ff58763a5c443f5bcedc1820240e454ce4e620e88520f04589e2ea/rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72a8d9564a717ee291f554eeb4bfeafe2309d5ec0aa6c475170bdab0f9ee8e88", size = 397035, upload-time = "2025-07-01T15:56:39.241Z" }, + { url = "https://files.pythonhosted.org/packages/06/ea/8667604229a10a520fcbf78b30ccc278977dcc0627beb7ea2c96b3becef0/rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:511d15193cbe013619dd05414c35a7dedf2088fcee93c6bbb7c77859765bd4e8", size = 514922, upload-time = "2025-07-01T15:56:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/24/e6/9ed5b625c0661c4882fc8cdf302bf8e96c73c40de99c31e0b95ed37d508c/rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aea1f9741b603a8d8fedb0ed5502c2bc0accbc51f43e2ad1337fe7259c2b77a5", size = 402822, upload-time = "2025-07-01T15:56:42.137Z" }, + { url = "https://files.pythonhosted.org/packages/8a/58/212c7b6fd51946047fb45d3733da27e2fa8f7384a13457c874186af691b1/rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4019a9d473c708cf2f16415688ef0b4639e07abaa569d72f74745bbeffafa2c7", size = 384336, upload-time = "2025-07-01T15:56:44.239Z" }, + { url = "https://files.pythonhosted.org/packages/aa/f5/a40ba78748ae8ebf4934d4b88e77b98497378bc2c24ba55ebe87a4e87057/rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:093d63b4b0f52d98ebae33b8c50900d3d67e0666094b1be7a12fffd7f65de74b", size = 416871, upload-time = "2025-07-01T15:56:46.284Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a6/33b1fc0c9f7dcfcfc4a4353daa6308b3ece22496ceece348b3e7a7559a09/rpds_py-0.26.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2abe21d8ba64cded53a2a677e149ceb76dcf44284202d737178afe7ba540c1eb", size = 559439, upload-time = "2025-07-01T15:56:48.549Z" }, + { url = "https://files.pythonhosted.org/packages/71/2d/ceb3f9c12f8cfa56d34995097f6cd99da1325642c60d1b6680dd9df03ed8/rpds_py-0.26.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:4feb7511c29f8442cbbc28149a92093d32e815a28aa2c50d333826ad2a20fdf0", size = 588380, upload-time = "2025-07-01T15:56:50.086Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/9de62c2150ca8e2e5858acf3f4f4d0d180a38feef9fdab4078bea63d8dba/rpds_py-0.26.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:e99685fc95d386da368013e7fb4269dd39c30d99f812a8372d62f244f662709c", size = 555334, upload-time = "2025-07-01T15:56:51.703Z" }, ] [[package]] @@ -5093,14 +5147,14 @@ wheels = [ [[package]] name = "scipy-stubs" -version = "1.15.3.0" +version = "1.16.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "optype" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/35c43bd7d412add4adcd68475702571b2489b50c40b6564f808b2355e452/scipy_stubs-1.15.3.0.tar.gz", hash = "sha256:e8f76c9887461cf9424c1e2ad78ea5dac71dd4cbb383dc85f91adfe8f74d1e17", size = 275699, upload-time = "2025-05-08T16:58:35.139Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/19/a8461383f7328300e83c34f58bf38ccc05f57c2289c0e54e2bea757de83c/scipy_stubs-1.16.0.2.tar.gz", hash = "sha256:f83aacaf2e899d044de6483e6112bf7a1942d683304077bc9e78cf6f21353acd", size = 306747, upload-time = "2025-07-01T23:19:04.513Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6c/42/cd8dc81f8060de1f14960885ad5b2d2651f41de8b93d09f3f919d6567a5a/scipy_stubs-1.15.3.0-py3-none-any.whl", hash = "sha256:a251254cf4fd6e7fb87c55c1feee92d32ddbc1f542ecdf6a0159cdb81c2fb62d", size = 459062, upload-time = "2025-05-08T16:58:33.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/30/b73418e6d3d8209fef684841d9a0e5b439d3528fa341a23b632fe47918dd/scipy_stubs-1.16.0.2-py3-none-any.whl", hash = "sha256:dc364d24a3accd1663e7576480bdb720533f94de8a05590354ff6d4a83d765c7", size = 491346, upload-time = "2025-07-01T23:19:03.222Z" }, ] [[package]] @@ -5137,38 +5191,6 @@ flask = [ { name = "markupsafe" }, ] -[[package]] -name = "setproctitle" -version = "1.3.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9e/af/56efe21c53ac81ac87e000b15e60b3d8104224b4313b6eacac3597bd183d/setproctitle-1.3.6.tar.gz", hash = "sha256:c9f32b96c700bb384f33f7cf07954bb609d35dd82752cef57fb2ee0968409169", size = 26889, upload-time = "2025-04-29T13:35:00.184Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/27/3b/8288d0cd969a63500dd62fc2c99ce6980f9909ccef0770ab1f86c361e0bf/setproctitle-1.3.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a1d856b0f4e4a33e31cdab5f50d0a14998f3a2d726a3fd5cb7c4d45a57b28d1b", size = 17412, upload-time = "2025-04-29T13:32:58.135Z" }, - { url = "https://files.pythonhosted.org/packages/39/37/43a5a3e25ca1048dbbf4db0d88d346226f5f1acd131bb8e660f4bfe2799f/setproctitle-1.3.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:50706b9c0eda55f7de18695bfeead5f28b58aa42fd5219b3b1692d554ecbc9ec", size = 11963, upload-time = "2025-04-29T13:32:59.17Z" }, - { url = "https://files.pythonhosted.org/packages/5b/47/f103c40e133154783c91a10ab08ac9fc410ed835aa85bcf7107cb882f505/setproctitle-1.3.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:af188f3305f0a65c3217c30c6d4c06891e79144076a91e8b454f14256acc7279", size = 31718, upload-time = "2025-04-29T13:33:00.36Z" }, - { url = "https://files.pythonhosted.org/packages/1f/13/7325dd1c008dd6c0ebd370ddb7505977054a87e406f142318e395031a792/setproctitle-1.3.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cce0ed8b3f64c71c140f0ec244e5fdf8ecf78ddf8d2e591d4a8b6aa1c1214235", size = 33027, upload-time = "2025-04-29T13:33:01.499Z" }, - { url = "https://files.pythonhosted.org/packages/0c/0a/6075bfea05a71379d77af98a9ac61163e8b6e5ef1ae58cd2b05871b2079c/setproctitle-1.3.6-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70100e2087fe05359f249a0b5f393127b3a1819bf34dec3a3e0d4941138650c9", size = 30223, upload-time = "2025-04-29T13:33:03.259Z" }, - { url = "https://files.pythonhosted.org/packages/cc/41/fbf57ec52f4f0776193bd94334a841f0bc9d17e745f89c7790f336420c65/setproctitle-1.3.6-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1065ed36bd03a3fd4186d6c6de5f19846650b015789f72e2dea2d77be99bdca1", size = 31204, upload-time = "2025-04-29T13:33:04.455Z" }, - { url = "https://files.pythonhosted.org/packages/97/b5/f799fb7a00de29fb0ac1dfd015528dea425b9e31a8f1068a0b3df52d317f/setproctitle-1.3.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4adf6a0013fe4e0844e3ba7583ec203ca518b9394c6cc0d3354df2bf31d1c034", size = 31181, upload-time = "2025-04-29T13:33:05.697Z" }, - { url = "https://files.pythonhosted.org/packages/b5/b7/81f101b612014ec61723436022c31146178813d6ca6b947f7b9c84e9daf4/setproctitle-1.3.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb7452849f6615871eabed6560ffedfe56bc8af31a823b6be4ce1e6ff0ab72c5", size = 30101, upload-time = "2025-04-29T13:33:07.223Z" }, - { url = "https://files.pythonhosted.org/packages/67/23/681232eed7640eab96719daa8647cc99b639e3daff5c287bd270ef179a73/setproctitle-1.3.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a094b7ce455ca341b59a0f6ce6be2e11411ba6e2860b9aa3dbb37468f23338f4", size = 32438, upload-time = "2025-04-29T13:33:08.538Z" }, - { url = "https://files.pythonhosted.org/packages/19/f8/4d075a7bdc3609ac71535b849775812455e4c40aedfbf0778a6f123b1774/setproctitle-1.3.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ad1c2c2baaba62823a7f348f469a967ece0062140ca39e7a48e4bbb1f20d54c4", size = 30625, upload-time = "2025-04-29T13:33:09.707Z" }, - { url = "https://files.pythonhosted.org/packages/5f/73/a2a8259ebee166aee1ca53eead75de0e190b3ddca4f716e5c7470ebb7ef6/setproctitle-1.3.6-cp311-cp311-win32.whl", hash = "sha256:8050c01331135f77ec99d99307bfbc6519ea24d2f92964b06f3222a804a3ff1f", size = 11488, upload-time = "2025-04-29T13:33:10.953Z" }, - { url = "https://files.pythonhosted.org/packages/c9/15/52cf5e1ff0727d53704cfdde2858eaf237ce523b0b04db65faa84ff83e13/setproctitle-1.3.6-cp311-cp311-win_amd64.whl", hash = "sha256:9b73cf0fe28009a04a35bb2522e4c5b5176cc148919431dcb73fdbdfaab15781", size = 12201, upload-time = "2025-04-29T13:33:12.389Z" }, - { url = "https://files.pythonhosted.org/packages/8f/fb/99456fd94d4207c5f6c40746a048a33a52b4239cd7d9c8d4889e2210ec82/setproctitle-1.3.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:af44bb7a1af163806bbb679eb8432fa7b4fb6d83a5d403b541b675dcd3798638", size = 17399, upload-time = "2025-04-29T13:33:13.406Z" }, - { url = "https://files.pythonhosted.org/packages/d5/48/9699191fe6062827683c43bfa9caac33a2c89f8781dd8c7253fa3dba85fd/setproctitle-1.3.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3cca16fd055316a48f0debfcbfb6af7cea715429fc31515ab3fcac05abd527d8", size = 11966, upload-time = "2025-04-29T13:33:14.976Z" }, - { url = "https://files.pythonhosted.org/packages/33/03/b085d192b9ecb9c7ce6ad6ef30ecf4110b7f39430b58a56245569827fcf4/setproctitle-1.3.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea002088d5554fd75e619742cefc78b84a212ba21632e59931b3501f0cfc8f67", size = 32017, upload-time = "2025-04-29T13:33:16.163Z" }, - { url = "https://files.pythonhosted.org/packages/ae/68/c53162e645816f97212002111420d1b2f75bf6d02632e37e961dc2cd6d8b/setproctitle-1.3.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb465dd5825356c1191a038a86ee1b8166e3562d6e8add95eec04ab484cfb8a2", size = 33419, upload-time = "2025-04-29T13:33:18.239Z" }, - { url = "https://files.pythonhosted.org/packages/ac/0d/119a45d15a816a6cf5ccc61b19729f82620095b27a47e0a6838216a95fae/setproctitle-1.3.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d2c8e20487b3b73c1fa72c56f5c89430617296cd380373e7af3a538a82d4cd6d", size = 30711, upload-time = "2025-04-29T13:33:19.571Z" }, - { url = "https://files.pythonhosted.org/packages/e3/fb/5e9b5068df9e9f31a722a775a5e8322a29a638eaaa3eac5ea7f0b35e6314/setproctitle-1.3.6-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0d6252098e98129a1decb59b46920d4eca17b0395f3d71b0d327d086fefe77d", size = 31742, upload-time = "2025-04-29T13:33:21.172Z" }, - { url = "https://files.pythonhosted.org/packages/35/88/54de1e73e8fce87d587889c7eedb48fc4ee2bbe4e4ca6331690d03024f86/setproctitle-1.3.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cf355fbf0d4275d86f9f57be705d8e5eaa7f8ddb12b24ced2ea6cbd68fdb14dc", size = 31925, upload-time = "2025-04-29T13:33:22.427Z" }, - { url = "https://files.pythonhosted.org/packages/f3/01/65948d7badd66e63e3db247b923143da142790fa293830fdecf832712c2d/setproctitle-1.3.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e288f8a162d663916060beb5e8165a8551312b08efee9cf68302687471a6545d", size = 30981, upload-time = "2025-04-29T13:33:23.739Z" }, - { url = "https://files.pythonhosted.org/packages/22/20/c495e61786f1d38d5dc340b9d9077fee9be3dfc7e89f515afe12e1526dbc/setproctitle-1.3.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b2e54f4a2dc6edf0f5ea5b1d0a608d2af3dcb5aa8c8eeab9c8841b23e1b054fe", size = 33209, upload-time = "2025-04-29T13:33:24.915Z" }, - { url = "https://files.pythonhosted.org/packages/98/3f/a457b8550fbd34d5b482fe20b8376b529e76bf1fbf9a474a6d9a641ab4ad/setproctitle-1.3.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b6f4abde9a2946f57e8daaf1160b2351bcf64274ef539e6675c1d945dbd75e2a", size = 31587, upload-time = "2025-04-29T13:33:26.123Z" }, - { url = "https://files.pythonhosted.org/packages/44/fe/743517340e5a635e3f1c4310baea20c16c66202f96a6f4cead222ffd6d84/setproctitle-1.3.6-cp312-cp312-win32.whl", hash = "sha256:db608db98ccc21248370d30044a60843b3f0f3d34781ceeea67067c508cd5a28", size = 11487, upload-time = "2025-04-29T13:33:27.403Z" }, - { url = "https://files.pythonhosted.org/packages/60/9a/d88f1c1f0f4efff1bd29d9233583ee341114dda7d9613941453984849674/setproctitle-1.3.6-cp312-cp312-win_amd64.whl", hash = "sha256:082413db8a96b1f021088e8ec23f0a61fec352e649aba20881895815388b66d3", size = 12208, upload-time = "2025-04-29T13:33:28.852Z" }, -] - [[package]] name = "setuptools" version = "80.9.0" @@ -5297,6 +5319,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1c/fc/9ba22f01b5cdacc8f5ed0d22304718d2c758fce3fd49a5372b886a86f37c/sqlalchemy-2.0.41-py3-none-any.whl", hash = "sha256:57df5dc6fdb5ed1a88a1ed2195fd31927e705cad62dedd86b46972752a80f576", size = 1911224, upload-time = "2025-05-14T17:39:42.154Z" }, ] +[[package]] +name = "sseclient-py" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/ed/3df5ab8bb0c12f86c28d0cadb11ed1de44a92ed35ce7ff4fd5518a809325/sseclient-py-1.8.0.tar.gz", hash = "sha256:c547c5c1a7633230a38dc599a21a2dc638f9b5c297286b48b46b935c71fac3e8", size = 7791, upload-time = "2023-09-01T19:39:20.45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/58/97655efdfeb5b4eeab85b1fc5d3fa1023661246c2ab2a26ea8e47402d4f2/sseclient_py-1.8.0-py2.py3-none-any.whl", hash = "sha256:4ecca6dc0b9f963f8384e9d7fd529bf93dd7d708144c4fb5da0e0a1a926fee83", size = 8828, upload-time = "2023-09-01T19:39:17.627Z" }, +] + [[package]] name = "starlette" version = "0.41.0" @@ -5475,27 +5506,27 @@ wheels = [ [[package]] name = "tokenizers" -version = "0.21.1" +version = "0.21.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "huggingface-hub" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/92/76/5ac0c97f1117b91b7eb7323dcd61af80d72f790b4df71249a7850c195f30/tokenizers-0.21.1.tar.gz", hash = "sha256:a1bb04dc5b448985f86ecd4b05407f5a8d97cb2c0532199b2a302a604a0165ab", size = 343256, upload-time = "2025-03-13T10:51:18.189Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ab/2d/b0fce2b8201635f60e8c95990080f58461cc9ca3d5026de2e900f38a7f21/tokenizers-0.21.2.tar.gz", hash = "sha256:fdc7cffde3e2113ba0e6cc7318c40e3438a4d74bbc62bf04bcc63bdfb082ac77", size = 351545, upload-time = "2025-06-24T10:24:52.449Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/1f/328aee25f9115bf04262e8b4e5a2050b7b7cf44b59c74e982db7270c7f30/tokenizers-0.21.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e78e413e9e668ad790a29456e677d9d3aa50a9ad311a40905d6861ba7692cf41", size = 2780767, upload-time = "2025-03-13T10:51:09.459Z" }, - { url = "https://files.pythonhosted.org/packages/ae/1a/4526797f3719b0287853f12c5ad563a9be09d446c44ac784cdd7c50f76ab/tokenizers-0.21.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:cd51cd0a91ecc801633829fcd1fda9cf8682ed3477c6243b9a095539de4aecf3", size = 2650555, upload-time = "2025-03-13T10:51:07.692Z" }, - { url = "https://files.pythonhosted.org/packages/4d/7a/a209b29f971a9fdc1da86f917fe4524564924db50d13f0724feed37b2a4d/tokenizers-0.21.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28da6b72d4fb14ee200a1bd386ff74ade8992d7f725f2bde2c495a9a98cf4d9f", size = 2937541, upload-time = "2025-03-13T10:50:56.679Z" }, - { url = "https://files.pythonhosted.org/packages/3c/1e/b788b50ffc6191e0b1fc2b0d49df8cff16fe415302e5ceb89f619d12c5bc/tokenizers-0.21.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:34d8cfde551c9916cb92014e040806122295a6800914bab5865deb85623931cf", size = 2819058, upload-time = "2025-03-13T10:50:59.525Z" }, - { url = "https://files.pythonhosted.org/packages/36/aa/3626dfa09a0ecc5b57a8c58eeaeb7dd7ca9a37ad9dd681edab5acd55764c/tokenizers-0.21.1-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aaa852d23e125b73d283c98f007e06d4595732104b65402f46e8ef24b588d9f8", size = 3133278, upload-time = "2025-03-13T10:51:04.678Z" }, - { url = "https://files.pythonhosted.org/packages/a4/4d/8fbc203838b3d26269f944a89459d94c858f5b3f9a9b6ee9728cdcf69161/tokenizers-0.21.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a21a15d5c8e603331b8a59548bbe113564136dc0f5ad8306dd5033459a226da0", size = 3144253, upload-time = "2025-03-13T10:51:01.261Z" }, - { url = "https://files.pythonhosted.org/packages/d8/1b/2bd062adeb7c7511b847b32e356024980c0ffcf35f28947792c2d8ad2288/tokenizers-0.21.1-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2fdbd4c067c60a0ac7eca14b6bd18a5bebace54eb757c706b47ea93204f7a37c", size = 3398225, upload-time = "2025-03-13T10:51:03.243Z" }, - { url = "https://files.pythonhosted.org/packages/8a/63/38be071b0c8e06840bc6046991636bcb30c27f6bb1e670f4f4bc87cf49cc/tokenizers-0.21.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2dd9a0061e403546f7377df940e866c3e678d7d4e9643d0461ea442b4f89e61a", size = 3038874, upload-time = "2025-03-13T10:51:06.235Z" }, - { url = "https://files.pythonhosted.org/packages/ec/83/afa94193c09246417c23a3c75a8a0a96bf44ab5630a3015538d0c316dd4b/tokenizers-0.21.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:db9484aeb2e200c43b915a1a0150ea885e35f357a5a8fabf7373af333dcc8dbf", size = 9014448, upload-time = "2025-03-13T10:51:10.927Z" }, - { url = "https://files.pythonhosted.org/packages/ae/b3/0e1a37d4f84c0f014d43701c11eb8072704f6efe8d8fc2dcdb79c47d76de/tokenizers-0.21.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:ed248ab5279e601a30a4d67bdb897ecbe955a50f1e7bb62bd99f07dd11c2f5b6", size = 8937877, upload-time = "2025-03-13T10:51:12.688Z" }, - { url = "https://files.pythonhosted.org/packages/ac/33/ff08f50e6d615eb180a4a328c65907feb6ded0b8f990ec923969759dc379/tokenizers-0.21.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:9ac78b12e541d4ce67b4dfd970e44c060a2147b9b2a21f509566d556a509c67d", size = 9186645, upload-time = "2025-03-13T10:51:14.723Z" }, - { url = "https://files.pythonhosted.org/packages/5f/aa/8ae85f69a9f6012c6f8011c6f4aa1c96154c816e9eea2e1b758601157833/tokenizers-0.21.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e5a69c1a4496b81a5ee5d2c1f3f7fbdf95e90a0196101b0ee89ed9956b8a168f", size = 9384380, upload-time = "2025-03-13T10:51:16.526Z" }, - { url = "https://files.pythonhosted.org/packages/e8/5b/a5d98c89f747455e8b7a9504910c865d5e51da55e825a7ae641fb5ff0a58/tokenizers-0.21.1-cp39-abi3-win32.whl", hash = "sha256:1039a3a5734944e09de1d48761ade94e00d0fa760c0e0551151d4dd851ba63e3", size = 2239506, upload-time = "2025-03-13T10:51:20.643Z" }, - { url = "https://files.pythonhosted.org/packages/e6/b6/072a8e053ae600dcc2ac0da81a23548e3b523301a442a6ca900e92ac35be/tokenizers-0.21.1-cp39-abi3-win_amd64.whl", hash = "sha256:0f0dcbcc9f6e13e675a66d7a5f2f225a736745ce484c1a4e07476a89ccdad382", size = 2435481, upload-time = "2025-03-13T10:51:19.243Z" }, + { url = "https://files.pythonhosted.org/packages/1d/cc/2936e2d45ceb130a21d929743f1e9897514691bec123203e10837972296f/tokenizers-0.21.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:342b5dfb75009f2255ab8dec0041287260fed5ce00c323eb6bab639066fef8ec", size = 2875206, upload-time = "2025-06-24T10:24:42.755Z" }, + { url = "https://files.pythonhosted.org/packages/6c/e6/33f41f2cc7861faeba8988e7a77601407bf1d9d28fc79c5903f8f77df587/tokenizers-0.21.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:126df3205d6f3a93fea80c7a8a266a78c1bd8dd2fe043386bafdd7736a23e45f", size = 2732655, upload-time = "2025-06-24T10:24:41.56Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1791eb329c07122a75b01035b1a3aa22ad139f3ce0ece1b059b506d9d9de/tokenizers-0.21.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a32cd81be21168bd0d6a0f0962d60177c447a1aa1b1e48fa6ec9fc728ee0b12", size = 3019202, upload-time = "2025-06-24T10:24:31.791Z" }, + { url = "https://files.pythonhosted.org/packages/05/15/fd2d8104faa9f86ac68748e6f7ece0b5eb7983c7efc3a2c197cb98c99030/tokenizers-0.21.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8bd8999538c405133c2ab999b83b17c08b7fc1b48c1ada2469964605a709ef91", size = 2934539, upload-time = "2025-06-24T10:24:34.567Z" }, + { url = "https://files.pythonhosted.org/packages/a5/2e/53e8fd053e1f3ffbe579ca5f9546f35ac67cf0039ed357ad7ec57f5f5af0/tokenizers-0.21.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5e9944e61239b083a41cf8fc42802f855e1dca0f499196df37a8ce219abac6eb", size = 3248665, upload-time = "2025-06-24T10:24:39.024Z" }, + { url = "https://files.pythonhosted.org/packages/00/15/79713359f4037aa8f4d1f06ffca35312ac83629da062670e8830917e2153/tokenizers-0.21.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:514cd43045c5d546f01142ff9c79a96ea69e4b5cda09e3027708cb2e6d5762ab", size = 3451305, upload-time = "2025-06-24T10:24:36.133Z" }, + { url = "https://files.pythonhosted.org/packages/38/5f/959f3a8756fc9396aeb704292777b84f02a5c6f25c3fc3ba7530db5feb2c/tokenizers-0.21.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b1b9405822527ec1e0f7d8d2fdb287a5730c3a6518189c968254a8441b21faae", size = 3214757, upload-time = "2025-06-24T10:24:37.784Z" }, + { url = "https://files.pythonhosted.org/packages/c5/74/f41a432a0733f61f3d21b288de6dfa78f7acff309c6f0f323b2833e9189f/tokenizers-0.21.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fed9a4d51c395103ad24f8e7eb976811c57fbec2af9f133df471afcd922e5020", size = 3121887, upload-time = "2025-06-24T10:24:40.293Z" }, + { url = "https://files.pythonhosted.org/packages/3c/6a/bc220a11a17e5d07b0dfb3b5c628621d4dcc084bccd27cfaead659963016/tokenizers-0.21.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c41862df3d873665ec78b6be36fcc30a26e3d4902e9dd8608ed61d49a48bc19", size = 9091965, upload-time = "2025-06-24T10:24:44.431Z" }, + { url = "https://files.pythonhosted.org/packages/6c/bd/ac386d79c4ef20dc6f39c4706640c24823dca7ebb6f703bfe6b5f0292d88/tokenizers-0.21.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:ed21dc7e624e4220e21758b2e62893be7101453525e3d23264081c9ef9a6d00d", size = 9053372, upload-time = "2025-06-24T10:24:46.455Z" }, + { url = "https://files.pythonhosted.org/packages/63/7b/5440bf203b2a5358f074408f7f9c42884849cd9972879e10ee6b7a8c3b3d/tokenizers-0.21.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:0e73770507e65a0e0e2a1affd6b03c36e3bc4377bd10c9ccf51a82c77c0fe365", size = 9298632, upload-time = "2025-06-24T10:24:48.446Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d2/faa1acac3f96a7427866e94ed4289949b2524f0c1878512516567d80563c/tokenizers-0.21.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:106746e8aa9014a12109e58d540ad5465b4c183768ea96c03cbc24c44d329958", size = 9470074, upload-time = "2025-06-24T10:24:50.378Z" }, + { url = "https://files.pythonhosted.org/packages/d8/a5/896e1ef0707212745ae9f37e84c7d50269411aef2e9ccd0de63623feecdf/tokenizers-0.21.2-cp39-abi3-win32.whl", hash = "sha256:cabda5a6d15d620b6dfe711e1af52205266d05b379ea85a8a301b3593c60e962", size = 2330115, upload-time = "2025-06-24T10:24:55.069Z" }, + { url = "https://files.pythonhosted.org/packages/13/c3/cc2755ee10be859c4338c962a35b9a663788c0c0b50c0bdd8078fb6870cf/tokenizers-0.21.2-cp39-abi3-win_amd64.whl", hash = "sha256:58747bb898acdb1007f37a7bbe614346e98dc28708ffb66a3fd50ce169ac6c98", size = 2509918, upload-time = "2025-06-24T10:24:53.71Z" }, ] [[package]] @@ -5599,20 +5630,20 @@ wheels = [ [[package]] name = "types-aiofiles" -version = "24.1.0.20250606" +version = "24.1.0.20250708" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/64/6e/fac4ffc896cb3faf2ac5d23747b65dd8bae1d9ee23305d1a3b12111c3989/types_aiofiles-24.1.0.20250606.tar.gz", hash = "sha256:48f9e26d2738a21e0b0f19381f713dcdb852a36727da8414b1ada145d40a18fe", size = 14364, upload-time = "2025-06-06T03:09:26.515Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/d6/5c44761bc11cb5c7505013a39f397a9016bfb3a5c932032b2db16c38b87b/types_aiofiles-24.1.0.20250708.tar.gz", hash = "sha256:c8207ed7385491ce5ba94da02658164ebd66b69a44e892288c9f20cbbf5284ff", size = 14322, upload-time = "2025-07-08T03:14:44.814Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/71/de/f2fa2ab8a5943898e93d8036941e05bfd1e1f377a675ee52c7c307dccb75/types_aiofiles-24.1.0.20250606-py3-none-any.whl", hash = "sha256:e568c53fb9017c80897a9aa15c74bf43b7ee90e412286ec1e0912b6e79301aee", size = 14276, upload-time = "2025-06-06T03:09:25.662Z" }, + { url = "https://files.pythonhosted.org/packages/44/e9/4e0cc79c630040aae0634ac9393341dc2aff1a5be454be9741cc6cc8989f/types_aiofiles-24.1.0.20250708-py3-none-any.whl", hash = "sha256:07f8f06465fd415d9293467d1c66cd074b2c3b62b679e26e353e560a8cf63720", size = 14320, upload-time = "2025-07-08T03:14:44.009Z" }, ] [[package]] name = "types-awscrt" -version = "0.27.2" +version = "0.27.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/36/6c/583522cfb3c330e92e726af517a91c13247e555e021791a60f1b03c6ff16/types_awscrt-0.27.2.tar.gz", hash = "sha256:acd04f57119eb15626ab0ba9157fc24672421de56e7bd7b9f61681fedee44e91", size = 16304, upload-time = "2025-05-16T03:10:08.712Z" } +sdist = { url = "https://files.pythonhosted.org/packages/94/95/02564024f8668feab6733a2c491005b5281b048b3d0573510622cbcd9fd4/types_awscrt-0.27.4.tar.gz", hash = "sha256:c019ba91a097e8a31d6948f6176ede1312963f41cdcacf82482ac877cbbcf390", size = 16941, upload-time = "2025-06-29T22:58:04.756Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4c/82/1ee2e5c9d28deac086ab3a6ff07c8bc393ef013a083f546c623699881715/types_awscrt-0.27.2-py3-none-any.whl", hash = "sha256:49a045f25bbd5ad2865f314512afced933aed35ddbafc252e2268efa8a787e4e", size = 37761, upload-time = "2025-05-16T03:10:07.466Z" }, + { url = "https://files.pythonhosted.org/packages/d4/40/cb4d04df4ac3520858f5b397a4ab89f34be2601000002a26edd8ddc0cac5/types_awscrt-0.27.4-py3-none-any.whl", hash = "sha256:a8c4b9d9ae66d616755c322aba75ab9bd793c6fef448917e6de2e8b8cdf66fb4", size = 39626, upload-time = "2025-06-29T22:58:03.157Z" }, ] [[package]] @@ -5659,11 +5690,11 @@ wheels = [ [[package]] name = "types-defusedxml" -version = "0.7.0.20250516" +version = "0.7.0.20250708" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/55/9d/3ba8b80536402f1a125bc5a44d82ab686aafa55a85f56160e076b2ac30de/types_defusedxml-0.7.0.20250516.tar.gz", hash = "sha256:164c2945077fa450f24ed09633f8b3a80694687fefbbc1cba5f24e4ba570666b", size = 10298, upload-time = "2025-05-16T03:08:18.951Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/4b/79d046a7211e110afd885be04bb9423546df2a662ed28251512d60e51fb6/types_defusedxml-0.7.0.20250708.tar.gz", hash = "sha256:7b785780cc11c18a1af086308bf94bf53a0907943a1d145dbe00189bef323cb8", size = 10541, upload-time = "2025-07-08T03:14:33.325Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/7b/567b0978150edccf7fa3aa8f2566ea9c3ffc9481ce7d64428166934d6d7f/types_defusedxml-0.7.0.20250516-py3-none-any.whl", hash = "sha256:00e793e5c385c3e142d7c2acc3b4ccea2fe0828cee11e35501f0ba40386630a0", size = 12576, upload-time = "2025-05-16T03:08:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/24/f8/870de7fbd5fee5643f05061db948df6bd574a05a42aee91e37ad47c999ef/types_defusedxml-0.7.0.20250708-py3-none-any.whl", hash = "sha256:cc426cbc31c61a0f1b1c2ad9b9ef9ef846645f28fd708cd7727a6353b5c52e54", size = 13478, upload-time = "2025-07-08T03:14:32.633Z" }, ] [[package]] @@ -5677,11 +5708,11 @@ wheels = [ [[package]] name = "types-docutils" -version = "0.21.0.20250604" +version = "0.21.0.20250708" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ef/d0/d28035370d669f14d4e23bd63d093207331f361afa24d2686d2c3fe6be8d/types_docutils-0.21.0.20250604.tar.gz", hash = "sha256:5a9cc7f5a4c5ef694aa0abc61111e0b1376a53dee90d65757f77f31acfcca8f2", size = 40953, upload-time = "2025-06-04T03:10:27.439Z" } +sdist = { url = "https://files.pythonhosted.org/packages/39/86/24394a71a04f416ca03df51863a3d3e2cd0542fdc40989188dca30ffb5bf/types_docutils-0.21.0.20250708.tar.gz", hash = "sha256:5625a82a9a2f26d8384545607c157e023a48ed60d940dfc738db125282864172", size = 42011, upload-time = "2025-07-08T03:14:24.214Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/89/91/887e9591c1ee50dfbf7c2fa2f3f51bc6db683013b6d2b0cd3983adf3d502/types_docutils-0.21.0.20250604-py3-none-any.whl", hash = "sha256:bfa8628176c06a80cdd1d6f3fb32e972e042db53538596488dfe0e9c5962b222", size = 65915, upload-time = "2025-06-04T03:10:26.067Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/8c1153fc1576a0dcffdd157c69a12863c3f9485054256f6791ea17d95aed/types_docutils-0.21.0.20250708-py3-none-any.whl", hash = "sha256:166630d1aec18b9ca02547873210e04bf7674ba8f8da9cd9e6a5e77dc99372c2", size = 67953, upload-time = "2025-07-08T03:14:23.057Z" }, ] [[package]] @@ -5733,11 +5764,11 @@ wheels = [ [[package]] name = "types-html5lib" -version = "1.1.11.20250516" +version = "1.1.11.20250708" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/ed/9f092ff479e2b5598941855f314a22953bb04b5fb38bcba3f880feb833ba/types_html5lib-1.1.11.20250516.tar.gz", hash = "sha256:65043a6718c97f7d52567cc0cdf41efbfc33b1f92c6c0c5e19f60a7ec69ae720", size = 16136, upload-time = "2025-05-16T03:07:12.231Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/3b/1f5ba4358cfc1421cced5cdb9d2b08b4b99e4f9a41da88ce079f6d1a7bf1/types_html5lib-1.1.11.20250708.tar.gz", hash = "sha256:24321720fdbac71cee50d5a4bec9b7448495b7217974cffe3fcf1ede4eef7afe", size = 16799, upload-time = "2025-07-08T03:13:53.14Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/3b/cb5b23c7b51bf48b8c9f175abb9dce2f1ecd2d2c25f92ea9f4e3720e9398/types_html5lib-1.1.11.20250516-py3-none-any.whl", hash = "sha256:5e407b14b1bd2b9b1107cbd1e2e19d4a0c46d60febd231c7ab7313d7405663c1", size = 21770, upload-time = "2025-05-16T03:07:11.102Z" }, + { url = "https://files.pythonhosted.org/packages/a8/50/5fc23cf647eee23acdd337c8150861d39980cf11f33dd87f78e87d2a4bad/types_html5lib-1.1.11.20250708-py3-none-any.whl", hash = "sha256:bb898066b155de7081cb182179e2ded31b9e0e234605e2cb46536894e68a6954", size = 22913, upload-time = "2025-07-08T03:13:52.098Z" }, ] [[package]] @@ -5856,11 +5887,11 @@ wheels = [ [[package]] name = "types-pymysql" -version = "1.1.0.20250516" +version = "1.1.0.20250708" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/db/11/cdaa90b82cb25c5e04e75f0b0616872aa5775b001096779375084f8dbbcf/types_pymysql-1.1.0.20250516.tar.gz", hash = "sha256:fea4a9776101cf893dfc868f42ce10d2e46dcc498c792cc7c9c0fe00cb744234", size = 19640, upload-time = "2025-05-16T03:06:54.568Z" } +sdist = { url = "https://files.pythonhosted.org/packages/65/a3/db349a06c64b8c041c165fc470b81d37404ec342014625c7a6b7f7a4f680/types_pymysql-1.1.0.20250708.tar.gz", hash = "sha256:2cbd7cfcf9313eda784910578c4f1d06f8cc03a15cd30ce588aa92dd6255011d", size = 21715, upload-time = "2025-07-08T03:13:56.463Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/64/129656e04ddda35d69faae914ce67cf60d83407ddd7afdef1e7c50bbb74a/types_pymysql-1.1.0.20250516-py3-none-any.whl", hash = "sha256:41c87a832e3ff503d5120cc6cebd64f6dcb3c407d9580a98b2cb3e3bcd109aa6", size = 20328, upload-time = "2025-05-16T03:06:53.681Z" }, + { url = "https://files.pythonhosted.org/packages/88/e5/7f72c520f527175b6455e955426fd4f971128b4fa2f8ab2f505f254a1ddc/types_pymysql-1.1.0.20250708-py3-none-any.whl", hash = "sha256:9252966d2795945b2a7a53d5cdc49fe8e4e2f3dde4c104ed7fc782a83114e365", size = 22860, upload-time = "2025-07-08T03:13:55.367Z" }, ] [[package]] @@ -5878,20 +5909,20 @@ wheels = [ [[package]] name = "types-python-dateutil" -version = "2.9.0.20250516" +version = "2.9.0.20250708" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ef/88/d65ed807393285204ab6e2801e5d11fbbea811adcaa979a2ed3b67a5ef41/types_python_dateutil-2.9.0.20250516.tar.gz", hash = "sha256:13e80d6c9c47df23ad773d54b2826bd52dbbb41be87c3f339381c1700ad21ee5", size = 13943, upload-time = "2025-05-16T03:06:58.385Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/95/6bdde7607da2e1e99ec1c1672a759d42f26644bbacf939916e086db34870/types_python_dateutil-2.9.0.20250708.tar.gz", hash = "sha256:ccdbd75dab2d6c9696c350579f34cffe2c281e4c5f27a585b2a2438dd1d5c8ab", size = 15834, upload-time = "2025-07-08T03:14:03.382Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/3f/b0e8db149896005adc938a1e7f371d6d7e9eca4053a29b108978ed15e0c2/types_python_dateutil-2.9.0.20250516-py3-none-any.whl", hash = "sha256:2b2b3f57f9c6a61fba26a9c0ffb9ea5681c9b83e69cd897c6b5f668d9c0cab93", size = 14356, upload-time = "2025-05-16T03:06:57.249Z" }, + { url = "https://files.pythonhosted.org/packages/72/52/43e70a8e57fefb172c22a21000b03ebcc15e47e97f5cb8495b9c2832efb4/types_python_dateutil-2.9.0.20250708-py3-none-any.whl", hash = "sha256:4d6d0cc1cc4d24a2dc3816024e502564094497b713f7befda4d5bc7a8e3fd21f", size = 17724, upload-time = "2025-07-08T03:14:02.593Z" }, ] [[package]] name = "types-python-http-client" -version = "3.3.7.20240910" +version = "3.3.7.20250708" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e1/d7/bb2754c2d1b20c1890593ec89799c99e8875b04f474197c41354f41e9d31/types-python-http-client-3.3.7.20240910.tar.gz", hash = "sha256:8a6ebd30ad4b90a329ace69c240291a6176388624693bc971a5ecaa7e9b05074", size = 2804, upload-time = "2024-09-10T02:38:31.608Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/a0/0ad93698a3ebc6846ca23aca20ff6f6f8ebe7b4f0c1de7f19e87c03dbe8f/types_python_http_client-3.3.7.20250708.tar.gz", hash = "sha256:5f85b32dc64671a4e5e016142169aa187c5abed0b196680944e4efd3d5ce3322", size = 7707, upload-time = "2025-07-08T03:14:36.197Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/95/8f492d37d99630e096acbb4071788483282a34a73ae89dd1a5727f4189cc/types_python_http_client-3.3.7.20240910-py3-none-any.whl", hash = "sha256:58941bd986fb8bb0f4f782ef376be145ece8023f391364fbcd22bd26b13a140e", size = 3917, upload-time = "2024-09-10T02:38:30.261Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/b88274658cf489e35175be8571c970e9a1219713bafd8fc9e166d7351ecb/types_python_http_client-3.3.7.20250708-py3-none-any.whl", hash = "sha256:e2fc253859decab36713d82fc7f205868c3ddeaee79dbb55956ad9ca77abe12b", size = 8890, upload-time = "2025-07-08T03:14:35.506Z" }, ] [[package]] @@ -6040,11 +6071,11 @@ wheels = [ [[package]] name = "typing-extensions" -version = "4.14.0" +version = "4.14.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d1/bc/51647cd02527e87d05cb083ccc402f93e441606ff1f01739a62c8ad09ba5/typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4", size = 107423, upload-time = "2025-06-02T14:52:11.399Z" } +sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/e0/552843e0d356fbb5256d21449fa957fa4eff3bbc135a74a691ee70c7c5da/typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af", size = 43839, upload-time = "2025-06-02T14:52:10.026Z" }, + { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" }, ] [[package]] @@ -6172,7 +6203,7 @@ pptx = [ [[package]] name = "unstructured-client" -version = "0.36.0" +version = "0.38.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiofiles" }, @@ -6183,9 +6214,9 @@ dependencies = [ { name = "pypdf" }, { name = "requests-toolbelt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9d/4d/d829dbef1138251de771cd52b277d93fb1c4e79d56be3e44e6d2ce76bd62/unstructured_client-0.36.0.tar.gz", hash = "sha256:ab293498100275c0e1d74c926c82dae2b3ba3fbb88945c0ba03b4b7a29197e4a", size = 86010, upload-time = "2025-05-29T00:11:11.429Z" } +sdist = { url = "https://files.pythonhosted.org/packages/85/60/412092671bfc4952640739f2c0c9b2f4c8af26a3c921738fd12621b4ddd8/unstructured_client-0.38.1.tar.gz", hash = "sha256:43ab0670dd8ff53d71e74f9b6dfe490a84a5303dab80a4873e118a840c6d46ca", size = 91781, upload-time = "2025-07-03T15:46:35.054Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/4a/ae162e583bbdd0996f92ad18871a737d710260d8c0cfd78f1be6aa0ac150/unstructured_client-0.36.0-py3-none-any.whl", hash = "sha256:d0ecf3ac4d481437d858147904ff6e41205032cf8353af5cdd3ebaa190481d6a", size = 195765, upload-time = "2025-05-29T00:11:09.677Z" }, + { url = "https://files.pythonhosted.org/packages/26/e0/8c249f00ba85fb4aba5c541463312befbfbf491105ff5c06e508089467be/unstructured_client-0.38.1-py3-none-any.whl", hash = "sha256:71e5467870d0a0119c788c29ec8baf5c0f7123f424affc9d6682eeeb7b8d45fa", size = 212626, upload-time = "2025-07-03T15:46:33.929Z" }, ] [[package]] @@ -6211,33 +6242,33 @@ wheels = [ [[package]] name = "urllib3" -version = "2.4.0" +version = "2.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8a/78/16493d9c386d8e60e442a35feac5e00f0913c0f4b7c217c11e8ec2ff53e0/urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466", size = 390672, upload-time = "2025-04-10T15:23:39.232Z" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680, upload-time = "2025-04-10T15:23:37.377Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, ] [[package]] name = "uuid6" -version = "2025.0.0" +version = "2025.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3f/49/06a089c184580f510e20226d9a081e4323d13db2fbc92d566697b5395c1e/uuid6-2025.0.0.tar.gz", hash = "sha256:bb78aa300e29db89b00410371d0c1f1824e59e29995a9daa3dedc8033d1d84ec", size = 13941, upload-time = "2025-06-11T20:02:05.324Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/b7/4c0f736ca824b3a25b15e8213d1bcfc15f8ac2ae48d1b445b310892dc4da/uuid6-2025.0.1.tar.gz", hash = "sha256:cd0af94fa428675a44e32c5319ec5a3485225ba2179eefcf4c3f205ae30a81bd", size = 13932, upload-time = "2025-07-04T18:30:35.186Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/50/4da47101af45b6cfa291559577993b52ee4399b3cd54ba307574a11e4f3a/uuid6-2025.0.0-py3-none-any.whl", hash = "sha256:2c73405ff5333c7181443958c6865e0d1b9b816bb160549e8d80ba186263cb3a", size = 7001, upload-time = "2025-06-11T20:02:04.521Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b2/93faaab7962e2aa8d6e174afb6f76be2ca0ce89fde14d3af835acebcaa59/uuid6-2025.0.1-py3-none-any.whl", hash = "sha256:80530ce4d02a93cdf82e7122ca0da3ebbbc269790ec1cb902481fa3e9cc9ff99", size = 6979, upload-time = "2025-07-04T18:30:34.001Z" }, ] [[package]] name = "uvicorn" -version = "0.34.3" +version = "0.35.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/de/ad/713be230bcda622eaa35c28f0d328c3675c371238470abdea52417f17a8e/uvicorn-0.34.3.tar.gz", hash = "sha256:35919a9a979d7a59334b6b10e05d77c1d0d574c50e0fc98b8b1a0f165708b55a", size = 76631, upload-time = "2025-06-01T07:48:17.531Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/42/e0e305207bb88c6b8d3061399c6a961ffe5fbb7e2aa63c9234df7259e9cd/uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01", size = 78473, upload-time = "2025-06-28T16:15:46.058Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/0d/8adfeaa62945f90d19ddc461c55f4a50c258af7662d34b6a3d5d1f8646f6/uvicorn-0.34.3-py3-none-any.whl", hash = "sha256:16246631db62bdfbf069b0645177d6e8a77ba950cfedbfd093acef9444e4d885", size = 62431, upload-time = "2025-06-01T07:48:15.664Z" }, + { url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406, upload-time = "2025-06-28T16:15:44.816Z" }, ] [package.optional-dependencies] @@ -6309,7 +6340,7 @@ wheels = [ [[package]] name = "wandb" -version = "0.20.1" +version = "0.21.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -6317,26 +6348,24 @@ dependencies = [ { name = "packaging" }, { name = "platformdirs" }, { name = "protobuf" }, - { name = "psutil" }, { name = "pydantic" }, { name = "pyyaml" }, { name = "requests" }, { name = "sentry-sdk" }, - { name = "setproctitle" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/62/1f/92be0ca87fb49eb48c16dcf0845a3579a57c4734fec2b95862cf5a0494a0/wandb-0.20.1.tar.gz", hash = "sha256:dbd3fc60dfe7bf83c4de24b206b99b44949fef323f817a783883db72fc5f3bfe", size = 40320062, upload-time = "2025-06-05T00:00:24.483Z" } +sdist = { url = "https://files.pythonhosted.org/packages/73/09/c84264a219e20efd615e4d5d150cc7d359d57d51328d3fa94ee02d70ed9c/wandb-0.21.0.tar.gz", hash = "sha256:473e01ef200b59d780416062991effa7349a34e51425d4be5ff482af2dc39e02", size = 40085784, upload-time = "2025-07-02T00:24:15.516Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/18/afcc37d0b93dd6f6d0f0c5683b9cfff9416ae1539931f58932a2938c0070/wandb-0.20.1-py3-none-any.whl", hash = "sha256:e6395cabf074247042be1cf0dc6ab0b06aa4c9538c2e1fdc5b507a690ce0cf17", size = 6458872, upload-time = "2025-06-04T23:59:55.441Z" }, - { url = "https://files.pythonhosted.org/packages/e6/b5/70f9e2a3d1380b729ae5853763d938edc50072df357f79bbd19b9aae8e3f/wandb-0.20.1-py3-none-macosx_10_14_x86_64.whl", hash = "sha256:2475a48c693adf677d40da9e1c8ceeaf86d745ffc3b7e3535731279d02f9e845", size = 22517483, upload-time = "2025-06-04T23:59:58.687Z" }, - { url = "https://files.pythonhosted.org/packages/cc/7e/4eb9aeb2fd974d410a8f2eb11b0219536503913a050d46a03206151705c8/wandb-0.20.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:99cce804c31ec1e0d1e691650a7d51773ed7329c41745d56384fa3655a0e9b2c", size = 22034511, upload-time = "2025-06-05T00:00:01.301Z" }, - { url = "https://files.pythonhosted.org/packages/34/38/1df22c2273e6f7ab0aae4fd032085d6d92ab112f5b261646e7dc5e675cfe/wandb-0.20.1-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:ce3ee412677a1679e04b21e03a91e1e02eb90faf658d682bee86c33cf5f32e09", size = 22720771, upload-time = "2025-06-05T00:00:04.122Z" }, - { url = "https://files.pythonhosted.org/packages/38/96/78fc7a7ea7158d136c84f481423f8736c9346a2387287ec8a6d92019975c/wandb-0.20.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e58ca32c7147161158f09b0fb5f5896876f8569d0d10ae7b64d0510c868ce33", size = 21537453, upload-time = "2025-06-05T00:00:09.474Z" }, - { url = "https://files.pythonhosted.org/packages/88/c9/41b8bdb493e5eda32b502bc1cc49d539335a92cacaf0ef304d7dae0240aa/wandb-0.20.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:591506ecbdd396648cc323ba270f3ab4aed3158e1dbfa7636c09f9f7f0253e1c", size = 23161349, upload-time = "2025-06-05T00:00:11.903Z" }, - { url = "https://files.pythonhosted.org/packages/7d/f2/79e783cc50a47d373dfbda862eb5396de8139167e8c6443a16ef0166106f/wandb-0.20.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:382508532db09893f81cc926b1d333caa4c8a7db057878899fadf929bbdb3b56", size = 21550624, upload-time = "2025-06-05T00:00:14.28Z" }, - { url = "https://files.pythonhosted.org/packages/26/32/23890a726302e7be28bda9fff47ce9b491af64e339aba4d32b3b8d1a7aaf/wandb-0.20.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:29ea495e49393db860f17437fe37e48018da90436ce10949b471780f09293bd7", size = 23237996, upload-time = "2025-06-05T00:00:16.647Z" }, - { url = "https://files.pythonhosted.org/packages/af/94/296e520b086b2a4f10e99bcea3cd5856421b9c004824663501e3789a713b/wandb-0.20.1-py3-none-win32.whl", hash = "sha256:455ee0a652e59ab1e4b546fa1dc833dd3063aa7e64eb8abf95d22f0e9f08c574", size = 22518456, upload-time = "2025-06-05T00:00:19.006Z" }, - { url = "https://files.pythonhosted.org/packages/52/5f/c44ad7b2a062ca5f4da99ae475cea274c38f6ec37bdaca1b1c653ee87274/wandb-0.20.1-py3-none-win_amd64.whl", hash = "sha256:6d2431652f096b7e394c29a99135a6441c02ed3198b963f0b351a5b5e56aeca0", size = 22518459, upload-time = "2025-06-05T00:00:21.374Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/65eac086e1bc337bb5f0eed65ba1fe4a6dbc62c97f094e8e9df1ef83ffed/wandb-0.21.0-py3-none-any.whl", hash = "sha256:316e8cd4329738f7562f7369e6eabeeb28ef9d473203f7ead0d03e5dba01c90d", size = 6504284, upload-time = "2025-07-02T00:23:46.671Z" }, + { url = "https://files.pythonhosted.org/packages/17/a7/80556ce9097f59e10807aa68f4a9b29d736a90dca60852a9e2af1641baf8/wandb-0.21.0-py3-none-macosx_10_14_x86_64.whl", hash = "sha256:701d9cbdfcc8550a330c1b54a26f1585519180e0f19247867446593d34ace46b", size = 21717388, upload-time = "2025-07-02T00:23:49.348Z" }, + { url = "https://files.pythonhosted.org/packages/23/ae/660bc75aa37bd23409822ea5ed616177d94873172d34271693c80405c820/wandb-0.21.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:01689faa6b691df23ba2367e0a1ecf6e4d0be44474905840098eedd1fbcb8bdf", size = 21141465, upload-time = "2025-07-02T00:23:52.602Z" }, + { url = "https://files.pythonhosted.org/packages/23/ab/9861929530be56557c74002868c85d0d8ac57050cc21863afe909ae3d46f/wandb-0.21.0-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:55d3f42ddb7971d1699752dff2b85bcb5906ad098d18ab62846c82e9ce5a238d", size = 21793511, upload-time = "2025-07-02T00:23:55.447Z" }, + { url = "https://files.pythonhosted.org/packages/de/52/e5cad2eff6fbed1ac06f4a5b718457fa2fd437f84f5c8f0d31995a2ef046/wandb-0.21.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:893508f0c7da48917448daa5cd622c27ce7ce15119adaa861185034c2bd7b14c", size = 20704643, upload-time = "2025-07-02T00:23:58.255Z" }, + { url = "https://files.pythonhosted.org/packages/83/8f/6bed9358cc33767c877b221d4f565e1ddf00caf4bbbe54d2e3bbc932c6a7/wandb-0.21.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4e8245a8912247ddf7654f7b5330f583a6c56ab88fee65589158490d583c57d", size = 22243012, upload-time = "2025-07-02T00:24:01.423Z" }, + { url = "https://files.pythonhosted.org/packages/be/61/9048015412ea5ca916844af55add4fed7c21fe1ad70bb137951e70b550c5/wandb-0.21.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2e4c4f951e0d02755e315679bfdcb5bc38c1b02e2e5abc5432b91a91bb0cf246", size = 20716440, upload-time = "2025-07-02T00:24:04.198Z" }, + { url = "https://files.pythonhosted.org/packages/02/d9/fcd2273d8ec3f79323e40a031aba5d32d6fa9065702010eb428b5ffbab62/wandb-0.21.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:873749966eeac0069e0e742e6210641b6227d454fb1dae2cf5c437c6ed42d3ca", size = 22320652, upload-time = "2025-07-02T00:24:07.175Z" }, + { url = "https://files.pythonhosted.org/packages/80/68/b8308db6b9c3c96dcd03be17c019aee105e1d7dc1e74d70756cdfb9241c6/wandb-0.21.0-py3-none-win32.whl", hash = "sha256:9d3cccfba658fa011d6cab9045fa4f070a444885e8902ae863802549106a5dab", size = 21484296, upload-time = "2025-07-02T00:24:10.147Z" }, + { url = "https://files.pythonhosted.org/packages/cf/96/71cc033e8abd00e54465e68764709ed945e2da2d66d764f72f4660262b22/wandb-0.21.0-py3-none-win_amd64.whl", hash = "sha256:28a0b2dad09d7c7344ac62b0276be18a2492a5578e4d7c84937a3e1991edaac7", size = 21484301, upload-time = "2025-07-02T00:24:12.658Z" }, ] [[package]] @@ -6552,11 +6581,11 @@ wheels = [ [[package]] name = "xlsxwriter" -version = "3.2.3" +version = "3.2.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a7/d1/e026d33dd5d552e5bf3a873dee54dad66b550230df8290d79394f09b2315/xlsxwriter-3.2.3.tar.gz", hash = "sha256:ad6fd41bdcf1b885876b1f6b7087560aecc9ae5a9cc2ba97dcac7ab2e210d3d5", size = 209135, upload-time = "2025-04-17T10:11:23.481Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/47/7704bac42ac6fe1710ae099b70e6a1e68ed173ef14792b647808c357da43/xlsxwriter-3.2.5.tar.gz", hash = "sha256:7e88469d607cdc920151c0ab3ce9cf1a83992d4b7bc730c5ffdd1a12115a7dbe", size = 213306, upload-time = "2025-06-17T08:59:14.619Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/37/b1/a252d499f2760b314fcf264d2b36fcc4343a1ecdb25492b210cb0db70a68/XlsxWriter-3.2.3-py3-none-any.whl", hash = "sha256:593f8296e8a91790c6d0378ab08b064f34a642b3feb787cf6738236bd0a4860d", size = 169433, upload-time = "2025-04-17T10:11:21.329Z" }, + { url = "https://files.pythonhosted.org/packages/fa/34/a22e6664211f0c8879521328000bdcae9bf6dbafa94a923e531f6d5b3f73/xlsxwriter-3.2.5-py3-none-any.whl", hash = "sha256:4f4824234e1eaf9d95df9a8fe974585ff91d0f5e3d3f12ace5b71e443c1c6abd", size = 172347, upload-time = "2025-06-17T08:59:13.453Z" }, ] [[package]] @@ -6625,14 +6654,14 @@ wheels = [ [[package]] name = "zope-event" -version = "5.0" +version = "5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "setuptools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/46/c2/427f1867bb96555d1d34342f1dd97f8c420966ab564d58d18469a1db8736/zope.event-5.0.tar.gz", hash = "sha256:bac440d8d9891b4068e2b5a2c5e2c9765a9df762944bda6955f96bb9b91e67cd", size = 17350, upload-time = "2023-06-23T06:28:35.709Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8b/c7/31e6f40282a2c548602c177826df281177caf79efaa101dd14314fb4ee73/zope_event-5.1.tar.gz", hash = "sha256:a153660e0c228124655748e990396b9d8295d6e4f546fa1b34f3319e1c666e7f", size = 18632, upload-time = "2025-06-26T07:14:22.72Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/42/f8dbc2b9ad59e927940325a22d6d3931d630c3644dae7e2369ef5d9ba230/zope.event-5.0-py3-none-any.whl", hash = "sha256:2832e95014f4db26c47a13fdaef84cef2f4df37e66b59d8f1f4a8f319a632c26", size = 6824, upload-time = "2023-06-23T06:28:32.652Z" }, + { url = "https://files.pythonhosted.org/packages/00/ed/d8c3f56c1edb0ee9b51461dd08580382e9589850f769b69f0dedccff5215/zope_event-5.1-py3-none-any.whl", hash = "sha256:53de8f0e9f61dc0598141ac591f49b042b6d74784dab49971b9cc91d0f73a7df", size = 6905, upload-time = "2025-06-26T07:14:21.779Z" }, ] [[package]] diff --git a/docker/.env.example b/docker/.env.example index a024566c8f..84b6152f0a 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -47,6 +47,11 @@ APP_WEB_URL= # ensuring port 5001 is externally accessible (see docker-compose.yaml). FILES_URL= +# INTERNAL_FILES_URL is used for plugin daemon communication within Docker network. +# Set this to the internal Docker service URL for proper plugin file access. +# Example: INTERNAL_FILES_URL=http://api:5001 +INTERNAL_FILES_URL= + # ------------------------------ # Server Configuration # ------------------------------ @@ -826,6 +831,9 @@ MAX_ITERATIONS_NUM=99 # The timeout for the text generation in millisecond TEXT_GENERATION_TIMEOUT_MS=60000 +# Allow rendering unsafe URLs which have "data:" scheme. +ALLOW_UNSAFE_DATA_SCHEME=false + # ------------------------------ # Environment Variables for db Service # ------------------------------ @@ -958,7 +966,7 @@ NGINX_SSL_PROTOCOLS=TLSv1.1 TLSv1.2 TLSv1.3 # Nginx performance tuning NGINX_WORKER_PROCESSES=auto -NGINX_CLIENT_MAX_BODY_SIZE=15M +NGINX_CLIENT_MAX_BODY_SIZE=100M NGINX_KEEPALIVE_TIMEOUT=65 # Proxy settings diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml index a6a4ed959a..7c1544acb9 100644 --- a/docker/docker-compose-template.yaml +++ b/docker/docker-compose-template.yaml @@ -2,7 +2,7 @@ x-shared-env: &shared-api-worker-env services: # API service api: - image: langgenius/dify-api:1.5.0 + image: langgenius/dify-api:1.6.0 restart: always environment: # Use the shared environment variables. @@ -31,7 +31,7 @@ services: # worker service # The Celery worker for processing the queue. worker: - image: langgenius/dify-api:1.5.0 + image: langgenius/dify-api:1.6.0 restart: always environment: # Use the shared environment variables. @@ -57,7 +57,7 @@ services: # Frontend web application. web: - image: langgenius/dify-web:1.5.0 + image: langgenius/dify-web:1.6.0 restart: always environment: CONSOLE_API_URL: ${CONSOLE_API_URL:-} @@ -67,6 +67,7 @@ services: TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000} CSP_WHITELIST: ${CSP_WHITELIST:-} ALLOW_EMBED: ${ALLOW_EMBED:-false} + ALLOW_UNSAFE_DATA_SCHEME: ${ALLOW_UNSAFE_DATA_SCHEME:-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:-} @@ -142,7 +143,7 @@ services: # plugin daemon plugin_daemon: - image: langgenius/dify-plugin-daemon:0.1.2-local + image: langgenius/dify-plugin-daemon:0.1.3-local restart: always environment: # Use the shared environment variables. @@ -168,7 +169,7 @@ services: PLUGIN_MEDIA_CACHE_PATH: ${PLUGIN_MEDIA_CACHE_PATH:-assets} PLUGIN_STORAGE_OSS_BUCKET: ${PLUGIN_STORAGE_OSS_BUCKET:-} S3_USE_AWS_MANAGED_IAM: ${PLUGIN_S3_USE_AWS_MANAGED_IAM:-false} - S3_USE_AWS: ${PLUGIN_S3_USE_AWS:-} + S3_USE_AWS: ${PLUGIN_S3_USE_AWS:-false} S3_ENDPOINT: ${PLUGIN_S3_ENDPOINT:-} S3_USE_PATH_STYLE: ${PLUGIN_S3_USE_PATH_STYLE:-false} AWS_ACCESS_KEY: ${PLUGIN_AWS_ACCESS_KEY:-} @@ -265,7 +266,7 @@ services: NGINX_SSL_CERT_KEY_FILENAME: ${NGINX_SSL_CERT_KEY_FILENAME:-dify.key} NGINX_SSL_PROTOCOLS: ${NGINX_SSL_PROTOCOLS:-TLSv1.1 TLSv1.2 TLSv1.3} NGINX_WORKER_PROCESSES: ${NGINX_WORKER_PROCESSES:-auto} - NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-15M} + NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-100M} NGINX_KEEPALIVE_TIMEOUT: ${NGINX_KEEPALIVE_TIMEOUT:-65} NGINX_PROXY_READ_TIMEOUT: ${NGINX_PROXY_READ_TIMEOUT:-3600s} NGINX_PROXY_SEND_TIMEOUT: ${NGINX_PROXY_SEND_TIMEOUT:-3600s} diff --git a/docker/docker-compose.middleware.yaml b/docker/docker-compose.middleware.yaml index 5308a1f978..0b1885755b 100644 --- a/docker/docker-compose.middleware.yaml +++ b/docker/docker-compose.middleware.yaml @@ -71,7 +71,7 @@ services: # plugin daemon plugin_daemon: - image: langgenius/dify-plugin-daemon:0.1.2-local + image: langgenius/dify-plugin-daemon:0.1.3-local restart: always env_file: - ./middleware.env diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 0d187176b0..ac9953aa33 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -11,6 +11,7 @@ x-shared-env: &shared-api-worker-env APP_API_URL: ${APP_API_URL:-} APP_WEB_URL: ${APP_WEB_URL:-} FILES_URL: ${FILES_URL:-} + INTERNAL_FILES_URL: ${INTERNAL_FILES_URL:-} LOG_LEVEL: ${LOG_LEVEL:-INFO} LOG_FILE: ${LOG_FILE:-/app/logs/server.log} LOG_FILE_MAX_SIZE: ${LOG_FILE_MAX_SIZE:-20} @@ -364,6 +365,7 @@ x-shared-env: &shared-api-worker-env MAX_PARALLEL_LIMIT: ${MAX_PARALLEL_LIMIT:-10} MAX_ITERATIONS_NUM: ${MAX_ITERATIONS_NUM:-99} TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000} + ALLOW_UNSAFE_DATA_SCHEME: ${ALLOW_UNSAFE_DATA_SCHEME:-false} POSTGRES_USER: ${POSTGRES_USER:-${DB_USERNAME}} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-${DB_PASSWORD}} POSTGRES_DB: ${POSTGRES_DB:-${DB_DATABASE}} @@ -420,7 +422,7 @@ x-shared-env: &shared-api-worker-env NGINX_SSL_CERT_KEY_FILENAME: ${NGINX_SSL_CERT_KEY_FILENAME:-dify.key} NGINX_SSL_PROTOCOLS: ${NGINX_SSL_PROTOCOLS:-TLSv1.1 TLSv1.2 TLSv1.3} NGINX_WORKER_PROCESSES: ${NGINX_WORKER_PROCESSES:-auto} - NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-15M} + NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-100M} NGINX_KEEPALIVE_TIMEOUT: ${NGINX_KEEPALIVE_TIMEOUT:-65} NGINX_PROXY_READ_TIMEOUT: ${NGINX_PROXY_READ_TIMEOUT:-3600s} NGINX_PROXY_SEND_TIMEOUT: ${NGINX_PROXY_SEND_TIMEOUT:-3600s} @@ -517,7 +519,7 @@ x-shared-env: &shared-api-worker-env services: # API service api: - image: langgenius/dify-api:1.5.0 + image: langgenius/dify-api:1.6.0 restart: always environment: # Use the shared environment variables. @@ -546,7 +548,7 @@ services: # worker service # The Celery worker for processing the queue. worker: - image: langgenius/dify-api:1.5.0 + image: langgenius/dify-api:1.6.0 restart: always environment: # Use the shared environment variables. @@ -572,7 +574,7 @@ services: # Frontend web application. web: - image: langgenius/dify-web:1.5.0 + image: langgenius/dify-web:1.6.0 restart: always environment: CONSOLE_API_URL: ${CONSOLE_API_URL:-} @@ -582,6 +584,7 @@ services: TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000} CSP_WHITELIST: ${CSP_WHITELIST:-} ALLOW_EMBED: ${ALLOW_EMBED:-false} + ALLOW_UNSAFE_DATA_SCHEME: ${ALLOW_UNSAFE_DATA_SCHEME:-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:-} @@ -657,7 +660,7 @@ services: # plugin daemon plugin_daemon: - image: langgenius/dify-plugin-daemon:0.1.2-local + image: langgenius/dify-plugin-daemon:0.1.3-local restart: always environment: # Use the shared environment variables. @@ -683,7 +686,7 @@ services: PLUGIN_MEDIA_CACHE_PATH: ${PLUGIN_MEDIA_CACHE_PATH:-assets} PLUGIN_STORAGE_OSS_BUCKET: ${PLUGIN_STORAGE_OSS_BUCKET:-} S3_USE_AWS_MANAGED_IAM: ${PLUGIN_S3_USE_AWS_MANAGED_IAM:-false} - S3_USE_AWS: ${PLUGIN_S3_USE_AWS:-} + S3_USE_AWS: ${PLUGIN_S3_USE_AWS:-false} S3_ENDPOINT: ${PLUGIN_S3_ENDPOINT:-} S3_USE_PATH_STYLE: ${PLUGIN_S3_USE_PATH_STYLE:-false} AWS_ACCESS_KEY: ${PLUGIN_AWS_ACCESS_KEY:-} @@ -780,7 +783,7 @@ services: NGINX_SSL_CERT_KEY_FILENAME: ${NGINX_SSL_CERT_KEY_FILENAME:-dify.key} NGINX_SSL_PROTOCOLS: ${NGINX_SSL_PROTOCOLS:-TLSv1.1 TLSv1.2 TLSv1.3} NGINX_WORKER_PROCESSES: ${NGINX_WORKER_PROCESSES:-auto} - NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-15M} + NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-100M} NGINX_KEEPALIVE_TIMEOUT: ${NGINX_KEEPALIVE_TIMEOUT:-65} NGINX_PROXY_READ_TIMEOUT: ${NGINX_PROXY_READ_TIMEOUT:-3600s} NGINX_PROXY_SEND_TIMEOUT: ${NGINX_PROXY_SEND_TIMEOUT:-3600s} diff --git a/docker/nginx/conf.d/default.conf.template b/docker/nginx/conf.d/default.conf.template index a458412d1e..48d7da8cf5 100644 --- a/docker/nginx/conf.d/default.conf.template +++ b/docker/nginx/conf.d/default.conf.template @@ -39,7 +39,10 @@ server { proxy_pass http://web:3000; include proxy.conf; } - + location /mcp { + proxy_pass http://api:5001; + include proxy.conf; + } # placeholder for acme challenge location ${ACME_CHALLENGE_LOCATION} diff --git a/web/.env.example b/web/.env.example index c30064ffed..37bfc939eb 100644 --- a/web/.env.example +++ b/web/.env.example @@ -32,6 +32,9 @@ 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= +# Allow rendering unsafe URLs which have "data:" scheme. +NEXT_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME=false + # Github Access Token, used for invoking Github API NEXT_PUBLIC_GITHUB_ACCESS_TOKEN= # The maximum number of top-k value for RAG. diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/cardView.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/cardView.tsx index 084adceef2..3d572b926a 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/cardView.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/cardView.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' import AppCard from '@/app/components/app/overview/appCard' import Loading from '@/app/components/base/loading' +import MCPServiceCard from '@/app/components/tools/mcp/mcp-service-card' import { ToastContext } from '@/app/components/base/toast' import { fetchAppDetail, @@ -31,6 +32,8 @@ const CardView: FC = ({ appId, isInPanel, className }) => { const appDetail = useAppStore(state => state.appDetail) const setAppDetail = useAppStore(state => state.setAppDetail) + const showMCPCard = isInPanel + const updateAppDetail = async () => { try { const res = await fetchAppDetail({ url: '/apps', id: appId }) @@ -117,6 +120,11 @@ const CardView: FC = ({ appId, isInPanel, className }) => { isInPanel={isInPanel} onChangeStatus={onChangeApiStatus} /> + {showMCPCard && ( + + )} ) } diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-popup.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-popup.tsx index 0efc5082a4..2afe451fe1 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-popup.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-popup.tsx @@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next' import { useBoolean } from 'ahooks' import TracingIcon from './tracing-icon' import ProviderPanel from './provider-panel' -import type { LangFuseConfig, LangSmithConfig, OpikConfig, WeaveConfig } from './type' +import type { AliyunConfig, ArizeConfig, LangFuseConfig, LangSmithConfig, OpikConfig, PhoenixConfig, WeaveConfig } from './type' import { TracingProvider } from './type' import ProviderConfigModal from './provider-config-modal' import Indicator from '@/app/components/header/indicator' @@ -23,11 +23,14 @@ export type PopupProps = { onStatusChange: (enabled: boolean) => void chosenProvider: TracingProvider | null onChooseProvider: (provider: TracingProvider) => void + arizeConfig: ArizeConfig | null + phoenixConfig: PhoenixConfig | null langSmithConfig: LangSmithConfig | null langFuseConfig: LangFuseConfig | null opikConfig: OpikConfig | null weaveConfig: WeaveConfig | null - onConfigUpdated: (provider: TracingProvider, payload: LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig) => void + aliyunConfig: AliyunConfig | null + onConfigUpdated: (provider: TracingProvider, payload: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig | AliyunConfig) => void onConfigRemoved: (provider: TracingProvider) => void } @@ -38,10 +41,13 @@ const ConfigPopup: FC = ({ onStatusChange, chosenProvider, onChooseProvider, + arizeConfig, + phoenixConfig, langSmithConfig, langFuseConfig, opikConfig, weaveConfig, + aliyunConfig, onConfigUpdated, onConfigRemoved, }) => { @@ -65,7 +71,7 @@ const ConfigPopup: FC = ({ } }, [onChooseProvider]) - const handleConfigUpdated = useCallback((payload: LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig) => { + const handleConfigUpdated = useCallback((payload: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig | AliyunConfig) => { onConfigUpdated(currentProvider!, payload) hideConfigModal() }, [currentProvider, hideConfigModal, onConfigUpdated]) @@ -75,8 +81,8 @@ const ConfigPopup: FC = ({ hideConfigModal() }, [currentProvider, hideConfigModal, onConfigRemoved]) - const providerAllConfigured = langSmithConfig && langFuseConfig && opikConfig && weaveConfig - const providerAllNotConfigured = !langSmithConfig && !langFuseConfig && !opikConfig && !weaveConfig + const providerAllConfigured = arizeConfig && phoenixConfig && langSmithConfig && langFuseConfig && opikConfig && weaveConfig && aliyunConfig + const providerAllNotConfigured = !arizeConfig && !phoenixConfig && !langSmithConfig && !langFuseConfig && !opikConfig && !weaveConfig && !aliyunConfig const switchContent = ( = ({ disabled={providerAllNotConfigured} /> ) + const arizePanel = ( + + ) + + const phoenixPanel = ( + + ) + const langSmithPanel = ( = ({ key="weave-provider-panel" /> ) + + const aliyunPanel = ( + + ) const configuredProviderPanel = () => { const configuredPanels: JSX.Element[] = [] @@ -152,12 +197,27 @@ const ConfigPopup: FC = ({ if (weaveConfig) configuredPanels.push(weavePanel) + if (arizeConfig) + configuredPanels.push(arizePanel) + + if (phoenixConfig) + configuredPanels.push(phoenixPanel) + + if (aliyunConfig) + configuredPanels.push(aliyunPanel) + return configuredPanels } const moreProviderPanel = () => { const notConfiguredPanels: JSX.Element[] = [] + if (!arizeConfig) + notConfiguredPanels.push(arizePanel) + + if (!phoenixConfig) + notConfiguredPanels.push(phoenixPanel) + if (!langFuseConfig) notConfiguredPanels.push(langfusePanel) @@ -170,16 +230,25 @@ const ConfigPopup: FC = ({ if (!weaveConfig) notConfiguredPanels.push(weavePanel) + if (!aliyunConfig) + notConfiguredPanels.push(aliyunPanel) + return notConfiguredPanels } const configuredProviderConfig = () => { + if (currentProvider === TracingProvider.arize) + return arizeConfig + if (currentProvider === TracingProvider.phoenix) + return phoenixConfig if (currentProvider === TracingProvider.langSmith) return langSmithConfig if (currentProvider === TracingProvider.langfuse) return langFuseConfig if (currentProvider === TracingProvider.opik) return opikConfig + if (currentProvider === TracingProvider.aliyun) + return aliyunConfig return weaveConfig } @@ -220,22 +289,25 @@ const ConfigPopup: FC = ({ ? ( <>
{t(`${I18N_PREFIX}.configProviderTitle.${providerAllConfigured ? 'configured' : 'notConfigured'}`)}
-
+
{langfusePanel} {langSmithPanel} {opikPanel} {weavePanel} + {arizePanel} + {phoenixPanel} + {aliyunPanel}
) : ( <>
{t(`${I18N_PREFIX}.configProviderTitle.configured`)}
-
+
{configuredProviderPanel()}
{t(`${I18N_PREFIX}.configProviderTitle.moreProvider`)}
-
+
{moreProviderPanel()}
diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config.ts b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config.ts index 5d3c4076bd..4c81b63ea2 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config.ts +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config.ts @@ -1,8 +1,11 @@ import { TracingProvider } from './type' export const docURL = { + [TracingProvider.arize]: 'https://docs.arize.com/arize', + [TracingProvider.phoenix]: 'https://docs.arize.com/phoenix', [TracingProvider.langSmith]: 'https://docs.smith.langchain.com/', [TracingProvider.langfuse]: 'https://docs.langfuse.com', [TracingProvider.opik]: 'https://www.comet.com/docs/opik/tracing/integrations/dify#setup-instructions', [TracingProvider.weave]: 'https://weave-docs.wandb.ai/', + [TracingProvider.aliyun]: 'https://help.aliyun.com/zh/arms/tracing-analysis/untitled-document-1750672984680', } diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx index 76e90ecf19..8bf18904be 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx @@ -7,12 +7,12 @@ import { import { useTranslation } from 'react-i18next' import { usePathname } from 'next/navigation' import { useBoolean } from 'ahooks' -import type { LangFuseConfig, LangSmithConfig, OpikConfig, WeaveConfig } from './type' +import type { AliyunConfig, ArizeConfig, LangFuseConfig, LangSmithConfig, OpikConfig, PhoenixConfig, WeaveConfig } from './type' import { TracingProvider } from './type' import TracingIcon from './tracing-icon' import ConfigButton from './config-button' import cn from '@/utils/classnames' -import { LangfuseIcon, LangsmithIcon, OpikIcon, WeaveIcon } from '@/app/components/base/icons/src/public/tracing' +import { AliyunIcon, ArizeIcon, LangfuseIcon, LangsmithIcon, OpikIcon, PhoenixIcon, WeaveIcon } from '@/app/components/base/icons/src/public/tracing' import Indicator from '@/app/components/header/indicator' import { fetchTracingConfig as doFetchTracingConfig, fetchTracingStatus, updateTracingStatus } from '@/service/apps' import type { TracingStatus } from '@/models/app' @@ -62,24 +62,33 @@ const Panel: FC = () => { } const inUseTracingProvider: TracingProvider | null = tracingStatus?.tracing_provider || null - const InUseProviderIcon - = inUseTracingProvider === TracingProvider.langSmith - ? LangsmithIcon - : inUseTracingProvider === TracingProvider.langfuse - ? LangfuseIcon - : inUseTracingProvider === TracingProvider.opik - ? OpikIcon - : inUseTracingProvider === TracingProvider.weave - ? WeaveIcon - : LangsmithIcon + const providerIconMap: Record> = { + [TracingProvider.arize]: ArizeIcon, + [TracingProvider.phoenix]: PhoenixIcon, + [TracingProvider.langSmith]: LangsmithIcon, + [TracingProvider.langfuse]: LangfuseIcon, + [TracingProvider.opik]: OpikIcon, + [TracingProvider.weave]: WeaveIcon, + [TracingProvider.aliyun]: AliyunIcon, + } + const InUseProviderIcon = inUseTracingProvider ? providerIconMap[inUseTracingProvider] : undefined + const [arizeConfig, setArizeConfig] = useState(null) + const [phoenixConfig, setPhoenixConfig] = useState(null) const [langSmithConfig, setLangSmithConfig] = useState(null) const [langFuseConfig, setLangFuseConfig] = useState(null) const [opikConfig, setOpikConfig] = useState(null) const [weaveConfig, setWeaveConfig] = useState(null) - const hasConfiguredTracing = !!(langSmithConfig || langFuseConfig || opikConfig || weaveConfig) + const [aliyunConfig, setAliyunConfig] = useState(null) + const hasConfiguredTracing = !!(langSmithConfig || langFuseConfig || opikConfig || weaveConfig || arizeConfig || phoenixConfig || aliyunConfig) const fetchTracingConfig = async () => { + const { tracing_config: arizeConfig, has_not_configured: arizeHasNotConfig } = await doFetchTracingConfig({ appId, provider: TracingProvider.arize }) + if (!arizeHasNotConfig) + setArizeConfig(arizeConfig as ArizeConfig) + const { tracing_config: phoenixConfig, has_not_configured: phoenixHasNotConfig } = await doFetchTracingConfig({ appId, provider: TracingProvider.phoenix }) + if (!phoenixHasNotConfig) + setPhoenixConfig(phoenixConfig as PhoenixConfig) const { tracing_config: langSmithConfig, has_not_configured: langSmithHasNotConfig } = await doFetchTracingConfig({ appId, provider: TracingProvider.langSmith }) if (!langSmithHasNotConfig) setLangSmithConfig(langSmithConfig as LangSmithConfig) @@ -92,12 +101,19 @@ const Panel: FC = () => { const { tracing_config: weaveConfig, has_not_configured: weaveHasNotConfig } = await doFetchTracingConfig({ appId, provider: TracingProvider.weave }) if (!weaveHasNotConfig) setWeaveConfig(weaveConfig as WeaveConfig) + const { tracing_config: aliyunConfig, has_not_configured: aliyunHasNotConfig } = await doFetchTracingConfig({ appId, provider: TracingProvider.aliyun }) + if (!aliyunHasNotConfig) + setAliyunConfig(aliyunConfig as AliyunConfig) } const handleTracingConfigUpdated = async (provider: TracingProvider) => { // call api to hide secret key value const { tracing_config } = await doFetchTracingConfig({ appId, provider }) - if (provider === TracingProvider.langSmith) + if (provider === TracingProvider.arize) + setArizeConfig(tracing_config as ArizeConfig) + else if (provider === TracingProvider.phoenix) + setPhoenixConfig(tracing_config as PhoenixConfig) + else if (provider === TracingProvider.langSmith) setLangSmithConfig(tracing_config as LangSmithConfig) else if (provider === TracingProvider.langfuse) setLangFuseConfig(tracing_config as LangFuseConfig) @@ -105,10 +121,16 @@ const Panel: FC = () => { setOpikConfig(tracing_config as OpikConfig) else if (provider === TracingProvider.weave) setWeaveConfig(tracing_config as WeaveConfig) + else if (provider === TracingProvider.aliyun) + setAliyunConfig(tracing_config as AliyunConfig) } const handleTracingConfigRemoved = (provider: TracingProvider) => { - if (provider === TracingProvider.langSmith) + if (provider === TracingProvider.arize) + setArizeConfig(null) + else if (provider === TracingProvider.phoenix) + setPhoenixConfig(null) + else if (provider === TracingProvider.langSmith) setLangSmithConfig(null) else if (provider === TracingProvider.langfuse) setLangFuseConfig(null) @@ -116,6 +138,8 @@ const Panel: FC = () => { setOpikConfig(null) else if (provider === TracingProvider.weave) setWeaveConfig(null) + else if (provider === TracingProvider.aliyun) + setAliyunConfig(null) if (provider === inUseTracingProvider) { handleTracingStatusChange({ enabled: false, @@ -170,10 +194,13 @@ const Panel: FC = () => { onStatusChange={handleTracingEnabledChange} chosenProvider={inUseTracingProvider} onChooseProvider={handleChooseProvider} + arizeConfig={arizeConfig} + phoenixConfig={phoenixConfig} langSmithConfig={langSmithConfig} langFuseConfig={langFuseConfig} opikConfig={opikConfig} weaveConfig={weaveConfig} + aliyunConfig={aliyunConfig} onConfigUpdated={handleTracingConfigUpdated} onConfigRemoved={handleTracingConfigRemoved} controlShowPopup={controlShowPopup} @@ -205,10 +232,13 @@ const Panel: FC = () => { onStatusChange={handleTracingEnabledChange} chosenProvider={inUseTracingProvider} onChooseProvider={handleChooseProvider} + arizeConfig={arizeConfig} + phoenixConfig={phoenixConfig} langSmithConfig={langSmithConfig} langFuseConfig={langFuseConfig} opikConfig={opikConfig} weaveConfig={weaveConfig} + aliyunConfig={aliyunConfig} onConfigUpdated={handleTracingConfigUpdated} onConfigRemoved={handleTracingConfigRemoved} controlShowPopup={controlShowPopup} diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-config-modal.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-config-modal.tsx index b6c97add48..318f1f61d6 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-config-modal.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-config-modal.tsx @@ -4,7 +4,7 @@ import React, { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { useBoolean } from 'ahooks' import Field from './field' -import type { LangFuseConfig, LangSmithConfig, OpikConfig, WeaveConfig } from './type' +import type { AliyunConfig, ArizeConfig, LangFuseConfig, LangSmithConfig, OpikConfig, PhoenixConfig, WeaveConfig } from './type' import { TracingProvider } from './type' import { docURL } from './config' import { @@ -22,15 +22,28 @@ import Divider from '@/app/components/base/divider' type Props = { appId: string type: TracingProvider - payload?: LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig | null + payload?: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig | AliyunConfig | null onRemoved: () => void onCancel: () => void - onSaved: (payload: LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig) => void + onSaved: (payload: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig | AliyunConfig) => void onChosen: (provider: TracingProvider) => void } const I18N_PREFIX = 'app.tracing.configProvider' +const arizeConfigTemplate = { + api_key: '', + space_id: '', + project: '', + endpoint: '', +} + +const phoenixConfigTemplate = { + api_key: '', + project: '', + endpoint: '', +} + const langSmithConfigTemplate = { api_key: '', project: '', @@ -58,6 +71,12 @@ const weaveConfigTemplate = { host: '', } +const aliyunConfigTemplate = { + app_name: '', + license_key: '', + endpoint: '', +} + const ProviderConfigModal: FC = ({ appId, type, @@ -71,11 +90,17 @@ const ProviderConfigModal: FC = ({ const isEdit = !!payload const isAdd = !isEdit const [isSaving, setIsSaving] = useState(false) - const [config, setConfig] = useState((() => { + const [config, setConfig] = useState((() => { if (isEdit) return payload - if (type === TracingProvider.langSmith) + if (type === TracingProvider.arize) + return arizeConfigTemplate + + else if (type === TracingProvider.phoenix) + return phoenixConfigTemplate + + else if (type === TracingProvider.langSmith) return langSmithConfigTemplate else if (type === TracingProvider.langfuse) @@ -84,6 +109,9 @@ const ProviderConfigModal: FC = ({ else if (type === TracingProvider.opik) return opikConfigTemplate + else if (type === TracingProvider.aliyun) + return aliyunConfigTemplate + return weaveConfigTemplate })()) const [isShowRemoveConfirm, { @@ -115,6 +143,24 @@ const ProviderConfigModal: FC = ({ const checkValid = useCallback(() => { let errorMessage = '' + if (type === TracingProvider.arize) { + const postData = config as ArizeConfig + if (!postData.api_key) + errorMessage = t('common.errorMsg.fieldRequired', { field: 'API Key' }) + if (!postData.space_id) + errorMessage = t('common.errorMsg.fieldRequired', { field: 'Space ID' }) + if (!errorMessage && !postData.project) + errorMessage = t('common.errorMsg.fieldRequired', { field: t(`${I18N_PREFIX}.project`) }) + } + + if (type === TracingProvider.phoenix) { + const postData = config as PhoenixConfig + if (!postData.api_key) + errorMessage = t('common.errorMsg.fieldRequired', { field: 'API Key' }) + if (!errorMessage && !postData.project) + errorMessage = t('common.errorMsg.fieldRequired', { field: t(`${I18N_PREFIX}.project`) }) + } + if (type === TracingProvider.langSmith) { const postData = config as LangSmithConfig if (!postData.api_key) @@ -146,6 +192,16 @@ const ProviderConfigModal: FC = ({ errorMessage = t('common.errorMsg.fieldRequired', { field: t(`${I18N_PREFIX}.project`) }) } + if (type === TracingProvider.aliyun) { + const postData = config as AliyunConfig + if (!errorMessage && !postData.app_name) + errorMessage = t('common.errorMsg.fieldRequired', { field: 'App Name' }) + if (!errorMessage && !postData.license_key) + errorMessage = t('common.errorMsg.fieldRequired', { field: 'License Key' }) + if (!errorMessage && !postData.endpoint) + errorMessage = t('common.errorMsg.fieldRequired', { field: 'Endpoint' }) + } + return errorMessage }, [config, t, type]) const handleSave = useCallback(async () => { @@ -195,6 +251,93 @@ const ProviderConfigModal: FC = ({
+ {type === TracingProvider.arize && ( + <> + + + + + + )} + {type === TracingProvider.phoenix && ( + <> + + + + + )} + {type === TracingProvider.aliyun && ( + <> + + + + + )} {type === TracingProvider.weave && ( <> { return ({ + [TracingProvider.arize]: ArizeIconBig, + [TracingProvider.phoenix]: PhoenixIconBig, [TracingProvider.langSmith]: LangsmithIconBig, [TracingProvider.langfuse]: LangfuseIconBig, [TracingProvider.opik]: OpikIconBig, [TracingProvider.weave]: WeaveIconBig, + [TracingProvider.aliyun]: AliyunIconBig, })[type] } diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/type.ts b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/type.ts index ed468caf65..78bca41ad2 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/type.ts +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/type.ts @@ -1,8 +1,24 @@ export enum TracingProvider { + arize = 'arize', + phoenix = 'phoenix', langSmith = 'langsmith', langfuse = 'langfuse', opik = 'opik', weave = 'weave', + aliyun = 'aliyun', +} + +export type ArizeConfig = { + api_key: string + space_id: string + project: string + endpoint: string +} + +export type PhoenixConfig = { + api_key: string + project: string + endpoint: string } export type LangSmithConfig = { @@ -31,3 +47,9 @@ export type WeaveConfig = { endpoint: string host: string } + +export type AliyunConfig = { + app_name: string + license_key: string + endpoint: string +} diff --git a/web/app/(commonLayout)/apps/AppCard.tsx b/web/app/(commonLayout)/apps/AppCard.tsx index b7c9a2eee4..f50cc10520 100644 --- a/web/app/(commonLayout)/apps/AppCard.tsx +++ b/web/app/(commonLayout)/apps/AppCard.tsx @@ -339,7 +339,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
{app.author_name}
·
-
{EditTimeText}
+
{EditTimeText}
diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx index fb3a9087ca..acaae3f720 100644 --- a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx @@ -77,7 +77,7 @@ const ExtraInfo = ({ isMobile, relatedApps, expand }: IExtraInfoProps) => { )} - {isMobile &&
+ {isMobile &&
{relatedAppsTotal || '--'}
} diff --git a/web/app/components/app-sidebar/app-info.tsx b/web/app/components/app-sidebar/app-info.tsx index c6d0e776dd..c28cc20df5 100644 --- a/web/app/components/app-sidebar/app-info.tsx +++ b/web/app/components/app-sidebar/app-info.tsx @@ -10,7 +10,6 @@ import { RiFileCopy2Line, RiFileDownloadLine, RiFileUploadLine, - RiMoreLine, } from '@remixicon/react' import AppIcon from '../base/app-icon' import SwitchAppModal from '../app/switch-app-modal' @@ -35,7 +34,8 @@ import ContentDialog from '@/app/components/base/content-dialog' import Button from '@/app/components/base/button' import CardView from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/cardView' import Divider from '../base/divider' -import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../base/portal-to-follow-elem' +import type { Operation } from './app-operations' +import AppOperations from './app-operations' export type IAppInfoProps = { expand: boolean @@ -186,14 +186,58 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx const { isCurrentWorkspaceEditor } = useAppContext() - const [showMore, setShowMore] = useState(false) - const handleTriggerMore = useCallback(() => { - setShowMore(true) - }, [setShowMore]) - if (!appDetail) return null + const operations = [ + { + id: 'edit', + title: t('app.editApp'), + icon: , + onClick: () => { + setOpen(false) + onDetailExpand?.(false) + setShowEditModal(true) + }, + }, + { + id: 'duplicate', + title: t('app.duplicate'), + icon: , + onClick: () => { + setOpen(false) + onDetailExpand?.(false) + setShowDuplicateModal(true) + }, + }, + { + id: 'export', + title: t('app.export'), + icon: , + onClick: exportCheck, + }, + (appDetail.mode !== 'agent-chat' && (appDetail.mode === 'advanced-chat' || appDetail.mode === 'workflow')) ? { + id: 'import', + title: t('workflow.common.importDSL'), + icon: , + onClick: () => { + setOpen(false) + onDetailExpand?.(false) + setShowImportDSLModal(true) + }, + } : undefined, + (appDetail.mode !== 'agent-chat' && (appDetail.mode === 'completion' || appDetail.mode === 'chat')) ? { + id: 'switch', + title: t('app.switch'), + icon: , + onClick: () => { + setOpen(false) + onDetailExpand?.(false) + setShowSwitchModal(true) + }, + } : undefined, + ].filter((op): op is Operation => Boolean(op)) + return (
{!onlyShowDetail && ( @@ -259,88 +303,10 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
{appDetail.description}
)} {/* operations */} -
- - - - {appDetail.mode !== 'agent-chat' && - - - - -
- { - (appDetail.mode === 'advanced-chat' || appDetail.mode === 'workflow') - &&
{ - setOpen(false) - onDetailExpand?.(false) - setShowImportDSLModal(true) - }}> - - {t('workflow.common.importDSL')} -
- } - { - (appDetail.mode === 'completion' || appDetail.mode === 'chat') - &&
{ - setOpen(false) - onDetailExpand?.(false) - setShowSwitchModal(true) - }}> - - {t('app.switch')} -
- } -
-
-
} -
+
void +} + +const AppOperations = ({ operations, gap }: { + operations: Operation[] + gap: number +}) => { + const { t } = useTranslation() + const [visibleOpreations, setVisibleOperations] = useState([]) + const [moreOperations, setMoreOperations] = useState([]) + const [showMore, setShowMore] = useState(false) + const navRef = useRef(null) + const handleTriggerMore = useCallback(() => { + setShowMore(true) + }, [setShowMore]) + + useEffect(() => { + const moreElement = document.getElementById('more') + const navElement = document.getElementById('nav') + let width = 0 + const containerWidth = navElement?.clientWidth ?? 0 + const moreWidth = moreElement?.clientWidth ?? 0 + + if (containerWidth === 0 || moreWidth === 0) return + + const updatedEntries: Record = operations.reduce((pre, cur) => { + pre[cur.id] = false + return pre + }, {} as Record) + const childrens = Array.from(navRef.current!.children).slice(0, -1) + for (let i = 0; i < childrens.length; i++) { + const child: any = childrens[i] + const id = child.dataset.targetid + if (!id) break + const childWidth = child.clientWidth + + if (width + gap + childWidth + moreWidth <= containerWidth) { + updatedEntries[id] = true + width += gap + childWidth + } + else { + if (i === childrens.length - 1 && width + childWidth <= containerWidth) + updatedEntries[id] = true + else + updatedEntries[id] = false + break + } + } + setVisibleOperations(operations.filter(item => updatedEntries[item.id])) + setMoreOperations(operations.filter(item => !updatedEntries[item.id])) + }, [operations, gap]) + + return ( + <> + {!visibleOpreations.length && } +
+ {visibleOpreations.map(operation => + , + )} + {visibleOpreations.length < operations.length && + + + + +
+ {moreOperations.map(item =>
+ {cloneElement(item.icon, { className: 'h-4 w-4 text-text-tertiary' })} + {item.title} +
)} +
+
+
} +
+ + ) +} + +export default AppOperations diff --git a/web/app/components/app-sidebar/basic.tsx b/web/app/components/app-sidebar/basic.tsx index 6a7d5a13c2..00357d6c27 100644 --- a/web/app/components/app-sidebar/basic.tsx +++ b/web/app/components/app-sidebar/basic.tsx @@ -2,6 +2,10 @@ import React from 'react' import { useTranslation } from 'react-i18next' import AppIcon from '../base/app-icon' import Tooltip from '@/app/components/base/tooltip' +import { + Code, + WindowCursor, +} from '@/app/components/base/icons/src/vender/workflow' export type IAppBasicProps = { iconType?: 'app' | 'api' | 'dataset' | 'webapp' | 'notion' @@ -14,25 +18,13 @@ export type IAppBasicProps = { textStyle?: { main?: string; extra?: string } isExtraInLine?: boolean mode?: string + hideType?: boolean } -const ApiSvg = - - - - - - - - const DatasetSvg = -const WebappSvg = - - - const NotionSvg = @@ -48,13 +40,17 @@ const NotionSvg = , - api: , + api:
+ +
, dataset: , - webapp: , + webapp:
+ +
, notion: , } -export default function AppBasic({ icon, icon_background, name, isExternal, type, hoverTip, textStyle, isExtraInLine, mode = 'expand', iconType = 'app' }: IAppBasicProps) { +export default function AppBasic({ icon, icon_background, name, isExternal, type, hoverTip, textStyle, isExtraInLine, mode = 'expand', iconType = 'app', hideType }: IAppBasicProps) { const { t } = useTranslation() return ( @@ -88,9 +84,10 @@ export default function AppBasic({ icon, icon_background, name, isExternal, type /> }
- {isExtraInLine ? ( + {!hideType && isExtraInLine && (
{type}
- ) : ( + )} + {!hideType && !isExtraInLine && (
{isExternal ? t('dataset.externalTag') : type}
)}
} diff --git a/web/app/components/app/configuration/config-var/select-var-type.tsx b/web/app/components/app/configuration/config-var/select-var-type.tsx index f82e931882..485f9932f3 100644 --- a/web/app/components/app/configuration/config-var/select-var-type.tsx +++ b/web/app/components/app/configuration/config-var/select-var-type.tsx @@ -2,7 +2,6 @@ import type { FC } from 'react' import React, { useState } from 'react' import { useTranslation } from 'react-i18next' -import cn from '@/utils/classnames' import OperationBtn from '@/app/components/app/configuration/base/operation-btn' import { PortalToFollowElem, @@ -28,11 +27,11 @@ type ItemProps = { const SelectItem: FC = ({ text, type, value, Icon, onClick }) => { return (
onClick(value)} > - {Icon ? : } -
{text}
+ {Icon ? : } +
{text}
) } @@ -57,17 +56,17 @@ const SelectVarType: FC = ({ }} > setOpen(v => !v)}> - + -
+
-
+
diff --git a/web/app/components/app/configuration/config/agent/agent-tools/index.tsx b/web/app/components/app/configuration/config/agent/agent-tools/index.tsx index a3149447d4..66fe85a170 100644 --- a/web/app/components/app/configuration/config/agent/agent-tools/index.tsx +++ b/web/app/components/app/configuration/config/agent/agent-tools/index.tsx @@ -30,15 +30,31 @@ import ConfigCredential from '@/app/components/tools/setting/build-in/config-cre import { updateBuiltInToolCredential } from '@/service/tools' import cn from '@/utils/classnames' import ToolPicker from '@/app/components/workflow/block-selector/tool-picker' -import type { ToolDefaultValue } from '@/app/components/workflow/block-selector/types' +import type { ToolDefaultValue, ToolValue } from '@/app/components/workflow/block-selector/types' import { canFindTool } from '@/utils' +import { useAllBuiltInTools, useAllCustomTools, useAllMCPTools, useAllWorkflowTools } from '@/service/use-tools' +import type { ToolWithProvider } from '@/app/components/workflow/types' import { useMittContextSelector } from '@/context/mitt-context' type AgentToolWithMoreInfo = AgentTool & { icon: any; collection?: Collection } | null const AgentTools: FC = () => { const { t } = useTranslation() const [isShowChooseTool, setIsShowChooseTool] = useState(false) - const { modelConfig, setModelConfig, collectionList } = useContext(ConfigContext) + const { modelConfig, setModelConfig } = useContext(ConfigContext) + const { data: buildInTools } = useAllBuiltInTools() + const { data: customTools } = useAllCustomTools() + const { data: workflowTools } = useAllWorkflowTools() + const { data: mcpTools } = useAllMCPTools() + const collectionList = useMemo(() => { + const allTools = [ + ...(buildInTools || []), + ...(customTools || []), + ...(workflowTools || []), + ...(mcpTools || []), + ] + return allTools + }, [buildInTools, customTools, workflowTools, mcpTools]) + const formattingChangedDispatcher = useFormattingChangedDispatcher() const [currentTool, setCurrentTool] = useState(null) const currentCollection = useMemo(() => { @@ -96,23 +112,38 @@ const AgentTools: FC = () => { } const [isDeleting, setIsDeleting] = useState(-1) - + const getToolValue = (tool: ToolDefaultValue) => { + return { + provider_id: tool.provider_id, + provider_type: tool.provider_type as CollectionType, + provider_name: tool.provider_name, + tool_name: tool.tool_name, + tool_label: tool.tool_label, + tool_parameters: tool.params, + notAuthor: !tool.is_team_authorization, + enabled: true, + } + } const handleSelectTool = (tool: ToolDefaultValue) => { const newModelConfig = produce(modelConfig, (draft) => { - draft.agentConfig.tools.push({ - provider_id: tool.provider_id, - provider_type: tool.provider_type as CollectionType, - provider_name: tool.provider_name, - tool_name: tool.tool_name, - tool_label: tool.tool_label, - tool_parameters: tool.params, - notAuthor: !tool.is_team_authorization, - enabled: true, - }) + draft.agentConfig.tools.push(getToolValue(tool)) }) setModelConfig(newModelConfig) } + const handleSelectMultipleTool = (tool: ToolDefaultValue[]) => { + const newModelConfig = produce(modelConfig, (draft) => { + draft.agentConfig.tools.push(...tool.map(getToolValue)) + }) + setModelConfig(newModelConfig) + } + const getProviderShowName = (item: AgentTool) => { + const type = item.provider_type + if(type === CollectionType.builtIn) + return item.provider_name.split('/').pop() + return item.provider_name + } + return ( <> { disabled={false} supportAddCustomTool onSelect={handleSelectTool} - selectedTools={tools as any} + onSelectMultiple={handleSelectMultipleTool} + selectedTools={tools as unknown as ToolValue[]} + canChooseMCPTool /> )} @@ -161,7 +194,7 @@ const AgentTools: FC = () => {
{item.isDeleted && } {!item.isDeleted && ( -
+
{typeof item.icon === 'string' &&
} {typeof item.icon !== 'string' && }
@@ -172,7 +205,7 @@ const AgentTools: FC = () => { (item.isDeleted || item.notAuthor || !item.enabled) ? 'opacity-50' : '', )} > - {item.provider_type === CollectionType.builtIn ? item.provider_name.split('/').pop() : item.tool_label} + {getProviderShowName(item)} {item.tool_label} {!item.isDeleted && ( { setIsShowSettingTool(false)} diff --git a/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx b/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx index 952ad66fc4..1ad814c6e9 100644 --- a/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx +++ b/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx @@ -24,10 +24,11 @@ import { fetchBuiltInToolList, fetchCustomToolList, fetchModelToolList, fetchWor import I18n from '@/context/i18n' import { getLanguage } from '@/i18n/language' import cn from '@/utils/classnames' +import type { ToolWithProvider } from '@/app/components/workflow/types' type Props = { showBackButton?: boolean - collection: Collection + collection: Collection | ToolWithProvider isBuiltIn?: boolean isModel?: boolean toolName: string @@ -51,9 +52,10 @@ const SettingBuiltInTool: FC = ({ const { locale } = useContext(I18n) const language = getLanguage(locale) const { t } = useTranslation() - - const [isLoading, setIsLoading] = useState(true) - const [tools, setTools] = useState([]) + const passedTools = (collection as ToolWithProvider).tools + const hasPassedTools = passedTools?.length > 0 + const [isLoading, setIsLoading] = useState(!hasPassedTools) + const [tools, setTools] = useState(hasPassedTools ? passedTools : []) const currTool = tools.find(tool => tool.name === toolName) const formSchemas = currTool ? toolParametersToFormSchemas(currTool.parameters) : [] const infoSchemas = formSchemas.filter(item => item.form === 'llm') @@ -63,7 +65,7 @@ const SettingBuiltInTool: FC = ({ const [currType, setCurrType] = useState('info') const isInfoActive = currType === 'info' useEffect(() => { - if (!collection) + if (!collection || hasPassedTools) return (async () => { diff --git a/web/app/components/app/overview/appCard.tsx b/web/app/components/app/overview/appCard.tsx index 9f3b3ac4a6..f11e111cb0 100644 --- a/web/app/components/app/overview/appCard.tsx +++ b/web/app/components/app/overview/appCard.tsx @@ -181,6 +181,7 @@ function AppCard({ icon={appInfo.icon} icon_background={appInfo.icon_background} name={basicName} + hideType type={ isApp ? t('appOverview.overview.appInfo.explanation') diff --git a/web/app/components/base/app-icon/index.tsx b/web/app/components/base/app-icon/index.tsx index ac17af1988..003d929c8c 100644 --- a/web/app/components/base/app-icon/index.tsx +++ b/web/app/components/base/app-icon/index.tsx @@ -18,6 +18,7 @@ export type AppIconProps = { imageUrl?: string | null className?: string innerIcon?: React.ReactNode + coverElement?: React.ReactNode onClick?: () => void } const appIconVariants = cva( @@ -51,6 +52,7 @@ const AppIcon: FC = ({ imageUrl, className, innerIcon, + coverElement, onClick, }) => { const isValidImageIcon = iconType === 'image' && imageUrl @@ -65,6 +67,7 @@ const AppIcon: FC = ({ ? app icon : (innerIcon || ((icon && icon !== '') ? : )) } + {coverElement} } diff --git a/web/app/components/base/chat/chat/question.tsx b/web/app/components/base/chat/chat/question.tsx index 30077125f9..cae8e2b8ce 100644 --- a/web/app/components/base/chat/chat/question.tsx +++ b/web/app/components/base/chat/chat/question.tsx @@ -98,7 +98,7 @@ const Question: FC = ({ return (
-
+
= ({
{ diff --git a/web/app/components/base/icons/assets/public/tracing/aliyun-icon-big.svg b/web/app/components/base/icons/assets/public/tracing/aliyun-icon-big.svg new file mode 100644 index 0000000000..210a1cd00b --- /dev/null +++ b/web/app/components/base/icons/assets/public/tracing/aliyun-icon-big.svg @@ -0,0 +1 @@ + diff --git a/web/app/components/base/icons/assets/public/tracing/aliyun-icon.svg b/web/app/components/base/icons/assets/public/tracing/aliyun-icon.svg new file mode 100644 index 0000000000..6f7645301c --- /dev/null +++ b/web/app/components/base/icons/assets/public/tracing/aliyun-icon.svg @@ -0,0 +1 @@ + diff --git a/web/app/components/base/icons/assets/public/tracing/arize-icon-big.svg b/web/app/components/base/icons/assets/public/tracing/arize-icon-big.svg new file mode 100644 index 0000000000..80667847ab --- /dev/null +++ b/web/app/components/base/icons/assets/public/tracing/arize-icon-big.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/app/components/base/icons/assets/public/tracing/arize-icon.svg b/web/app/components/base/icons/assets/public/tracing/arize-icon.svg new file mode 100644 index 0000000000..f43e28ff83 --- /dev/null +++ b/web/app/components/base/icons/assets/public/tracing/arize-icon.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/app/components/base/icons/assets/public/tracing/phoenix-icon-big.svg b/web/app/components/base/icons/assets/public/tracing/phoenix-icon-big.svg new file mode 100644 index 0000000000..d22e928449 --- /dev/null +++ b/web/app/components/base/icons/assets/public/tracing/phoenix-icon-big.svg @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/app/components/base/icons/assets/public/tracing/phoenix-icon.svg b/web/app/components/base/icons/assets/public/tracing/phoenix-icon.svg new file mode 100644 index 0000000000..b30edd6c97 --- /dev/null +++ b/web/app/components/base/icons/assets/public/tracing/phoenix-icon.svg @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/app/components/base/icons/assets/vender/line/others/search-menu.svg b/web/app/components/base/icons/assets/vender/line/others/search-menu.svg new file mode 100644 index 0000000000..f61f69f4ba --- /dev/null +++ b/web/app/components/base/icons/assets/vender/line/others/search-menu.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/web/app/components/base/icons/assets/vender/other/mcp.svg b/web/app/components/base/icons/assets/vender/other/mcp.svg new file mode 100644 index 0000000000..7415c060dd --- /dev/null +++ b/web/app/components/base/icons/assets/vender/other/mcp.svg @@ -0,0 +1,4 @@ + + + + diff --git a/web/app/components/base/icons/assets/vender/other/no-tool-placeholder.svg b/web/app/components/base/icons/assets/vender/other/no-tool-placeholder.svg new file mode 100644 index 0000000000..8b8729412f --- /dev/null +++ b/web/app/components/base/icons/assets/vender/other/no-tool-placeholder.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/app/components/base/icons/assets/vender/workflow/window-cursor.svg b/web/app/components/base/icons/assets/vender/workflow/window-cursor.svg new file mode 100644 index 0000000000..af8a9bac94 --- /dev/null +++ b/web/app/components/base/icons/assets/vender/workflow/window-cursor.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/web/app/components/base/icons/src/public/tracing/AliyunIcon.json b/web/app/components/base/icons/src/public/tracing/AliyunIcon.json new file mode 100644 index 0000000000..5cbb52c237 --- /dev/null +++ b/web/app/components/base/icons/src/public/tracing/AliyunIcon.json @@ -0,0 +1,118 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "xmlns": "http://www.w3.org/2000/svg", + "xmlns:xlink": "http://www.w3.org/1999/xlink", + "fill": "none", + "version": "1.1", + "width": "65", + "height": "16", + "viewBox": "0 0 65 16" + }, + "children": [ + { + "type": "element", + "name": "defs", + "children": [ + { + "type": "element", + "name": "clipPath", + "attributes": { + "id": "master_svg0_42_34281" + }, + "children": [ + { + "type": "element", + "name": "rect", + "attributes": { + "x": "0", + "y": "0", + "width": "19", + "height": "16", + "rx": "0" + } + } + ] + } + ] + }, + { + "type": "element", + "name": "g", + "children": [ + { + "type": "element", + "name": "g", + "attributes": { + "clip-path": "url(#master_svg0_42_34281)" + }, + "children": [ + { + "type": "element", + "name": "g", + "children": [ + { + "type": "element", + "name": "g", + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M4.06862,14.6667C3.79213,14.6667,3.45463,14.5688,3.05614,14.373C2.97908,14.3351,2.92692,14.3105,2.89968,14.2992C2.33193,14.0628,1.82911,13.7294,1.39123,13.2989C0.463742,12.3871,0,11.2874,0,10C0,8.71258,0.463742,7.61293,1.39123,6.70107C2.16172,5.94358,3.06404,5.50073,4.09819,5.37252C4.23172,3.98276,4.81755,2.77756,5.85569,1.75693C7.04708,0.585642,8.4857,0,10.1716,0C11.5256,0,12.743,0.396982,13.8239,1.19095C14.8847,1.97019,15.61,2.97855,16,4.21604L14.7045,4.61063C14.4016,3.64918,13.8374,2.86532,13.0121,2.25905C12.1719,1.64191,11.2251,1.33333,10.1716,1.33333C8.8602,1.33333,7.74124,1.7888,6.81467,2.69974C5.88811,3.61067,5.42483,4.71076,5.42483,6L5.42483,6.66667L4.74673,6.66667C3.81172,6.66667,3.01288,6.99242,2.35021,7.64393C1.68754,8.2954,1.35621,9.08076,1.35621,10C1.35621,10.9192,1.68754,11.7046,2.35021,12.3561C2.66354,12.6641,3.02298,12.9026,3.42852,13.0714C3.48193,13.0937,3.55988,13.13,3.66237,13.1803C3.87004,13.2823,4.00545,13.3333,4.06862,13.3333L4.06862,14.6667Z", + "fill-rule": "evenodd", + "fill": "#FF6A00", + "fill-opacity": "1" + } + } + ] + }, + { + "type": "element", + "name": "g", + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M13.458613505859375,7.779393492279053C12.975613505859375,7.717463492279053,12.484813505859375,7.686503492279053,11.993983505859376,7.686503492279053C11.152583505859376,7.686503492279053,10.303403505859375,7.779393492279053,9.493183505859374,7.941943492279053C8.682953505859375,8.104503492279052,7.903893505859375,8.359943492279053,7.155983505859375,8.654083492279053C6.657383505859375,8.870823492279053,6.158783505859375,9.128843492279053,5.660181505859375,9.428153492279053C5.332974751859375,9.621673492279053,5.239486705859375,10.070633492279054,5.434253505859375,10.395743492279053L7.413073505859375,13.298533492279052C7.639003505859375,13.623603492279052,8.090863505859375,13.716463492279052,8.418073505859375,13.523003492279052C8.547913505859375,13.435263492279052,8.763453505859374,13.326893492279053,9.064693505859374,13.197863492279053C9.516553505859374,13.004333492279052,9.976203505859374,12.872733492279053,10.459223505859375,12.779863492279052C10.942243505859375,12.679263492279052,11.433053505859375,12.617333492279052,11.955023505859375,12.617333492279052L13.380683505859375,7.810353492279052L13.458613505859375,7.779393492279053ZM15.273813505859374,8.135463492279053L15.016753505859375,5.333333492279053L13.458613505859375,7.787133492279053C13.817013505859375,7.818093492279052,14.144213505859375,7.880023492279053,14.494743505859375,7.949683492279053C14.494743505859375,7.944523492279053,14.754433505859375,8.006453492279054,15.273813505859374,8.135463492279053ZM12.064083505859376,12.648273492279053L11.378523505859375,14.970463492279054L12.515943505859376,16.00003349227905L14.074083505859376,15.643933492279054L14.525943505859376,13.027603492279052C14.198743505859374,12.934663492279054,13.879283505859375,12.834063492279054,13.552083505859375,12.772133492279053C13.069083505859375,12.717933492279052,12.578283505859375,12.648273492279053,12.064083505859376,12.648273492279053ZM18.327743505859374,9.428153492279053C17.829143505859374,9.128843492279053,17.330543505859374,8.870823492279053,16.831943505859375,8.654083492279053C16.348943505859374,8.460573492279053,15.826943505859376,8.267053492279054,15.305013505859375,8.135463492279053L15.305013505859375,8.267053492279054L14.463613505859374,13.043063492279053C14.596083505859376,13.105003492279053,14.759683505859375,13.135933492279053,14.884283505859376,13.205603492279053C15.185523505859376,13.334623492279052,15.401043505859375,13.443003492279052,15.530943505859375,13.530733492279053C15.858143505859376,13.724263492279054,16.341143505859375,13.623603492279052,16.535943505859375,13.306263492279053L18.514743505859375,10.403483492279053C18.779643505859376,10.039673492279054,18.686143505859377,9.621673492279053,18.327743505859374,9.428153492279053Z", + "fill": "#FF6A00", + "fill-opacity": "1" + } + } + ] + } + ] + } + ] + }, + { + "type": "element", + "name": "g", + "children": [ + { + "type": "element", + "name": "g", + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M25.044,2.668L34.676,2.668L34.676,4.04L25.044,4.04L25.044,2.668ZM29.958,7.82Q29.258,9.066,28.355,10.41Q27.451999999999998,11.754,26.92,12.3L32.506,11.782Q31.442,10.158,30.84,9.346L32.058,8.562000000000001Q32.786,9.5,33.843,11.012Q34.9,12.524,35.516,13.546L34.214,14.526Q33.891999999999996,13.966,33.346000000000004,13.098Q32.016,13.182,29.734,13.378Q27.451999999999998,13.574,25.87,13.742L25.31,13.812L24.834,13.882L24.414,12.468Q24.708,12.37,24.862000000000002,12.265Q25.016,12.16,25.121,12.069Q25.226,11.978,25.268,11.936Q25.912,11.32,26.724,10.165Q27.536,9.01,28.208,7.82L23.854,7.82L23.854,6.434L35.866,6.434L35.866,7.82L29.958,7.82ZM42.656,7.414L42.656,8.576L41.354,8.576L41.354,1.814L42.656,1.87L42.656,7.036Q43.314,5.846,43.888000000000005,4.369Q44.462,2.892,44.714,1.6600000000000001L46.086,1.981999Q45.96,2.612,45.722,3.41L49.6,3.41L49.6,4.74L45.274,4.74Q44.616,6.56,43.706,8.128L42.656,7.414ZM38.596000000000004,2.346L39.884,2.402L39.884,8.212L38.596000000000004,8.212L38.596000000000004,2.346ZM46.184,4.964Q46.688,5.356,47.5,6.175Q48.312,6.994,48.788,7.582L47.751999999999995,8.59Q47.346000000000004,8.072,46.576,7.274Q45.806,6.476,45.204,5.902L46.184,4.964ZM48.41,9.01L48.41,12.706L49.894,12.706L49.894,13.966L37.391999999999996,13.966L37.391999999999996,12.706L38.848,12.706L38.848,9.01L48.41,9.01ZM41.676,10.256L40.164,10.256L40.164,12.706L41.676,12.706L41.676,10.256ZM42.908,12.706L44.364000000000004,12.706L44.364000000000004,10.256L42.908,10.256L42.908,12.706ZM45.582,12.706L47.108000000000004,12.706L47.108000000000004,10.256L45.582,10.256L45.582,12.706ZM54.906,7.456L55.116,8.394L54.178,8.814L54.178,12.818Q54.178,13.434,54.031,13.735Q53.884,14.036,53.534,14.162Q53.184,14.288,52.456,14.358L51.867999999999995,14.414L51.476,13.084L52.162,13.028Q52.512,13,52.652,12.958Q52.792,12.916,52.841,12.797Q52.89,12.678,52.89,12.384L52.89,9.36Q51.980000000000004,9.724,51.322,9.948L51.013999999999996,8.576Q51.798,8.324,52.89,7.876L52.89,5.524L51.42,5.524L51.42,4.166L52.89,4.166L52.89,1.7579989999999999L54.178,1.814L54.178,4.166L55.214,4.166L55.214,5.524L54.178,5.524L54.178,7.316L54.808,7.022L54.906,7.456ZM56.894,4.5440000000000005L56.894,6.098L55.564,6.098L55.564,3.256L58.686,3.256Q58.42,2.346,58.266,1.9260000000000002L59.624,1.7579989999999999Q59.848,2.276,60.142,3.256L63.25,3.256L63.25,6.098L61.962,6.098L61.962,4.5440000000000005L56.894,4.5440000000000005ZM59.008,6.322Q58.392,6.938,57.685,7.512Q56.978,8.086,55.956,8.841999999999999L55.242,7.764Q56.824,6.728,58.126,5.37L59.008,6.322ZM60.422,5.37Q61.024,5.776,62.095,6.581Q63.166,7.386,63.656,7.806L62.942,8.982Q62.368,8.45,61.332,7.652Q60.296,6.854,59.666,6.434L60.422,5.37ZM62.592,10.256L60.044,10.256L60.044,12.566L63.572,12.566L63.572,13.826L55.144,13.826L55.144,12.566L58.63,12.566L58.63,10.256L56.054,10.256L56.054,8.982L62.592,8.982L62.592,10.256Z", + "fill": "#FF6A00", + "fill-opacity": "1" + } + } + ] + } + ] + } + ] + } + ] + }, + "name": "AliyunIcon" +} diff --git a/web/app/components/base/icons/src/public/tracing/AliyunIcon.tsx b/web/app/components/base/icons/src/public/tracing/AliyunIcon.tsx new file mode 100644 index 0000000000..5b062b8a86 --- /dev/null +++ b/web/app/components/base/icons/src/public/tracing/AliyunIcon.tsx @@ -0,0 +1,16 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './AliyunIcon.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase' + +const Icon = React.forwardRef, Omit>(( + props, + ref, +) => ) + +Icon.displayName = 'AliyunIcon' + +export default Icon diff --git a/web/app/components/base/icons/src/public/tracing/AliyunIconBig.json b/web/app/components/base/icons/src/public/tracing/AliyunIconBig.json new file mode 100644 index 0000000000..ea60744daf --- /dev/null +++ b/web/app/components/base/icons/src/public/tracing/AliyunIconBig.json @@ -0,0 +1,71 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "xmlns": "http://www.w3.org/2000/svg", + "xmlns:xlink": "http://www.w3.org/1999/xlink", + "fill": "none", + "version": "1.1", + "width": "96", + "height": "24", + "viewBox": "0 0 96 24" + }, + "children": [ + { + "type": "element", + "name": "g", + "children": [ + { + "type": "element", + "name": "g", + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M6.10294,22C5.68819,22,5.18195,21.8532,4.58421,21.5595C4.46861,21.5027,4.39038,21.4658,4.34951,21.4488C3.49789,21.0943,2.74367,20.5941,2.08684,19.9484C0.695613,18.5806,0,16.9311,0,15C0,13.0689,0.695612,11.4194,2.08684,10.0516C3.24259,8.91537,4.59607,8.2511,6.14728,8.05878C6.34758,5.97414,7.22633,4.16634,8.78354,2.63539C10.5706,0.878463,12.7286,0,15.2573,0C17.2884,0,19.1146,0.595472,20.7358,1.78642C22.327,2.95528,23.4151,4.46783,24,6.32406L22.0568,6.91594C21.6024,5.47377,20.7561,4.29798,19.5181,3.38858C18.2579,2.46286,16.8377,2,15.2573,2C13.2903,2,11.6119,2.6832,10.222,4.04961C8.83217,5.41601,8.13725,7.06614,8.13725,9L8.13725,10L7.12009,10C5.71758,10,4.51932,10.4886,3.52532,11.4659C2.53132,12.4431,2.03431,13.6211,2.03431,15C2.03431,16.3789,2.53132,17.5569,3.52532,18.5341C3.99531,18.9962,4.53447,19.3538,5.14278,19.6071C5.2229,19.6405,5.33983,19.695,5.49356,19.7705C5.80505,19.9235,6.00818,20,6.10294,20L6.10294,22Z", + "fill-rule": "evenodd", + "fill": "#FF6A00", + "fill-opacity": "1" + } + } + ] + }, + { + "type": "element", + "name": "g", + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M20.18796103515625,11.66909C19.46346103515625,11.5762,18.72726103515625,11.52975,17.991011035156248,11.52975C16.728921035156247,11.52975,15.45515103515625,11.66909,14.23981103515625,11.91292C13.02447103515625,12.156749999999999,11.85588103515625,12.539909999999999,10.73402103515625,12.98113C9.98612103515625,13.306239999999999,9.23822103515625,13.69327,8.49031803515625,14.14223C7.99950790415625,14.43251,7.85927603515625,15.10595,8.15142503515625,15.59361L11.11966103515625,19.9478C11.45855103515625,20.4354,12.13634103515625,20.5747,12.627151035156249,20.2845C12.821921035156251,20.152900000000002,13.14523103515625,19.990299999999998,13.59708103515625,19.796799999999998C14.27487103515625,19.506500000000003,14.964341035156249,19.3091,15.68887103515625,19.169800000000002C16.413401035156248,19.018900000000002,17.14962103515625,18.926000000000002,17.93258103515625,18.926000000000002L20.071061035156248,11.715530000000001L20.18796103515625,11.66909ZM22.91076103515625,12.20319L22.525161035156252,8L20.18796103515625,11.6807C20.72556103515625,11.72714,21.21636103515625,11.82003,21.74216103515625,11.92453C21.74216103515625,11.91679,22.13166103515625,12.00968,22.91076103515625,12.20319ZM18.09616103515625,18.9724L17.06782103515625,22.4557L18.773961035156248,24L21.11116103515625,23.465899999999998L21.788961035156248,19.5414C21.298161035156248,19.402,20.81896103515625,19.2511,20.32816103515625,19.1582C19.60366103515625,19.076900000000002,18.86746103515625,18.9724,18.09616103515625,18.9724ZM27.49166103515625,14.14223C26.74376103515625,13.69327,25.99586103515625,13.306239999999999,25.24796103515625,12.98113C24.52346103515625,12.69086,23.74046103515625,12.40058,22.95756103515625,12.20319L22.95756103515625,12.40058L21.69546103515625,19.5646C21.89416103515625,19.6575,22.139561035156248,19.7039,22.32646103515625,19.8084C22.77836103515625,20.0019,23.101661035156248,20.1645,23.29646103515625,20.2961C23.78726103515625,20.586399999999998,24.51176103515625,20.4354,24.80396103515625,19.959400000000002L27.77216103515625,15.605229999999999C28.16946103515625,15.05951,28.02926103515625,14.43251,27.49166103515625,14.14223Z", + "fill": "#FF6A00", + "fill-opacity": "1" + } + } + ] + }, + { + "type": "element", + "name": "g", + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M35.785,3.8624638671875L50.233000000000004,3.8624638671875L50.233000000000004,5.9204638671875L35.785,5.9204638671875L35.785,3.8624638671875ZM43.156,11.5904638671875Q42.106,13.4594638671875,40.7515,15.4754638671875Q39.397,17.4914638671875,38.599000000000004,18.3104638671875L46.978,17.5334638671875Q45.382,15.0974638671875,44.479,13.8794638671875L46.306,12.7034638671875Q47.397999999999996,14.1104638671875,48.9835,16.3784638671875Q50.569,18.6464638671875,51.492999999999995,20.1794638671875L49.54,21.6494638671875Q49.057,20.8094638671875,48.238,19.5074638671875Q46.243,19.6334638671875,42.82,19.9274638671875Q39.397,20.2214638671875,37.024,20.4734638671875L36.184,20.5784638671875L35.47,20.6834638671875L34.84,18.5624638671875Q35.281,18.4154638671875,35.512,18.2579638671875Q35.743,18.1004638671875,35.9005,17.963963867187502Q36.058,17.8274638671875,36.121,17.7644638671875Q37.087,16.840463867187502,38.305,15.1079638671875Q39.522999999999996,13.3754638671875,40.531,11.5904638671875L34,11.5904638671875L34,9.5114638671875L52.018,9.5114638671875L52.018,11.5904638671875L43.156,11.5904638671875ZM62.203,10.9814638671875L62.203,12.7244638671875L60.25,12.7244638671875L60.25,2.5814638671875L62.203,2.6654638671875L62.203,10.4144638671875Q63.19,8.6294638671875,64.051,6.4139638671875Q64.912,4.1984638671875,65.28999999999999,2.3504638671875L67.348,2.8334628671875Q67.15899999999999,3.7784638671875,66.80199999999999,4.9754638671875L72.619,4.9754638671875L72.619,6.9704638671875L66.13,6.9704638671875Q65.143,9.7004638671875,63.778,12.0524638671875L62.203,10.9814638671875ZM56.113,3.3794638671875L58.045,3.4634638671875L58.045,12.1784638671875L56.113,12.1784638671875L56.113,3.3794638671875ZM67.495,7.3064638671875Q68.251,7.8944638671875,69.469,9.1229638671875Q70.687,10.3514638671875,71.40100000000001,11.2334638671875L69.84700000000001,12.7454638671875Q69.238,11.9684638671875,68.083,10.7714638671875Q66.928,9.5744638671875,66.025,8.7134638671875L67.495,7.3064638671875ZM70.834,13.3754638671875L70.834,18.9194638671875L73.06,18.9194638671875L73.06,20.8094638671875L54.307,20.8094638671875L54.307,18.9194638671875L56.491,18.9194638671875L56.491,13.3754638671875L70.834,13.3754638671875ZM60.733000000000004,15.2444638671875L58.465,15.2444638671875L58.465,18.9194638671875L60.733000000000004,18.9194638671875L60.733000000000004,15.2444638671875ZM62.581,18.9194638671875L64.765,18.9194638671875L64.765,15.2444638671875L62.581,15.2444638671875L62.581,18.9194638671875ZM66.592,18.9194638671875L68.881,18.9194638671875L68.881,15.2444638671875L66.592,15.2444638671875L66.592,18.9194638671875ZM80.578,11.0444638671875L80.893,12.4514638671875L79.48599999999999,13.0814638671875L79.48599999999999,19.0874638671875Q79.48599999999999,20.0114638671875,79.2655,20.4629638671875Q79.045,20.9144638671875,78.52000000000001,21.1034638671875Q77.995,21.2924638671875,76.90299999999999,21.3974638671875L76.021,21.4814638671875L75.43299999999999,19.4864638671875L76.462,19.4024638671875Q76.987,19.3604638671875,77.197,19.2974638671875Q77.407,19.2344638671875,77.4805,19.0559638671875Q77.554,18.8774638671875,77.554,18.4364638671875L77.554,13.9004638671875Q76.189,14.4464638671875,75.202,14.7824638671875L74.74000000000001,12.7244638671875Q75.916,12.3464638671875,77.554,11.6744638671875L77.554,8.1464638671875L75.34899999999999,8.1464638671875L75.34899999999999,6.1094638671875L77.554,6.1094638671875L77.554,2.4974628671875L79.48599999999999,2.5814638671875L79.48599999999999,6.1094638671875L81.03999999999999,6.1094638671875L81.03999999999999,8.1464638671875L79.48599999999999,8.1464638671875L79.48599999999999,10.8344638671875L80.431,10.3934638671875L80.578,11.0444638671875ZM83.56,6.6764638671875L83.56,9.0074638671875L81.565,9.0074638671875L81.565,4.7444638671875L86.24799999999999,4.7444638671875Q85.84899999999999,3.3794638671875,85.618,2.7494638671875L87.655,2.4974628671875Q87.991,3.2744638671875,88.432,4.7444638671875L93.094,4.7444638671875L93.094,9.0074638671875L91.162,9.0074638671875L91.162,6.6764638671875L83.56,6.6764638671875ZM86.731,9.3434638671875Q85.807,10.2674638671875,84.7465,11.1284638671875Q83.686,11.9894638671875,82.15299999999999,13.1234638671875L81.082,11.5064638671875Q83.455,9.9524638671875,85.408,7.9154638671875L86.731,9.3434638671875ZM88.852,7.9154638671875Q89.755,8.5244638671875,91.3615,9.731963867187499Q92.968,10.9394638671875,93.703,11.5694638671875L92.632,13.3334638671875Q91.771,12.5354638671875,90.217,11.3384638671875Q88.663,10.1414638671875,87.718,9.5114638671875L88.852,7.9154638671875ZM92.107,15.2444638671875L88.285,15.2444638671875L88.285,18.7094638671875L93.577,18.7094638671875L93.577,20.5994638671875L80.935,20.5994638671875L80.935,18.7094638671875L86.164,18.7094638671875L86.164,15.2444638671875L82.3,15.2444638671875L82.3,13.3334638671875L92.107,13.3334638671875L92.107,15.2444638671875Z", + "fill": "#FF6A00", + "fill-opacity": "1" + } + } + ] + } + ] + } + ] + }, + "name": "AliyunBigIcon" +} diff --git a/web/app/components/base/icons/src/public/tracing/AliyunIconBig.tsx b/web/app/components/base/icons/src/public/tracing/AliyunIconBig.tsx new file mode 100644 index 0000000000..0924f70fbd --- /dev/null +++ b/web/app/components/base/icons/src/public/tracing/AliyunIconBig.tsx @@ -0,0 +1,16 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './AliyunIconBig.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase' + +const Icon = React.forwardRef, Omit>(( + props, + ref, +) => ) + +Icon.displayName = 'AliyunIconBig' + +export default Icon diff --git a/web/app/components/base/icons/src/public/tracing/ArizeIcon.json b/web/app/components/base/icons/src/public/tracing/ArizeIcon.json new file mode 100644 index 0000000000..fc438819ee --- /dev/null +++ b/web/app/components/base/icons/src/public/tracing/ArizeIcon.json @@ -0,0 +1,122 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "id": "Layer_2", + "data-name": "Layer 2", + "xmlns": "http://www.w3.org/2000/svg", + "viewBox": "0 0 74 16", + "width": "74", + "height": "16" + }, + "children": [ + { + "type": "element", + "name": "g", + "attributes": { + "id": "Layer_1-2", + "data-name": "Layer 1" + }, + "children": [ + { + "type": "element", + "name": "rect", + "attributes": { + "style": "fill: none;", + "y": "0", + "width": "74", + "height": "16" + }, + "children": [] + }, + { + "type": "element", + "name": "g", + "attributes": { + "id": "Arize_-_standard", + "data-name": "Arize - standard" + }, + "children": [ + { + "type": "element", + "name": "g", + "attributes": {}, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M67.29,9.7h-7.52c0,.08,0,.15.01.21.35,1.03,1.03,1.73,2.13,1.92,1.13.19,2.14,0,2.86-1.01.06-.09.19-.18.29-.19.66-.02,1.33,0,1.99,0-.1,1.79-2.59,3.32-5.07,3.14-2.66-.2-4.61-2.53-4.39-5.25.25-3.08,2.44-4.88,5.58-4.56,2.7.27,4.45,2.69,4.12,5.76ZM59.81,7.77h5.3c-.28-1.25-1.36-2.01-2.78-1.98-1.25.03-2.32.87-2.52,1.98Z" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: #ff008c;", + "d": "M13.87,1.69c.4-.02.74.17.99.54,2.03,2.92,4.06,5.85,6.08,8.77.42.61.28,1.33-.29,1.73-.56.39-1.31.24-1.74-.38-1.4-2.01-2.79-4.02-4.19-6.04-.03-.04-.05-.08-.08-.11-.55-.78-1.1-.78-1.64,0-1.41,2.03-2.82,4.06-4.23,6.09-.23.34-.52.59-.93.63-.51.06-.92-.13-1.19-.57-.28-.46-.25-.92.05-1.35.68-.98,1.36-1.96,2.04-2.93,1.34-1.93,2.68-3.85,4.02-5.78.26-.37.59-.6,1.11-.59Z" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M35.23,13.62h-2.01v-.77c-.07.01-.1,0-.13.02-1.64,1.16-3.39,1.25-5.12.34-1.74-.92-2.47-2.51-2.45-4.45.02-2.17,1.22-3.97,3.1-4.57,1.57-.5,3.09-.45,4.46.62.02.02.06.02.14.05v-.79h2.02v9.54ZM27.57,8.88c0,.11.01.31.03.5.14,1.39,1.18,2.39,2.61,2.51,1.4.12,2.53-.63,2.92-1.97.09-.32.14-.65.15-.98.09-2.15-1.5-3.51-3.56-3.07-1.28.27-2.13,1.43-2.15,3.02Z" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M53.61,5.93h-4.96v-1.85h7.76c0,.54.01,1.07,0,1.61,0,.12-.12.24-.21.34-1.52,1.79-3.05,3.57-4.57,5.36-.07.09-.14.18-.26.32h5.02v1.91h-7.83c0-.54-.01-1.08,0-1.61,0-.11.11-.22.19-.31,1.55-1.83,3.1-3.65,4.64-5.47.06-.07.11-.14.21-.28Z" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M39.6,13.69h-2.02V4.15h2.02v1.03c.54-.32,1.04-.68,1.58-.94.57-.28,1.19-.27,1.85-.23v1.96c-.1,0-.2.02-.3.02-1.58.02-2.68.9-3.01,2.46-.08.38-.11.77-.11,1.16-.01,1.24,0,2.47,0,3.71v.38Z" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M44.74,4.06h1.99v9.56h-1.99V4.06Z" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: #ff008c;", + "d": "M13.82,13.02c.84,0,1.49.64,1.5,1.46,0,.83-.68,1.53-1.5,1.52-.82,0-1.47-.67-1.48-1.5,0-.83.64-1.48,1.47-1.48Z" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M47.13,1.41c0,.8-.61,1.43-1.39,1.43-.8,0-1.44-.63-1.44-1.43,0-.78.63-1.41,1.42-1.41.79,0,1.41.62,1.41,1.41Z" + }, + "children": [] + } + ] + } + ] + } + ] + } + ] + }, + "name": "ArizeIcon" +} diff --git a/web/app/components/base/icons/src/public/tracing/ArizeIcon.tsx b/web/app/components/base/icons/src/public/tracing/ArizeIcon.tsx new file mode 100644 index 0000000000..dac1ec280e --- /dev/null +++ b/web/app/components/base/icons/src/public/tracing/ArizeIcon.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './ArizeIcon.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'ArizeIcon' + +export default Icon diff --git a/web/app/components/base/icons/src/public/tracing/ArizeIconBig.json b/web/app/components/base/icons/src/public/tracing/ArizeIconBig.json new file mode 100644 index 0000000000..57be25e5b3 --- /dev/null +++ b/web/app/components/base/icons/src/public/tracing/ArizeIconBig.json @@ -0,0 +1,122 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "id": "Layer_2", + "data-name": "Layer 2", + "xmlns": "http://www.w3.org/2000/svg", + "viewBox": "0 0 111 24", + "width": "111", + "height": "24" + }, + "children": [ + { + "type": "element", + "name": "g", + "attributes": { + "id": "Layer_1-2", + "data-name": "Layer 1" + }, + "children": [ + { + "type": "element", + "name": "rect", + "attributes": { + "style": "fill: none;", + "y": "0", + "width": "111", + "height": "24" + }, + "children": [] + }, + { + "type": "element", + "name": "g", + "attributes": { + "id": "Arize_-_standard", + "data-name": "Arize - standard" + }, + "children": [ + { + "type": "element", + "name": "g", + "attributes": {}, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M100.94,14.55h-11.29c0,.13-.01.23.02.31.53,1.55,1.54,2.59,3.19,2.88,1.7.29,3.22,0,4.3-1.52.09-.13.28-.27.43-.28.99-.02,1.99-.01,2.99-.01-.16,2.69-3.89,4.98-7.6,4.7-3.99-.3-6.91-3.79-6.58-7.88.37-4.62,3.67-7.31,8.37-6.85,4.05.4,6.68,4.04,6.19,8.64ZM89.71,11.66h7.96c-.43-1.88-2.04-3.02-4.17-2.97-1.87.04-3.48,1.3-3.78,2.97Z" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: #ff008c;", + "d": "M20.81,2.53c.59-.03,1.11.26,1.49.8,3.05,4.38,6.09,8.77,9.13,13.16.63.91.43,1.99-.43,2.59-.84.59-1.97.35-2.62-.57-2.1-3.01-4.19-6.04-6.28-9.05-.04-.06-.08-.11-.12-.17-.83-1.17-1.65-1.18-2.47,0-2.11,3.05-4.23,6.09-6.34,9.14-.35.5-.77.88-1.4.95-.77.08-1.39-.19-1.79-.86-.41-.69-.38-1.38.07-2.03,1.01-1.47,2.04-2.93,3.06-4.4,2.01-2.89,4.03-5.77,6.03-8.67.39-.56.88-.9,1.67-.89Z" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M52.84,20.43h-3.02v-1.16c-.1.02-.16.01-.19.03-2.46,1.73-5.09,1.88-7.68.51-2.61-1.38-3.71-3.77-3.68-6.67.03-3.26,1.82-5.96,4.64-6.86,2.35-.75,4.64-.67,6.69.93.04.03.09.03.2.07v-1.18h3.03v14.31ZM41.36,13.32c.01.17.02.46.05.75.22,2.09,1.76,3.58,3.91,3.76,2.1.18,3.8-.95,4.38-2.96.14-.47.2-.98.22-1.47.13-3.22-2.25-5.27-5.33-4.61-1.92.41-3.19,2.14-3.23,4.53Z" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M80.41,8.9h-7.44v-2.77h11.64c0,.81.02,1.61-.01,2.41,0,.17-.19.35-.32.51-2.28,2.68-4.57,5.36-6.85,8.04-.11.13-.21.26-.38.47h7.53v2.86h-11.74c0-.82-.02-1.62.01-2.42,0-.16.17-.33.28-.47,2.32-2.74,4.64-5.47,6.96-8.21.09-.1.16-.21.32-.42Z" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M59.39,20.54h-3.03V6.22h3.03v1.54c.8-.48,1.55-1.01,2.37-1.41.85-.41,1.79-.41,2.77-.35v2.94c-.16.01-.3.03-.45.03-2.37.03-4.01,1.36-4.51,3.69-.12.57-.16,1.16-.16,1.74-.02,1.85,0,3.71,0,5.56v.57Z" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M67.1,6.09h2.99v14.33h-2.99V6.09Z" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: #ff008c;", + "d": "M20.73,19.53c1.25,0,2.24.96,2.25,2.19.01,1.24-1.02,2.29-2.24,2.28-1.23-.01-2.21-1.01-2.22-2.24,0-1.25.96-2.22,2.21-2.22Z" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M70.7,2.11c0,1.19-.92,2.14-2.09,2.15-1.19.01-2.16-.95-2.16-2.14C66.46.95,67.4,0,68.58,0c1.18,0,2.12.93,2.12,2.11Z" + }, + "children": [] + } + ] + } + ] + } + ] + } + ] + }, + "name": "ArizeIconBig" +} diff --git a/web/app/components/base/icons/src/public/tracing/ArizeIconBig.tsx b/web/app/components/base/icons/src/public/tracing/ArizeIconBig.tsx new file mode 100644 index 0000000000..f817b481e3 --- /dev/null +++ b/web/app/components/base/icons/src/public/tracing/ArizeIconBig.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './ArizeIconBig.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'ArizeIconBig' + +export default Icon diff --git a/web/app/components/base/icons/src/public/tracing/PhoenixIcon.json b/web/app/components/base/icons/src/public/tracing/PhoenixIcon.json new file mode 100644 index 0000000000..c02847bc03 --- /dev/null +++ b/web/app/components/base/icons/src/public/tracing/PhoenixIcon.json @@ -0,0 +1,853 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "id": "Layer_2", + "data-name": "Layer 2", + "xmlns": "http://www.w3.org/2000/svg", + "xmlns:xlink": "http://www.w3.org/1999/xlink", + "viewBox": "0 0 74 16", + "width": "74", + "height": "16" + }, + "children": [ + { + "type": "element", + "name": "defs", + "attributes": {}, + "children": [ + { + "type": "element", + "name": "linearGradient", + "attributes": { + "id": "linear-gradient", + "x1": "6.05", + "y1": ".98", + "x2": "13.18", + "y2": "13.34", + "gradientUnits": "userSpaceOnUse" + }, + "children": [ + { + "type": "element", + "name": "stop", + "attributes": { + "offset": "0", + "stop-color": "#11bab5" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": ".5", + "stop-color": "#00adee" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": "1", + "stop-color": "#0094c5" + }, + "children": [] + } + ] + }, + { + "type": "element", + "name": "linearGradient", + "attributes": { + "id": "linear-gradient-2", + "x1": "12.99", + "y1": "10.87", + "x2": "6.97", + "y2": ".45", + "gradientUnits": "userSpaceOnUse" + }, + "children": [ + { + "type": "element", + "name": "stop", + "attributes": { + "offset": "0", + "stop-color": "#fcfdff", + "stop-opacity": "0" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": ".12", + "stop-color": "#fcfdff", + "stop-opacity": ".17" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": ".3", + "stop-color": "#fcfdff", + "stop-opacity": ".42" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": ".47", + "stop-color": "#fcfdff", + "stop-opacity": ".63" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": ".64", + "stop-color": "#fcfdff", + "stop-opacity": ".79" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": ".78", + "stop-color": "#fcfdff", + "stop-opacity": ".9" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": ".91", + "stop-color": "#fcfdff", + "stop-opacity": ".97" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": "1", + "stop-color": "#fcfdff" + }, + "children": [] + } + ] + }, + { + "type": "element", + "name": "linearGradient", + "attributes": { + "id": "linear-gradient-3", + "x1": "11.21", + "y1": "10.8", + "x2": "6.88", + "y2": "3.29", + "xlink:href": "#linear-gradient-2" + }, + "children": [] + }, + { + "type": "element", + "name": "linearGradient", + "attributes": { + "id": "linear-gradient-4", + "x1": "10.62", + "y1": "11.35", + "x2": "8.2", + "y2": "7.16", + "xlink:href": "#linear-gradient-2" + }, + "children": [] + }, + { + "type": "element", + "name": "linearGradient", + "attributes": { + "id": "linear-gradient-5", + "x1": "10.72", + "y1": "12.2", + "x2": "9.21", + "y2": "9.59", + "xlink:href": "#linear-gradient-2" + }, + "children": [] + }, + { + "type": "element", + "name": "linearGradient", + "attributes": { + "id": "linear-gradient-6", + "x1": "8.17", + "y1": "11.96", + "x2": "8.17", + "y2": "14.71", + "gradientUnits": "userSpaceOnUse" + }, + "children": [ + { + "type": "element", + "name": "stop", + "attributes": { + "offset": "0", + "stop-color": "#231f20" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": ".03", + "stop-color": "#231f20", + "stop-opacity": ".9" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": ".22", + "stop-color": "#231f20", + "stop-opacity": ".4" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": ".42", + "stop-color": "#231f20", + "stop-opacity": ".1" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": ".65", + "stop-color": "#231f20", + "stop-opacity": "0" + }, + "children": [] + } + ] + }, + { + "type": "element", + "name": "linearGradient", + "attributes": { + "id": "Fade_to_Black_2", + "data-name": "Fade to Black 2", + "x1": "11.26", + "y1": "11.7", + "x2": "11.26", + "y2": "13.1", + "gradientUnits": "userSpaceOnUse" + }, + "children": [ + { + "type": "element", + "name": "stop", + "attributes": { + "offset": "0", + "stop-color": "#231f20" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": "1", + "stop-color": "#231f20", + "stop-opacity": "0" + }, + "children": [] + } + ] + }, + { + "type": "element", + "name": "linearGradient", + "attributes": { + "id": "linear-gradient-7", + "x1": "11.94", + "y1": "8.07", + "x2": "11.94", + "y2": "6.92", + "gradientUnits": "userSpaceOnUse" + }, + "children": [ + { + "type": "element", + "name": "stop", + "attributes": { + "offset": "0", + "stop-color": "#231f20" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": ".03", + "stop-color": "#231f20", + "stop-opacity": ".95" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": ".51", + "stop-color": "#231f20", + "stop-opacity": ".26" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": ".81", + "stop-color": "#231f20", + "stop-opacity": "0" + }, + "children": [] + } + ] + }, + { + "type": "element", + "name": "linearGradient", + "attributes": { + "id": "linear-gradient-8", + "x1": "14.45", + "y1": "8.92", + "x2": "14.45", + "y2": "6.14", + "gradientUnits": "userSpaceOnUse" + }, + "children": [ + { + "type": "element", + "name": "stop", + "attributes": { + "offset": "0", + "stop-color": "#231f20" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": ".18", + "stop-color": "#231f20", + "stop-opacity": ".48" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": ".38", + "stop-color": "#231f20", + "stop-opacity": ".12" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": ".58", + "stop-color": "#231f20", + "stop-opacity": "0" + }, + "children": [] + } + ] + }, + { + "type": "element", + "name": "linearGradient", + "attributes": { + "id": "Fade_to_Black_2-2", + "data-name": "Fade to Black 2", + "x1": "17.03", + "y1": "11.1", + "x2": "16.07", + "y2": "9.44", + "xlink:href": "#Fade_to_Black_2" + }, + "children": [] + }, + { + "type": "element", + "name": "linearGradient", + "attributes": { + "id": "linear-gradient-9", + "x1": "17.8", + "y1": "10.64", + "x2": "16.36", + "y2": "8.16", + "xlink:href": "#linear-gradient-2" + }, + "children": [] + }, + { + "type": "element", + "name": "linearGradient", + "attributes": { + "id": "linear-gradient-10", + "x1": "14.07", + "y1": "10.22", + "x2": "16.16", + "y2": "9.02", + "gradientUnits": "userSpaceOnUse" + }, + "children": [ + { + "type": "element", + "name": "stop", + "attributes": { + "offset": "0", + "stop-color": "#231f20" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": ".31", + "stop-color": "#231f20", + "stop-opacity": ".5" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": ".67", + "stop-color": "#231f20", + "stop-opacity": ".13" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": "1", + "stop-color": "#231f20", + "stop-opacity": "0" + }, + "children": [] + } + ] + } + ] + }, + { + "type": "element", + "name": "g", + "attributes": { + "id": "Layer_1-2", + "data-name": "Layer 1" + }, + "children": [ + { + "type": "element", + "name": "rect", + "attributes": { + "style": "fill: none;", + "y": ".02", + "width": "74", + "height": "16" + }, + "children": [] + }, + { + "type": "element", + "name": "g", + "attributes": { + "id": "Phoenix_horiz_-_gradient", + "data-name": "Phoenix horiz - gradient" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: url(#linear-gradient);", + "d": "M17.91,10c-.04-.19-.14-.36-.28-.51-.06-.06-.12-.11-.19-.16-.03-.02-.07-.05-.1-.07-.04-.03-.09-.05-.14-.08-.19-.09-.34-.19-.48-.29-.06-.04-.11-.09-.16-.13-.12-.11-.21-.2-.27-.3-.02-.03-.04-.07-.05-.1,0-.01-.02-.03-.03-.04-.02-.02-.05-.02-.08-.02-.04,0-.08.04-.08.09,0,.03,0,.07,0,.11,0,.02,0,.04-.03.05,0,0,0,0-.02,0-.03,0-.06.02-.07.04-.02.02-.02.05-.02.08,0,.03.01.04.02.06h0s0,.03.01.04c.01.03.03.05.05.08.03.05.07.09.11.15.01.02.02.04,0,.06-.01.02-.03.03-.05.03-.25-.03-.46-.09-.67-.16-.02,0-.05-.02-.07-.03-.18-.08.15-.41.3-.64.66-1.02.8-2.19.81-3.38,0-1.15-.12-2.27-.35-3.39h0c-.15-.94-.27-1.29-.35-1.42-.02-.03-.04-.06-.06-.07-.02-.01-.03,0-.03,0-.04,0-.09.04-.15.1-.25.26-.23.57-.17.89.31,1.47.55,2.95.44,4.47-.07,1.04-.27,2.05-1,2.86-.04.05-.09.1-.14.14-.05.05-.14.16-.2.11-.08-.06,0-.18.02-.24.43-1,.55-2.05.48-3.12,0-.02,0-.05,0-.07h0s0-.04,0-.06c-.01-.14-.02-.28-.04-.43-.18-1.71-.4-1.82-.4-1.82h0s-.03-.02-.06-.03c-.12-.02-.18.08-.23.15-.23.35-.13.72-.05,1.08.26,1.28.27,2.55-.18,3.79-.04.11-.09.22-.14.33-.05.12-.09.26-.28.2-.18-.06-.13-.19-.1-.31.06-.31.11-.62.14-.93h0s0-.06,0-.09c0-.05,0-.11.01-.16,0-.05,0-.09,0-.13,0-.02,0-.04,0-.07.04-.99-.13-1.44-.19-1.57,0-.02-.01-.03-.02-.04h0s0,0,0,0c-.07-.1-.17-.09-.27,0-.17.17-.27.37-.22.62.13.74.05,1.46-.08,2.19-.02.1-.06.27-.27.23-.14-.03-.17-.09-.18-.21,0-.09,0-.19,0-.28,0,0,0,0,0,0h0s0-.03,0-.04h0s0-.01,0-.02c0-.13-.02-.25-.03-.38,0-.01,0-.02,0-.04-.08-.71-.18-.78-.18-.78-.07-.09-.17-.07-.26.02-.17.17-.26.37-.22.62.03.19.02.13.04.29.01.08.03.23.03.28,0,.04-.01.08-.04.11-.09.09-.23,0-.34-.05-2.08-.86-3.86-2.07-5.42-3.6-.61-.62-1.65-1.88-1.65-1.88h0s-.09-.07-.16-.04c-.14.06-.13.23-.14.38-.02.29.13.51.3.7,2.35,2.74,5.21,4.71,8.67,5.69l.62.16s.01,0,.02,0c0,0,0,0,0,0l.54.14h.01s-.04.01-.06.02c-.59.12-1.76-.1-2.69-.32-.46-.1-.92-.22-1.36-.36-.02,0-.03,0-.03,0h0c-1.73-.55-3.32-1.44-4.7-2.81-.13-.13-.25-.27-.37-.41-.11-.13-.34-.4-.41-.5,0-.01-.02-.02-.03-.03h0s0,0,0,0c-.02-.02-.05-.04-.09-.03-.1.02-.12.12-.14.21-.07.34.04.62.26.88,1.42,1.59,3.2,2.61,5.21,3.28,1.14.38,2.31.61,3.5.76-.71.23-1.44.3-2.18.28-1.43-.04-2.77-.38-4.03-.99-.46-.24-.9-.5-1.01-.58-.07-.04-.13-.07-.2-.02-.13.1-.04.27,0,.4.08.26.25.43.49.55,2.05,1.08,4.22,1.52,6.52,1.13.23-.04.45-.08.7-.16-.39.33-.83.55-1.3.72-1.44.53-2.88.51-4.31.15-.46-.12-.99-.32-.99-.32h0c-.08-.03-.15-.05-.21.04-.1.15-.01.3.08.45.21.33.63.36.95.48.11.04-.19.24-.29.31-.47.36-.94.69-1.41,1.04l-.31.23h0c-.05.04-.1.08-.08.15.03.09.12.1.2.12.34.07.62-.04.89-.24.57-.43,1.15-.84,1.71-1.28.21-.17.4-.15.7-.08-.73.55-1.4,1.06-2.08,1.57-.69.52-1.38,1.04-2.07,1.56l-.46.34s-.01,0-.02.01h0s0,0,0,0c-.04.03-.07.07-.06.11.02.1.14.13.24.15.41.07.66-.08.94-.29,1.39-1.05,2.79-2.09,4.18-3.14.31-.24.63-.37,1.08-.32-.38.29-.75.56-1.11.83l-.98.73s-.01.01-.02.02h0c-.05.04-.09.09-.07.16.04.13.19.15.33.17.24.04.45-.05.64-.19.11-.08.22-.16.33-.24h0s1.5-1.19,1.5-1.19c0,0,.35-.25.76-.49.04-.02.07-.05.11-.07.02,0,.05-.02.11-.05.04-.02.09-.05.13-.07.06-.03.14-.07.22-.12.24-.1.93-.42,1.51-1.03.27-.21.66-.48.95-.62h0s0,0,0,0c.02-.01.04-.02.07-.03h0s0,0,0,0c.01,0,.03-.01.04-.01h.04c.11-.05.28-.06.31.18,0,.06,0,.12,0,.18,0,.05-.01.1-.02.14,0,.04,0,.08.04.1,0,0,.01,0,.02,0,.04.02.09.01.12-.02.01-.01.02-.03.04-.04.15-.15.3-.26.45-.33.23-.1.47-.1.72,0,.12.05.23.11.34.19.05.03.1.07.14.12.03.03.06.05.08.08h0s.02.02.02.02c0,0,0,0,.01.01.02.02.05.02.08.02.03,0,.06-.03.07-.06,0,0,0-.01,0-.02.07-.21.09-.4.04-.59Z" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: #fddab4;", + "d": "M13.48,7.75c-.02.1-.03.21-.05.31.02-.1.04-.21.05-.31h0Z" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: #fddab4;", + "d": "M15.33,8.9s.02,0,.02,0c0,0-.02,0-.02,0" + }, + "children": [] + }, + { + "type": "element", + "name": "polyline", + "attributes": { + "style": "fill: #fddab4;", + "points": "14.46 8.33 14.46 8.33 14.46 8.33" + }, + "children": [] + }, + { + "type": "element", + "name": "polyline", + "attributes": { + "style": "fill: #fddab4;", + "points": "14.48 8.26 14.48 8.26 14.48 8.26" + }, + "children": [] + }, + { + "type": "element", + "name": "polyline", + "attributes": { + "style": "fill: #fddab4;", + "points": "14.49 8.25 14.49 8.25 14.49 8.25" + }, + "children": [] + }, + { + "type": "element", + "name": "polyline", + "attributes": { + "style": "fill: #fddab4;", + "points": "15.64 8.25 15.63 8.25 15.64 8.25" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: #fddab4;", + "d": "M13.81,8.17s-.02.06-.04.08c.01-.03.02-.06.04-.08" + }, + "children": [] + }, + { + "type": "element", + "name": "polyline", + "attributes": { + "style": "fill: #fddab4;", + "points": "12.57 8.11 12.57 8.11 12.57 8.11" + }, + "children": [] + }, + { + "type": "element", + "name": "polyline", + "attributes": { + "style": "fill: #fddab4;", + "points": "12.39 8.03 12.39 8.03 12.39 8.03" + }, + "children": [] + }, + { + "type": "element", + "name": "polyline", + "attributes": { + "style": "fill: #fddab4;", + "points": "12.39 8.03 12.39 8.03 12.39 8.03" + }, + "children": [] + }, + { + "type": "element", + "name": "polyline", + "attributes": { + "style": "fill: #fddab4;", + "points": "12.39 8.03 12.39 8.03 12.39 8.03" + }, + "children": [] + }, + { + "type": "element", + "name": "polyline", + "attributes": { + "style": "fill: #fddab4;", + "points": "12.38 8.02 12.38 8.03 12.38 8.02" + }, + "children": [] + }, + { + "type": "element", + "name": "polyline", + "attributes": { + "style": "fill: #fddab4;", + "points": "12.38 8.02 12.38 8.02 12.38 8.02" + }, + "children": [] + }, + { + "type": "element", + "name": "polyline", + "attributes": { + "style": "fill: #fddab4;", + "points": "12.35 7.89 12.35 7.89 12.35 7.89" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: #fddab4;", + "d": "M12.81,7.81s0,.04-.01.06c0-.02,0-.04.01-.06" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: #fddab4;", + "d": "M11.67,7.67s-.03.02-.05.03c.02,0,.03-.02.05-.03" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: #e5b3a5;", + "d": "M12.39,8.03s.03.03.05.04h0s-.03-.03-.05-.04M12.39,8.03s0,0,0,0c0,0,0,0,0,0M12.39,8.03s0,0,0,0c0,0,0,0,0,0M12.38,8.03s0,0,0,0c0,0,0,0,0,0M12.38,8.02s0,0,0,0c0,0,0,0,0,0M12.38,8.02s0,0,0,0c0,0,0,0,0,0M12.35,7.89s0,0,0,0c0,0,0,0,0,0M12.35,7.89h0s0,0,0,0c0,0,0,0,0,0M11.45,7.68s0,0,0,0c.04.02.09.03.13.03.02,0,.03,0,.05,0-.02,0-.03,0-.05,0-.04,0-.09-.02-.13-.03M12.34,7.54s0,0,0,0c0,0,0,0,0,0" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: #e5b3a5;", + "d": "M15.35,8.91s0,0,.01,0h0s0,0-.01,0M14.52,8.58s0,0,0,0c0,0,0,0,0,0M14.39,8.51s.01.06.04.08c.01,0,.02.01.03.01.02,0,.04,0,.05-.02-.02.01-.04.02-.05.02-.01,0-.02,0-.03-.01-.03-.02-.04-.05-.04-.08M14.46,8.33s0,0,0,.01c0,0,0,0,0-.01M14.48,8.26s-.02.05-.03.07c0-.02.02-.05.03-.07M14.49,8.25s0,0,0,0c0,0,0,0,0,0M15.63,8.25s0,0,0,0c0,0,0,0,0,0M15.64,8.24s0,0,0,0c0,0,0,0,0,0M14.49,8.24s0,0,0,0c0,0,0,0,0,0M13.4,8.21c0,.07.03.13.13.16.03.01.06.01.08.01.09,0,.13-.06.16-.13-.03.07-.07.13-.16.13-.02,0-.05,0-.08-.01-.1-.03-.13-.09-.13-.16M12.57,8.11h0,0M12.74,8.04s-.08.07-.14.07c0,0-.01,0-.02,0,0,0,.01,0,.02,0,.07,0,.11-.03.14-.07M14.05,7.54s0,0,0,0c-.03.1-.06.2-.1.3-.04.11-.09.22-.14.33h0c.05-.11.1-.22.14-.33.04-.1.07-.2.1-.3M12.87,7.49c-.02.11-.03.22-.05.33.02-.11.04-.22.06-.33h0M15.52,6.97s0,0,0,0c-.15.49-.38.95-.75,1.36-.04.05-.09.1-.14.14h0s.09-.09.14-.14c.37-.41.6-.87.75-1.36" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: url(#linear-gradient-2); opacity: .4;", + "d": "M4.17,2.09s-.04,0-.06.01c-.1.04-.12.14-.13.25.52.68,2.56,3.18,5.78,4.9,0,0,3.15,1.66,6.32,1.84-.25-.03-.46-.09-.67-.16-.01,0-.02,0-.04-.01h0c-1.15-.25-2.13-.56-2.83-.81.01,0,.03,0,.04,0-.01,0-.03,0-.04,0-.04,0-.07-.02-.1-.04-.55-.2-.9-.35-.98-.39-.04-.02-.08-.04-.12-.05-2.08-.86-3.86-2.07-5.42-3.6-.61-.62-1.65-1.88-1.65-1.88h0s-.06-.05-.1-.05" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: url(#linear-gradient-3); opacity: .4;", + "d": "M4.39,4.75s-.01,0-.02,0c-.1.02-.12.12-.14.21,0,.02,0,.03,0,.05,1.61,2.18,3.77,3.07,3.77,3.07,2.15,1.01,3.92,1.22,5,1.22.45,0,.77-.04.96-.06-.09,0-.18.01-.28.01-.63,0-1.53-.17-2.29-.35-.46-.1-.92-.22-1.36-.36-.02,0-.03,0-.03,0h0c-1.73-.55-3.32-1.44-4.7-2.81-.13-.13-.25-.27-.37-.41-.11-.13-.34-.4-.41-.5,0-.01-.02-.02-.03-.03h0s0,0,0,0c-.02-.02-.04-.03-.07-.03" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: url(#linear-gradient-4); opacity: .4", + "d": "M5.86,8.53s-.05,0-.08.03c-.11.08-.06.22-.02.35.81.49,2.66,1.44,4.84,1.44.83,0,1.7-.14,2.59-.48-.64.21-1.3.28-1.96.28-.07,0-.15,0-.22,0-1.43-.04-2.77-.38-4.03-.99-.46-.24-.9-.5-1.01-.58-.04-.03-.08-.05-.12-.05" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: url(#linear-gradient-5); opacity: .4;", + "d": "M13.34,10.6c-.35.27-.75.46-1.16.61-.77.28-1.55.41-2.32.41-.67,0-1.33-.09-1.99-.26-.46-.12-.99-.32-.99-.32h0s-.07-.03-.1-.03c-.04,0-.08.02-.11.06-.05.07-.05.14-.04.21.51.24,1.6.66,2.93.66,1.15,0,2.49-.32,3.78-1.34" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: url(#linear-gradient-6); opacity: .4;", + "d": "M9.91,12.25c-1.25,0-2.19-.25-2.31-.29.04.01.07.02.1.03.11.04-.19.24-.29.31-.35.27-.69.52-1.04.77h1.06c.33-.25.67-.5.99-.75.12-.1.24-.13.37-.13.1,0,.2.02.33.05-.73.55-1.4,1.06-2.08,1.57-.4.3-.79.6-1.19.9h1.07c.96-.72,1.92-1.44,2.87-2.16.22-.17.44-.28.71-.32-.2.01-.4.02-.58.02Z" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: url(#Fade_to_Black_2); opacity: .4;", + "d": "M9.69,13.1h1.03l.76-.6s.35-.25.76-.49c.04-.02.07-.05.11-.07.02,0,.05-.02.11-.05.04-.02.09-.05.13-.07.07-.03.15-.08.23-.12-.73.31-1.49.46-2.18.52,0,0,.02,0,.03,0,.06,0,.12,0,.18.01-.38.29-.75.56-1.11.83l-.06.05Z" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: url(#linear-gradient-7); opacity: .4;", + "d": "M12.35,7.89c0-.09,0-.19,0-.28h0s0,0,0,0c0-.01,0-.03,0-.04h0s0-.02,0-.02c0-.08,0-.16-.02-.23-.25-.1-.48-.24-.69-.39,0,.02,0,.04,0,.07.03.19.02.13.04.29.01.08.03.23.03.28,0,.04-.01.08-.04.11-.03.03-.06.04-.09.04-.04,0-.09-.02-.13-.03.08.04.43.19.98.39-.06-.04-.08-.09-.08-.18Z" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: url(#linear-gradient-8); opacity: .4;", + "d": "M16.36,6.14c-.23.32-.51.6-.84.83-.15.49-.38.95-.75,1.36-.04.05-.09.1-.14.14-.05.04-.11.12-.17.12-.01,0-.02,0-.03-.01-.08-.06,0-.18.02-.24.14-.32.24-.65.32-.98-.23.09-.47.15-.72.18-.03.1-.06.2-.1.3-.04.11-.09.22-.14.33-.05.1-.08.21-.2.21-.02,0-.05,0-.08-.01-.18-.06-.13-.19-.1-.31.03-.16.06-.32.08-.49-.22,0-.43-.04-.64-.08-.02.13-.04.26-.07.39-.02.09-.05.24-.21.24-.02,0-.04,0-.06,0,.69.25,1.68.56,2.83.81h0s-.02,0-.03-.01c-.18-.08.15-.41.3-.64.43-.66.64-1.37.73-2.12Z" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: url(#Fade_to_Black_2-2); opacity: .5;", + "d": "M16.19,9.7c-.14,0-.29.01-.43.04-.03,0-.05,0-.08,0-.04,0-.08,0-.11-.02,0,0,.01.01.02.02.06.09.1.18.11.28.02.02.03.04.04.07h0s0,0,0,0c0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0h0s0,0,0,0c0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0t0,0s0,0,0,0c0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0s0,0,0,0c0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0s0,0,0,0c0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0h0s0,0,0,0h0s0,0,0,0h0s0,0,0,0h0s0,0,0,0h0s0,0,0,0h0s0,0,0,0h0s0,0,0,0h0s0,0,0,0c0,0,0,0,0,0,0,0,0,0,0,0h0s0,0,0,0h0s0,0,0,0h0s0,0,0,0h0s0,0,0,0h0s0,0,0,0h0s0,0,0,0h0s0,0,0,0h0s0,0,0,0h0s0,.05,0,.07c0,.04,0,.07,0,.11,0,.05-.01.1-.02.14,0,0,0,.01,0,.02,0,.03.02.06.04.08h.02s.03.02.05.02c.03,0,.06-.01.08-.03.01-.01.02-.03.04-.04.15-.15.3-.26.45-.33.12-.05.23-.07.35-.07s.24.02.36.07c.12.05.23.11.34.19.05.03.1.07.14.12.03.03.06.05.08.08h0s.02.02.02.02h.01s.04.03.06.03c-.1-.21-.22-.41-.4-.56-.33-.29-.75-.41-1.18-.41" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: url(#linear-gradient-9); opacity: .4;", + "d": "M16.15,8.3h-.02s-.08.05-.08.09c0,.03,0,.07,0,.11,0,.01,0,.02,0,.03.18.26.56.73,1.15.91,0,0,.91.47.68,1.16h0v-.02c.08-.21.09-.4.05-.59-.04-.19-.14-.36-.28-.51-.06-.06-.12-.11-.19-.16-.03-.02-.07-.05-.1-.07-.04-.03-.09-.05-.14-.08-.19-.09-.34-.19-.48-.29-.06-.04-.11-.09-.16-.13-.12-.11-.21-.2-.27-.3-.02-.03-.04-.07-.05-.1,0-.01-.02-.03-.03-.04-.02-.01-.04-.02-.06-.02" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: url(#linear-gradient-10); opacity: .5;", + "d": "M15,10.21l.18-.11.77-1.03s-.42-.08-.63-.17c0,0-.21.96-.99,1.77l.29-.21c.11-.08.22-.15.38-.25Z" + }, + "children": [] + }, + { + "type": "element", + "name": "g", + "attributes": {}, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: #404041;", + "d": "M25.33,4.73h1.97c1.42,0,2.07.73,2.07,2.23v.74c0,1.5-.65,2.23-2.07,2.23h-1.14v4.09h-.82V4.73ZM27.3,9.19c.86,0,1.25-.39,1.25-1.44v-.84c0-1.04-.39-1.44-1.25-1.44h-1.14v3.71h1.14Z" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: #404041;", + "d": "M32.4,4.73h.82v4.08h2.54v-4.08h.82v9.3h-.82v-4.48h-2.54v4.48h-.82V4.73Z" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: #404041;", + "d": "M39.82,11.9v-5.02c0-1.48.74-2.27,2.09-2.27s2.09.8,2.09,2.27v5.02c0,1.48-.74,2.27-2.09,2.27s-2.09-.8-2.09-2.27ZM43.17,11.95v-5.13c0-1-.45-1.48-1.26-1.48s-1.26.48-1.26,1.48v5.13c0,1,.45,1.46,1.26,1.46s1.26-.47,1.26-1.46Z" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: #404041;", + "d": "M47.23,4.73h3.72v.74h-2.9v3.34h2.38v.74h-2.38v3.72h2.9v.76h-3.72V4.73Z" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: #404041;", + "d": "M53.93,4.73h1.09l2.42,7.19v-7.19h.77v9.3h-.88l-2.64-7.93v7.93h-.76V4.73Z" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: #404041;", + "d": "M61.55,4.73h.82v9.3h-.82V4.73Z" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: #404041;", + "d": "M67.19,9.29l-1.82-4.56h.88l1.46,3.69,1.48-3.69h.8l-1.82,4.56,1.9,4.74h-.88l-1.54-3.91-1.55,3.91h-.8l1.9-4.74Z" + }, + "children": [] + } + ] + } + ] + } + ] + } + ] + }, + "name": "PhoenixIcon" +} diff --git a/web/app/components/base/icons/src/public/tracing/PhoenixIcon.tsx b/web/app/components/base/icons/src/public/tracing/PhoenixIcon.tsx new file mode 100644 index 0000000000..e0d36e065d --- /dev/null +++ b/web/app/components/base/icons/src/public/tracing/PhoenixIcon.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './PhoenixIcon.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'PhoenixIcon' + +export default Icon diff --git a/web/app/components/base/icons/src/public/tracing/PhoenixIconBig.json b/web/app/components/base/icons/src/public/tracing/PhoenixIconBig.json new file mode 100644 index 0000000000..f48a2f53ae --- /dev/null +++ b/web/app/components/base/icons/src/public/tracing/PhoenixIconBig.json @@ -0,0 +1,853 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "id": "Layer_2", + "data-name": "Layer 2", + "xmlns": "http://www.w3.org/2000/svg", + "xmlns:xlink": "http://www.w3.org/1999/xlink", + "viewBox": "0 0 111 24", + "width": "111", + "height": "24" + }, + "children": [ + { + "type": "element", + "name": "defs", + "attributes": {}, + "children": [ + { + "type": "element", + "name": "linearGradient", + "attributes": { + "id": "linear-gradient", + "x1": "9.07", + "y1": "1.47", + "x2": "19.77", + "y2": "20", + "gradientUnits": "userSpaceOnUse" + }, + "children": [ + { + "type": "element", + "name": "stop", + "attributes": { + "offset": "0", + "stop-color": "#11bab5" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": ".5", + "stop-color": "#00adee" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": "1", + "stop-color": "#0094c5" + }, + "children": [] + } + ] + }, + { + "type": "element", + "name": "linearGradient", + "attributes": { + "id": "linear-gradient-2", + "x1": "19.48", + "y1": "16.3", + "x2": "10.46", + "y2": ".67", + "gradientUnits": "userSpaceOnUse" + }, + "children": [ + { + "type": "element", + "name": "stop", + "attributes": { + "offset": "0", + "stop-color": "#fcfdff", + "stop-opacity": "0" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": ".12", + "stop-color": "#fcfdff", + "stop-opacity": ".17" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": ".3", + "stop-color": "#fcfdff", + "stop-opacity": ".42" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": ".47", + "stop-color": "#fcfdff", + "stop-opacity": ".63" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": ".64", + "stop-color": "#fcfdff", + "stop-opacity": ".79" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": ".78", + "stop-color": "#fcfdff", + "stop-opacity": ".9" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": ".91", + "stop-color": "#fcfdff", + "stop-opacity": ".97" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": "1", + "stop-color": "#fcfdff" + }, + "children": [] + } + ] + }, + { + "type": "element", + "name": "linearGradient", + "attributes": { + "id": "linear-gradient-3", + "x1": "16.82", + "y1": "16.21", + "x2": "10.32", + "y2": "4.94", + "xlink:href": "#linear-gradient-2" + }, + "children": [] + }, + { + "type": "element", + "name": "linearGradient", + "attributes": { + "id": "linear-gradient-4", + "x1": "15.92", + "y1": "17.03", + "x2": "12.29", + "y2": "10.74", + "xlink:href": "#linear-gradient-2" + }, + "children": [] + }, + { + "type": "element", + "name": "linearGradient", + "attributes": { + "id": "linear-gradient-5", + "x1": "16.08", + "y1": "18.3", + "x2": "13.82", + "y2": "14.38", + "xlink:href": "#linear-gradient-2" + }, + "children": [] + }, + { + "type": "element", + "name": "linearGradient", + "attributes": { + "id": "linear-gradient-6", + "x1": "12.26", + "y1": "17.94", + "x2": "12.26", + "y2": "22.07", + "gradientUnits": "userSpaceOnUse" + }, + "children": [ + { + "type": "element", + "name": "stop", + "attributes": { + "offset": "0", + "stop-color": "#231f20" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": ".03", + "stop-color": "#231f20", + "stop-opacity": ".9" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": ".22", + "stop-color": "#231f20", + "stop-opacity": ".4" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": ".42", + "stop-color": "#231f20", + "stop-opacity": ".1" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": ".65", + "stop-color": "#231f20", + "stop-opacity": "0" + }, + "children": [] + } + ] + }, + { + "type": "element", + "name": "linearGradient", + "attributes": { + "id": "Fade_to_Black_2", + "data-name": "Fade to Black 2", + "x1": "16.89", + "y1": "17.55", + "x2": "16.89", + "y2": "19.66", + "gradientUnits": "userSpaceOnUse" + }, + "children": [ + { + "type": "element", + "name": "stop", + "attributes": { + "offset": "0", + "stop-color": "#231f20" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": "1", + "stop-color": "#231f20", + "stop-opacity": "0" + }, + "children": [] + } + ] + }, + { + "type": "element", + "name": "linearGradient", + "attributes": { + "id": "linear-gradient-7", + "x1": "17.91", + "y1": "12.1", + "x2": "17.91", + "y2": "10.38", + "gradientUnits": "userSpaceOnUse" + }, + "children": [ + { + "type": "element", + "name": "stop", + "attributes": { + "offset": "0", + "stop-color": "#231f20" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": ".03", + "stop-color": "#231f20", + "stop-opacity": ".95" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": ".51", + "stop-color": "#231f20", + "stop-opacity": ".26" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": ".81", + "stop-color": "#231f20", + "stop-opacity": "0" + }, + "children": [] + } + ] + }, + { + "type": "element", + "name": "linearGradient", + "attributes": { + "id": "linear-gradient-8", + "x1": "21.67", + "y1": "13.37", + "x2": "21.67", + "y2": "9.21", + "gradientUnits": "userSpaceOnUse" + }, + "children": [ + { + "type": "element", + "name": "stop", + "attributes": { + "offset": "0", + "stop-color": "#231f20" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": ".18", + "stop-color": "#231f20", + "stop-opacity": ".48" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": ".38", + "stop-color": "#231f20", + "stop-opacity": ".12" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": ".58", + "stop-color": "#231f20", + "stop-opacity": "0" + }, + "children": [] + } + ] + }, + { + "type": "element", + "name": "linearGradient", + "attributes": { + "id": "Fade_to_Black_2-2", + "data-name": "Fade to Black 2", + "x1": "25.54", + "y1": "16.65", + "x2": "24.1", + "y2": "14.16", + "xlink:href": "#Fade_to_Black_2" + }, + "children": [] + }, + { + "type": "element", + "name": "linearGradient", + "attributes": { + "id": "linear-gradient-9", + "x1": "26.7", + "y1": "15.97", + "x2": "24.55", + "y2": "12.24", + "xlink:href": "#linear-gradient-2" + }, + "children": [] + }, + { + "type": "element", + "name": "linearGradient", + "attributes": { + "id": "linear-gradient-10", + "x1": "21.11", + "y1": "15.34", + "x2": "24.24", + "y2": "13.53", + "gradientUnits": "userSpaceOnUse" + }, + "children": [ + { + "type": "element", + "name": "stop", + "attributes": { + "offset": "0", + "stop-color": "#231f20" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": ".31", + "stop-color": "#231f20", + "stop-opacity": ".5" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": ".67", + "stop-color": "#231f20", + "stop-opacity": ".13" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": "1", + "stop-color": "#231f20", + "stop-opacity": "0" + }, + "children": [] + } + ] + } + ] + }, + { + "type": "element", + "name": "g", + "attributes": { + "id": "Layer_1-2", + "data-name": "Layer 1" + }, + "children": [ + { + "type": "element", + "name": "rect", + "attributes": { + "style": "fill: none;", + "y": ".04", + "width": "111", + "height": "24" + }, + "children": [] + }, + { + "type": "element", + "name": "g", + "attributes": { + "id": "Phoenix_horiz_-_gradient", + "data-name": "Phoenix horiz - gradient" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: url(#linear-gradient);", + "d": "M26.87,15c-.06-.28-.2-.53-.42-.76-.08-.09-.18-.17-.28-.25-.05-.04-.1-.07-.15-.1-.07-.04-.14-.08-.21-.12-.28-.14-.51-.29-.72-.44-.08-.06-.17-.13-.24-.19-.19-.16-.31-.3-.4-.45-.03-.05-.06-.1-.08-.15-.01-.02-.03-.04-.05-.05-.03-.02-.08-.04-.12-.03-.07.01-.12.07-.13.13,0,.05,0,.11,0,.17,0,.03-.01.07-.04.08,0,0-.01,0-.02,0-.04,0-.08.03-.11.07-.03.04-.03.08-.02.13,0,.04.02.06.03.09v.02s.02.03.03.04c.02.04.04.08.07.12.05.07.1.14.17.22.02.02.02.06,0,.09-.02.03-.05.04-.08.04-.37-.05-.7-.13-1-.25-.04-.01-.07-.03-.11-.04-.28-.12.22-.61.45-.96,1-1.53,1.2-3.29,1.21-5.07,0-1.72-.18-3.41-.52-5.08h0c-.22-1.4-.41-1.93-.53-2.13-.03-.05-.05-.08-.08-.1-.03-.02-.05-.01-.05-.01-.06,0-.14.06-.23.15-.37.39-.35.86-.25,1.34.47,2.21.82,4.43.66,6.7-.11,1.56-.4,3.07-1.5,4.3-.07.07-.14.14-.21.21-.08.08-.21.24-.3.17-.13-.09-.01-.27.03-.36.64-1.5.82-3.07.73-4.69,0-.04,0-.07,0-.11h0s0-.06,0-.09c-.02-.21-.04-.43-.06-.64-.28-2.56-.61-2.73-.61-2.73h0s-.05-.03-.09-.04c-.17-.04-.27.12-.35.23-.35.52-.19,1.07-.08,1.62.39,1.92.4,3.82-.28,5.69-.06.17-.14.34-.21.5-.08.17-.14.39-.42.3-.26-.09-.19-.29-.16-.47.09-.47.16-.93.2-1.4h0s0-.09.01-.13c0-.08.01-.16.02-.24,0-.07,0-.13.01-.2,0-.03,0-.07,0-.1.05-1.48-.2-2.16-.29-2.36-.01-.02-.02-.05-.03-.07h0s0,0,0,0c-.1-.15-.26-.13-.4,0-.26.25-.4.56-.33.93.19,1.1.08,2.2-.13,3.28-.03.15-.09.41-.4.34-.21-.05-.26-.14-.27-.32,0-.14,0-.28,0-.42,0,0,0,0,0-.01h0s0-.04,0-.06h0s0-.02,0-.03c0-.19-.02-.38-.05-.57,0-.02,0-.04,0-.05-.12-1.07-.27-1.17-.27-1.17-.1-.13-.25-.11-.39.03-.26.25-.39.55-.33.93.04.28.03.19.06.43.02.12.05.35.05.42,0,.06-.02.12-.06.16-.13.14-.34,0-.5-.07-3.12-1.29-5.79-3.1-8.12-5.4-.92-.94-2.48-2.83-2.48-2.83h0c-.06-.07-.13-.11-.24-.06-.2.09-.2.35-.22.57-.04.44.2.76.45,1.06,3.52,4.11,7.82,7.06,13,8.54l.93.25s.02,0,.03,0c0,0,0,0,0,0l.8.21h.02s-.06.02-.09.03c-.88.17-2.63-.15-4.04-.47-.7-.15-1.38-.32-2.05-.53-.03,0-.05-.01-.05-.01h0c-2.6-.83-4.97-2.17-7.05-4.21-.2-.19-.38-.4-.56-.61-.16-.2-.5-.61-.62-.75-.01-.02-.03-.03-.04-.05h0s0,0,0,0c-.04-.04-.08-.06-.14-.05-.14.03-.19.18-.21.31-.11.51.05.93.39,1.31,2.13,2.39,4.8,3.91,7.81,4.91,1.71.57,3.46.91,5.25,1.13-1.07.35-2.16.45-3.27.42-2.14-.05-4.15-.57-6.04-1.49-.7-.36-1.34-.76-1.52-.86-.1-.07-.2-.11-.3-.03-.19.15-.06.4,0,.6.12.38.38.65.73.83,3.08,1.63,6.32,2.28,9.78,1.7.35-.06.68-.12,1.05-.25-.58.5-1.25.83-1.95,1.08-2.17.79-4.32.76-6.46.22-.69-.18-1.49-.48-1.49-.48h0c-.12-.05-.23-.07-.32.06-.16.22-.02.46.12.67.32.5.94.55,1.43.72.17.05-.29.36-.43.47-.71.54-1.4,1.04-2.11,1.56l-.46.34h0c-.08.05-.15.12-.11.23.04.13.18.15.31.18.51.1.94-.06,1.33-.36.86-.64,1.73-1.26,2.56-1.93.32-.25.61-.23,1.04-.12-1.09.82-2.11,1.59-3.13,2.36-1.03.78-2.07,1.56-3.1,2.33l-.68.51s-.02.01-.03.02h-.01s0,0,0,0c-.06.05-.1.1-.09.17.03.15.21.19.36.22.61.11.99-.12,1.41-.44,2.09-1.57,4.19-3.13,6.27-4.71.47-.36.95-.56,1.62-.48-.57.43-1.12.84-1.67,1.25l-1.47,1.09s-.02.02-.03.02h0c-.08.06-.13.13-.1.24.06.19.29.22.49.25.37.05.68-.08.96-.29.17-.12.33-.24.5-.37h0s2.25-1.79,2.25-1.79c0,0,.53-.38,1.15-.73.05-.03.11-.07.17-.1.02-.01.08-.04.17-.08.07-.03.13-.07.2-.1.1-.05.21-.11.33-.18.36-.15,1.4-.64,2.26-1.55.41-.32.98-.73,1.43-.92h0s0,0,0,0c.03-.02.07-.03.1-.04h0s0,0,0,0c.02,0,.04-.02.06-.02l.06-.02c.16-.05.43-.06.46.29,0,.09,0,.18,0,.27,0,.07-.02.15-.03.21-.01.06.01.12.06.15,0,0,.02.01.02.01.06.03.14.02.18-.03.02-.02.03-.04.05-.06.22-.23.44-.39.68-.49.35-.14.7-.14,1.07,0,.18.07.35.16.51.28.07.05.14.11.21.17.04.04.08.08.12.12h0s.03.04.03.04c0,0,.01.01.02.02.04.03.08.04.12.03.05-.01.09-.05.11-.1,0,0,0-.02.01-.03.11-.31.13-.6.07-.89Z" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: #fddab4;", + "d": "M20.22,11.62c-.03.16-.05.31-.08.47.03-.16.06-.31.08-.47h0Z" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: #fddab4;", + "d": "M22.99,13.35s.02,0,.03.01c-.01,0-.02,0-.03-.01" + }, + "children": [] + }, + { + "type": "element", + "name": "polyline", + "attributes": { + "style": "fill: #fddab4;", + "points": "21.68 12.5 21.68 12.5 21.68 12.5" + }, + "children": [] + }, + { + "type": "element", + "name": "polyline", + "attributes": { + "style": "fill: #fddab4;", + "points": "21.73 12.39 21.73 12.39 21.73 12.39" + }, + "children": [] + }, + { + "type": "element", + "name": "polyline", + "attributes": { + "style": "fill: #fddab4;", + "points": "21.74 12.37 21.73 12.38 21.74 12.37" + }, + "children": [] + }, + { + "type": "element", + "name": "polyline", + "attributes": { + "style": "fill: #fddab4;", + "points": "23.45 12.37 23.45 12.38 23.45 12.37" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: #fddab4;", + "d": "M20.72,12.26s-.04.08-.06.12c.02-.04.04-.08.06-.12" + }, + "children": [] + }, + { + "type": "element", + "name": "polyline", + "attributes": { + "style": "fill: #fddab4;", + "points": "18.86 12.17 18.86 12.17 18.86 12.17" + }, + "children": [] + }, + { + "type": "element", + "name": "polyline", + "attributes": { + "style": "fill: #fddab4;", + "points": "18.58 12.04 18.58 12.04 18.58 12.04" + }, + "children": [] + }, + { + "type": "element", + "name": "polyline", + "attributes": { + "style": "fill: #fddab4;", + "points": "18.58 12.04 18.58 12.04 18.58 12.04" + }, + "children": [] + }, + { + "type": "element", + "name": "polyline", + "attributes": { + "style": "fill: #fddab4;", + "points": "18.58 12.04 18.58 12.04 18.58 12.04" + }, + "children": [] + }, + { + "type": "element", + "name": "polyline", + "attributes": { + "style": "fill: #fddab4;", + "points": "18.58 12.04 18.58 12.04 18.58 12.04" + }, + "children": [] + }, + { + "type": "element", + "name": "polyline", + "attributes": { + "style": "fill: #fddab4;", + "points": "18.58 12.03 18.58 12.04 18.58 12.03" + }, + "children": [] + }, + { + "type": "element", + "name": "polyline", + "attributes": { + "style": "fill: #fddab4;", + "points": "18.52 11.84 18.52 11.84 18.52 11.84" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: #fddab4;", + "d": "M19.22,11.72s-.01.06-.02.09c0-.03.01-.06.02-.09" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: #fddab4;", + "d": "M17.51,11.51s-.04.04-.07.05c.02,0,.05-.02.07-.05" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: #e5b3a5;", + "d": "M18.58,12.04s.04.04.07.06h0s-.05-.04-.07-.06M18.58,12.04s0,0,0,0c0,0,0,0,0,0M18.58,12.04s0,0,0,0c0,0,0,0,0,0M18.58,12.04s0,0,0,0c0,0,0,0,0,0M18.58,12.04s0,0,0,0c0,0,0,0,0,0M18.57,12.03s0,0,0,0c0,0,0,0,0,0M18.52,11.84s0,0,0,0c0,0,0,0,0,0M18.52,11.84h0s0,0,0,0c0,0,0,0,0,0M17.17,11.51s0,0,0,0c.06.03.13.05.19.05.03,0,.05,0,.07-.01-.02,0-.05.01-.07.01-.06,0-.13-.02-.19-.05M18.52,11.31s0,0,0,0c0,0,0,0,0,0" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: #e5b3a5;", + "d": "M23.02,13.37s.01,0,.02,0h0s-.01,0-.02,0M21.78,12.86s0,0,0,0c0,0,0,0,0,0M21.59,12.76s.02.08.06.11c.02.01.03.02.05.02.03,0,.05-.01.08-.03-.03.02-.05.03-.08.03-.02,0-.03,0-.05-.02-.04-.03-.06-.07-.06-.11M21.68,12.5s0,.01,0,.02c0,0,0-.01,0-.02M21.73,12.39s-.03.07-.04.11c.01-.03.03-.07.04-.11M21.73,12.38s0,0,0,.01c0,0,0,0,0-.01M23.45,12.38s0,0,0,0c0,0,0,0,0,0M23.45,12.37s0,0,0,0c0,0,0,0,0,0M21.74,12.37s0,0,0,0c0,0,0,0,0,0M20.1,12.32c0,.1.04.19.19.24.05.02.09.02.12.02.13,0,.19-.09.24-.2-.05.1-.11.2-.24.2-.04,0-.08,0-.12-.02-.15-.05-.19-.14-.19-.24M18.86,12.17h0,0M19.11,12.07c-.05.06-.11.1-.22.1-.01,0-.02,0-.03,0,.01,0,.02,0,.03,0,.1,0,.17-.04.22-.1M21.08,11.32s0,0,0,0c-.05.15-.09.3-.15.44-.06.17-.14.34-.21.5h0c.08-.16.15-.33.21-.5.05-.15.1-.3.15-.45M19.3,11.23c-.02.16-.05.33-.08.49.03-.16.06-.33.08-.49h0M23.28,10.45s0,0,0,0c-.22.73-.57,1.42-1.12,2.04-.07.07-.14.14-.21.21h0c.07-.07.14-.14.21-.21.55-.62.9-1.31,1.12-2.04" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: url(#linear-gradient-2); opacity: .4;", + "d": "M6.25,3.13s-.06,0-.09.02c-.14.06-.18.21-.2.37.78,1.02,3.84,4.77,8.67,7.35,0,0,4.72,2.49,9.48,2.76-.37-.05-.7-.13-1-.25-.02,0-.04-.01-.05-.02h0c-1.73-.37-3.2-.84-4.24-1.21.02,0,.04,0,.06,0-.02,0-.04,0-.06,0-.06-.01-.11-.03-.15-.05-.82-.3-1.35-.53-1.47-.59-.06-.03-.12-.06-.17-.08-3.12-1.29-5.79-3.1-8.12-5.4-.92-.94-2.48-2.83-2.48-2.83h0s-.09-.08-.15-.08" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: url(#linear-gradient-3); opacity: .4;", + "d": "M6.59,7.12s-.02,0-.03,0c-.14.03-.19.18-.21.31,0,.02,0,.05-.01.07,2.41,3.27,5.65,4.6,5.65,4.6,3.23,1.52,5.88,1.83,7.5,1.83.67,0,1.16-.05,1.44-.09-.13.01-.27.02-.42.02-.95,0-2.3-.26-3.43-.52-.7-.15-1.38-.32-2.05-.53-.03,0-.05-.01-.05-.01h0c-2.6-.83-4.97-2.17-7.05-4.21-.2-.19-.38-.4-.56-.61-.16-.2-.5-.61-.62-.75-.01-.02-.03-.03-.04-.05h0s0,0,0,0c-.03-.03-.06-.05-.1-.05" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: url(#linear-gradient-4); opacity: .4;", + "d": "M8.78,12.8s-.08.01-.12.04c-.16.13-.09.34-.02.52,1.21.73,3.98,2.16,7.27,2.16,1.24,0,2.55-.2,3.88-.72-.96.31-1.94.43-2.94.43-.11,0-.22,0-.33,0-2.14-.05-4.15-.57-6.04-1.49-.7-.36-1.34-.76-1.52-.86-.06-.04-.12-.07-.18-.07" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: url(#linear-gradient-5); opacity: .4;", + "d": "M20.02,15.9c-.53.4-1.12.68-1.74.91-1.16.42-2.32.61-3.47.61-1,0-1.99-.14-2.99-.39-.69-.18-1.49-.48-1.49-.48h0c-.05-.02-.1-.04-.15-.04-.06,0-.12.03-.17.1-.07.1-.08.21-.06.32.76.35,2.4.98,4.39.98,1.73,0,3.73-.47,5.67-2.01" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: url(#linear-gradient-6); opacity: .4;", + "d": "M14.87,18.37c-1.87,0-3.29-.38-3.47-.43.05.02.1.03.15.05.17.05-.29.36-.43.47-.52.4-1.04.78-1.56,1.16h1.59c.5-.37,1-.74,1.49-1.13.18-.14.35-.2.55-.2.15,0,.31.03.49.08-1.09.82-2.11,1.59-3.13,2.36-.6.45-1.19.9-1.79,1.34h1.6c1.44-1.08,2.88-2.15,4.31-3.24.33-.25.67-.42,1.07-.48-.3.02-.59.03-.88.03Z" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: url(#Fade_to_Black_2); opacity: .4;", + "d": "M14.54,19.66h1.55l1.14-.91s.53-.38,1.15-.73c.05-.03.11-.07.17-.1.02-.01.08-.04.17-.08.07-.03.13-.07.2-.1.1-.05.22-.11.35-.19-1.1.46-2.23.69-3.28.77.01,0,.03,0,.04,0,.09,0,.18,0,.27.02-.57.43-1.12.84-1.67,1.25l-.09.07Z" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: url(#linear-gradient-7); opacity: .4;", + "d": "M18.52,11.84c0-.14,0-.28,0-.42h0s0-.01,0-.01c0-.02,0-.04,0-.06h0s0-.03,0-.03c0-.12-.01-.23-.03-.35-.37-.16-.72-.35-1.04-.59,0,.03,0,.07,0,.1.04.28.03.19.06.43.02.12.05.35.05.42,0,.06-.02.12-.06.16-.04.04-.09.06-.14.06-.06,0-.13-.02-.19-.05.12.06.65.29,1.48.59-.09-.05-.12-.14-.12-.27Z" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: url(#linear-gradient-8); opacity: .4;", + "d": "M24.54,9.21c-.34.48-.77.9-1.26,1.24-.22.73-.57,1.42-1.12,2.04-.07.07-.14.14-.21.21-.07.07-.17.19-.25.19-.02,0-.03,0-.05-.02-.13-.09-.01-.27.03-.36.21-.48.37-.98.48-1.47-.35.13-.71.22-1.08.27-.05.15-.09.3-.15.44-.06.17-.14.34-.21.5-.07.14-.12.32-.3.32-.04,0-.08,0-.12-.02-.26-.09-.19-.29-.16-.47.05-.24.09-.49.12-.73-.33-.01-.65-.05-.96-.12-.03.19-.06.39-.1.58-.02.13-.08.35-.31.35-.03,0-.06,0-.09-.01,1.04.37,2.51.84,4.24,1.21h0s-.03-.01-.05-.02c-.28-.12.22-.61.45-.96.64-.98.95-2.06,1.1-3.18Z" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: url(#Fade_to_Black_2-2); opacity: .5;", + "d": "M24.28,14.56c-.22,0-.43.02-.65.06-.04,0-.08.01-.12.01-.06,0-.12,0-.17-.03,0,.01.02.02.03.03.09.13.14.27.16.42.02.03.04.06.06.1h0s0,0,0,0c0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0h0s0,0,0,0c0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0t0,0s0,0,0,0c0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0s0,0,0,0c0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0s0,0,0,0c0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0h0s0,0,0,0h0s0,0,0,0h0s0,0,0,0h0s0,0,0,0h0s0,0,0,0h0s0,0,0,0h0s0,0,0,0h0s0,0,0,0c0,0,0,0,0,0,0,0,0,0,0,0h0s0,0,0,0h0s0,0,0,0h0s0,0,0,0h0s0,0,0,0h0s0,0,0,0h0s0,0,0,0h0s0,0,0,0h0s0,0,0,0h0s0,.07,0,.1c0,.05,0,.11,0,.17,0,.07-.02.15-.03.21,0,0,0,.02,0,.03,0,.05.02.09.06.12h.02s.05.03.07.03c.04,0,.08-.02.11-.05.02-.02.03-.04.05-.06.22-.23.44-.39.68-.49.17-.07.35-.11.53-.11s.36.04.55.11c.18.07.35.16.51.28.07.05.14.11.21.17.04.04.08.08.12.12h0s.03.04.03.04l.02.02s.06.03.09.03c-.15-.32-.33-.61-.6-.84-.5-.43-1.13-.62-1.77-.62" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: url(#linear-gradient-9); opacity: .4;", + "d": "M24.22,12.45h-.03c-.07.01-.12.07-.13.14,0,.05,0,.11,0,.17,0,.02,0,.04-.01.05.27.4.84,1.1,1.72,1.36,0,0,1.36.71,1.01,1.74h0v-.03c.12-.31.14-.6.08-.89-.06-.28-.2-.53-.42-.76-.08-.09-.18-.17-.28-.25-.05-.04-.1-.07-.15-.1-.07-.04-.14-.08-.21-.12-.28-.14-.51-.29-.72-.44-.08-.06-.17-.13-.24-.19-.19-.16-.31-.3-.4-.45-.03-.05-.06-.1-.08-.15-.01-.02-.03-.04-.05-.05-.03-.02-.06-.03-.09-.03" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: url(#linear-gradient-10); opacity: .5;", + "d": "M22.5,15.32l.28-.16,1.16-1.55s-.63-.12-.94-.26c0,0-.32,1.45-1.49,2.66l.43-.32c.17-.12.33-.23.56-.37Z" + }, + "children": [] + }, + { + "type": "element", + "name": "g", + "attributes": {}, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: #404041;", + "d": "M38,7.1h2.95c2.13,0,3.11,1.1,3.11,3.35v1.12c0,2.25-.98,3.35-3.11,3.35h-1.71v6.14h-1.24V7.1ZM40.95,13.78c1.3,0,1.87-.58,1.87-2.15v-1.26c0-1.55-.58-2.15-1.87-2.15h-1.71v5.56h1.71Z" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: #404041;", + "d": "M48.61,7.1h1.24v6.12h3.81v-6.12h1.24v13.95h-1.24v-6.72h-3.81v6.72h-1.24V7.1Z" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: #404041;", + "d": "M59.73,17.84v-7.54c0-2.21,1.12-3.41,3.13-3.41s3.13,1.2,3.13,3.41v7.54c0,2.21-1.12,3.41-3.13,3.41s-3.13-1.2-3.13-3.41ZM64.75,17.92v-7.69c0-1.5-.68-2.21-1.89-2.21s-1.89.72-1.89,2.21v7.69c0,1.5.68,2.19,1.89,2.19s1.89-.7,1.89-2.19Z" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: #404041;", + "d": "M70.85,7.1h5.58v1.12h-4.35v5h3.57v1.12h-3.57v5.58h4.35v1.14h-5.58V7.1Z" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: #404041;", + "d": "M80.9,7.1h1.63l3.63,10.78V7.1h1.16v13.95h-1.32l-3.97-11.9v11.9h-1.14V7.1Z" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: #404041;", + "d": "M92.32,7.1h1.24v13.95h-1.24V7.1Z" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "style": "fill: #404041;", + "d": "M100.79,13.94l-2.73-6.84h1.32l2.19,5.54,2.21-5.54h1.2l-2.73,6.84,2.85,7.12h-1.32l-2.31-5.86-2.33,5.86h-1.2l2.85-7.12Z" + }, + "children": [] + } + ] + } + ] + } + ] + } + ] + }, + "name": "PhoenixIconBig" +} diff --git a/web/app/components/base/icons/src/public/tracing/PhoenixIconBig.tsx b/web/app/components/base/icons/src/public/tracing/PhoenixIconBig.tsx new file mode 100644 index 0000000000..9131e6bea6 --- /dev/null +++ b/web/app/components/base/icons/src/public/tracing/PhoenixIconBig.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './PhoenixIconBig.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'PhoenixIconBig' + +export default Icon diff --git a/web/app/components/base/icons/src/public/tracing/index.ts b/web/app/components/base/icons/src/public/tracing/index.ts index 36b59e479b..07e3385f46 100644 --- a/web/app/components/base/icons/src/public/tracing/index.ts +++ b/web/app/components/base/icons/src/public/tracing/index.ts @@ -1,9 +1,15 @@ +export { default as ArizeIconBig } from './ArizeIconBig' +export { default as ArizeIcon } from './ArizeIcon' export { default as LangfuseIconBig } from './LangfuseIconBig' export { default as LangfuseIcon } from './LangfuseIcon' export { default as LangsmithIconBig } from './LangsmithIconBig' export { default as LangsmithIcon } from './LangsmithIcon' export { default as OpikIconBig } from './OpikIconBig' export { default as OpikIcon } from './OpikIcon' +export { default as PhoenixIconBig } from './PhoenixIconBig' +export { default as PhoenixIcon } from './PhoenixIcon' export { default as TracingIcon } from './TracingIcon' export { default as WeaveIconBig } from './WeaveIconBig' export { default as WeaveIcon } from './WeaveIcon' +export { default as AliyunIconBig } from './AliyunIconBig' +export { default as AliyunIcon } from './AliyunIcon' diff --git a/web/app/components/base/icons/src/vender/line/others/SearchMenu.json b/web/app/components/base/icons/src/vender/line/others/SearchMenu.json new file mode 100644 index 0000000000..5222574040 --- /dev/null +++ b/web/app/components/base/icons/src/vender/line/others/SearchMenu.json @@ -0,0 +1,77 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "32", + "height": "32", + "viewBox": "0 0 32 32", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M28.0049 16C28.0049 20.4183 24.4231 24 20.0049 24C15.5866 24 12.0049 20.4183 12.0049 16C12.0049 11.5817 15.5866 8 20.0049 8C24.4231 8 28.0049 11.5817 28.0049 16Z", + "stroke": "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M4.00488 16H6.67155", + "stroke": "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M4.00488 9.33334H8.00488", + "stroke": "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M4.00488 22.6667H8.00488", + "stroke": "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M26 22L29.3333 25.3333", + "stroke": "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round" + }, + "children": [] + } + ] + }, + "name": "SearchMenu" +} \ No newline at end of file diff --git a/web/app/components/base/icons/src/vender/line/others/SearchMenu.tsx b/web/app/components/base/icons/src/vender/line/others/SearchMenu.tsx new file mode 100644 index 0000000000..4826abb20f --- /dev/null +++ b/web/app/components/base/icons/src/vender/line/others/SearchMenu.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './SearchMenu.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'SearchMenu' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/line/others/index.ts b/web/app/components/base/icons/src/vender/line/others/index.ts index 19d5f1ebb5..2322e9d9f1 100644 --- a/web/app/components/base/icons/src/vender/line/others/index.ts +++ b/web/app/components/base/icons/src/vender/line/others/index.ts @@ -9,4 +9,5 @@ export { default as GlobalVariable } from './GlobalVariable' export { default as Icon3Dots } from './Icon3Dots' export { default as LongArrowLeft } from './LongArrowLeft' export { default as LongArrowRight } from './LongArrowRight' +export { default as SearchMenu } from './SearchMenu' export { default as Tools } from './Tools' diff --git a/web/app/components/base/icons/src/vender/other/Mcp.json b/web/app/components/base/icons/src/vender/other/Mcp.json new file mode 100644 index 0000000000..7caa70b16b --- /dev/null +++ b/web/app/components/base/icons/src/vender/other/Mcp.json @@ -0,0 +1,35 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "16", + "height": "16", + "viewBox": "0 0 16 16", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M9.20626 1.68651C9.61828 1.68651 10.014 1.8473 10.3093 2.13466C10.4536 2.27516 10.5684 2.44313 10.6468 2.62868C10.7252 2.81422 10.7657 3.01358 10.7659 3.21501C10.7661 3.41645 10.7259 3.61588 10.6478 3.80156C10.5697 3.98723 10.4552 4.1554 10.3111 4.29614L5.86656 8.65516C5.81837 8.70203 5.78006 8.75808 5.7539 8.82001C5.72775 8.88194 5.71427 8.94848 5.71427 9.01571C5.71427 9.08294 5.72775 9.14948 5.7539 9.21141C5.78006 9.27334 5.81837 9.32939 5.86656 9.37626C5.96503 9.47212 6.09703 9.52576 6.23445 9.52576C6.37187 9.52576 6.50387 9.47212 6.60234 9.37626L6.66222 9.31698L6.66345 9.31576L11.0463 5.01725C11.3417 4.73067 11.7372 4.57056 12.1488 4.5709C12.5604 4.57124 12.9556 4.73202 13.2506 5.01908L13.2811 5.04902C13.4256 5.18967 13.5405 5.35786 13.6189 5.54363C13.6973 5.72941 13.7377 5.92903 13.7377 6.13068C13.7377 6.33233 13.6973 6.53195 13.6189 6.71773C13.5405 6.9035 13.4256 7.07169 13.2811 7.21234L7.96082 12.43C7.84828 12.5393 7.75882 12.6701 7.69773 12.8147C7.63664 12.9592 7.60517 13.1145 7.60517 13.2714C7.60517 13.4284 7.63664 13.5837 7.69773 13.7282C7.75882 13.8728 7.84828 14.0036 7.96082 14.1129L9.05348 15.1842C9.15192 15.2799 9.28378 15.3334 9.42106 15.3334C9.55834 15.3334 9.6902 15.2799 9.78864 15.1842C9.83683 15.1373 9.87514 15.0813 9.9013 15.0194C9.92746 14.9574 9.94094 14.8909 9.94094 14.8237C9.94094 14.7564 9.92746 14.6899 9.9013 14.628C9.87514 14.566 9.83683 14.51 9.78864 14.4631L8.69598 13.3912C8.67992 13.3756 8.66716 13.357 8.65844 13.3363C8.64973 13.3157 8.64523 13.2935 8.64523 13.2711C8.64523 13.2488 8.64973 13.2266 8.65844 13.206C8.66716 13.1853 8.67992 13.1667 8.69598 13.1511L14.0163 7.93405C14.2572 7.69971 14.4488 7.41943 14.5796 7.10979C14.7104 6.80014 14.7778 6.46742 14.7778 6.13129C14.7778 5.79516 14.7104 5.46244 14.5796 5.1528C14.4488 4.84315 14.2572 4.56288 14.0163 4.32853L13.9857 4.29797C13.6978 4.01697 13.3493 3.80582 12.9669 3.6808C12.5845 3.55578 12.1785 3.52022 11.7802 3.57687C11.8371 3.1838 11.8001 2.78285 11.6722 2.40684C11.5443 2.03083 11.3292 1.69045 11.0445 1.41356C10.5524 0.93469 9.89288 0.666748 9.20626 0.666748C8.51964 0.666748 7.86012 0.93469 7.36805 1.41356L1.48555 7.18239C1.43735 7.22926 1.39905 7.28532 1.37289 7.34725C1.34673 7.40917 1.33325 7.47572 1.33325 7.54294C1.33325 7.61017 1.34673 7.67672 1.37289 7.73864C1.39905 7.80057 1.43735 7.85663 1.48555 7.9035C1.58399 7.99918 1.71585 8.0527 1.85313 8.0527C1.9904 8.0527 2.12227 7.99918 2.22071 7.9035L8.10321 2.13466C8.39848 1.8473 8.79424 1.68651 9.20626 1.68651Z", + "fill": "currentColor" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M9.68688 3.41201C9.66072 3.47394 9.62241 3.52999 9.57422 3.57686L5.22314 7.8436C5.07864 7.98425 4.96378 8.15243 4.88535 8.33821C4.80693 8.52399 4.76652 8.7236 4.76652 8.92526C4.76652 9.12691 4.80693 9.32652 4.88535 9.5123C4.96378 9.69808 5.07864 9.86626 5.22314 10.0069C5.51841 10.2943 5.91417 10.4551 6.32619 10.4551C6.73821 10.4551 7.13397 10.2943 7.42924 10.0069L11.7797 5.74017C11.8782 5.64431 12.0102 5.59067 12.1476 5.59067C12.285 5.59067 12.417 5.64431 12.5155 5.74017C12.5637 5.78704 12.602 5.8431 12.6281 5.90503C12.6543 5.96696 12.6678 6.0335 12.6678 6.10073C12.6678 6.16795 12.6543 6.2345 12.6281 6.29643C12.602 6.35835 12.5637 6.41441 12.5155 6.46128L8.1644 10.728C7.67225 11.2067 7.01276 11.4746 6.32619 11.4746C5.63962 11.4746 4.98013 11.2067 4.48798 10.728C4.24701 10.4937 4.05547 10.2134 3.92468 9.90375C3.79389 9.59411 3.7265 9.26139 3.7265 8.92526C3.7265 8.58912 3.79389 8.2564 3.92468 7.94676C4.05547 7.63712 4.24701 7.35684 4.48798 7.1225L8.83845 2.85576C8.93691 2.75989 9.06891 2.70625 9.20633 2.70625C9.34375 2.70625 9.47575 2.75989 9.57422 2.85576C9.62241 2.90263 9.66072 2.95868 9.68688 3.02061C9.71304 3.08254 9.72651 3.14908 9.72651 3.21631C9.72651 3.28353 9.71304 3.35008 9.68688 3.41201Z", + "fill": "currentColor" + }, + "children": [] + } + ] + }, + "name": "Mcp" +} \ No newline at end of file diff --git a/web/app/components/base/icons/src/vender/other/Mcp.tsx b/web/app/components/base/icons/src/vender/other/Mcp.tsx new file mode 100644 index 0000000000..00ffa4a831 --- /dev/null +++ b/web/app/components/base/icons/src/vender/other/Mcp.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './Mcp.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'Mcp' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/other/NoToolPlaceholder.json b/web/app/components/base/icons/src/vender/other/NoToolPlaceholder.json new file mode 100644 index 0000000000..d33d62d344 --- /dev/null +++ b/web/app/components/base/icons/src/vender/other/NoToolPlaceholder.json @@ -0,0 +1,279 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "204", + "height": "36", + "viewBox": "0 0 204 36", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "g", + "attributes": { + "opacity": "0.1" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M3.33333 18.3333C3.33333 18.5423 3.64067 18.9056 4.35365 19.2621C5.27603 19.7233 6.58451 20 8 20C9.41547 20 10.7239 19.7233 11.6463 19.2621C12.3593 18.9056 12.6667 18.5423 12.6667 18.3333V16.8858C11.5667 17.5655 9.88487 18 8 18C6.11515 18 4.43331 17.5655 3.33333 16.8858V18.3333ZM12.6667 20.2191C11.5667 20.8988 9.88487 21.3333 8 21.3333C6.11515 21.3333 4.43331 20.8988 3.33333 20.2191V21.6667C3.33333 21.8756 3.64067 22.2389 4.35365 22.5954C5.27603 23.0566 6.58451 23.3333 8 23.3333C9.41547 23.3333 10.7239 23.0566 11.6463 22.5954C12.3593 22.2389 12.6667 21.8756 12.6667 21.6667V20.2191ZM2 21.6667V15C2 13.3431 4.68629 12 8 12C11.3137 12 14 13.3431 14 15V21.6667C14 23.3235 11.3137 24.6667 8 24.6667C4.68629 24.6667 2 23.3235 2 21.6667ZM8 16.6667C9.41547 16.6667 10.7239 16.3899 11.6463 15.9288C12.3593 15.5723 12.6667 15.2089 12.6667 15C12.6667 14.7911 12.3593 14.4277 11.6463 14.0712C10.7239 13.6101 9.41547 13.3333 8 13.3333C6.58451 13.3333 5.27603 13.6101 4.35365 14.0712C3.64067 14.4277 3.33333 14.7911 3.33333 15C3.33333 15.2089 3.64067 15.5723 4.35365 15.9288C5.27603 16.3899 6.58451 16.6667 8 16.6667Z", + "fill": "currentColor", + "fill-opacity": "0.3" + }, + "children": [] + } + ] + }, + { + "type": "element", + "name": "g", + "attributes": { + "opacity": "0.3" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M41.3337 11.3333C41.7019 11.3333 42.0003 11.6318 42.0003 12V15.3333C42.0003 15.7015 41.7019 16 41.3337 16H38.0003V24.6667C38.0003 25.0349 37.7019 25.3333 37.3337 25.3333H34.667C34.2988 25.3333 34.0003 25.0349 34.0003 24.6667V16H30.3337C29.9655 16 29.667 15.7015 29.667 15.3333V13.7454C29.667 13.4929 29.8097 13.262 30.0355 13.1491L33.667 11.3333H41.3337ZM38.0003 12.6667H33.9818L31.0003 14.1574V14.6667H35.3337V24H36.667V14.6667H38.0003V12.6667ZM40.667 12.6667H39.3337V14.6667H40.667V12.6667Z", + "fill": "currentColor", + "fill-opacity": "0.3" + }, + "children": [] + } + ] + }, + { + "type": "element", + "name": "g", + "attributes": { + "opacity": "0.6" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M60.6667 13.3333C60.6667 11.8606 61.8606 10.6667 63.3333 10.6667C64.8061 10.6667 66 11.8606 66 13.3333H69.3333C69.7015 13.3333 70 13.6318 70 14V16.7805C70 16.9969 69.8949 17.1998 69.7183 17.3248C69.5415 17.4497 69.3152 17.4811 69.1112 17.409C68.973 17.3602 68.8237 17.3333 68.6667 17.3333C67.9303 17.3333 67.3333 17.9303 67.3333 18.6667C67.3333 19.4031 67.9303 20 68.6667 20C68.8237 20 68.973 19.9731 69.1112 19.9243C69.3152 19.8522 69.5415 19.8836 69.7183 20.0085C69.8949 20.1335 70 20.3365 70 20.5529V23.3333C70 23.7015 69.7015 24 69.3333 24H58.6667C58.2985 24 58 23.7015 58 23.3333V14C58 13.6318 58.2985 13.3333 58.6667 13.3333H60.6667ZM63.3333 12C62.597 12 62 12.5969 62 13.3333C62 13.4903 62.0269 13.6397 62.0757 13.7778C62.1478 13.9819 62.1164 14.2082 61.9915 14.3849C61.8665 14.5616 61.6635 14.6667 61.4471 14.6667H59.3333V22.6667H68.6667V21.3333C67.1939 21.3333 66 20.1394 66 18.6667C66 17.1939 67.1939 16 68.6667 16V14.6667H65.2195C65.0031 14.6667 64.8002 14.5616 64.6752 14.3849C64.5503 14.2082 64.5189 13.9819 64.591 13.7778C64.6398 13.6397 64.6667 13.4904 64.6667 13.3333C64.6667 12.5969 64.0697 12 63.3333 12Z", + "fill": "currentColor", + "fill-opacity": "0.3" + }, + "children": [] + } + ] + }, + { + "type": "element", + "name": "rect", + "attributes": { + "x": "84.5", + "y": "0.5", + "width": "35", + "height": "35", + "rx": "9.5", + "stroke": "#101828", + "stroke-opacity": "0.04" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M96.167 16.3333H107.834V25.5H96.167V16.3333Z", + "stroke": "currentColor", + "stroke-width": "1.5", + "stroke-linecap": "round", + "stroke-linejoin": "round" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M100.333 19.6667H103.666", + "stroke": "currentColor", + "stroke-width": "1.5", + "stroke-linecap": "round", + "stroke-linejoin": "round" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M107.833 12.1667L105.583 15.9167", + "stroke": "currentColor", + "stroke-width": "1.5", + "stroke-linecap": "round", + "stroke-linejoin": "round" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M97.4888 11.9517C97.5447 11.9238 97.5901 11.8784 97.6181 11.8224L97.9911 11.0765C98.0976 10.8634 98.4017 10.8634 98.5083 11.0765L98.8813 11.8224C98.9092 11.8784 98.9546 11.9238 99.0106 11.9517L99.7565 12.3247C99.9696 12.4313 99.9696 12.7354 99.7565 12.842L99.0106 13.2149C98.9546 13.2429 98.9092 13.2883 98.8813 13.3442L98.5083 14.0902C98.4017 14.3033 98.0976 14.3033 97.9911 14.0902L97.6181 13.3442C97.5901 13.2883 97.5447 13.2429 97.4888 13.2149L96.7429 12.842C96.5297 12.7354 96.5297 12.4313 96.7429 12.3247L97.4888 11.9517Z", + "fill": "currentColor" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M101.882 10.5438C101.952 10.5089 102.009 10.4521 102.044 10.3822L102.51 9.4498C102.643 9.1834 103.023 9.1834 103.157 9.4498L103.623 10.3822C103.658 10.4521 103.714 10.5089 103.784 10.5438L104.717 11.0101C104.983 11.1432 104.983 11.5234 104.717 11.6566L103.784 12.1228C103.714 12.1578 103.658 12.2145 103.623 12.2845L103.157 13.2169C103.023 13.4833 102.643 13.4833 102.51 13.2169L102.044 12.2845C102.009 12.2145 101.952 12.1578 101.882 12.1228L100.95 11.6566C100.683 11.5234 100.683 11.1432 100.95 11.0101L101.882 10.5438Z", + "fill": "currentColor" + }, + "children": [] + }, + { + "type": "element", + "name": "g", + "attributes": { + "opacity": "0.6", + "clip-path": "url(#clip0_9296_51042)" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M145.809 14.7521L145.645 15.1292C145.525 15.4053 145.143 15.4053 145.022 15.1292L144.858 14.7521C144.565 14.0796 144.037 13.5443 143.379 13.2514L142.872 13.0261C142.599 12.9044 142.599 12.5059 142.872 12.3841L143.35 12.1714C144.026 11.871 144.563 11.3158 144.851 10.6206L145.02 10.213C145.138 9.92899 145.53 9.92899 145.647 10.213L145.816 10.6206C146.104 11.3158 146.641 11.871 147.317 12.1714L147.795 12.3841C148.069 12.5059 148.069 12.9044 147.795 13.0261L147.289 13.2514C146.63 13.5443 146.102 14.0796 145.809 14.7521ZM138 11.3333C140.712 11.3333 142.951 13.3571 143.289 15.9766L144.79 18.3358C144.889 18.4911 144.869 18.7231 144.64 18.8211L143.334 19.3807V21.3333C143.334 22.0697 142.737 22.6667 142 22.6667H140.668L140.667 24.6667H134.667L134.667 22.2041C134.667 21.4168 134.376 20.6725 133.837 20.0007C133.105 19.0875 132.667 17.9283 132.667 16.6667C132.667 13.7211 135.055 11.3333 138 11.3333ZM138 12.6667C135.791 12.6667 134 14.4575 134 16.6667C134 17.5899 134.312 18.4619 134.878 19.1666C135.607 20.076 136.001 21.1115 136 22.2042L136 23.3333H139.334L139.335 21.3333H142V18.5013L143.033 18.0587L142.005 16.4417L141.967 16.1475C141.711 14.1676 140.017 12.6667 138 12.6667ZM144.993 21.3286L146.103 22.0683C146.88 20.9041 147.334 19.5051 147.334 18.0001C147.334 17.5447 147.292 17.0991 147.213 16.6667L145.917 17C145.972 17.3252 146 17.6593 146 18.0001C146 19.2314 145.629 20.3761 144.993 21.3286Z", + "fill": "currentColor", + "fill-opacity": "0.3" + }, + "children": [] + } + ] + }, + { + "type": "element", + "name": "g", + "attributes": { + "opacity": "0.3" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "fill-rule": "evenodd", + "clip-rule": "evenodd", + "d": "M167.149 16.3522L167.251 16.3822L174.224 19.0391L174.317 19.082C174.722 19.308 174.777 19.8792 174.424 20.179L174.341 20.2389L171.817 21.8171L170.239 24.3418C169.962 24.784 169.324 24.7511 169.082 24.3171L169.039 24.2246L166.382 17.2513C166.188 16.742 166.644 16.2421 167.149 16.3522ZM169.812 22.5085L170.767 20.9811L170.811 20.9186C170.858 20.859 170.916 20.8076 170.981 20.7669L172.508 19.8119L168.152 18.1524L169.812 22.5085Z", + "fill": "currentColor", + "fill-opacity": "0.3" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M165.212 20.3978L163.562 22.0475L162.619 21.1048L164.269 19.4551L165.212 20.3978Z", + "fill": "currentColor", + "fill-opacity": "0.3" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M163.666 18H161.333V16.6667H163.666V18Z", + "fill": "currentColor", + "fill-opacity": "0.3" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M165.212 14.2689L164.269 15.2116L162.619 13.5619L163.562 12.6192L165.212 14.2689Z", + "fill": "currentColor", + "fill-opacity": "0.3" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M172.047 13.5619L170.397 15.2116L169.455 14.2689L171.104 12.6192L172.047 13.5619Z", + "fill": "currentColor", + "fill-opacity": "0.3" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M168 13.6667H166.666V11.3333H168V13.6667Z", + "fill": "currentColor", + "fill-opacity": "0.3" + }, + "children": [] + } + ] + }, + { + "type": "element", + "name": "g", + "attributes": { + "opacity": "0.1" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M202.666 23.3333V14.6667L201.333 12H190.666L189.333 14.669V23.3333C189.333 23.7015 189.631 24 190 24H202C202.368 24 202.666 23.7015 202.666 23.3333ZM190.666 16H201.333V22.6667H190.666V16ZM191.49 13.3333H200.509L201.176 14.6667H190.824L191.49 13.3333ZM198 17.3333H194V18.6667H198V17.3333Z", + "fill": "currentColor", + "fill-opacity": "0.3" + }, + "children": [] + } + ] + }, + { + "type": "element", + "name": "defs", + "attributes": {}, + "children": [ + { + "type": "element", + "name": "clipPath", + "attributes": { + "id": "clip0_9296_51042" + }, + "children": [ + { + "type": "element", + "name": "rect", + "attributes": { + "width": "16", + "height": "16", + "fill": "white", + "transform": "translate(132 10)" + }, + "children": [] + } + ] + } + ] + } + ] + }, + "name": "NoToolPlaceholder" +} \ No newline at end of file diff --git a/web/app/components/base/icons/src/vender/other/NoToolPlaceholder.tsx b/web/app/components/base/icons/src/vender/other/NoToolPlaceholder.tsx new file mode 100644 index 0000000000..da8fddee22 --- /dev/null +++ b/web/app/components/base/icons/src/vender/other/NoToolPlaceholder.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './NoToolPlaceholder.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'NoToolPlaceholder' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/other/index.ts b/web/app/components/base/icons/src/vender/other/index.ts index 8ddf5e7a86..8a7bb7ae28 100644 --- a/web/app/components/base/icons/src/vender/other/index.ts +++ b/web/app/components/base/icons/src/vender/other/index.ts @@ -1,5 +1,7 @@ export { default as AnthropicText } from './AnthropicText' export { default as Generator } from './Generator' export { default as Group } from './Group' +export { default as Mcp } from './Mcp' +export { default as NoToolPlaceholder } from './NoToolPlaceholder' export { default as Openai } from './Openai' export { default as ReplayLine } from './ReplayLine' diff --git a/web/app/components/base/icons/src/vender/workflow/WindowCursor.json b/web/app/components/base/icons/src/vender/workflow/WindowCursor.json new file mode 100644 index 0000000000..b64ba912bb --- /dev/null +++ b/web/app/components/base/icons/src/vender/workflow/WindowCursor.json @@ -0,0 +1,62 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "16", + "height": "16", + "viewBox": "0 0 16 16", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M1.33325 4.66663C1.33325 3.56206 2.22869 2.66663 3.33325 2.66663H12.6666C13.7712 2.66663 14.6666 3.56206 14.6666 4.66663V8.16663C14.6666 8.53483 14.3681 8.83329 13.9999 8.83329C13.6317 8.83329 13.3333 8.53483 13.3333 8.16663V4.66663C13.3333 4.29844 13.0348 3.99996 12.6666 3.99996H3.33325C2.96507 3.99996 2.66659 4.29844 2.66659 4.66663V12C2.66659 12.3682 2.96507 12.6666 3.33325 12.6666H7.99992C8.36812 12.6666 8.66658 12.9651 8.66658 13.3333C8.66658 13.7015 8.36812 14 7.99992 14H3.33325C2.22869 14 1.33325 13.1046 1.33325 12V4.66663Z", + "fill": "currentColor" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M3.66659 5.83329C3.66659 6.29353 4.03968 6.66663 4.49992 6.66663C4.96016 6.66663 5.33325 6.29353 5.33325 5.83329C5.33325 5.37305 4.96016 4.99996 4.49992 4.99996C4.03968 4.99996 3.66659 5.37305 3.66659 5.83329Z", + "fill": "currentColor" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M5.99992 5.83329C5.99992 6.29353 6.37301 6.66663 6.83325 6.66663C7.29352 6.66663 7.66658 6.29353 7.66658 5.83329C7.66658 5.37305 7.29352 4.99996 6.83325 4.99996C6.37301 4.99996 5.99992 5.37305 5.99992 5.83329Z", + "fill": "currentColor" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M8.33325 5.83329C8.33325 6.29353 8.70632 6.66663 9.16658 6.66663C9.62685 6.66663 9.99992 6.29353 9.99992 5.83329C9.99992 5.37305 9.62685 4.99996 9.16658 4.99996C8.70632 4.99996 8.33325 5.37305 8.33325 5.83329Z", + "fill": "currentColor" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M10.5293 9.69609C10.2933 9.62349 10.0365 9.68729 9.86185 9.86189C9.68725 10.0365 9.62345 10.2934 9.69605 10.5294L11.0294 14.8627C11.1095 15.1231 11.3401 15.3086 11.6116 15.331C11.8832 15.3535 12.1411 15.2085 12.2629 14.9648L13.1635 13.1636L14.9647 12.263C15.2085 12.1411 15.3535 11.8832 15.331 11.6116C15.3085 11.3401 15.1231 11.1096 14.8627 11.0294L10.5293 9.69609Z", + "fill": "currentColor" + }, + "children": [] + } + ] + }, + "name": "WindowCursor" +} \ No newline at end of file diff --git a/web/app/components/base/icons/src/vender/workflow/WindowCursor.tsx b/web/app/components/base/icons/src/vender/workflow/WindowCursor.tsx new file mode 100644 index 0000000000..8f48dc0b14 --- /dev/null +++ b/web/app/components/base/icons/src/vender/workflow/WindowCursor.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './WindowCursor.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'WindowCursor' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/workflow/index.ts b/web/app/components/base/icons/src/vender/workflow/index.ts index 7167b71b44..61fbd4b21c 100644 --- a/web/app/components/base/icons/src/vender/workflow/index.ts +++ b/web/app/components/base/icons/src/vender/workflow/index.ts @@ -19,3 +19,4 @@ export { default as ParameterExtractor } from './ParameterExtractor' export { default as QuestionClassifier } from './QuestionClassifier' export { default as TemplatingTransform } from './TemplatingTransform' export { default as VariableX } from './VariableX' +export { default as WindowCursor } from './WindowCursor' diff --git a/web/app/components/base/markdown-blocks/form.tsx b/web/app/components/base/markdown-blocks/form.tsx index ab7e7cef53..b71193d8f9 100644 --- a/web/app/components/base/markdown-blocks/form.tsx +++ b/web/app/components/base/markdown-blocks/form.tsx @@ -28,6 +28,7 @@ enum SUPPORTED_TYPES { DATETIME = 'datetime', CHECKBOX = 'checkbox', SELECT = 'select', + HIDDEN = 'hidden', } const MarkdownForm = ({ node }: any) => { const { onSend } = useChatContext() @@ -37,8 +38,12 @@ const MarkdownForm = ({ node }: any) => { useEffect(() => { const initialValues: { [key: string]: any } = {} node.children.forEach((child: any) => { - if ([SUPPORTED_TAGS.INPUT, SUPPORTED_TAGS.TEXTAREA].includes(child.tagName)) - initialValues[child.properties.name] = child.properties.value + if ([SUPPORTED_TAGS.INPUT, SUPPORTED_TAGS.TEXTAREA].includes(child.tagName)) { + initialValues[child.properties.name] + = (child.tagName === SUPPORTED_TAGS.INPUT && child.properties.type === SUPPORTED_TYPES.HIDDEN) + ? (child.properties.value || '') + : child.properties.value + } }) setFormValues(initialValues) }, [node.children]) @@ -180,6 +185,17 @@ const MarkdownForm = ({ node }: any) => { ) } + if (child.properties.type === SUPPORTED_TYPES.HIDDEN) { + return ( + + ) + } + return ( { - return ['http:', 'https:', '//', 'mailto:'].some(prefix => url.startsWith(prefix)) + const validPrefixes = ['http:', 'https:', '//', 'mailto:'] + if (ALLOW_UNSAFE_DATA_SCHEME) validPrefixes.push('data:') + return validPrefixes.some(prefix => url.startsWith(prefix)) } diff --git a/web/app/components/base/markdown/markdown-utils.ts b/web/app/components/base/markdown/markdown-utils.ts index 209fcd0b32..0089bef0ac 100644 --- a/web/app/components/base/markdown/markdown-utils.ts +++ b/web/app/components/base/markdown/markdown-utils.ts @@ -4,6 +4,7 @@ * Includes preprocessing for LaTeX and custom "think" tags. */ import { flow } from 'lodash-es' +import { ALLOW_UNSAFE_DATA_SCHEME } from '@/config' export const preprocessLaTeX = (content: string) => { if (typeof content !== 'string') @@ -86,5 +87,8 @@ export const customUrlTransform = (uri: string): string | undefined => { if (PERMITTED_SCHEME_REGEX.test(scheme)) return uri + if (ALLOW_UNSAFE_DATA_SCHEME && scheme === 'data:') + return uri + return undefined } diff --git a/web/app/components/base/prompt-editor/index.tsx b/web/app/components/base/prompt-editor/index.tsx index 94a65e4b62..a87a51cd50 100644 --- a/web/app/components/base/prompt-editor/index.tsx +++ b/web/app/components/base/prompt-editor/index.tsx @@ -64,8 +64,9 @@ import cn from '@/utils/classnames' export type PromptEditorProps = { instanceId?: string compact?: boolean + wrapperClassName?: string className?: string - placeholder?: string + placeholder?: string | JSX.Element placeholderClassName?: string style?: React.CSSProperties value?: string @@ -85,6 +86,7 @@ export type PromptEditorProps = { const PromptEditor: FC = ({ instanceId, compact, + wrapperClassName, className, placeholder, placeholderClassName, @@ -147,10 +149,25 @@ const PromptEditor: FC = ({ return ( -
+
} - placeholder={} + contentEditable={ + + } + placeholder={ + + } ErrorBoundary={LexicalErrorBoundary} /> { const { t } = useTranslation() diff --git a/web/app/components/base/select/index.tsx b/web/app/components/base/select/index.tsx index 44cb0522f6..77d229672f 100644 --- a/web/app/components/base/select/index.tsx +++ b/web/app/components/base/select/index.tsx @@ -116,7 +116,7 @@ const Select: FC = ({ if (!disabled) setOpen(!open) } - } className={classNames(`flex items-center h-9 w-full rounded-lg border-0 ${bgClassName} py-1.5 pl-3 pr-10 shadow-sm sm:text-sm sm:leading-6 focus-visible:outline-none focus-visible:bg-state-base-hover group-hover:bg-state-base-hover`, optionClassName)}> + } className={classNames(`flex h-9 w-full items-center rounded-lg border-0 ${bgClassName} py-1.5 pl-3 pr-10 shadow-sm focus-visible:bg-state-base-hover focus-visible:outline-none group-hover:bg-state-base-hover sm:text-sm sm:leading-6`, optionClassName)}>
{selectedItem?.name}
} = ({ value={item} className={({ active }: { active: boolean }) => classNames( - 'relative cursor-default select-none py-2 pl-3 pr-9 rounded-lg hover:bg-state-base-hover text-text-secondary', + 'relative cursor-default select-none rounded-lg py-2 pl-3 pr-9 text-text-secondary hover:bg-state-base-hover', active ? 'bg-state-base-hover' : '', optionClassName, ) @@ -225,8 +225,8 @@ const SimpleSelect: FC = ({ if (listboxRef.current) onOpenChange?.(listboxRef.current.getAttribute('data-open') !== null) }) - }} className={classNames(`flex items-center w-full h-full rounded-lg border-0 bg-components-input-bg-normal pl-3 pr-10 sm:text-sm sm:leading-6 focus-visible:outline-none focus-visible:bg-state-base-hover-alt group-hover/simple-select:bg-state-base-hover-alt ${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`, className)}> - {selectedItem?.name ?? localPlaceholder} + }} className={classNames(`flex h-full w-full items-center rounded-lg border-0 bg-components-input-bg-normal pl-3 pr-10 focus-visible:bg-state-base-hover-alt focus-visible:outline-none group-hover/simple-select:bg-state-base-hover-alt sm:text-sm sm:leading-6 ${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`, className)}> + {selectedItem?.name ?? localPlaceholder} {isLoading ? : (selectedItem && !notClearable) @@ -252,13 +252,13 @@ const SimpleSelect: FC = ({ )} {(!disabled) && ( - + {items.map((item: Item) => ( = ({ : (
@@ -358,7 +358,7 @@ const PortalSelect: FC = ({
{items.map((item: Item) => (
= ({ > {popupContent && (
triggerMethod === 'hover' && setHoverPopup()} diff --git a/web/app/components/datasets/create/index.tsx b/web/app/components/datasets/create/index.tsx index b1e4087226..a1ff2f5d87 100644 --- a/web/app/components/datasets/create/index.tsx +++ b/web/app/components/datasets/create/index.tsx @@ -122,7 +122,7 @@ const DatasetUpdateForm = ({ datasetId }: DatasetUpdateFormProps) => { return return ( -
+
{step === 1 && setShowModal(true) const modalCloseHandle = () => setShowModal(false) - const updateCurrentFile = (file: File) => { + const updateCurrentFile = useCallback((file: File) => { setCurrentFile(file) - } - const hideFilePreview = () => { + }, []) + + const hideFilePreview = useCallback(() => { setCurrentFile(undefined) - } + }, []) - const updateCurrentPage = (page: NotionPage) => { + const updateCurrentPage = useCallback((page: NotionPage) => { setCurrentNotionPage(page) - } + }, []) - const hideNotionPagePreview = () => { + const hideNotionPagePreview = useCallback(() => { setCurrentNotionPage(undefined) - } + }, []) - const hideWebsitePreview = () => { + const updateWebsite = useCallback((website: CrawlResultItem) => { + setCurrentWebsite(website) + }, []) + + const hideWebsitePreview = useCallback(() => { setCurrentWebsite(undefined) - } + }, []) const shouldShowDataSourceTypeList = !datasetId || (datasetId && !dataset?.data_source_type) const isInCreatePage = shouldShowDataSourceTypeList @@ -139,7 +144,7 @@ const StepOne = ({
{ shouldShowDataSourceTypeList && ( -
+
{t('datasetCreation.steps.one')}
) @@ -158,8 +163,8 @@ const StepOne = ({ if (dataSourceTypeDisable) return changeType(DataSourceType.FILE) - hideFilePreview() hideNotionPagePreview() + hideWebsitePreview() }} > @@ -182,7 +187,7 @@ const StepOne = ({ return changeType(DataSourceType.NOTION) hideFilePreview() - hideNotionPagePreview() + hideWebsitePreview() }} > @@ -201,7 +206,13 @@ const StepOne = ({ dataSourceType === DataSourceType.WEB && s.active, dataSourceTypeDisable && dataSourceType !== DataSourceType.WEB && s.disabled, )} - onClick={() => changeType(DataSourceType.WEB)} + onClick={() => { + if (dataSourceTypeDisable) + return + changeType(DataSourceType.WEB) + hideFilePreview() + hideNotionPagePreview() + }} >
0 ? page : 1, + limit: (limit > 0 && limit <= 100) ? limit : 10, + keyword: keyword ? decodeURIComponent(keyword) : '', + } +} + +// Update the URL search string with the given query parameters. +function updateSearchParams(query: DocumentListQuery, searchParams: URLSearchParams) { + const { page, limit, keyword } = query || {} + + const hasNonDefaultParams = (page && page > 1) || (limit && limit !== 10) || (keyword && keyword.trim()) + + if (hasNonDefaultParams) { + searchParams.set('page', (page || 1).toString()) + searchParams.set('limit', (limit || 10).toString()) + } + else { + searchParams.delete('page') + searchParams.delete('limit') + } + + if (keyword && keyword.trim()) + searchParams.set('keyword', encodeURIComponent(keyword)) + else + searchParams.delete('keyword') +} + +function useDocumentListQueryState() { + const searchParams = useSearchParams() + const query = useMemo(() => parseParams(searchParams), [searchParams]) + + const router = useRouter() + const pathname = usePathname() + + // Helper function to update specific query parameters + const updateQuery = useCallback((updates: Partial) => { + const newQuery = { ...query, ...updates } + const params = new URLSearchParams() + updateSearchParams(newQuery, params) + const search = params.toString() + const queryString = search ? `?${search}` : '' + router.push(`${pathname}${queryString}`, { scroll: false }) + }, [query, router, pathname]) + + // Helper function to reset query to defaults + const resetQuery = useCallback(() => { + const params = new URLSearchParams() + updateSearchParams(DEFAULT_QUERY, params) + const search = params.toString() + const queryString = search ? `?${search}` : '' + router.push(`${pathname}${queryString}`, { scroll: false }) + }, [router, pathname]) + + return useMemo(() => ({ + query, + updateQuery, + resetQuery, + }), [query, updateQuery, resetQuery]) +} + +export default useDocumentListQueryState diff --git a/web/app/components/datasets/documents/index.tsx b/web/app/components/datasets/documents/index.tsx index 2840e5fa4a..676581a50f 100644 --- a/web/app/components/datasets/documents/index.tsx +++ b/web/app/components/datasets/documents/index.tsx @@ -26,6 +26,7 @@ import cn from '@/utils/classnames' import { useDocumentList, useInvalidDocumentDetailKey, useInvalidDocumentList } from '@/service/knowledge/use-document' import { useInvalid } from '@/service/use-base' import { useChildSegmentListKey, useSegmentListKey } from '@/service/knowledge/use-segment' +import useDocumentListQueryState from './hooks/use-document-list-query-state' import useEditDocumentMetadata from '../metadata/hooks/use-edit-dataset-metadata' import DatasetMetadataDrawer from '../metadata/metadata-dataset/dataset-metadata-drawer' import StatusWithAction from '../common/document-status-with-action/status-with-action' @@ -82,7 +83,6 @@ type IDocumentsProps = { } export const fetcher = (url: string) => get(url, {}, {}) -const DEFAULT_LIMIT = 10 const Documents: FC = ({ datasetId }) => { const { t } = useTranslation() @@ -91,8 +91,12 @@ const Documents: FC = ({ datasetId }) => { const isFreePlan = plan.type === 'sandbox' const [inputValue, setInputValue] = useState('') // the input value const [searchValue, setSearchValue] = useState('') - const [currPage, setCurrPage] = React.useState(0) - const [limit, setLimit] = useState(DEFAULT_LIMIT) + + // Use the new hook for URL state management + const { query, updateQuery } = useDocumentListQueryState() + const [currPage, setCurrPage] = React.useState(query.page - 1) // Convert to 0-based index + const [limit, setLimit] = useState(query.limit) + const router = useRouter() const { dataset } = useDatasetDetailContext() const [notionPageSelectorModalVisible, setNotionPageSelectorModalVisible] = useState(false) @@ -103,6 +107,45 @@ const Documents: FC = ({ datasetId }) => { const embeddingAvailable = !!dataset?.embedding_available const debouncedSearchValue = useDebounce(searchValue, { wait: 500 }) + // Initialize search value from URL on mount + useEffect(() => { + if (query.keyword) { + setInputValue(query.keyword) + setSearchValue(query.keyword) + } + }, []) // Only run on mount + + // Sync local state with URL query changes + useEffect(() => { + setCurrPage(query.page - 1) + setLimit(query.limit) + if (query.keyword !== searchValue) { + setInputValue(query.keyword) + setSearchValue(query.keyword) + } + }, [query]) + + // Update URL when pagination changes + const handlePageChange = (newPage: number) => { + setCurrPage(newPage) + updateQuery({ page: newPage + 1 }) // Convert to 1-based index + } + + // Update URL when limit changes + const handleLimitChange = (newLimit: number) => { + setLimit(newLimit) + setCurrPage(0) // Reset to first page when limit changes + updateQuery({ limit: newLimit, page: 1 }) + } + + // Update URL when search changes + useEffect(() => { + if (debouncedSearchValue !== query.keyword) { + setCurrPage(0) // Reset to first page when search changes + updateQuery({ keyword: debouncedSearchValue, page: 1 }) + } + }, [debouncedSearchValue, query.keyword, updateQuery]) + const { data: documentsRes, isFetching: isListLoading } = useDocumentList({ datasetId, query: { @@ -327,9 +370,9 @@ const Documents: FC = ({ datasetId }) => { pagination={{ total, limit, - onLimitChange: setLimit, + onLimitChange: handleLimitChange, current: currPage, - onChange: setCurrPage, + onChange: handlePageChange, }} onManageMetadata={showEditMetadataModal} /> diff --git a/web/app/components/datasets/documents/list.tsx b/web/app/components/datasets/documents/list.tsx index cb349ee01c..c54b6a1a37 100644 --- a/web/app/components/datasets/documents/list.tsx +++ b/web/app/components/datasets/documents/list.tsx @@ -11,6 +11,8 @@ import { RiEqualizer2Line, RiLoopLeftLine, RiMoreFill, + RiPauseCircleLine, + RiPlayCircleLine, } from '@remixicon/react' import { useContext } from 'use-context-selector' import { useRouter } from 'next/navigation' @@ -42,7 +44,7 @@ import { useDatasetDetailContextWithSelector as useDatasetDetailContext } from ' import type { Props as PaginationProps } from '@/app/components/base/pagination' import Pagination from '@/app/components/base/pagination' import Checkbox from '@/app/components/base/checkbox' -import { useDocumentArchive, useDocumentDelete, useDocumentDisable, useDocumentEnable, useDocumentUnArchive, useSyncDocument, useSyncWebsite } from '@/service/knowledge/use-document' +import { useDocumentArchive, useDocumentDelete, useDocumentDisable, useDocumentEnable, useDocumentPause, useDocumentResume, useDocumentUnArchive, useSyncDocument, useSyncWebsite } from '@/service/knowledge/use-document' import { extensionToFileType } from '@/app/components/datasets/hit-testing/utils/extension-to-file-type' import useBatchEditDocumentMetadata from '../metadata/hooks/use-batch-edit-document-metadata' import EditMetadataBatchModal from '@/app/components/datasets/metadata/edit-metadata-batch/modal' @@ -168,7 +170,7 @@ export const StatusItem: FC<{
} -type OperationName = 'delete' | 'archive' | 'enable' | 'disable' | 'sync' | 'un_archive' +type OperationName = 'delete' | 'archive' | 'enable' | 'disable' | 'sync' | 'un_archive' | 'pause' | 'resume' // operation action for list and detail export const OperationAction: FC<{ @@ -180,13 +182,14 @@ export const OperationAction: FC<{ id: string data_source_type: string doc_form: string + display_status?: string } datasetId: string onUpdate: (operationName?: string) => void scene?: 'list' | 'detail' className?: string }> = ({ embeddingAvailable, datasetId, detail, onUpdate, scene = 'list', className = '' }) => { - const { id, enabled = false, archived = false, data_source_type } = detail || {} + const { id, enabled = false, archived = false, data_source_type, display_status } = detail || {} const [showModal, setShowModal] = useState(false) const [deleting, setDeleting] = useState(false) const { notify } = useContext(ToastContext) @@ -199,6 +202,8 @@ export const OperationAction: FC<{ const { mutateAsync: deleteDocument } = useDocumentDelete() const { mutateAsync: syncDocument } = useSyncDocument() const { mutateAsync: syncWebsite } = useSyncWebsite() + const { mutateAsync: pauseDocument } = useDocumentPause() + const { mutateAsync: resumeDocument } = useDocumentResume() const isListScene = scene === 'list' const onOperate = async (operationName: OperationName) => { @@ -222,6 +227,12 @@ export const OperationAction: FC<{ else opApi = syncWebsite break + case 'pause': + opApi = pauseDocument + break + case 'resume': + opApi = resumeDocument + break default: opApi = deleteDocument setDeleting(true) @@ -323,6 +334,18 @@ export const OperationAction: FC<{ )} + {!archived && display_status?.toLowerCase() === 'indexing' && ( +
onOperate('pause')}> + + {t('datasetDocuments.list.action.pause')} +
+ )} + {!archived && display_status?.toLowerCase() === 'paused' && ( +
onOperate('resume')}> + + {t('datasetDocuments.list.action.resume')} +
+ )} {!archived &&
onOperate('archive')}> {t('datasetDocuments.list.action.archive')} @@ -575,7 +598,6 @@ const DocumentList: FC = ({ ) }} /> - {/* {doc.position} */} {index + 1}
@@ -626,7 +648,7 @@ const DocumentList: FC = ({ diff --git a/web/app/components/header/account-dropdown/index.tsx b/web/app/components/header/account-dropdown/index.tsx index 08a61b7391..9b36fc6020 100644 --- a/web/app/components/header/account-dropdown/index.tsx +++ b/web/app/components/header/account-dropdown/index.tsx @@ -36,7 +36,7 @@ import { useDocLink } from '@/context/i18n' export default function AppSelector() { const itemClassName = ` - flex items-center w-full h-9 pl-3 pr-2 text-text-secondary system-md-regular + flex items-center w-full h-8 pl-3 pr-2 text-text-secondary system-md-regular rounded-lg hover:bg-state-base-hover cursor-pointer gap-1 ` const router = useRouter() @@ -87,24 +87,24 @@ export default function AppSelector() { backdrop-blur-sm focus:outline-none " > - -
-
-
- {userProfile.name} - {isEducationAccount && ( - - - EDU - - )} -
-
{userProfile.email}
-
- -
-
+ +
+
+
+ {userProfile.name} + {isEducationAccount && ( + + + EDU + + )} +
+
{userProfile.email}
+
+ +
+
export type TypeWithI18N = { @@ -19,6 +21,8 @@ export enum FormTypeEnum { toolSelector = 'tool-selector', multiToolSelector = 'array[tools]', appSelector = 'app-selector', + object = 'object', + array = 'array', dynamicSelect = 'dynamic-select', } @@ -109,6 +113,7 @@ export type FormShowOnObject = { } export type CredentialFormSchemaBase = { + name: string variable: string label: TypeWithI18N type: FormTypeEnum @@ -118,6 +123,7 @@ export type CredentialFormSchemaBase = { show_on: FormShowOnObject[] url?: string scope?: string + input_schema?: SchemaRoot } export type CredentialFormSchemaTextInput = CredentialFormSchemaBase & { diff --git a/web/app/components/header/account-setting/model-provider-page/model-modal/Form.tsx b/web/app/components/header/account-setting/model-provider-page/model-modal/Form.tsx index c5af4ed8a1..f1e3595d1e 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-modal/Form.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-modal/Form.tsx @@ -54,6 +54,7 @@ type FormProps< nodeId?: string nodeOutputVars?: NodeOutPutVar[], availableNodes?: Node[], + canChooseMCPTool?: boolean } function Form< @@ -79,6 +80,7 @@ function Form< nodeId, nodeOutputVars, availableNodes, + canChooseMCPTool, }: FormProps) { const language = useLanguage() const [changeKey, setChangeKey] = useState('') @@ -377,6 +379,7 @@ function Form< value={value[variable] || []} onChange={item => handleFormChange(variable, item as any)} supportCollapse + canChooseMCPTool={canChooseMCPTool} /> {fieldMoreInfo?.(formSchema)} {validating && changeKey === variable && } diff --git a/web/app/components/header/index.tsx b/web/app/components/header/index.tsx index 48973e50a8..fc511d2954 100644 --- a/web/app/components/header/index.tsx +++ b/web/app/components/header/index.tsx @@ -79,7 +79,7 @@ const Header = () => { } return ( -
+
{systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo diff --git a/web/app/components/header/nav/index.tsx b/web/app/components/header/nav/index.tsx index dc68b712cb..70cbd6d37b 100644 --- a/web/app/components/header/nav/index.tsx +++ b/web/app/components/header/nav/index.tsx @@ -54,7 +54,7 @@ const Nav = ({
setAppDetail()} className={classNames(` - flex items-center h-7 px-2.5 cursor-pointer rounded-[10px] + flex h-7 cursor-pointer items-center rounded-[10px] px-2.5 ${isActivated ? 'text-components-main-nav-nav-button-text-active' : 'text-components-main-nav-nav-button-text'} ${curNav && isActivated && 'hover:bg-components-main-nav-nav-button-bg-active-hover'} `)} diff --git a/web/app/components/header/plugins-nav/index.tsx b/web/app/components/header/plugins-nav/index.tsx index b1f903bafb..7b28e27639 100644 --- a/web/app/components/header/plugins-nav/index.tsx +++ b/web/app/components/header/plugins-nav/index.tsx @@ -31,8 +31,8 @@ const PluginsNav = ({ )}>
diff --git a/web/app/components/plugins/marketplace/list/card-wrapper.tsx b/web/app/components/plugins/marketplace/list/card-wrapper.tsx index 5e4b621796..8d9f3a5b10 100644 --- a/web/app/components/plugins/marketplace/list/card-wrapper.tsx +++ b/web/app/components/plugins/marketplace/list/card-wrapper.tsx @@ -1,7 +1,7 @@ 'use client' import { useTheme } from 'next-themes' import { RiArrowRightUpLine } from '@remixicon/react' -import { getPluginLinkInMarketplace } from '../utils' +import { getPluginDetailLinkInMarketplace, getPluginLinkInMarketplace } from '../utils' import Card from '@/app/components/plugins/card' import CardMoreInfo from '@/app/components/plugins/card/card-more-info' import type { Plugin } from '@/app/components/plugins/types' @@ -83,7 +83,7 @@ const CardWrapper = ({ return ( void + onAddedCustomTool?: () => void } const SearchBox = ({ search, @@ -23,46 +27,62 @@ const SearchBox = ({ size = 'small', placeholder = '', locale, + supportAddCustomTool, + onShowAddCustomCollectionModal, }: SearchBoxProps) => { return (
- -
-
-
- { - onSearchChange(e.target.value) - }} - placeholder={placeholder} - /> - { - search && ( -
- onSearchChange('')}> - - -
- ) - } +
+
+
+ + { + onSearchChange(e.target.value) + }} + placeholder={placeholder} + /> + { + search && ( +
+ onSearchChange('')}> + + +
+ ) + } +
+
+
+ {supportAddCustomTool && ( +
+ + + +
+ )}
) } diff --git a/web/app/components/plugins/marketplace/search-box/tags-filter.tsx b/web/app/components/plugins/marketplace/search-box/tags-filter.tsx index edf50dc874..bae6491727 100644 --- a/web/app/components/plugins/marketplace/search-box/tags-filter.tsx +++ b/web/app/components/plugins/marketplace/search-box/tags-filter.tsx @@ -2,9 +2,7 @@ import { useState } from 'react' import { - RiArrowDownSLine, - RiCloseCircleFill, - RiFilter3Line, + RiPriceTag3Line, } from '@remixicon/react' import { PortalToFollowElem, @@ -57,47 +55,15 @@ const TagsFilter = ({ onClick={() => setOpen(v => !v)} >
-
- +
+
-
- { - !selectedTagsLength && t('pluginTags.allTags') - } - { - !!selectedTagsLength && tags.map(tag => tagsMap[tag].label).slice(0, 2).join(',') - } - { - selectedTagsLength > 2 && ( -
- +{selectedTagsLength - 2} -
- ) - } -
- { - !!selectedTagsLength && ( - onTagsChange([])} - /> - ) - } - { - !selectedTagsLength && ( - - ) - }
diff --git a/web/app/components/plugins/marketplace/utils.ts b/web/app/components/plugins/marketplace/utils.ts index c112a4aad8..b3032968ab 100644 --- a/web/app/components/plugins/marketplace/utils.ts +++ b/web/app/components/plugins/marketplace/utils.ts @@ -38,6 +38,12 @@ export const getPluginLinkInMarketplace = (plugin: Plugin, params?: Record { + if (plugin.type === 'bundle') + return `/bundles/${plugin.org}/${plugin.name}` + return `/plugins/${plugin.org}/${plugin.name}` +} + export const getMarketplacePluginsByCollectionId = async (collectionId: string, query?: CollectionsAndPluginsSearchParams) => { let plugins = [] as Plugin[] diff --git a/web/app/components/plugins/plugin-detail-panel/model-selector/index.tsx b/web/app/components/plugins/plugin-detail-panel/model-selector/index.tsx index 60c63db5ea..51702a780a 100644 --- a/web/app/components/plugins/plugin-detail-panel/model-selector/index.tsx +++ b/web/app/components/plugins/plugin-detail-panel/model-selector/index.tsx @@ -25,6 +25,8 @@ import LLMParamsPanel from './llm-params-panel' import TTSParamsPanel from './tts-params-panel' import { useProviderContext } from '@/context/provider-context' import cn from '@/utils/classnames' +import Toast from '@/app/components/base/toast' +import { fetchAndMergeValidCompletionParams } from '@/utils/completion-params' export type ModelParameterModalProps = { popupClassName?: string @@ -121,17 +123,42 @@ const ModelParameterModal: FC = ({ return !isAPIKeySet || hasDeprecated || modelDisabled }, [hasDeprecated, isAPIKeySet, modelDisabled]) - const handleChangeModel = ({ provider, model }: DefaultModel) => { + const handleChangeModel = async ({ provider, model }: DefaultModel) => { const targetProvider = scopedModelList.find(modelItem => modelItem.provider === provider) const targetModelItem = targetProvider?.models.find((modelItem: { model: string }) => modelItem.model === model) const model_type = targetModelItem?.model_type as string + + let nextCompletionParams: FormValue = {} + + if (model_type === ModelTypeEnum.textGeneration) { + try { + const { params: filtered, removedDetails } = await fetchAndMergeValidCompletionParams( + provider, + model, + value?.completion_params, + ) + nextCompletionParams = filtered + + const keys = Object.keys(removedDetails || {}) + if (keys.length) { + Toast.notify({ + type: 'warning', + message: `${t('common.modelProvider.parametersInvalidRemoved')}: ${keys.map(k => `${k} (${removedDetails[k]})`).join(', ')}`, + }) + } + } + catch (e) { + Toast.notify({ type: 'error', message: t('common.error') }) + } + } + setModel({ provider, model, model_type, ...(model_type === ModelTypeEnum.textGeneration ? { mode: targetModelItem?.model_properties.mode as string, - completion_params: {}, + completion_params: nextCompletionParams, } : {}), }) } diff --git a/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/index.tsx b/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/index.tsx index fef79644cd..2c700c6dc8 100644 --- a/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/index.tsx +++ b/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/index.tsx @@ -13,6 +13,7 @@ import type { Node } from 'reactflow' import type { NodeOutPutVar } from '@/app/components/workflow/types' import cn from '@/utils/classnames' import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid/general' +import { useAllMCPTools } from '@/service/use-tools' type Props = { disabled?: boolean @@ -26,6 +27,7 @@ type Props = { nodeOutputVars: NodeOutPutVar[], availableNodes: Node[], nodeId?: string + canChooseMCPTool?: boolean } const MultipleToolSelector = ({ @@ -40,9 +42,16 @@ const MultipleToolSelector = ({ nodeOutputVars, availableNodes, nodeId, + canChooseMCPTool, }: Props) => { const { t } = useTranslation() - const enabledCount = value.filter(item => item.enabled).length + const { data: mcpTools } = useAllMCPTools() + const enabledCount = value.filter((item) => { + const isMCPTool = mcpTools?.find(tool => tool.id === item.provider_name) + if(isMCPTool) + return item.enabled && canChooseMCPTool + return item.enabled + }).length // collapse control const [collapse, setCollapse] = React.useState(false) const handleCollapse = () => { @@ -66,6 +75,19 @@ const MultipleToolSelector = ({ setOpen(false) } + const handleAddMultiple = (val: ToolValue[]) => { + const newValue = [...value, ...val] + // deduplication + const deduplication = newValue.reduce((acc, cur) => { + if (!acc.find(item => item.provider_name === cur.provider_name && item.tool_name === cur.tool_name)) + acc.push(cur) + return acc + }, [] as ToolValue[]) + // update value + onChange(deduplication) + setOpen(false) + } + // delete tool const handleDelete = (index: number) => { const newValue = [...value] @@ -140,8 +162,10 @@ const MultipleToolSelector = ({ value={item} selectedTools={value} onSelect={item => handleConfigure(item, index)} + onSelectMultiple={handleAddMultiple} onDelete={() => handleDelete(index)} supportEnableSwitch + canChooseMCPTool={canChooseMCPTool} isEdit />
@@ -164,6 +188,8 @@ const MultipleToolSelector = ({ panelShowState={panelShowState} onPanelShowStateChange={setPanelShowState} isEdit={false} + canChooseMCPTool={canChooseMCPTool} + onSelectMultiple={handleAddMultiple} /> ) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx index 350fe50933..42467ce111 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx @@ -5,7 +5,6 @@ import { useTranslation } from 'react-i18next' import Link from 'next/link' import { RiArrowLeftLine, - RiArrowRightUpLine, } from '@remixicon/react' import { PortalToFollowElem, @@ -15,6 +14,7 @@ import { import ToolTrigger from '@/app/components/plugins/plugin-detail-panel/tool-selector/tool-trigger' import ToolItem from '@/app/components/plugins/plugin-detail-panel/tool-selector/tool-item' import ToolPicker from '@/app/components/workflow/block-selector/tool-picker' +import ToolForm from '@/app/components/workflow/nodes/tool/components/tool-form' import Button from '@/app/components/base/button' import Indicator from '@/app/components/header/indicator' import ToolCredentialForm from '@/app/components/plugins/plugin-detail-panel/tool-selector/tool-credentials-form' @@ -23,13 +23,13 @@ import Textarea from '@/app/components/base/textarea' import Divider from '@/app/components/base/divider' import TabSlider from '@/app/components/base/tab-slider-plain' import ReasoningConfigForm from '@/app/components/plugins/plugin-detail-panel/tool-selector/reasoning-config-form' -import Form from '@/app/components/header/account-setting/model-provider-page/model-modal/Form' import { generateFormValue, getPlainValue, getStructureValue, toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema' import { useAppContext } from '@/context/app-context' import { useAllBuiltInTools, useAllCustomTools, + useAllMCPTools, useAllWorkflowTools, useInvalidateAllBuiltInTools, useUpdateProviderCredentials, @@ -54,15 +54,9 @@ type Props = { scope?: string value?: ToolValue selectedTools?: ToolValue[] + onSelect: (tool: ToolValue) => void + onSelectMultiple: (tool: ToolValue[]) => void isEdit?: boolean - onSelect: (tool: { - provider_name: string - tool_name: string - tool_label: string - settings?: Record - parameters?: Record - extra?: Record - }) => void onDelete?: () => void supportEnableSwitch?: boolean supportAddCustomTool?: boolean @@ -74,6 +68,7 @@ type Props = { nodeOutputVars: NodeOutPutVar[], availableNodes: Node[], nodeId?: string, + canChooseMCPTool?: boolean, } const ToolSelector: FC = ({ value, @@ -83,6 +78,7 @@ const ToolSelector: FC = ({ placement = 'left', offset = 4, onSelect, + onSelectMultiple, onDelete, scope, supportEnableSwitch, @@ -94,6 +90,7 @@ const ToolSelector: FC = ({ nodeOutputVars, availableNodes, nodeId = '', + canChooseMCPTool, }) => { const { t } = useTranslation() const [isShow, onShowChange] = useState(false) @@ -105,6 +102,7 @@ const ToolSelector: FC = ({ const { data: buildInTools } = useAllBuiltInTools() const { data: customTools } = useAllCustomTools() const { data: workflowTools } = useAllWorkflowTools() + const { data: mcpTools } = useAllMCPTools() const invalidateAllBuiltinTools = useInvalidateAllBuiltInTools() const invalidateInstalledPluginList = useInvalidateInstalledPluginList() @@ -112,18 +110,19 @@ const ToolSelector: FC = ({ const { inMarketPlace, manifest } = usePluginInstalledCheck(value?.provider_name) const currentProvider = useMemo(() => { - const mergedTools = [...(buildInTools || []), ...(customTools || []), ...(workflowTools || [])] + const mergedTools = [...(buildInTools || []), ...(customTools || []), ...(workflowTools || []), ...(mcpTools || [])] return mergedTools.find((toolWithProvider) => { return toolWithProvider.id === value?.provider_name }) - }, [value, buildInTools, customTools, workflowTools]) + }, [value, buildInTools, customTools, workflowTools, mcpTools]) const [isShowChooseTool, setIsShowChooseTool] = useState(false) - const handleSelectTool = (tool: ToolDefaultValue) => { + const getToolValue = (tool: ToolDefaultValue) => { const settingValues = generateFormValue(tool.params, toolParametersToFormSchemas(tool.paramSchemas.filter(param => param.form !== 'llm') as any)) const paramValues = generateFormValue(tool.params, toolParametersToFormSchemas(tool.paramSchemas.filter(param => param.form === 'llm') as any), true) - const toolValue = { + return { provider_name: tool.provider_id, + provider_show_name: tool.provider_name, type: tool.provider_type, tool_name: tool.tool_name, tool_label: tool.tool_label, @@ -136,9 +135,16 @@ const ToolSelector: FC = ({ }, schemas: tool.paramSchemas, } + } + const handleSelectTool = (tool: ToolDefaultValue) => { + const toolValue = getToolValue(tool) onSelect(toolValue) // setIsShowChooseTool(false) } + const handleSelectMultipleTool = (tool: ToolDefaultValue[]) => { + const toolValues = tool.map(item => getToolValue(item)) + onSelectMultiple(toolValues) + } const handleDescriptionChange = (e: React.ChangeEvent) => { onSelect({ @@ -169,7 +175,6 @@ const ToolSelector: FC = ({ const handleSettingsFormChange = (v: Record) => { const newValue = getStructureValue(v) - const toolValue = { ...value, settings: newValue, @@ -250,7 +255,9 @@ const ToolSelector: FC = ({ = ({

} + canChooseMCPTool={canChooseMCPTool} /> )} @@ -285,7 +293,6 @@ const ToolSelector: FC = ({
{t('plugin.detailPanel.toolSelector.toolLabel')}
= ({ disabled={false} supportAddCustomTool onSelect={handleSelectTool} + onSelectMultiple={handleSelectMultipleTool} scope={scope} selectedTools={selectedTools} + canChooseMCPTool={canChooseMCPTool} />
@@ -390,24 +399,13 @@ const ToolSelector: FC = ({ {/* user settings form */} {(currType === 'settings' || userSettingsOnly) && ( )} diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/reasoning-config-form.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/reasoning-config-form.tsx index 750a8cfff6..98ad490348 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/reasoning-config-form.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/reasoning-config-form.tsx @@ -3,25 +3,34 @@ import { useTranslation } from 'react-i18next' import produce from 'immer' import { RiArrowRightUpLine, + RiBracesLine, } from '@remixicon/react' import Tooltip from '@/app/components/base/tooltip' import Switch from '@/app/components/base/switch' -import Input from '@/app/components/workflow/nodes/_base/components/input-support-select-var' +import MixedVariableTextInput from '@/app/components/workflow/nodes/tool/components/mixed-variable-text-input' +import Input from '@/app/components/base/input' +import FormInputTypeSwitch from '@/app/components/workflow/nodes/_base/components/form-input-type-switch' +import FormInputBoolean from '@/app/components/workflow/nodes/_base/components/form-input-boolean' +import { SimpleSelect } from '@/app/components/base/select' +import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor' import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker' import AppSelector from '@/app/components/plugins/plugin-detail-panel/app-selector' import ModelParameterModal from '@/app/components/plugins/plugin-detail-panel/model-selector' +import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { Node } from 'reactflow' import type { NodeOutPutVar, ValueSelector, - Var, } from '@/app/components/workflow/types' import type { ToolVarInputs } from '@/app/components/workflow/nodes/tool/types' import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types' import { VarType } from '@/app/components/workflow/types' import cn from '@/utils/classnames' +import { useBoolean } from 'ahooks' +import SchemaModal from './schema-modal' +import type { SchemaRoot } from '@/app/components/workflow/nodes/llm/types' type Props = { value: Record @@ -42,73 +51,46 @@ const ReasoningConfigForm: React.FC = ({ }) => { const { t } = useTranslation() const language = useLanguage() - const handleAutomatic = (key: string, val: any) => { + const getVarKindType = (type: FormTypeEnum) => { + if (type === FormTypeEnum.file || type === FormTypeEnum.files) + return VarKindType.variable + if (type === FormTypeEnum.select || type === FormTypeEnum.boolean || type === FormTypeEnum.textNumber || type === FormTypeEnum.array || type === FormTypeEnum.object) + return VarKindType.constant + if (type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput) + return VarKindType.mixed + } + + const handleAutomatic = (key: string, val: any, type: FormTypeEnum) => { onChange({ ...value, [key]: { - value: val ? null : value[key]?.value, + value: val ? null : { type: getVarKindType(type), value: null }, auto: val ? 1 : 0, }, }) } - - const [inputsIsFocus, setInputsIsFocus] = useState>({}) - const handleInputFocus = useCallback((variable: string) => { - return (value: boolean) => { - setInputsIsFocus((prev) => { - return { - ...prev, - [variable]: value, - } - }) - } - }, []) - const handleNotMixedTypeChange = useCallback((variable: string) => { - return (varValue: ValueSelector | string, varKindType: VarKindType) => { - const newValue = produce(value, (draft: ToolVarInputs) => { - const target = draft[variable].value - if (target) { - target.type = varKindType - target.value = varValue - } - else { - draft[variable].value = { - type: varKindType, - value: varValue, - } - } - }) - onChange(newValue) - } - }, [value, onChange]) - const handleMixedTypeChange = useCallback((variable: string) => { - return (itemValue: string) => { - const newValue = produce(value, (draft: ToolVarInputs) => { - const target = draft[variable].value - if (target) { - target.value = itemValue - } - else { - draft[variable].value = { - type: VarKindType.mixed, - value: itemValue, - } - } - }) - onChange(newValue) - } - }, [value, onChange]) - const handleFileChange = useCallback((variable: string) => { - return (varValue: ValueSelector | string) => { - const newValue = produce(value, (draft: ToolVarInputs) => { + const handleTypeChange = useCallback((variable: string, defaultValue: any) => { + return (newType: VarKindType) => { + const res = produce(value, (draft: ToolVarInputs) => { draft[variable].value = { - type: VarKindType.variable, - value: varValue, + type: newType, + value: newType === VarKindType.variable ? '' : defaultValue, } }) - onChange(newValue) + onChange(res) } - }, [value, onChange]) + }, [onChange, value]) + const handleValueChange = useCallback((variable: string, varType: FormTypeEnum) => { + return (newValue: any) => { + const res = produce(value, (draft: ToolVarInputs) => { + draft[variable].value = { + type: getVarKindType(varType), + value: newValue, + } + }) + onChange(res) + } + }, [onChange, value]) const handleAppChange = useCallback((variable: string) => { return (app: { app_id: string @@ -132,9 +114,29 @@ const ReasoningConfigForm: React.FC = ({ onChange(newValue) } }, [onChange, value]) + const handleVariableSelectorChange = useCallback((variable: string) => { + return (newValue: ValueSelector | string) => { + const res = produce(value, (draft: ToolVarInputs) => { + draft[variable].value = { + type: VarKindType.variable, + value: newValue, + } + }) + onChange(res) + } + }, [onChange, value]) - const renderField = (schema: any) => { + const [isShowSchema, { + setTrue: showSchema, + setFalse: hideSchema, + }] = useBoolean(false) + + const [schema, setSchema] = useState(null) + const [schemaRootName, setSchemaRootName] = useState('') + + const renderField = (schema: any, showSchema: (schema: SchemaRoot, rootName: string) => void) => { const { + default: defaultValue, variable, label, required, @@ -142,6 +144,9 @@ const ReasoningConfigForm: React.FC = ({ type, scope, url, + input_schema, + placeholder, + options, } = schema const auto = value[variable]?.auto const tooltipContent = (tooltip && ( @@ -149,89 +154,150 @@ const ReasoningConfigForm: React.FC = ({ popupContent={
{tooltip[language] || tooltip.en_US}
} - triggerClassName='ml-1 w-4 h-4' + triggerClassName='ml-0.5 w-4 h-4' asChild={false} /> )) const varInput = value[variable].value + const isString = type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput const isNumber = type === FormTypeEnum.textNumber - const isSelect = type === FormTypeEnum.select + const isObject = type === FormTypeEnum.object + const isArray = type === FormTypeEnum.array + const isShowJSONEditor = isObject || isArray const isFile = type === FormTypeEnum.file || type === FormTypeEnum.files + const isBoolean = type === FormTypeEnum.boolean + const isSelect = type === FormTypeEnum.select const isAppSelector = type === FormTypeEnum.appSelector const isModelSelector = type === FormTypeEnum.modelSelector - // const isToolSelector = type === FormTypeEnum.toolSelector - const isString = !isNumber && !isSelect && !isFile && !isAppSelector && !isModelSelector + const showTypeSwitch = isNumber || isObject || isArray + const isConstant = varInput?.type === VarKindType.constant || !varInput?.type + const showVariableSelector = isFile || varInput?.type === VarKindType.variable + const targetVarType = () => { + if (isString) + return VarType.string + else if (isNumber) + return VarType.number + else if (type === FormTypeEnum.files) + return VarType.arrayFile + else if (type === FormTypeEnum.file) + return VarType.file + else if (isBoolean) + return VarType.boolean + else if (isObject) + return VarType.object + else if (isArray) + return VarType.arrayObject + else + return VarType.string + } + const getFilterVar = () => { + if (isNumber) + return (varPayload: any) => varPayload.type === VarType.number + else if (isString) + return (varPayload: any) => [VarType.string, VarType.number, VarType.secret].includes(varPayload.type) + else if (isFile) + return (varPayload: any) => [VarType.file, VarType.arrayFile].includes(varPayload.type) + else if (isBoolean) + return (varPayload: any) => varPayload.type === VarType.boolean + else if (isObject) + return (varPayload: any) => varPayload.type === VarType.object + else if (isArray) + return (varPayload: any) => [VarType.array, VarType.arrayString, VarType.arrayNumber, VarType.arrayObject].includes(varPayload.type) + return undefined + } + return ( -
+
-
- {label[language] || label.en_US} +
+ {label[language] || label.en_US} {required && ( * )} {tooltipContent} + · + {targetVarType()} + {isShowJSONEditor && ( + + {t('workflow.nodes.agent.clickToViewParameterSchema')} +
} + asChild={false}> +
showSchema(input_schema as SchemaRoot, label[language] || label.en_US)} + > + +
+ + )} +
-
handleAutomatic(variable, !auto)}> +
handleAutomatic(variable, !auto, type)}> {t('plugin.detailPanel.toolSelector.auto')} handleAutomatic(variable, val)} + onChange={val => handleAutomatic(variable, val, type)} />
{auto === 0 && ( - <> +
+ {showTypeSwitch && ( + + )} {isString && ( - )} - {/* {isString && ( - varPayload.type === VarType.number || varPayload.type === VarType.secret || varPayload.type === VarType.string} - /> - )} */} - {(isNumber || isSelect) && ( - varPayload.type === schema._type : undefined} - availableVars={isSelect ? nodeOutputVars : undefined} - schema={schema} + onChange={handleValueChange(variable, type)} + placeholder={placeholder?.[language] || placeholder?.en_US} /> )} - {isFile && ( - varPayload.type === VarType.file || varPayload.type === VarType.arrayFile} + {isBoolean && ( + )} + {isSelect && ( + { + if (option.show_on.length) + return option.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value) + + return true + }).map((option: { value: any; label: { [x: string]: any; en_US: any } }) => ({ value: option.value, name: option.label[language] || option.label.en_US }))} + onSelect={item => handleValueChange(variable, type)(item.value as string)} + placeholder={placeholder?.[language] || placeholder?.en_US} + /> + )} + {isShowJSONEditor && isConstant && ( +
+ {placeholder?.[language] || placeholder?.en_US}
} + /> +
+ )} {isAppSelector && ( = ({ scope={scope} /> )} - + {showVariableSelector && ( + + )} +
)} {url && ( = ({ } return (
- {schemas.map(schema => renderField(schema))} + {!isShowSchema && schemas.map(schema => renderField(schema, (s: SchemaRoot, rootName: string) => { + setSchema(s) + setSchemaRootName(rootName) + showSchema() + }))} + {isShowSchema && ( + + )}
) } diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/schema-modal.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/schema-modal.tsx new file mode 100644 index 0000000000..cd4cf71bac --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/schema-modal.tsx @@ -0,0 +1,59 @@ +'use client' +import type { FC } from 'react' +import React from 'react' +import Modal from '@/app/components/base/modal' +import VisualEditor from '@/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor' +import type { SchemaRoot } from '@/app/components/workflow/nodes/llm/types' +import { MittProvider, VisualEditorContextProvider } from '@/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/context' +import { useTranslation } from 'react-i18next' +import { RiCloseLine } from '@remixicon/react' + +type Props = { + isShow: boolean + schema: SchemaRoot + rootName: string + onClose: () => void +} + +const SchemaModal: FC = ({ + isShow, + schema, + rootName, + onClose, +}) => { + const { t } = useTranslation() + return ( + +
+ {/* Header */} +
+
+ {t('workflow.nodes.agent.parameterSchema')} +
+
+ +
+
+ {/* Content */} +
+ + + + + +
+
+
+ ) +} +export default React.memo(SchemaModal) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/tool-item.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/tool-item.tsx index d74fccf968..5cc9b7a3a8 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/tool-item.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/tool-item.tsx @@ -17,10 +17,13 @@ import { ToolTipContent } from '@/app/components/base/tooltip/content' import { InstallPluginButton } from '@/app/components/workflow/nodes/_base/components/install-plugin-button' import { SwitchPluginVersion } from '@/app/components/workflow/nodes/_base/components/switch-plugin-version' import cn from '@/utils/classnames' +import McpToolNotSupportTooltip from '@/app/components/workflow/nodes/_base/components/mcp-tool-not-support-tooltip' type Props = { icon?: any providerName?: string + isMCPTool?: boolean + providerShowName?: string toolLabel?: string showSwitch?: boolean switchValue?: boolean @@ -35,11 +38,14 @@ type Props = { onInstall?: () => void versionMismatch?: boolean open: boolean + canChooseMCPTool?: boolean, } const ToolItem = ({ open, icon, + isMCPTool, + providerShowName, providerName, toolLabel, showSwitch, @@ -54,11 +60,13 @@ const ToolItem = ({ isError, errorTip, versionMismatch, + canChooseMCPTool, }: Props) => { const { t } = useTranslation() - const providerNameText = providerName?.split('/').pop() + const providerNameText = isMCPTool ? providerShowName : providerName?.split('/').pop() const isTransparent = uninstalled || versionMismatch || isError const [isDeleting, setIsDeleting] = useState(false) + const isShowCanNotChooseMCPTip = isMCPTool && !canChooseMCPTool return (
{icon && ( -
+
{typeof icon === 'string' &&
} {typeof icon !== 'string' && }
@@ -75,18 +83,19 @@ const ToolItem = ({ {!icon && (
)} -
+
{providerNameText}
{toolLabel}
- {!noAuth && !isError && !uninstalled && !versionMismatch && ( + {!noAuth && !isError && !uninstalled && !versionMismatch && !isShowCanNotChooseMCPTip && ( @@ -103,7 +112,7 @@ const ToolItem = ({
- {!isError && !uninstalled && !noAuth && !versionMismatch && showSwitch && ( + {!isError && !uninstalled && !noAuth && !versionMismatch && !isShowCanNotChooseMCPTip && showSwitch && (
e.stopPropagation()}>
)} + {isShowCanNotChooseMCPTip && ( + + )} {!isError && !uninstalled && !versionMismatch && noAuth && (
+
+
{t('tools.createTool.authMethod.value')}
+ setTempCredential({ ...tempCredential, api_key_value: e.target.value })} + placeholder={t('tools.createTool.authMethod.types.apiValuePlaceholder')!} + /> +
+ )}
diff --git a/web/app/components/tools/edit-custom-collection-modal/modal.tsx b/web/app/components/tools/edit-custom-collection-modal/modal.tsx index 190c72790e..ce7ba8a735 100644 --- a/web/app/components/tools/edit-custom-collection-modal/modal.tsx +++ b/web/app/components/tools/edit-custom-collection-modal/modal.tsx @@ -184,6 +184,7 @@ const EditCustomCollectionModal: FC = ({ onClose={onHide} closable className='!h-[calc(100vh-16px)] !max-w-[630px] !p-0' + wrapperClassName='z-[1000]' >
diff --git a/web/app/components/tools/mcp/create-card.tsx b/web/app/components/tools/mcp/create-card.tsx new file mode 100644 index 0000000000..7416f85a2f --- /dev/null +++ b/web/app/components/tools/mcp/create-card.tsx @@ -0,0 +1,75 @@ +'use client' +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useContext } from 'use-context-selector' +import { + RiAddCircleFill, + RiArrowRightUpLine, + RiBookOpenLine, +} from '@remixicon/react' +import MCPModal from './modal' +import I18n from '@/context/i18n' +import { getLanguage } from '@/i18n/language' +import { useAppContext } from '@/context/app-context' +import { useCreateMCP } from '@/service/use-tools' +import type { ToolWithProvider } from '@/app/components/workflow/types' + +type Props = { + handleCreate: (provider: ToolWithProvider) => void +} + +const NewMCPCard = ({ handleCreate }: Props) => { + const { t } = useTranslation() + const { locale } = useContext(I18n) + const language = getLanguage(locale) + const { isCurrentWorkspaceManager } = useAppContext() + + const { mutateAsync: createMCP } = useCreateMCP() + + const create = async (info: any) => { + const provider = await createMCP(info) + handleCreate(provider) + } + + const linkUrl = useMemo(() => { + if (language.startsWith('zh_')) + return 'https://docs.dify.ai/zh-hans/guides/tools/mcp' + if (language.startsWith('ja_jp')) + return 'https://docs.dify.ai/ja_jp/guides/tools/mcp' + return 'https://docs.dify.ai/en/guides/tools/mcp' + }, [language]) + + const [showModal, setShowModal] = useState(false) + + return ( + <> + {isCurrentWorkspaceManager && ( + + )} + {showModal && ( + setShowModal(false)} + /> + )} + + ) +} +export default NewMCPCard diff --git a/web/app/components/tools/mcp/detail/content.tsx b/web/app/components/tools/mcp/detail/content.tsx new file mode 100644 index 0000000000..3f927b990d --- /dev/null +++ b/web/app/components/tools/mcp/detail/content.tsx @@ -0,0 +1,308 @@ +'use client' +import React, { useCallback, useEffect } from 'react' +import type { FC } from 'react' +import { useBoolean } from 'ahooks' +import copy from 'copy-to-clipboard' +import { useTranslation } from 'react-i18next' +import { useAppContext } from '@/context/app-context' +import { + RiCloseLine, + RiLoader2Line, + RiLoopLeftLine, +} from '@remixicon/react' +import type { ToolWithProvider } from '../../../workflow/types' +import Icon from '@/app/components/plugins/card/base/card-icon' +import ActionButton from '@/app/components/base/action-button' +import Button from '@/app/components/base/button' +import Confirm from '@/app/components/base/confirm' +import Indicator from '@/app/components/header/indicator' +import Tooltip from '@/app/components/base/tooltip' +import MCPModal from '../modal' +import OperationDropdown from './operation-dropdown' +import ListLoading from './list-loading' +import ToolItem from './tool-item' +import { + useAuthorizeMCP, + useDeleteMCP, + useInvalidateMCPTools, + useMCPTools, + useUpdateMCP, + useUpdateMCPTools, +} from '@/service/use-tools' +import { openOAuthPopup } from '@/hooks/use-oauth' +import cn from '@/utils/classnames' + +type Props = { + detail: ToolWithProvider + onUpdate: (isDelete?: boolean) => void + onHide: () => void + isTriggerAuthorize: boolean + onFirstCreate: () => void +} + +const MCPDetailContent: FC = ({ + detail, + onUpdate, + onHide, + isTriggerAuthorize, + onFirstCreate, +}) => { + const { t } = useTranslation() + const { isCurrentWorkspaceManager } = useAppContext() + + const { data, isFetching: isGettingTools } = useMCPTools(detail.is_team_authorization ? detail.id : '') + const invalidateMCPTools = useInvalidateMCPTools() + const { mutateAsync: updateTools, isPending: isUpdating } = useUpdateMCPTools() + const { mutateAsync: authorizeMcp, isPending: isAuthorizing } = useAuthorizeMCP() + const toolList = data?.tools || [] + + const [isShowUpdateConfirm, { + setTrue: showUpdateConfirm, + setFalse: hideUpdateConfirm, + }] = useBoolean(false) + + const handleUpdateTools = useCallback(async () => { + hideUpdateConfirm() + if (!detail) + return + await updateTools(detail.id) + invalidateMCPTools(detail.id) + onUpdate() + }, [detail, hideUpdateConfirm, invalidateMCPTools, onUpdate, updateTools]) + + const { mutateAsync: updateMCP } = useUpdateMCP({}) + const { mutateAsync: deleteMCP } = useDeleteMCP({}) + + const [isShowUpdateModal, { + setTrue: showUpdateModal, + setFalse: hideUpdateModal, + }] = useBoolean(false) + + const [isShowDeleteConfirm, { + setTrue: showDeleteConfirm, + setFalse: hideDeleteConfirm, + }] = useBoolean(false) + + const [deleting, { + setTrue: showDeleting, + setFalse: hideDeleting, + }] = useBoolean(false) + + const handleOAuthCallback = useCallback(() => { + if (!isCurrentWorkspaceManager) + return + if (!detail.id) + return + handleUpdateTools() + }, [detail.id, handleUpdateTools, isCurrentWorkspaceManager]) + + const handleAuthorize = useCallback(async () => { + onFirstCreate() + if (!isCurrentWorkspaceManager) + return + if (!detail) + return + const res = await authorizeMcp({ + provider_id: detail.id, + }) + if (res.result === 'success') + handleUpdateTools() + + else if (res.authorization_url) + openOAuthPopup(res.authorization_url, handleOAuthCallback) + }, [onFirstCreate, isCurrentWorkspaceManager, detail, authorizeMcp, handleUpdateTools, handleOAuthCallback]) + + const handleUpdate = useCallback(async (data: any) => { + if (!detail) + return + const res = await updateMCP({ + ...data, + provider_id: detail.id, + }) + if ((res as any)?.result === 'success') { + hideUpdateModal() + onUpdate() + handleAuthorize() + } + }, [detail, updateMCP, hideUpdateModal, onUpdate, handleAuthorize]) + + const handleDelete = useCallback(async () => { + if (!detail) + return + showDeleting() + const res = await deleteMCP(detail.id) + hideDeleting() + if ((res as any)?.result === 'success') { + hideDeleteConfirm() + onUpdate(true) + } + }, [detail, showDeleting, deleteMCP, hideDeleting, hideDeleteConfirm, onUpdate]) + + useEffect(() => { + if (isTriggerAuthorize) + handleAuthorize() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + if (!detail) + return null + + return ( + <> +
+
+
+ +
+
+
+
{detail.name}
+
+
+ +
copy(detail.server_identifier || '')}>{detail.server_identifier}
+
+
·
+ +
{detail.server_url}
+
+
+
+
+ + + + +
+
+
+ {!isAuthorizing && detail.is_team_authorization && ( + + )} + {!detail.is_team_authorization && !isAuthorizing && ( + + )} + {isAuthorizing && ( + + )} +
+
+
+ {((detail.is_team_authorization && isGettingTools) || isUpdating) && ( + <> +
+
+ {!isUpdating &&
{t('tools.mcp.gettingTools')}
} + {isUpdating &&
{t('tools.mcp.updateTools')}
} +
+
+
+
+ +
+ + )} + {!isUpdating && detail.is_team_authorization && !isGettingTools && !toolList.length && ( +
+
{t('tools.mcp.toolsEmpty')}
+ +
+ )} + {!isUpdating && !isGettingTools && toolList.length > 0 && ( + <> +
+
+ {toolList.length > 1 &&
{t('tools.mcp.toolsNum', { count: toolList.length })}
} + {toolList.length === 1 &&
{t('tools.mcp.onlyTool')}
} +
+
+ +
+
+
+ {toolList.map(tool => ( + + ))} +
+ + )} + + {!isUpdating && !detail.is_team_authorization && ( +
+ {!isAuthorizing &&
{t('tools.mcp.authorizingRequired')}
} + {isAuthorizing &&
{t('tools.mcp.authorizing')}
} +
{t('tools.mcp.authorizeTip')}
+
+ )} +
+ {isShowUpdateModal && ( + + )} + {isShowDeleteConfirm && ( + + {t('tools.mcp.deleteConfirmTitle', { mcp: detail.name })} +
+ } + onCancel={hideDeleteConfirm} + onConfirm={handleDelete} + isLoading={deleting} + isDisabled={deleting} + /> + )} + {isShowUpdateConfirm && ( + + )} + + ) +} + +export default MCPDetailContent diff --git a/web/app/components/tools/mcp/detail/list-loading.tsx b/web/app/components/tools/mcp/detail/list-loading.tsx new file mode 100644 index 0000000000..babf050d8b --- /dev/null +++ b/web/app/components/tools/mcp/detail/list-loading.tsx @@ -0,0 +1,37 @@ +'use client' +import React from 'react' +import cn from '@/utils/classnames' + +const ListLoading = () => { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) +} + +export default ListLoading diff --git a/web/app/components/tools/mcp/detail/operation-dropdown.tsx b/web/app/components/tools/mcp/detail/operation-dropdown.tsx new file mode 100644 index 0000000000..d2cbc8825d --- /dev/null +++ b/web/app/components/tools/mcp/detail/operation-dropdown.tsx @@ -0,0 +1,88 @@ +'use client' +import type { FC } from 'react' +import React, { useCallback, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { + RiDeleteBinLine, + RiEditLine, + RiMoreFill, +} from '@remixicon/react' +import ActionButton from '@/app/components/base/action-button' +import { + PortalToFollowElem, + PortalToFollowElemContent, + PortalToFollowElemTrigger, +} from '@/app/components/base/portal-to-follow-elem' +import cn from '@/utils/classnames' + +type Props = { + inCard?: boolean + onOpenChange?: (open: boolean) => void + onEdit: () => void + onRemove: () => void +} + +const OperationDropdown: FC = ({ + inCard, + onOpenChange, + onEdit, + onRemove, +}) => { + const { t } = useTranslation() + const [open, doSetOpen] = useState(false) + const openRef = useRef(open) + const setOpen = useCallback((v: boolean) => { + doSetOpen(v) + openRef.current = v + onOpenChange?.(v) + }, [doSetOpen]) + + const handleTrigger = useCallback(() => { + setOpen(!openRef.current) + }, [setOpen]) + + return ( + + +
+ + + +
+
+ +
+
{ + onEdit() + handleTrigger() + }} + > + +
{t('tools.mcp.operation.edit')}
+
+
{ + onRemove() + handleTrigger() + }} + > + +
{t('tools.mcp.operation.remove')}
+
+
+
+
+ ) +} +export default React.memo(OperationDropdown) diff --git a/web/app/components/tools/mcp/detail/provider-detail.tsx b/web/app/components/tools/mcp/detail/provider-detail.tsx new file mode 100644 index 0000000000..56f26f8582 --- /dev/null +++ b/web/app/components/tools/mcp/detail/provider-detail.tsx @@ -0,0 +1,56 @@ +'use client' +import React from 'react' +import type { FC } from 'react' +import Drawer from '@/app/components/base/drawer' +import MCPDetailContent from './content' +import type { ToolWithProvider } from '../../../workflow/types' +import cn from '@/utils/classnames' + +type Props = { + detail?: ToolWithProvider + onUpdate: () => void + onHide: () => void + isTriggerAuthorize: boolean + onFirstCreate: () => void +} + +const MCPDetailPanel: FC = ({ + detail, + onUpdate, + onHide, + isTriggerAuthorize, + onFirstCreate, +}) => { + const handleUpdate = (isDelete = false) => { + if (isDelete) + onHide() + onUpdate() + } + + if (!detail) + return null + + return ( + + {detail && ( + + )} + + ) +} + +export default MCPDetailPanel diff --git a/web/app/components/tools/mcp/detail/tool-item.tsx b/web/app/components/tools/mcp/detail/tool-item.tsx new file mode 100644 index 0000000000..dec82edcca --- /dev/null +++ b/web/app/components/tools/mcp/detail/tool-item.tsx @@ -0,0 +1,41 @@ +'use client' +import React from 'react' +import { useContext } from 'use-context-selector' +import type { Tool } from '@/app/components/tools/types' +import I18n from '@/context/i18n' +import { getLanguage } from '@/i18n/language' +import Tooltip from '@/app/components/base/tooltip' +import cn from '@/utils/classnames' + +type Props = { + tool: Tool +} + +const MCPToolItem = ({ + tool, +}: Props) => { + const { locale } = useContext(I18n) + const language = getLanguage(locale) + + return ( + +
{tool.label[language]}
+
{tool.description[language]}
+
+ )} + > +
+
{tool.label[language]}
+
{tool.description[language]}
+
+ + ) +} +export default MCPToolItem diff --git a/web/app/components/tools/mcp/hooks.ts b/web/app/components/tools/mcp/hooks.ts new file mode 100644 index 0000000000..b2b521557f --- /dev/null +++ b/web/app/components/tools/mcp/hooks.ts @@ -0,0 +1,12 @@ +import dayjs from 'dayjs' +import { useCallback } from 'react' +import { useI18N } from '@/context/i18n' + +export const useFormatTimeFromNow = () => { + const { locale } = useI18N() + const formatTimeFromNow = useCallback((time: number) => { + return dayjs(time).locale(locale === 'zh-Hans' ? 'zh-cn' : locale).fromNow() + }, [locale]) + + return { formatTimeFromNow } +} diff --git a/web/app/components/tools/mcp/index.tsx b/web/app/components/tools/mcp/index.tsx new file mode 100644 index 0000000000..5a1e5cf3bf --- /dev/null +++ b/web/app/components/tools/mcp/index.tsx @@ -0,0 +1,98 @@ +'use client' +import { useMemo, useState } from 'react' +import NewMCPCard from './create-card' +import MCPCard from './provider-card' +import MCPDetailPanel from './detail/provider-detail' +import { + useAllToolProviders, +} from '@/service/use-tools' +import type { ToolWithProvider } from '@/app/components/workflow/types' +import cn from '@/utils/classnames' + +type Props = { + searchText: string +} + +function renderDefaultCard() { + const defaultCards = Array.from({ length: 36 }, (_, index) => ( +
= 4 && index < 8 && 'opacity-50', + index >= 8 && index < 12 && 'opacity-40', + index >= 12 && index < 16 && 'opacity-30', + index >= 16 && index < 20 && 'opacity-25', + index >= 20 && index < 24 && 'opacity-20', + )} + >
+ )) + return defaultCards +} + +const MCPList = ({ + searchText, +}: Props) => { + const { data: list = [] as ToolWithProvider[], refetch } = useAllToolProviders() + const [isTriggerAuthorize, setIsTriggerAuthorize] = useState(false) + + const filteredList = useMemo(() => { + return list.filter((collection) => { + if (searchText) + return Object.values(collection.name).some(value => (value as string).toLowerCase().includes(searchText.toLowerCase())) + return collection.type === 'mcp' + }) as ToolWithProvider[] + }, [list, searchText]) + + const [currentProviderID, setCurrentProviderID] = useState() + + const currentProvider = useMemo(() => { + return list.find(provider => provider.id === currentProviderID) + }, [list, currentProviderID]) + + const handleCreate = async (provider: ToolWithProvider) => { + await refetch() // update list + setCurrentProviderID(provider.id) + setIsTriggerAuthorize(true) + } + + const handleUpdate = async (providerID: string) => { + await refetch() // update list + setCurrentProviderID(providerID) + setIsTriggerAuthorize(true) + } + return ( + <> +
+ + {filteredList.map(provider => ( + + ))} + {!list.length && renderDefaultCard()} +
+ {currentProvider && ( + setCurrentProviderID(undefined)} + onUpdate={refetch} + isTriggerAuthorize={isTriggerAuthorize} + onFirstCreate={() => setIsTriggerAuthorize(false)} + /> + )} + + ) +} +export default MCPList diff --git a/web/app/components/tools/mcp/mcp-server-modal.tsx b/web/app/components/tools/mcp/mcp-server-modal.tsx new file mode 100644 index 0000000000..9eb33f21ec --- /dev/null +++ b/web/app/components/tools/mcp/mcp-server-modal.tsx @@ -0,0 +1,134 @@ +'use client' +import React from 'react' +import { useTranslation } from 'react-i18next' +import { RiCloseLine } from '@remixicon/react' +import Modal from '@/app/components/base/modal' +import Button from '@/app/components/base/button' +import Textarea from '@/app/components/base/textarea' +import Divider from '@/app/components/base/divider' +import MCPServerParamItem from '@/app/components/tools/mcp/mcp-server-param-item' +import type { + MCPServerDetail, +} from '@/app/components/tools/types' +import { + useCreateMCPServer, + useInvalidateMCPServerDetail, + useUpdateMCPServer, +} from '@/service/use-tools' +import cn from '@/utils/classnames' + +export type ModalProps = { + appID: string + latestParams?: any[] + data?: MCPServerDetail + show: boolean + onHide: () => void +} + +const MCPServerModal = ({ + appID, + latestParams = [], + data, + show, + onHide, +}: ModalProps) => { + const { t } = useTranslation() + const { mutateAsync: createMCPServer, isPending: creating } = useCreateMCPServer() + const { mutateAsync: updateMCPServer, isPending: updating } = useUpdateMCPServer() + const invalidateMCPServerDetail = useInvalidateMCPServerDetail() + + const [description, setDescription] = React.useState(data?.description || '') + const [params, setParams] = React.useState(data?.parameters || {}) + + const handleParamChange = (variable: string, value: string) => { + setParams(prev => ({ + ...prev, + [variable]: value, + })) + } + + const getParamValue = () => { + const res = {} as any + latestParams.map((param) => { + res[param.variable] = params[param.variable] + return param + }) + return res + } + + const submit = async () => { + if (!data) { + await createMCPServer({ + appID, + description, + parameters: getParamValue(), + }) + invalidateMCPServerDetail(appID) + onHide() + } + else { + await updateMCPServer({ + appID, + id: data.id, + description, + parameters: getParamValue(), + }) + invalidateMCPServerDetail(appID) + onHide() + } + } + + return ( + +
+ +
+
+ {!data ? t('tools.mcp.server.modal.addTitle') : t('tools.mcp.server.modal.editTitle')} +
+
+
+
+
{t('tools.mcp.server.modal.description')}
+
*
+
+ +
+ {latestParams.length > 0 && ( +
+
+
{t('tools.mcp.server.modal.parameters')}
+ +
+
{t('tools.mcp.server.modal.parametersTip')}
+
+ {latestParams.map(paramItem => ( + handleParamChange(paramItem.variable, value)} + /> + ))} +
+
+ )} +
+
+ + +
+
+ ) +} + +export default MCPServerModal diff --git a/web/app/components/tools/mcp/mcp-server-param-item.tsx b/web/app/components/tools/mcp/mcp-server-param-item.tsx new file mode 100644 index 0000000000..a48d1b92b0 --- /dev/null +++ b/web/app/components/tools/mcp/mcp-server-param-item.tsx @@ -0,0 +1,37 @@ +'use client' +import React from 'react' +import { useTranslation } from 'react-i18next' +import Textarea from '@/app/components/base/textarea' + +type Props = { + data?: any + value: string + onChange: (value: string) => void +} + +const MCPServerParamItem = ({ + data, + value, + onChange, +}: Props) => { + const { t } = useTranslation() + + return ( +
+
+
{data.label}
+
·
+
{data.variable}
+
{data.type}
+
+ +
+ ) +} + +export default MCPServerParamItem diff --git a/web/app/components/tools/mcp/mcp-service-card.tsx b/web/app/components/tools/mcp/mcp-service-card.tsx new file mode 100644 index 0000000000..c0c542da26 --- /dev/null +++ b/web/app/components/tools/mcp/mcp-service-card.tsx @@ -0,0 +1,246 @@ +'use client' +import React, { useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { RiEditLine, RiLoopLeftLine } from '@remixicon/react' +import { + Mcp, +} from '@/app/components/base/icons/src/vender/other' +import Button from '@/app/components/base/button' +import Tooltip from '@/app/components/base/tooltip' +import Switch from '@/app/components/base/switch' +import Divider from '@/app/components/base/divider' +import CopyFeedback from '@/app/components/base/copy-feedback' +import Confirm from '@/app/components/base/confirm' +import type { AppDetailResponse } from '@/models/app' +import { useAppContext } from '@/context/app-context' +import type { AppSSO } from '@/types/app' +import Indicator from '@/app/components/header/indicator' +import MCPServerModal from '@/app/components/tools/mcp/mcp-server-modal' +import { useAppWorkflow } from '@/service/use-workflow' +import { + useInvalidateMCPServerDetail, + useMCPServerDetail, + useRefreshMCPServerCode, + useUpdateMCPServer, +} from '@/service/use-tools' +import { BlockEnum } from '@/app/components/workflow/types' +import cn from '@/utils/classnames' +import { fetchAppDetail } from '@/service/apps' + +export type IAppCardProps = { + appInfo: AppDetailResponse & Partial +} + +function MCPServiceCard({ + appInfo, +}: IAppCardProps) { + const { t } = useTranslation() + const appId = appInfo.id + const { mutateAsync: updateMCPServer } = useUpdateMCPServer() + const { mutateAsync: refreshMCPServerCode, isPending: genLoading } = useRefreshMCPServerCode() + const invalidateMCPServerDetail = useInvalidateMCPServerDetail() + const { isCurrentWorkspaceManager, isCurrentWorkspaceEditor } = useAppContext() + const [showConfirmDelete, setShowConfirmDelete] = useState(false) + const [showMCPServerModal, setShowMCPServerModal] = useState(false) + + const isAdvancedApp = appInfo?.mode === 'advanced-chat' || appInfo?.mode === 'workflow' + const isBasicApp = !isAdvancedApp + const { data: currentWorkflow } = useAppWorkflow(isAdvancedApp ? appId : '') + const [basicAppConfig, setBasicAppConfig] = useState({}) + const basicAppInputForm = useMemo(() => { + if(!isBasicApp || !basicAppConfig?.user_input_form) + return [] + return basicAppConfig.user_input_form.map((item: any) => { + const type = Object.keys(item)[0] + return { + ...item[type], + type: type || 'text-input', + } + }) + }, [basicAppConfig.user_input_form, isBasicApp]) + useEffect(() => { + if(isBasicApp && appId) { + (async () => { + const res = await fetchAppDetail({ url: '/apps', id: appId }) + setBasicAppConfig(res?.model_config || {}) + })() + } + }, [appId, isBasicApp]) + const { data: detail } = useMCPServerDetail(appId) + const { id, status, server_code } = detail ?? {} + + const appUnpublished = isAdvancedApp ? !currentWorkflow?.graph : !basicAppConfig.updated_at + const serverPublished = !!id + const serverActivated = status === 'active' + const serverURL = serverPublished ? `${appInfo.api_base_url.replace('/v1', '')}/mcp/server/${server_code}/mcp` : '***********' + const toggleDisabled = !isCurrentWorkspaceEditor || appUnpublished + + const [activated, setActivated] = useState(serverActivated) + + const latestParams = useMemo(() => { + if(isAdvancedApp) { + if (!currentWorkflow?.graph) + return [] + const startNode = currentWorkflow?.graph.nodes.find(node => node.data.type === BlockEnum.Start) as any + return startNode?.data.variables as any[] || [] + } + return basicAppInputForm + }, [currentWorkflow, basicAppInputForm, isAdvancedApp]) + + const onGenCode = async () => { + await refreshMCPServerCode(detail?.id || '') + invalidateMCPServerDetail(appId) + } + + const onChangeStatus = async (state: boolean) => { + setActivated(state) + if (state) { + if (!serverPublished) { + setShowMCPServerModal(true) + return + } + + await updateMCPServer({ + appID: appId, + id: id || '', + description: detail?.description || '', + parameters: detail?.parameters || {}, + status: 'active', + }) + invalidateMCPServerDetail(appId) + } + else { + await updateMCPServer({ + appID: appId, + id: id || '', + description: detail?.description || '', + parameters: detail?.parameters || {}, + status: 'inactive', + }) + invalidateMCPServerDetail(appId) + } + } + + const handleServerModalHide = () => { + setShowMCPServerModal(false) + if (!serverActivated) + setActivated(false) + } + + useEffect(() => { + setActivated(serverActivated) + }, [serverActivated]) + + if (!currentWorkflow && isAdvancedApp) + return null + + return ( + <> +
+
+
+
+
+
+ +
+
+
+ {t('tools.mcp.server.title')} +
+
+
+
+ +
+ {serverActivated + ? t('appOverview.overview.status.running') + : t('appOverview.overview.status.disable')} +
+
+ +
+ +
+
+
+
+
+ {t('tools.mcp.server.url')} +
+
+
+
+ {serverURL} +
+
+ {serverPublished && ( + <> + + + {isCurrentWorkspaceManager && ( + +
setShowConfirmDelete(true)} + > + +
+
+ )} + + )} +
+
+
+
+ +
+
+
+ {showMCPServerModal && ( + + )} + {/* button copy link/ button regenerate */} + {showConfirmDelete && ( + { + onGenCode() + setShowConfirmDelete(false) + }} + onCancel={() => setShowConfirmDelete(false)} + /> + )} + + ) +} + +export default MCPServiceCard diff --git a/web/app/components/tools/mcp/mock.ts b/web/app/components/tools/mcp/mock.ts new file mode 100644 index 0000000000..f271f67ed3 --- /dev/null +++ b/web/app/components/tools/mcp/mock.ts @@ -0,0 +1,154 @@ +const tools = [ + { + author: 'Novice', + name: 'NOTION_ADD_PAGE_CONTENT', + label: { + en_US: 'NOTION_ADD_PAGE_CONTENT', + zh_Hans: 'NOTION_ADD_PAGE_CONTENT', + pt_BR: 'NOTION_ADD_PAGE_CONTENT', + ja_JP: 'NOTION_ADD_PAGE_CONTENT', + }, + description: { + en_US: 'Adds a single content block to a notion page. multiple calls needed for multiple blocks. note: only supports adding to notion pages. blocks that can contain children: - page (any block type) - toggle (any nested content) - to-do (nested to-dos/blocks) - bulleted list (nested lists/blocks) - numbered list (nested lists/blocks) - callout (child blocks) - quote (nested blocks)', + zh_Hans: 'Adds a single content block to a notion page. multiple calls needed for multiple blocks. note: only supports adding to notion pages. blocks that can contain children: - page (any block type) - toggle (any nested content) - to-do (nested to-dos/blocks) - bulleted list (nested lists/blocks) - numbered list (nested lists/blocks) - callout (child blocks) - quote (nested blocks)', + pt_BR: 'Adds a single content block to a notion page. multiple calls needed for multiple blocks. note: only supports adding to notion pages. blocks that can contain children: - page (any block type) - toggle (any nested content) - to-do (nested to-dos/blocks) - bulleted list (nested lists/blocks) - numbered list (nested lists/blocks) - callout (child blocks) - quote (nested blocks)', + ja_JP: 'Adds a single content block to a notion page. multiple calls needed for multiple blocks. note: only supports adding to notion pages. blocks that can contain children: - page (any block type) - toggle (any nested content) - to-do (nested to-dos/blocks) - bulleted list (nested lists/blocks) - numbered list (nested lists/blocks) - callout (child blocks) - quote (nested blocks)', + }, + parameters: [ + { + name: 'after', + label: { + en_US: 'after', + zh_Hans: 'after', + pt_BR: 'after', + ja_JP: 'after', + }, + placeholder: null, + scope: null, + auto_generate: null, + template: null, + required: false, + default: null, + min: null, + max: null, + precision: null, + options: [], + type: 'string', + human_description: { + en_US: 'The ID of the existing block that the new block should be appended after. If not provided, content will be appended at the end of the page.', + zh_Hans: 'The ID of the existing block that the new block should be appended after. If not provided, content will be appended at the end of the page.', + pt_BR: 'The ID of the existing block that the new block should be appended after. If not provided, content will be appended at the end of the page.', + ja_JP: 'The ID of the existing block that the new block should be appended after. If not provided, content will be appended at the end of the page.', + }, + form: 'llm', + llm_description: 'The ID of the existing block that the new block should be appended after. If not provided, content will be appended at the end of the page.', + }, + { + name: 'content_block', + label: { + en_US: 'content_block', + zh_Hans: 'content_block', + pt_BR: 'content_block', + ja_JP: 'content_block', + }, + placeholder: null, + scope: null, + auto_generate: null, + template: null, + required: false, + default: null, + min: null, + max: null, + precision: null, + options: [], + type: 'string', + human_description: { + en_US: 'Child content to append to a page.', + zh_Hans: 'Child content to append to a page.', + pt_BR: 'Child content to append to a page.', + ja_JP: 'Child content to append to a page.', + }, + form: 'llm', + llm_description: 'Child content to append to a page.', + }, + { + name: 'parent_block_id', + label: { + en_US: 'parent_block_id', + zh_Hans: 'parent_block_id', + pt_BR: 'parent_block_id', + ja_JP: 'parent_block_id', + }, + placeholder: null, + scope: null, + auto_generate: null, + template: null, + required: false, + default: null, + min: null, + max: null, + precision: null, + options: [], + type: 'string', + human_description: { + en_US: 'The ID of the page which the children will be added.', + zh_Hans: 'The ID of the page which the children will be added.', + pt_BR: 'The ID of the page which the children will be added.', + ja_JP: 'The ID of the page which the children will be added.', + }, + form: 'llm', + llm_description: 'The ID of the page which the children will be added.', + }, + ], + labels: [], + output_schema: null, + }, +] + +export const listData = [ + { + id: 'fdjklajfkljadslf111', + author: 'KVOJJJin', + name: 'GOGOGO', + icon: 'https://cloud.dify.dev/console/api/workspaces/694cc430-fa36-4458-86a0-4a98c09c4684/model-providers/langgenius/openai/openai/icon_small/en_US', + server_url: 'https://mcp.composio.dev/notion/****/abc', + type: 'mcp', + is_team_authorization: true, + tools, + update_elapsed_time: 1744793369, + label: { + en_US: 'GOGOGO', + zh_Hans: 'GOGOGO', + }, + }, + { + id: 'fdjklajfkljadslf222', + author: 'KVOJJJin', + name: 'GOGOGO2', + icon: 'https://cloud.dify.dev/console/api/workspaces/694cc430-fa36-4458-86a0-4a98c09c4684/model-providers/langgenius/openai/openai/icon_small/en_US', + server_url: 'https://mcp.composio.dev/notion/****/abc', + type: 'mcp', + is_team_authorization: false, + tools: [], + update_elapsed_time: 1744793369, + label: { + en_US: 'GOGOGO2', + zh_Hans: 'GOGOGO2', + }, + }, + { + id: 'fdjklajfkljadslf333', + author: 'KVOJJJin', + name: 'GOGOGO3', + icon: 'https://cloud.dify.dev/console/api/workspaces/694cc430-fa36-4458-86a0-4a98c09c4684/model-providers/langgenius/openai/openai/icon_small/en_US', + server_url: 'https://mcp.composio.dev/notion/****/abc', + type: 'mcp', + is_team_authorization: true, + tools, + update_elapsed_time: 1744793369, + label: { + en_US: 'GOGOGO3', + zh_Hans: 'GOGOGO3', + }, + }, +] diff --git a/web/app/components/tools/mcp/modal.tsx b/web/app/components/tools/mcp/modal.tsx new file mode 100644 index 0000000000..0e57cb149b --- /dev/null +++ b/web/app/components/tools/mcp/modal.tsx @@ -0,0 +1,221 @@ +'use client' +import React, { useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { getDomain } from 'tldts' +import { RiCloseLine, RiEditLine } from '@remixicon/react' +import AppIconPicker from '@/app/components/base/app-icon-picker' +import type { AppIconSelection } from '@/app/components/base/app-icon-picker' +import AppIcon from '@/app/components/base/app-icon' +import Modal from '@/app/components/base/modal' +import Button from '@/app/components/base/button' +import Input from '@/app/components/base/input' +import type { AppIconType } from '@/types/app' +import type { ToolWithProvider } from '@/app/components/workflow/types' +import { noop } from 'lodash-es' +import Toast from '@/app/components/base/toast' +import { uploadRemoteFileInfo } from '@/service/common' +import cn from '@/utils/classnames' +import { useHover } from 'ahooks' + +export type DuplicateAppModalProps = { + data?: ToolWithProvider + show: boolean + onConfirm: (info: { + name: string + server_url: string + icon_type: AppIconType + icon: string + icon_background?: string | null + server_identifier: string + }) => void + onHide: () => void +} + +const DEFAULT_ICON = { type: 'emoji', icon: '🧿', background: '#EFF1F5' } +const extractFileId = (url: string) => { + const match = url.match(/files\/(.+?)\/file-preview/) + return match ? match[1] : null +} +const getIcon = (data?: ToolWithProvider) => { + if (!data) + return DEFAULT_ICON as AppIconSelection + if (typeof data.icon === 'string') + return { type: 'image', url: data.icon, fileId: extractFileId(data.icon) } as AppIconSelection + return { + ...data.icon, + icon: data.icon.content, + type: 'emoji', + } as unknown as AppIconSelection +} + +const MCPModal = ({ + data, + show, + onConfirm, + onHide, +}: DuplicateAppModalProps) => { + const { t } = useTranslation() + const isCreate = !data + + const originalServerUrl = data?.server_url + const originalServerID = data?.server_identifier + const [url, setUrl] = React.useState(data?.server_url || '') + const [name, setName] = React.useState(data?.name || '') + const [appIcon, setAppIcon] = useState(getIcon(data)) + const [showAppIconPicker, setShowAppIconPicker] = useState(false) + const [serverIdentifier, setServerIdentifier] = React.useState(data?.server_identifier || '') + const [isFetchingIcon, setIsFetchingIcon] = useState(false) + const appIconRef = useRef(null) + const isHovering = useHover(appIconRef) + + const isValidUrl = (string: string) => { + try { + const urlPattern = /^(https?:\/\/)((([a-z\d]([a-z\d-]*[a-z\d])*)\.)+[a-z]{2,}|((\d{1,3}\.){3}\d{1,3}))(\:\d+)?(\/[-a-z\d%_.~+]*)*(\?[;&a-z\d%_.~+=-]*)?/i + return urlPattern.test(string) + } + catch (e) { + return false + } + } + + const isValidServerID = (str: string) => { + return /^[a-z0-9_-]{1,24}$/.test(str) + } + + const handleBlur = async (url: string) => { + if (data) + return + if (!isValidUrl(url)) + return + const domain = getDomain(url) + const remoteIcon = `https://www.google.com/s2/favicons?domain=${domain}&sz=128` + setIsFetchingIcon(true) + try { + const res = await uploadRemoteFileInfo(remoteIcon, undefined, true) + setAppIcon({ type: 'image', url: res.url, fileId: extractFileId(res.url) || '' }) + } + catch (e) { + console.error('Failed to fetch remote icon:', e) + Toast.notify({ type: 'warning', message: 'Failed to fetch remote icon' }) + } + finally { + setIsFetchingIcon(false) + } + } + + const submit = async () => { + if (!isValidUrl(url)) { + Toast.notify({ type: 'error', message: 'invalid server url' }) + return + } + if (!isValidServerID(serverIdentifier.trim())) { + Toast.notify({ type: 'error', message: 'invalid server identifier' }) + return + } + await onConfirm({ + server_url: originalServerUrl === url ? '[__HIDDEN__]' : url.trim(), + name, + icon_type: appIcon.type, + icon: appIcon.type === 'emoji' ? appIcon.icon : appIcon.fileId, + icon_background: appIcon.type === 'emoji' ? appIcon.background : undefined, + server_identifier: serverIdentifier.trim(), + }) + if(isCreate) + onHide() + } + + return ( + <> + +
+ +
+
{!isCreate ? t('tools.mcp.modal.editTitle') : t('tools.mcp.modal.title')}
+
+
+
+ {t('tools.mcp.modal.serverUrl')} +
+ setUrl(e.target.value)} + onBlur={e => handleBlur(e.target.value.trim())} + placeholder={t('tools.mcp.modal.serverUrlPlaceholder')} + /> + {originalServerUrl && originalServerUrl !== url && ( +
+ {t('tools.mcp.modal.serverUrlWarning')} +
+ )} +
+
+
+
+ {t('tools.mcp.modal.name')} +
+ setName(e.target.value)} + placeholder={t('tools.mcp.modal.namePlaceholder')} + /> +
+
+ + +
) : null + } + onClick={() => { setShowAppIconPicker(true) }} + /> +
+
+
+
+ {t('tools.mcp.modal.serverIdentifier')} +
+
{t('tools.mcp.modal.serverIdentifierTip')}
+ setServerIdentifier(e.target.value)} + placeholder={t('tools.mcp.modal.serverIdentifierPlaceholder')} + /> + {originalServerID && originalServerID !== serverIdentifier && ( +
+ {t('tools.mcp.modal.serverIdentifierWarning')} +
+ )} +
+
+
+ + +
+ + {showAppIconPicker && { + setAppIcon(payload) + setShowAppIconPicker(false) + }} + onClose={() => { + setAppIcon(getIcon(data)) + setShowAppIconPicker(false) + }} + />} + + + ) +} + +export default MCPModal diff --git a/web/app/components/tools/mcp/provider-card.tsx b/web/app/components/tools/mcp/provider-card.tsx new file mode 100644 index 0000000000..677e25c533 --- /dev/null +++ b/web/app/components/tools/mcp/provider-card.tsx @@ -0,0 +1,152 @@ +'use client' +import { useCallback, useState } from 'react' +import { useBoolean } from 'ahooks' +import { useTranslation } from 'react-i18next' +import { useAppContext } from '@/context/app-context' +import { RiHammerFill } from '@remixicon/react' +import Indicator from '@/app/components/header/indicator' +import Icon from '@/app/components/plugins/card/base/card-icon' +import { useFormatTimeFromNow } from './hooks' +import type { ToolWithProvider } from '../../workflow/types' +import Confirm from '@/app/components/base/confirm' +import MCPModal from './modal' +import OperationDropdown from './detail/operation-dropdown' +import { useDeleteMCP, useUpdateMCP } from '@/service/use-tools' +import cn from '@/utils/classnames' + +type Props = { + currentProvider?: ToolWithProvider + data: ToolWithProvider + handleSelect: (providerID: string) => void + onUpdate: (providerID: string) => void + onDeleted: () => void +} + +const MCPCard = ({ + currentProvider, + data, + onUpdate, + handleSelect, + onDeleted, +}: Props) => { + const { t } = useTranslation() + const { formatTimeFromNow } = useFormatTimeFromNow() + const { isCurrentWorkspaceManager } = useAppContext() + + const { mutateAsync: updateMCP } = useUpdateMCP({}) + const { mutateAsync: deleteMCP } = useDeleteMCP({}) + + const [isOperationShow, setIsOperationShow] = useState(false) + + const [isShowUpdateModal, { + setTrue: showUpdateModal, + setFalse: hideUpdateModal, + }] = useBoolean(false) + + const [isShowDeleteConfirm, { + setTrue: showDeleteConfirm, + setFalse: hideDeleteConfirm, + }] = useBoolean(false) + + const [deleting, { + setTrue: showDeleting, + setFalse: hideDeleting, + }] = useBoolean(false) + + const handleUpdate = useCallback(async (form: any) => { + const res = await updateMCP({ + ...form, + provider_id: data.id, + }) + if ((res as any)?.result === 'success') { + hideUpdateModal() + onUpdate(data.id) + } + }, [data, updateMCP, hideUpdateModal, onUpdate]) + + const handleDelete = useCallback(async () => { + showDeleting() + const res = await deleteMCP(data.id) + hideDeleting() + if ((res as any)?.result === 'success') { + hideDeleteConfirm() + onDeleted() + } + }, [showDeleting, deleteMCP, data.id, hideDeleting, hideDeleteConfirm, onDeleted]) + + return ( +
handleSelect(data.id)} + className={cn( + 'group relative flex cursor-pointer flex-col rounded-xl border-[1.5px] border-transparent bg-components-card-bg shadow-xs hover:bg-components-card-bg-alt hover:shadow-md', + currentProvider?.id === data.id && 'border-components-option-card-option-selected-border bg-components-card-bg-alt', + )} + > +
+
+ +
+
+
{data.name}
+
{data.server_identifier}
+
+
+
+
+
+ + {data.tools.length > 0 && ( +
{t('tools.mcp.toolsCount', { count: data.tools.length })}
+ )} + {!data.tools.length && ( +
{t('tools.mcp.noTools')}
+ )} +
+
/
+
{`${t('tools.mcp.updateTime')} ${formatTimeFromNow(data.updated_at! * 1000)}`}
+
+ {data.is_team_authorization && data.tools.length > 0 && } + {(!data.is_team_authorization || !data.tools.length) && ( +
+ {t('tools.mcp.noConfigured')} + +
+ )} +
+ {isCurrentWorkspaceManager && ( + + )} + {isShowUpdateModal && ( + + )} + {isShowDeleteConfirm && ( + + {t('tools.mcp.deleteConfirmTitle', { mcp: data.name })} +
+ } + onCancel={hideDeleteConfirm} + onConfirm={handleDelete} + isLoading={deleting} + isDisabled={deleting} + /> + )} +
+ ) +} +export default MCPCard diff --git a/web/app/components/tools/provider-list.tsx b/web/app/components/tools/provider-list.tsx index 0970daab9c..ecfa5f6ea2 100644 --- a/web/app/components/tools/provider-list.tsx +++ b/web/app/components/tools/provider-list.tsx @@ -15,11 +15,29 @@ import WorkflowToolEmpty from '@/app/components/tools/add-tool-modal/empty' import Card from '@/app/components/plugins/card' import CardMoreInfo from '@/app/components/plugins/card/card-more-info' import PluginDetailPanel from '@/app/components/plugins/plugin-detail-panel' +import MCPList from './mcp' import { useAllToolProviders } from '@/service/use-tools' import { useInstalledPluginList, useInvalidateInstalledPluginList } from '@/service/use-plugins' import { useGlobalPublicStore } from '@/context/global-public-context' +import { ToolTypeEnum } from '../workflow/block-selector/types' +const getToolType = (type: string) => { + switch (type) { + case 'builtin': + return ToolTypeEnum.BuiltIn + case 'api': + return ToolTypeEnum.Custom + case 'workflow': + return ToolTypeEnum.Workflow + case 'mcp': + return ToolTypeEnum.MCP + default: + return ToolTypeEnum.BuiltIn + } +} const ProviderList = () => { + // const searchParams = useSearchParams() + // searchParams.get('category') === 'workflow' const { t } = useTranslation() const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures) const containerRef = useRef(null) @@ -31,6 +49,7 @@ const ProviderList = () => { { value: 'builtin', text: t('tools.type.builtIn') }, { value: 'api', text: t('tools.type.custom') }, { value: 'workflow', text: t('tools.type.workflow') }, + { value: 'mcp', text: 'MCP' }, ] const [tagFilterValue, setTagFilterValue] = useState([]) const handleTagsChange = (value: string[]) => { @@ -72,7 +91,7 @@ const ProviderList = () => { className='relative flex grow flex-col overflow-y-auto bg-background-body' >
{ options={options} />
- + {activeTab !== 'mcp' && ( + + )} { />
- {(filteredCollectionList.length > 0 || activeTab !== 'builtin') && ( + {activeTab !== 'mcp' && (
{ />
))} - {!filteredCollectionList.length && activeTab === 'workflow' &&
} + {!filteredCollectionList.length && activeTab === 'workflow' &&
}
)} {!filteredCollectionList.length && activeTab === 'builtin' && ( )} - { - enable_marketplace && activeTab === 'builtin' && ( - { - containerRef.current?.scrollTo({ top: containerRef.current.scrollHeight, behavior: 'smooth' }) - }} - searchPluginText={keywords} - filterPluginTags={tagFilterValue} - /> - ) - } -
-
+ {enable_marketplace && activeTab === 'builtin' && ( + { + containerRef.current?.scrollTo({ top: containerRef.current.scrollHeight, behavior: 'smooth' }) + }} + searchPluginText={keywords} + filterPluginTags={tagFilterValue} + /> + )} + {activeTab === 'mcp' && ( + + )} +
+
{currentProvider && !currentProvider.plugin_id && ( { return ( <> {isCurrentWorkspaceManager && ( -
-
setIsShowEditCustomCollectionModal(true)}> +
+
setIsShowEditCustomCollectionModal(true)}>
-
- +
+
-
{t('tools.createCustomTool')}
+
{t('tools.createCustomTool')}
- diff --git a/web/app/components/tools/provider/tool-item.tsx b/web/app/components/tools/provider/tool-item.tsx index 161b62963b..d79d20cb9c 100644 --- a/web/app/components/tools/provider/tool-item.tsx +++ b/web/app/components/tools/provider/tool-item.tsx @@ -29,7 +29,7 @@ const ToolItem = ({ return ( <>
!disabled && setShowDetail(true)} >
{tool.label[language]}
diff --git a/web/app/components/tools/types.ts b/web/app/components/tools/types.ts index 32c468cde8..b83919ad18 100644 --- a/web/app/components/tools/types.ts +++ b/web/app/components/tools/types.ts @@ -7,7 +7,8 @@ export enum LOC { export enum AuthType { none = 'none', - apiKey = 'api_key', + apiKeyHeader = 'api_key_header', + apiKeyQuery = 'api_key_query', } export enum AuthHeaderPrefix { @@ -21,6 +22,7 @@ export type Credential = { api_key_header?: string api_key_value?: string api_key_header_prefix?: AuthHeaderPrefix + api_key_query_param?: string } export enum CollectionType { @@ -29,6 +31,7 @@ export enum CollectionType { custom = 'api', model = 'model', workflow = 'workflow', + mcp = 'mcp', } export type Emoji = { @@ -50,6 +53,10 @@ export type Collection = { labels: string[] plugin_id?: string letter?: string + // MCP Server + server_url?: string + updated_at?: number + server_identifier?: string } export type ToolParameter = { @@ -168,3 +175,11 @@ export type WorkflowToolProviderResponse = { } privacy_policy: string } + +export type MCPServerDetail = { + id: string + server_code: string + description: string + status: string + parameters?: Record +} diff --git a/web/app/components/tools/utils/to-form-schema.ts b/web/app/components/tools/utils/to-form-schema.ts index 179f59021e..ee7f3379ad 100644 --- a/web/app/components/tools/utils/to-form-schema.ts +++ b/web/app/components/tools/utils/to-form-schema.ts @@ -1,4 +1,7 @@ import type { ToolCredential, ToolParameter } from '../types' +import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types' + export const toType = (type: string) => { switch (type) { case 'string': @@ -54,7 +57,7 @@ export const toolCredentialToFormSchemas = (parameters: ToolCredential[]) => { return formSchemas } -export const addDefaultValue = (value: Record, formSchemas: { variable: string; default?: any }[]) => { +export const addDefaultValue = (value: Record, formSchemas: { variable: string; type: string; default?: any }[]) => { const newValues = { ...value } formSchemas.forEach((formSchema) => { const itemValue = value[formSchema.variable] @@ -64,14 +67,47 @@ export const addDefaultValue = (value: Record, formSchemas: { varia return newValues } -export const generateFormValue = (value: Record, formSchemas: { variable: string; default?: any }[], isReasoning = false) => { +const correctInitialData = (type: string, target: any, defaultValue: any) => { + if (type === 'text-input' || type === 'secret-input') + target.type = 'mixed' + + if (type === 'boolean') { + if (typeof defaultValue === 'string') + target.value = defaultValue === 'true' || defaultValue === '1' + + if (typeof defaultValue === 'boolean') + target.value = defaultValue + + if (typeof defaultValue === 'number') + target.value = defaultValue === 1 + } + + if (type === 'number-input') { + if (typeof defaultValue === 'string' && defaultValue !== '') + target.value = Number.parseFloat(defaultValue) + } + + if (type === 'app-selector' || type === 'model-selector') + target.value = defaultValue + + return target +} + +export const generateFormValue = (value: Record, formSchemas: { variable: string; default?: any; type: string }[], isReasoning = false) => { const newValues = {} as any formSchemas.forEach((formSchema) => { const itemValue = value[formSchema.variable] if ((formSchema.default !== undefined) && (value === undefined || itemValue === null || itemValue === '' || itemValue === undefined)) { + const value = formSchema.default newValues[formSchema.variable] = { - ...(isReasoning ? { value: null, auto: 1 } : { value: formSchema.default }), + value: { + type: 'constant', + value: formSchema.default, + }, + ...(isReasoning ? { auto: 1, value: null } : {}), } + if (!isReasoning) + newValues[formSchema.variable].value = correctInitialData(formSchema.type, newValues[formSchema.variable].value, value) } }) return newValues @@ -80,7 +116,9 @@ export const generateFormValue = (value: Record, formSchemas: { var export const getPlainValue = (value: Record) => { const plainValue = { ...value } Object.keys(plainValue).forEach((key) => { - plainValue[key] = value[key].value + plainValue[key] = { + ...value[key].value, + } }) return plainValue } @@ -94,3 +132,65 @@ export const getStructureValue = (value: Record) => { }) return newValue } + +export const getConfiguredValue = (value: Record, formSchemas: { variable: string; type: string; default?: any }[]) => { + const newValues = { ...value } + formSchemas.forEach((formSchema) => { + const itemValue = value[formSchema.variable] + if ((formSchema.default !== undefined) && (value === undefined || itemValue === null || itemValue === '' || itemValue === undefined)) { + const value = formSchema.default + newValues[formSchema.variable] = { + type: 'constant', + value: formSchema.default, + } + newValues[formSchema.variable] = correctInitialData(formSchema.type, newValues[formSchema.variable], value) + } + }) + return newValues +} + +const getVarKindType = (type: FormTypeEnum) => { + if (type === FormTypeEnum.file || type === FormTypeEnum.files) + return VarKindType.variable + if (type === FormTypeEnum.select || type === FormTypeEnum.boolean || type === FormTypeEnum.textNumber) + return VarKindType.constant + if (type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput) + return VarKindType.mixed + } + +export const generateAgentToolValue = (value: Record, formSchemas: { variable: string; default?: any; type: string }[], isReasoning = false) => { + const newValues = {} as any + if (!isReasoning) { + formSchemas.forEach((formSchema) => { + const itemValue = value[formSchema.variable] + newValues[formSchema.variable] = { + value: { + type: 'constant', + value: itemValue.value, + }, + } + newValues[formSchema.variable].value = correctInitialData(formSchema.type, newValues[formSchema.variable].value, itemValue.value) + }) + } + else { + formSchemas.forEach((formSchema) => { + const itemValue = value[formSchema.variable] + if (itemValue.auto === 1) { + newValues[formSchema.variable] = { + auto: 1, + value: null, + } + } + else { + newValues[formSchema.variable] = { + auto: 0, + value: itemValue.value || { + type: getVarKindType(formSchema.type as FormTypeEnum), + value: null, + }, + } + } + }) + } + return newValues +} diff --git a/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx b/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx index f183dd9545..fd48935147 100644 --- a/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx +++ b/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx @@ -3,7 +3,7 @@ import { useCallback, useMemo, } from 'react' -import { useNodes } from 'reactflow' +import { useStore as useReactflowStore } from 'reactflow' import { RiApps2AddLine } from '@remixicon/react' import { useTranslation } from 'react-i18next' import { @@ -22,9 +22,8 @@ import { BlockEnum, InputVarType, } from '@/app/components/workflow/types' -import type { StartNodeType } from '@/app/components/workflow/nodes/start/types' import { useToastContext } from '@/app/components/base/toast' -import { usePublishWorkflow, useResetWorkflowVersionHistory } from '@/service/use-workflow' +import { useInvalidateAppWorkflow, usePublishWorkflow, useResetWorkflowVersionHistory } from '@/service/use-workflow' import type { PublishWorkflowParams } from '@/types/workflow' import { fetchAppDetail } from '@/service/apps' import { useStore as useAppStore } from '@/app/components/app/store' @@ -42,9 +41,9 @@ const FeaturesTrigger = () => { const publishedAt = useStore(s => s.publishedAt) const draftUpdatedAt = useStore(s => s.draftUpdatedAt) const toolPublished = useStore(s => s.toolPublished) - const nodes = useNodes() - const startNode = nodes.find(node => node.data.type === BlockEnum.Start) - const startVariables = startNode?.data.variables + const startVariables = useReactflowStore( + s => s.getNodes().find(node => node.data.type === BlockEnum.Start)?.data.variables, + ) const fileSettings = useFeatures(s => s.features.file) const variables = useMemo(() => { const data = startVariables || [] @@ -90,6 +89,7 @@ const FeaturesTrigger = () => { } }, [appID, setAppDetail]) const { mutateAsync: publishWorkflow } = usePublishWorkflow(appID!) + const updatePublishedWorkflow = useInvalidateAppWorkflow() const onPublish = useCallback(async (params?: PublishWorkflowParams) => { if (await handleCheckBeforePublish()) { const res = await publishWorkflow({ @@ -99,6 +99,7 @@ const FeaturesTrigger = () => { if (res) { notify({ type: 'success', message: t('common.api.actionSuccess') }) + updatePublishedWorkflow(appID!) updateAppDetail() workflowStore.getState().setPublishedAt(res.created_at) resetWorkflowVersionHistory() @@ -107,7 +108,7 @@ const FeaturesTrigger = () => { else { throw new Error('Checklist failed') } - }, [handleCheckBeforePublish, notify, t, workflowStore, publishWorkflow, resetWorkflowVersionHistory, updateAppDetail]) + }, [handleCheckBeforePublish, publishWorkflow, notify, t, updatePublishedWorkflow, appID, updateAppDetail, workflowStore, resetWorkflowVersionHistory]) const onPublisherToggle = useCallback((state: boolean) => { if (state) diff --git a/web/app/components/workflow-app/components/workflow-main.tsx b/web/app/components/workflow-app/components/workflow-main.tsx index 6be190c897..f0b9bab2cf 100644 --- a/web/app/components/workflow-app/components/workflow-main.tsx +++ b/web/app/components/workflow-app/components/workflow-main.tsx @@ -7,7 +7,10 @@ import { WorkflowWithInnerContext } from '@/app/components/workflow' import type { WorkflowProps } from '@/app/components/workflow' import WorkflowChildren from './workflow-children' import { + useConfigsMap, + useInspectVarsCrud, useNodesSyncDraft, + useSetWorkflowVarsWithValue, useWorkflowRefreshDraft, useWorkflowRun, useWorkflowStartRun, @@ -61,6 +64,24 @@ const WorkflowMain = ({ handleWorkflowStartRunInChatflow, handleWorkflowStartRunInWorkflow, } = useWorkflowStartRun() + const { fetchInspectVars } = useSetWorkflowVarsWithValue() + const { + hasNodeInspectVars, + hasSetInspectVar, + fetchInspectVarValue, + editInspectVarValue, + renameInspectVarName, + appendNodeInspectVars, + deleteInspectVar, + deleteNodeInspectorVars, + deleteAllInspectorVars, + isInspectVarEdited, + resetToLastRunVar, + invalidateSysVarValues, + resetConversationVar, + invalidateConversationVarValues, + } = useInspectVarsCrud() + const configsMap = useConfigsMap() const hooksStore = useMemo(() => { return { @@ -75,6 +96,22 @@ const WorkflowMain = ({ handleStartWorkflowRun, handleWorkflowStartRunInChatflow, handleWorkflowStartRunInWorkflow, + fetchInspectVars, + hasNodeInspectVars, + hasSetInspectVar, + fetchInspectVarValue, + editInspectVarValue, + renameInspectVarName, + appendNodeInspectVars, + deleteInspectVar, + deleteNodeInspectorVars, + deleteAllInspectorVars, + isInspectVarEdited, + resetToLastRunVar, + invalidateSysVarValues, + resetConversationVar, + invalidateConversationVarValues, + configsMap, } }, [ syncWorkflowDraftWhenPageClose, @@ -88,6 +125,22 @@ const WorkflowMain = ({ handleStartWorkflowRun, handleWorkflowStartRunInChatflow, handleWorkflowStartRunInWorkflow, + fetchInspectVars, + hasNodeInspectVars, + hasSetInspectVar, + fetchInspectVarValue, + editInspectVarValue, + renameInspectVarName, + appendNodeInspectVars, + deleteInspectVar, + deleteNodeInspectorVars, + deleteAllInspectorVars, + isInspectVarEdited, + resetToLastRunVar, + invalidateSysVarValues, + resetConversationVar, + invalidateConversationVarValues, + configsMap, ]) return ( diff --git a/web/app/components/workflow-app/hooks/index.ts b/web/app/components/workflow-app/hooks/index.ts index 6373a8591c..1ee7c030b9 100644 --- a/web/app/components/workflow-app/hooks/index.ts +++ b/web/app/components/workflow-app/hooks/index.ts @@ -5,3 +5,6 @@ export * from './use-workflow-run' export * from './use-workflow-start-run' export * from './use-is-chat-mode' export * from './use-workflow-refresh-draft' +export * from './use-fetch-workflow-inspect-vars' +export * from './use-inspect-vars-crud' +export * from './use-configs-map' diff --git a/web/app/components/workflow-app/hooks/use-configs-map.ts b/web/app/components/workflow-app/hooks/use-configs-map.ts new file mode 100644 index 0000000000..0db4f77856 --- /dev/null +++ b/web/app/components/workflow-app/hooks/use-configs-map.ts @@ -0,0 +1,12 @@ +import { useMemo } from 'react' +import { useStore } from '@/app/components/workflow/store' + +export const useConfigsMap = () => { + const appId = useStore(s => s.appId) + return useMemo(() => { + return { + conversationVarsUrl: `apps/${appId}/workflows/draft/conversation-variables`, + systemVarsUrl: `apps/${appId}/workflows/draft/system-variables`, + } + }, [appId]) +} diff --git a/web/app/components/workflow-app/hooks/use-fetch-workflow-inspect-vars.ts b/web/app/components/workflow-app/hooks/use-fetch-workflow-inspect-vars.ts index 9d3ff84929..07580c097e 100644 --- a/web/app/components/workflow-app/hooks/use-fetch-workflow-inspect-vars.ts +++ b/web/app/components/workflow-app/hooks/use-fetch-workflow-inspect-vars.ts @@ -1,19 +1,23 @@ +import { useCallback } from 'react' import type { NodeWithVar, VarInInspect } from '@/types/workflow' -import { useWorkflowStore } from '../../workflow/store' +import { useWorkflowStore } from '@/app/components/workflow/store' import { useStoreApi } from 'reactflow' import type { Node } from '@/app/components/workflow/types' import { fetchAllInspectVars } from '@/service/workflow' import { useInvalidateConversationVarValues, useInvalidateSysVarValues } from '@/service/use-workflow' -import { useNodesInteractionsWithoutSync } from '../../workflow/hooks/use-nodes-interactions-without-sync' -const useSetWorkflowVarsWithValue = () => { +import { useNodesInteractionsWithoutSync } from '@/app/components/workflow/hooks/use-nodes-interactions-without-sync' +import { useConfigsMap } from './use-configs-map' + +export const useSetWorkflowVarsWithValue = () => { const workflowStore = useWorkflowStore() - const { setNodesWithInspectVars, appId } = workflowStore.getState() const store = useStoreApi() - const invalidateConversationVarValues = useInvalidateConversationVarValues(appId) - const invalidateSysVarValues = useInvalidateSysVarValues(appId) + const { conversationVarsUrl, systemVarsUrl } = useConfigsMap() + const invalidateConversationVarValues = useInvalidateConversationVarValues(conversationVarsUrl) + const invalidateSysVarValues = useInvalidateSysVarValues(systemVarsUrl) const { handleCancelAllNodeSuccessStatus } = useNodesInteractionsWithoutSync() - const setInspectVarsToStore = (inspectVars: VarInInspect[]) => { + const setInspectVarsToStore = useCallback((inspectVars: VarInInspect[]) => { + const { setNodesWithInspectVars } = workflowStore.getState() const { getNodes } = store.getState() const nodeArr = getNodes() const nodesKeyValue: Record = {} @@ -51,18 +55,17 @@ const useSetWorkflowVarsWithValue = () => { return nodeWithVar }) setNodesWithInspectVars(res) - } + }, [workflowStore, store]) - const fetchInspectVars = async () => { + const fetchInspectVars = useCallback(async () => { + const { appId } = workflowStore.getState() invalidateConversationVarValues() invalidateSysVarValues() const data = await fetchAllInspectVars(appId) setInspectVarsToStore(data) handleCancelAllNodeSuccessStatus() // to make sure clear node output show the unset status - } + }, [workflowStore, invalidateConversationVarValues, invalidateSysVarValues, setInspectVarsToStore, handleCancelAllNodeSuccessStatus]) return { fetchInspectVars, } } - -export default useSetWorkflowVarsWithValue diff --git a/web/app/components/workflow-app/hooks/use-inspect-vars-crud.ts b/web/app/components/workflow-app/hooks/use-inspect-vars-crud.ts new file mode 100644 index 0000000000..ce052b7ed4 --- /dev/null +++ b/web/app/components/workflow-app/hooks/use-inspect-vars-crud.ts @@ -0,0 +1,234 @@ +import { fetchNodeInspectVars } from '@/service/workflow' +import { useStore, useWorkflowStore } from '@/app/components/workflow/store' +import type { ValueSelector } from '@/app/components/workflow/types' +import type { VarInInspect } from '@/types/workflow' +import { VarInInspectType } from '@/types/workflow' +import { + useDeleteAllInspectorVars, + useDeleteInspectVar, + useDeleteNodeInspectorVars, + useEditInspectorVar, + useInvalidateConversationVarValues, + useInvalidateSysVarValues, + useResetConversationVar, + useResetToLastRunValue, +} from '@/service/use-workflow' +import { useCallback } from 'react' +import { isConversationVar, isENV, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils' +import produce from 'immer' +import type { Node } from '@/app/components/workflow/types' +import { useNodesInteractionsWithoutSync } from '@/app/components/workflow/hooks/use-nodes-interactions-without-sync' +import { useEdgesInteractionsWithoutSync } from '@/app/components/workflow/hooks/use-edges-interactions-without-sync' +import { useConfigsMap } from './use-configs-map' + +export const useInspectVarsCrud = () => { + const workflowStore = useWorkflowStore() + const appId = useStore(s => s.appId) + const { conversationVarsUrl, systemVarsUrl } = useConfigsMap() + const invalidateConversationVarValues = useInvalidateConversationVarValues(conversationVarsUrl) + const { mutateAsync: doResetConversationVar } = useResetConversationVar(appId) + const { mutateAsync: doResetToLastRunValue } = useResetToLastRunValue(appId) + const invalidateSysVarValues = useInvalidateSysVarValues(systemVarsUrl) + + const { mutateAsync: doDeleteAllInspectorVars } = useDeleteAllInspectorVars(appId) + const { mutate: doDeleteNodeInspectorVars } = useDeleteNodeInspectorVars(appId) + const { mutate: doDeleteInspectVar } = useDeleteInspectVar(appId) + + const { mutateAsync: doEditInspectorVar } = useEditInspectorVar(appId) + const { handleCancelNodeSuccessStatus } = useNodesInteractionsWithoutSync() + const { handleEdgeCancelRunningStatus } = useEdgesInteractionsWithoutSync() + const getNodeInspectVars = useCallback((nodeId: string) => { + const { nodesWithInspectVars } = workflowStore.getState() + const node = nodesWithInspectVars.find(node => node.nodeId === nodeId) + return node + }, [workflowStore]) + + const getVarId = useCallback((nodeId: string, varName: string) => { + const node = getNodeInspectVars(nodeId) + if (!node) + return undefined + const varId = node.vars.find((varItem) => { + return varItem.selector[1] === varName + })?.id + return varId + }, [getNodeInspectVars]) + + const getInspectVar = useCallback((nodeId: string, name: string): VarInInspect | undefined => { + const node = getNodeInspectVars(nodeId) + if (!node) + return undefined + + const variable = node.vars.find((varItem) => { + return varItem.name === name + }) + return variable + }, [getNodeInspectVars]) + + const hasSetInspectVar = useCallback((nodeId: string, name: string, sysVars: VarInInspect[], conversationVars: VarInInspect[]) => { + const isEnv = isENV([nodeId]) + if (isEnv) // always have value + return true + const isSys = isSystemVar([nodeId]) + if (isSys) + return sysVars.some(varItem => varItem.selector?.[1]?.replace('sys.', '') === name) + const isChatVar = isConversationVar([nodeId]) + if (isChatVar) + return conversationVars.some(varItem => varItem.selector?.[1] === name) + return getInspectVar(nodeId, name) !== undefined + }, [getInspectVar]) + + const hasNodeInspectVars = useCallback((nodeId: string) => { + return !!getNodeInspectVars(nodeId) + }, [getNodeInspectVars]) + + const fetchInspectVarValue = useCallback(async (selector: ValueSelector) => { + const { + appId, + setNodeInspectVars, + } = workflowStore.getState() + const nodeId = selector[0] + const isSystemVar = nodeId === 'sys' + const isConversationVar = nodeId === 'conversation' + if (isSystemVar) { + invalidateSysVarValues() + return + } + if (isConversationVar) { + invalidateConversationVarValues() + return + } + const vars = await fetchNodeInspectVars(appId, nodeId) + setNodeInspectVars(nodeId, vars) + }, [workflowStore, invalidateSysVarValues, invalidateConversationVarValues]) + + // after last run would call this + const appendNodeInspectVars = useCallback((nodeId: string, payload: VarInInspect[], allNodes: Node[]) => { + const { + nodesWithInspectVars, + setNodesWithInspectVars, + } = workflowStore.getState() + const nodes = produce(nodesWithInspectVars, (draft) => { + const nodeInfo = allNodes.find(node => node.id === nodeId) + if (nodeInfo) { + const index = draft.findIndex(node => node.nodeId === nodeId) + if (index === -1) { + draft.unshift({ + nodeId, + nodeType: nodeInfo.data.type, + title: nodeInfo.data.title, + vars: payload, + nodePayload: nodeInfo.data, + }) + } + else { + draft[index].vars = payload + // put the node to the topAdd commentMore actions + draft.unshift(draft.splice(index, 1)[0]) + } + } + }) + setNodesWithInspectVars(nodes) + handleCancelNodeSuccessStatus(nodeId) + }, [workflowStore, handleCancelNodeSuccessStatus]) + + const hasNodeInspectVar = useCallback((nodeId: string, varId: string) => { + const { nodesWithInspectVars } = workflowStore.getState() + const targetNode = nodesWithInspectVars.find(item => item.nodeId === nodeId) + if(!targetNode || !targetNode.vars) + return false + return targetNode.vars.some(item => item.id === varId) + }, [workflowStore]) + + const deleteInspectVar = useCallback(async (nodeId: string, varId: string) => { + const { deleteInspectVar } = workflowStore.getState() + if(hasNodeInspectVar(nodeId, varId)) { + await doDeleteInspectVar(varId) + deleteInspectVar(nodeId, varId) + } + }, [doDeleteInspectVar, workflowStore, hasNodeInspectVar]) + + const resetConversationVar = useCallback(async (varId: string) => { + await doResetConversationVar(varId) + invalidateConversationVarValues() + }, [doResetConversationVar, invalidateConversationVarValues]) + + const deleteNodeInspectorVars = useCallback(async (nodeId: string) => { + const { deleteNodeInspectVars } = workflowStore.getState() + if (hasNodeInspectVars(nodeId)) { + await doDeleteNodeInspectorVars(nodeId) + deleteNodeInspectVars(nodeId) + } + }, [doDeleteNodeInspectorVars, workflowStore, hasNodeInspectVars]) + + const deleteAllInspectorVars = useCallback(async () => { + const { deleteAllInspectVars } = workflowStore.getState() + await doDeleteAllInspectorVars() + await invalidateConversationVarValues() + await invalidateSysVarValues() + deleteAllInspectVars() + handleEdgeCancelRunningStatus() + }, [doDeleteAllInspectorVars, invalidateConversationVarValues, invalidateSysVarValues, workflowStore, handleEdgeCancelRunningStatus]) + + const editInspectVarValue = useCallback(async (nodeId: string, varId: string, value: any) => { + const { setInspectVarValue } = workflowStore.getState() + await doEditInspectorVar({ + varId, + value, + }) + setInspectVarValue(nodeId, varId, value) + if (nodeId === VarInInspectType.conversation) + invalidateConversationVarValues() + if (nodeId === VarInInspectType.system) + invalidateSysVarValues() + }, [doEditInspectorVar, invalidateConversationVarValues, invalidateSysVarValues, workflowStore]) + + const renameInspectVarName = useCallback(async (nodeId: string, oldName: string, newName: string) => { + const { renameInspectVarName } = workflowStore.getState() + const varId = getVarId(nodeId, oldName) + if (!varId) + return + + const newSelector = [nodeId, newName] + await doEditInspectorVar({ + varId, + name: newName, + }) + renameInspectVarName(nodeId, varId, newSelector) + }, [doEditInspectorVar, getVarId, workflowStore]) + + const isInspectVarEdited = useCallback((nodeId: string, name: string) => { + const inspectVar = getInspectVar(nodeId, name) + if (!inspectVar) + return false + + return inspectVar.edited + }, [getInspectVar]) + + const resetToLastRunVar = useCallback(async (nodeId: string, varId: string) => { + const { resetToLastRunVar } = workflowStore.getState() + const isSysVar = nodeId === 'sys' + const data = await doResetToLastRunValue(varId) + + if(isSysVar) + invalidateSysVarValues() + else + resetToLastRunVar(nodeId, varId, data.value) + }, [doResetToLastRunValue, invalidateSysVarValues, workflowStore]) + + return { + hasNodeInspectVars, + hasSetInspectVar, + fetchInspectVarValue, + editInspectVarValue, + renameInspectVarName, + appendNodeInspectVars, + deleteInspectVar, + deleteNodeInspectorVars, + deleteAllInspectorVars, + isInspectVarEdited, + resetToLastRunVar, + invalidateSysVarValues, + resetConversationVar, + invalidateConversationVarValues, + } +} diff --git a/web/app/components/workflow-app/hooks/use-workflow-run.ts b/web/app/components/workflow-app/hooks/use-workflow-run.ts index 99b88238f1..4c34d2ffb1 100644 --- a/web/app/components/workflow-app/hooks/use-workflow-run.ts +++ b/web/app/components/workflow-app/hooks/use-workflow-run.ts @@ -20,7 +20,7 @@ import type { VersionHistory } from '@/types/workflow' import { noop } from 'lodash-es' import { useNodesSyncDraft } from './use-nodes-sync-draft' import { useInvalidAllLastRun } from '@/service/use-workflow' -import useSetWorkflowVarsWithValue from './use-fetch-workflow-inspect-vars' +import { useSetWorkflowVarsWithValue } from './use-fetch-workflow-inspect-vars' export const useWorkflowRun = () => { const store = useStoreApi() diff --git a/web/app/components/workflow/block-selector/all-tools.tsx b/web/app/components/workflow/block-selector/all-tools.tsx index 36831aee3c..870d791d4f 100644 --- a/web/app/components/workflow/block-selector/all-tools.tsx +++ b/web/app/components/workflow/block-selector/all-tools.tsx @@ -5,10 +5,11 @@ import { useState, } from 'react' import type { + BlockEnum, OnSelectBlock, ToolWithProvider, } from '../types' -import type { ToolValue } from './types' +import type { ToolDefaultValue, ToolValue } from './types' import { ToolTypeEnum } from './types' import Tools from './tools' import { useToolTabs } from './hooks' @@ -17,8 +18,6 @@ import cn from '@/utils/classnames' import { useGetLanguage } from '@/context/i18n' import type { ListRef } from '@/app/components/workflow/block-selector/market-place-plugin/list' import PluginList, { type ListProps } from '@/app/components/workflow/block-selector/market-place-plugin/list' -import ActionButton from '../../base/action-button' -import { RiAddLine } from '@remixicon/react' import { PluginType } from '../../plugins/types' import { useMarketplacePlugins } from '../../plugins/marketplace/hooks' import { useGlobalPublicStore } from '@/context/global-public-context' @@ -31,11 +30,12 @@ type AllToolsProps = { buildInTools: ToolWithProvider[] customTools: ToolWithProvider[] workflowTools: ToolWithProvider[] + mcpTools: ToolWithProvider[] onSelect: OnSelectBlock - supportAddCustomTool?: boolean - onAddedCustomTool?: () => void - onShowAddCustomCollectionModal?: () => void + canNotSelectMultiple?: boolean + onSelectMultiple?: (type: BlockEnum, tools: ToolDefaultValue[]) => void selectedTools?: ToolValue[] + canChooseMCPTool?: boolean } const DEFAULT_TAGS: AllToolsProps['tags'] = [] @@ -46,12 +46,14 @@ const AllTools = ({ searchText, tags = DEFAULT_TAGS, onSelect, + canNotSelectMultiple, + onSelectMultiple, buildInTools, workflowTools, customTools, - supportAddCustomTool, - onShowAddCustomCollectionModal, + mcpTools = [], selectedTools, + canChooseMCPTool, }: AllToolsProps) => { const language = useGetLanguage() const tabs = useToolTabs() @@ -64,13 +66,15 @@ const AllTools = ({ const tools = useMemo(() => { let mergedTools: ToolWithProvider[] = [] if (activeTab === ToolTypeEnum.All) - mergedTools = [...buildInTools, ...customTools, ...workflowTools] + mergedTools = [...buildInTools, ...customTools, ...workflowTools, ...mcpTools] if (activeTab === ToolTypeEnum.BuiltIn) mergedTools = buildInTools if (activeTab === ToolTypeEnum.Custom) mergedTools = customTools if (activeTab === ToolTypeEnum.Workflow) mergedTools = workflowTools + if (activeTab === ToolTypeEnum.MCP) + mergedTools = mcpTools if (!hasFilter) return mergedTools.filter(toolWithProvider => toolWithProvider.tools.length > 0) @@ -80,7 +84,7 @@ const AllTools = ({ return tool.label[language].toLowerCase().includes(searchText.toLowerCase()) || tool.name.toLowerCase().includes(searchText.toLowerCase()) }) }) - }, [activeTab, buildInTools, customTools, workflowTools, searchText, language, hasFilter]) + }, [activeTab, buildInTools, customTools, workflowTools, mcpTools, searchText, language, hasFilter]) const { queryPluginsWithDebounced: fetchPlugins, @@ -88,9 +92,8 @@ const AllTools = ({ } = useMarketplacePlugins() const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures) - useEffect(() => { - if (enable_marketplace) return + if (!enable_marketplace) return if (searchText || tags.length > 0) { fetchPlugins({ query: searchText, @@ -103,10 +106,11 @@ const AllTools = ({ const pluginRef = useRef(null) const wrapElemRef = useRef(null) + const isSupportGroupView = [ToolTypeEnum.All, ToolTypeEnum.BuiltIn].includes(activeTab) return ( -
-
+
+
{ tabs.map(tab => ( @@ -124,17 +128,8 @@ const AllTools = ({ )) }
- - {supportAddCustomTool && ( -
-
- - - -
+ {isSupportGroupView && ( + )}
{/* Plugins from marketplace */} {enable_marketplace && { ] } -export const useToolTabs = () => { +export const useToolTabs = (isHideMCPTools?: boolean) => { const { t } = useTranslation() - - return [ + const tabs = [ { key: ToolTypeEnum.All, name: t('workflow.tabs.allTool'), @@ -52,4 +51,12 @@ export const useToolTabs = () => { name: t('workflow.tabs.workflowTool'), }, ] + if(!isHideMCPTools) { + tabs.push({ + key: ToolTypeEnum.MCP, + name: 'MCP', + }) + } + + return tabs } diff --git a/web/app/components/workflow/block-selector/index-bar.tsx b/web/app/components/workflow/block-selector/index-bar.tsx index 4d8bedffbe..097a16eb94 100644 --- a/web/app/components/workflow/block-selector/index-bar.tsx +++ b/web/app/components/workflow/block-selector/index-bar.tsx @@ -83,8 +83,8 @@ const IndexBar: FC = ({ letters, itemRefs, className }) => { element.scrollIntoView({ behavior: 'smooth' }) } return ( -
-
+
+
{letters.map(letter => (
handleIndexClick(letter)}> {letter} diff --git a/web/app/components/workflow/block-selector/index.tsx b/web/app/components/workflow/block-selector/index.tsx index 9e55a24d9e..0673ca0c0d 100644 --- a/web/app/components/workflow/block-selector/index.tsx +++ b/web/app/components/workflow/block-selector/index.tsx @@ -129,33 +129,35 @@ const NodeSelector: FC = ({
-
e.stopPropagation()}> - {activeTab === TabsEnum.Blocks && ( - setSearchText(e.target.value)} - onClear={() => setSearchText('')} - /> - )} - {activeTab === TabsEnum.Tools && ( - - )} - -
e.stopPropagation()}> + {activeTab === TabsEnum.Blocks && ( + setSearchText(e.target.value)} + onClear={() => setSearchText('')} + /> + )} + {activeTab === TabsEnum.Tools && ( + + )} +
+ } onSelect={handleSelect} searchText={searchText} tags={tags} diff --git a/web/app/components/workflow/block-selector/market-place-plugin/list.tsx b/web/app/components/workflow/block-selector/market-place-plugin/list.tsx index e2b4a7acc6..dce877ab91 100644 --- a/web/app/components/workflow/block-selector/market-place-plugin/list.tsx +++ b/web/app/components/workflow/block-selector/market-place-plugin/list.tsx @@ -80,7 +80,7 @@ const List = forwardRef(({ ) } - const maxWidthClassName = toolContentClassName || 'max-w-[300px]' + const maxWidthClassName = toolContentClassName || 'max-w-[100%]' return ( <> @@ -109,18 +109,20 @@ const List = forwardRef(({ onAction={noop} /> ))} -
-
- - - {t('plugin.searchInMarketplace')} - -
-
+ {list.length > 0 && ( +
+
+ + + {t('plugin.searchInMarketplace')} + +
+
+ )}
) diff --git a/web/app/components/workflow/block-selector/tabs.tsx b/web/app/components/workflow/block-selector/tabs.tsx index 67aaaba1a5..3f3fed2ca9 100644 --- a/web/app/components/workflow/block-selector/tabs.tsx +++ b/web/app/components/workflow/block-selector/tabs.tsx @@ -1,6 +1,6 @@ import type { FC } from 'react' import { memo } from 'react' -import { useAllBuiltInTools, useAllCustomTools, useAllWorkflowTools } from '@/service/use-tools' +import { useAllBuiltInTools, useAllCustomTools, useAllMCPTools, useAllWorkflowTools } from '@/service/use-tools' import type { BlockEnum } from '../types' import { useTabs } from './hooks' import type { ToolDefaultValue } from './types' @@ -16,6 +16,7 @@ export type TabsProps = { tags: string[] onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void availableBlocksTypes?: BlockEnum[] + filterElem: React.ReactNode noBlocks?: boolean } const Tabs: FC = ({ @@ -25,26 +26,28 @@ const Tabs: FC = ({ searchText, onSelect, availableBlocksTypes, + filterElem, noBlocks, }) => { const tabs = useTabs() const { data: buildInTools } = useAllBuiltInTools() const { data: customTools } = useAllCustomTools() const { data: workflowTools } = useAllWorkflowTools() + const { data: mcpTools } = useAllMCPTools() return (
e.stopPropagation()}> { !noBlocks && ( -
+
{ tabs.map(tab => (
onActiveTabChange(tab.key)} @@ -56,25 +59,30 @@ const Tabs: FC = ({
) } + {filterElem} { activeTab === TabsEnum.Blocks && !noBlocks && ( - +
+ +
) } { activeTab === TabsEnum.Tools && ( ) } diff --git a/web/app/components/workflow/block-selector/tool-picker.tsx b/web/app/components/workflow/block-selector/tool-picker.tsx index dbb49fde75..d97a4f3a1b 100644 --- a/web/app/components/workflow/block-selector/tool-picker.tsx +++ b/web/app/components/workflow/block-selector/tool-picker.tsx @@ -23,7 +23,7 @@ import { } from '@/service/tools' import type { CustomCollectionBackend } from '@/app/components/tools/types' import Toast from '@/app/components/base/toast' -import { useAllBuiltInTools, useAllCustomTools, useAllWorkflowTools, useInvalidateAllCustomTools } from '@/service/use-tools' +import { useAllBuiltInTools, useAllCustomTools, useAllMCPTools, useAllWorkflowTools, useInvalidateAllCustomTools } from '@/service/use-tools' import cn from '@/utils/classnames' type Props = { @@ -35,9 +35,11 @@ type Props = { isShow: boolean onShowChange: (isShow: boolean) => void onSelect: (tool: ToolDefaultValue) => void + onSelectMultiple: (tools: ToolDefaultValue[]) => void supportAddCustomTool?: boolean scope?: string selectedTools?: ToolValue[] + canChooseMCPTool?: boolean } const ToolPicker: FC = ({ @@ -48,10 +50,12 @@ const ToolPicker: FC = ({ isShow, onShowChange, onSelect, + onSelectMultiple, supportAddCustomTool, scope = 'all', selectedTools, panelClassName, + canChooseMCPTool, }) => { const { t } = useTranslation() const [searchText, setSearchText] = useState('') @@ -61,6 +65,7 @@ const ToolPicker: FC = ({ const { data: customTools } = useAllCustomTools() const invalidateCustomTools = useInvalidateAllCustomTools() const { data: workflowTools } = useAllWorkflowTools() + const { data: mcpTools } = useAllMCPTools() const { builtinToolList, customToolList, workflowToolList } = useMemo(() => { if (scope === 'plugins') { @@ -102,6 +107,10 @@ const ToolPicker: FC = ({ onSelect(tool!) } + const handleSelectMultiple = (_type: BlockEnum, tools: ToolDefaultValue[]) => { + onSelectMultiple(tools) + } + const [isShowEditCollectionToolModal, { setFalse: hideEditCustomCollectionModal, setTrue: showEditCustomCollectionModal, @@ -142,7 +151,7 @@ const ToolPicker: FC = ({ -
+
= ({ onTagsChange={setTags} size='small' placeholder={t('plugin.searchTools')!} + supportAddCustomTool={supportAddCustomTool} + onAddedCustomTool={handleAddedCustomTool} + onShowAddCustomCollectionModal={showEditCustomCollectionModal} + inputClassName='grow' + />
diff --git a/web/app/components/workflow/block-selector/tool/action-item.tsx b/web/app/components/workflow/block-selector/tool/action-item.tsx index dc9b9b9114..e5e33614b0 100644 --- a/web/app/components/workflow/block-selector/tool/action-item.tsx +++ b/web/app/components/workflow/block-selector/tool/action-item.tsx @@ -10,13 +10,12 @@ import { useGetLanguage } from '@/context/i18n' import BlockIcon from '../../block-icon' import cn from '@/utils/classnames' import { useTranslation } from 'react-i18next' -import { RiCheckLine } from '@remixicon/react' -import Badge from '@/app/components/base/badge' type Props = { provider: ToolWithProvider payload: Tool disabled?: boolean + isAdded?: boolean onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void } @@ -25,6 +24,7 @@ const ToolItem: FC = ({ payload, onSelect, disabled, + isAdded, }) => { const { t } = useTranslation() @@ -71,18 +71,16 @@ const ToolItem: FC = ({ output_schema: payload.output_schema, paramSchemas: payload.parameters, params, + meta: provider.meta, }) }} > -
{payload.label[language]}
- {disabled && - -
{t('tools.addToolModal.added')}
-
- } +
+ {payload.label[language]} +
+ {isAdded && ( +
{t('tools.addToolModal.added')}
+ )}
) diff --git a/web/app/components/workflow/block-selector/tool/tool-list-flat-view/list.tsx b/web/app/components/workflow/block-selector/tool/tool-list-flat-view/list.tsx index ef671ca1f8..ca462c082e 100644 --- a/web/app/components/workflow/block-selector/tool/tool-list-flat-view/list.tsx +++ b/web/app/components/workflow/block-selector/tool/tool-list-flat-view/list.tsx @@ -11,21 +11,29 @@ import { useMemo } from 'react' type Props = { payload: ToolWithProvider[] isShowLetterIndex: boolean + indexBar: React.ReactNode hasSearchText: boolean onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void + canNotSelectMultiple?: boolean + onSelectMultiple?: (type: BlockEnum, tools: ToolDefaultValue[]) => void letters: string[] toolRefs: any selectedTools?: ToolValue[] + canChooseMCPTool?: boolean } const ToolViewFlatView: FC = ({ letters, payload, isShowLetterIndex, + indexBar, hasSearchText, onSelect, + canNotSelectMultiple, + onSelectMultiple, toolRefs, selectedTools, + canChooseMCPTool, }) => { const firstLetterToolIds = useMemo(() => { const res: Record = {} @@ -37,26 +45,31 @@ const ToolViewFlatView: FC = ({ return res }, [payload, letters]) return ( -
- {payload.map(tool => ( -
{ - const letter = firstLetterToolIds[tool.id] - if (letter) - toolRefs.current[letter] = el - }} - > - -
- ))} +
+
+ {payload.map(tool => ( +
{ + const letter = firstLetterToolIds[tool.id] + if (letter) + toolRefs.current[letter] = el + }} + > + +
+ ))} +
+ {isShowLetterIndex && indexBar}
) } diff --git a/web/app/components/workflow/block-selector/tool/tool-list-tree-view/item.tsx b/web/app/components/workflow/block-selector/tool/tool-list-tree-view/item.tsx index d6c567f8e2..b3f7aab4df 100644 --- a/web/app/components/workflow/block-selector/tool/tool-list-tree-view/item.tsx +++ b/web/app/components/workflow/block-selector/tool/tool-list-tree-view/item.tsx @@ -12,7 +12,10 @@ type Props = { toolList: ToolWithProvider[] hasSearchText: boolean onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void + canNotSelectMultiple?: boolean + onSelectMultiple?: (type: BlockEnum, tools: ToolDefaultValue[]) => void selectedTools?: ToolValue[] + canChooseMCPTool?: boolean } const Item: FC = ({ @@ -20,7 +23,10 @@ const Item: FC = ({ toolList, hasSearchText, onSelect, + canNotSelectMultiple, + onSelectMultiple, selectedTools, + canChooseMCPTool, }) => { return (
@@ -36,7 +42,10 @@ const Item: FC = ({ isShowLetterIndex={false} hasSearchText={hasSearchText} onSelect={onSelect} + canNotSelectMultiple={canNotSelectMultiple} + onSelectMultiple={onSelectMultiple} selectedTools={selectedTools} + canChooseMCPTool={canChooseMCPTool} /> ))}
diff --git a/web/app/components/workflow/block-selector/tool/tool-list-tree-view/list.tsx b/web/app/components/workflow/block-selector/tool/tool-list-tree-view/list.tsx index f3f98279c8..d85d1ea682 100644 --- a/web/app/components/workflow/block-selector/tool/tool-list-tree-view/list.tsx +++ b/web/app/components/workflow/block-selector/tool/tool-list-tree-view/list.tsx @@ -12,14 +12,20 @@ type Props = { payload: Record hasSearchText: boolean onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void + canNotSelectMultiple?: boolean + onSelectMultiple?: (type: BlockEnum, tools: ToolDefaultValue[]) => void selectedTools?: ToolValue[] + canChooseMCPTool?: boolean } const ToolListTreeView: FC = ({ payload, hasSearchText, onSelect, + canNotSelectMultiple, + onSelectMultiple, selectedTools, + canChooseMCPTool, }) => { const { t } = useTranslation() const getI18nGroupName = useCallback((name: string) => { @@ -46,7 +52,10 @@ const ToolListTreeView: FC = ({ toolList={payload[groupName]} hasSearchText={hasSearchText} onSelect={onSelect} + canNotSelectMultiple={canNotSelectMultiple} + onSelectMultiple={onSelectMultiple} selectedTools={selectedTools} + canChooseMCPTool={canChooseMCPTool} /> ))}
diff --git a/web/app/components/workflow/block-selector/tool/tool.tsx b/web/app/components/workflow/block-selector/tool/tool.tsx index d48d0bfc90..83ae062737 100644 --- a/web/app/components/workflow/block-selector/tool/tool.tsx +++ b/web/app/components/workflow/block-selector/tool/tool.tsx @@ -1,6 +1,6 @@ 'use client' import type { FC } from 'react' -import React, { useEffect, useMemo } from 'react' +import React, { useCallback, useEffect, useMemo, useRef } from 'react' import cn from '@/utils/classnames' import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react' import { useGetLanguage } from '@/context/i18n' @@ -13,36 +13,108 @@ import { ViewType } from '../view-type-select' import ActionItem from './action-item' import BlockIcon from '../../block-icon' import { useTranslation } from 'react-i18next' +import { useHover } from 'ahooks' +import McpToolNotSupportTooltip from '../../nodes/_base/components/mcp-tool-not-support-tooltip' +import { Mcp } from '@/app/components/base/icons/src/vender/other' type Props = { className?: string payload: ToolWithProvider viewType: ViewType - isShowLetterIndex: boolean hasSearchText: boolean onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void + canNotSelectMultiple?: boolean + onSelectMultiple?: (type: BlockEnum, tools: ToolDefaultValue[]) => void selectedTools?: ToolValue[] + canChooseMCPTool?: boolean } const Tool: FC = ({ className, payload, viewType, - isShowLetterIndex, hasSearchText, onSelect, + canNotSelectMultiple, + onSelectMultiple, selectedTools, + canChooseMCPTool, }) => { const { t } = useTranslation() const language = useGetLanguage() const isFlatView = viewType === ViewType.flat + const notShowProvider = payload.type === CollectionType.workflow const actions = payload.tools - const hasAction = true // Now always support actions + const hasAction = !notShowProvider const [isFold, setFold] = React.useState(true) - const getIsDisabled = (tool: ToolType) => { + const ref = useRef(null) + const isHovering = useHover(ref) + const isMCPTool = payload.type === CollectionType.mcp + const isShowCanNotChooseMCPTip = !canChooseMCPTool && isMCPTool + const getIsDisabled = useCallback((tool: ToolType) => { if (!selectedTools || !selectedTools.length) return false - return selectedTools.some(selectedTool => selectedTool.provider_name === payload.name && selectedTool.tool_name === tool.name) - } + return selectedTools.some(selectedTool => (selectedTool.provider_name === payload.name || selectedTool.provider_name === payload.id) && selectedTool.tool_name === tool.name) + }, [payload.id, payload.name, selectedTools]) + + const totalToolsNum = actions.length + const selectedToolsNum = actions.filter(action => getIsDisabled(action)).length + const isAllSelected = selectedToolsNum === totalToolsNum + + const notShowProviderSelectInfo = useMemo(() => { + if (isAllSelected) { + return ( + + {t('tools.addToolModal.added')} + + ) + } + }, [isAllSelected, t]) + const selectedInfo = useMemo(() => { + if (isHovering && !isAllSelected) { + return ( + { + onSelectMultiple?.(BlockEnum.Tool, actions.filter(action => !getIsDisabled(action)).map((tool) => { + const params: Record = {} + if (tool.parameters) { + tool.parameters.forEach((item) => { + params[item.name] = '' + }) + } + return { + provider_id: payload.id, + provider_type: payload.type, + provider_name: payload.name, + tool_name: tool.name, + tool_label: tool.label[language], + tool_description: tool.description[language], + title: tool.label[language], + is_team_authorization: payload.is_team_authorization, + output_schema: tool.output_schema, + paramSchemas: tool.parameters, + params, + } + })) + }} + > + {t('workflow.tabs.addAll')} + + ) + } + + if (selectedToolsNum === 0) + return <> + + return ( + + {isAllSelected + ? t('workflow.tabs.allAdded') + : `${selectedToolsNum} / ${totalToolsNum}` + } + + ) + }, [actions, getIsDisabled, isAllSelected, isHovering, language, onSelectMultiple, payload.id, payload.is_team_authorization, payload.name, payload.type, selectedToolsNum, t, totalToolsNum]) + useEffect(() => { if (hasSearchText && isFold) { setFold(false) @@ -71,59 +143,73 @@ const Tool: FC = ({ return (
{ - if (hasAction) + if (hasAction) { setFold(!isFold) + return + } - // Now always support actions - // if (payload.parameters) { - // payload.parameters.forEach((item) => { - // params[item.name] = '' - // }) - // } - // onSelect(BlockEnum.Tool, { - // provider_id: payload.id, - // provider_type: payload.type, - // provider_name: payload.name, - // tool_name: payload.name, - // tool_label: payload.label[language], - // title: payload.label[language], - // params: {}, - // }) + const tool = actions[0] + const params: Record = {} + if (tool.parameters) { + tool.parameters.forEach((item) => { + params[item.name] = '' + }) + } + onSelect(BlockEnum.Tool, { + provider_id: payload.id, + provider_type: payload.type, + provider_name: payload.name, + tool_name: tool.name, + tool_label: tool.label[language], + tool_description: tool.description[language], + title: tool.label[language], + is_team_authorization: payload.is_team_authorization, + output_schema: tool.output_schema, + paramSchemas: tool.parameters, + params, + }) }} > -
+
-
{payload.label[language]}
+
+ {notShowProvider ? actions[0]?.label[language] : payload.label[language]} + {isFlatView && groupName && ( + {groupName} + )} + {isMCPTool && } +
-
- {isFlatView && ( -
{groupName}
- )} +
+ {!isShowCanNotChooseMCPTip && !canNotSelectMultiple && (notShowProvider ? notShowProviderSelectInfo : selectedInfo)} + {isShowCanNotChooseMCPTip && } {hasAction && ( - + )}
- {hasAction && !isFold && ( + {!notShowProvider && hasAction && !isFold && ( actions.map(action => ( )) )} diff --git a/web/app/components/workflow/block-selector/tools.tsx b/web/app/components/workflow/block-selector/tools.tsx index 2562501524..da47432b04 100644 --- a/web/app/components/workflow/block-selector/tools.tsx +++ b/web/app/components/workflow/block-selector/tools.tsx @@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next' import type { BlockEnum, ToolWithProvider } from '../types' import IndexBar, { groupItems } from './index-bar' import type { ToolDefaultValue, ToolValue } from './types' +import type { ToolTypeEnum } from './types' import { ViewType } from './view-type-select' import Empty from '@/app/components/tools/add-tool-modal/empty' import { useGetLanguage } from '@/context/i18n' @@ -15,25 +16,34 @@ import ToolListFlatView from './tool/tool-list-flat-view/list' import classNames from '@/utils/classnames' type ToolsProps = { - showWorkflowEmpty: boolean onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void + canNotSelectMultiple?: boolean + onSelectMultiple?: (type: BlockEnum, tools: ToolDefaultValue[]) => void tools: ToolWithProvider[] viewType: ViewType hasSearchText: boolean + toolType?: ToolTypeEnum + isAgent?: boolean className?: string indexBarClassName?: string selectedTools?: ToolValue[] + canChooseMCPTool?: boolean } const Blocks = ({ - showWorkflowEmpty, onSelect, + canNotSelectMultiple, + onSelectMultiple, tools, viewType, hasSearchText, + toolType, + isAgent, className, indexBarClassName, selectedTools, + canChooseMCPTool, }: ToolsProps) => { + // const tools: any = [] const { t } = useTranslation() const language = useGetLanguage() const isFlatView = viewType === ViewType.flat @@ -87,15 +97,15 @@ const Blocks = ({ const toolRefs = useRef({}) return ( -
+
{ - !tools.length && !showWorkflowEmpty && ( -
{t('workflow.tabs.noResult')}
+ !tools.length && hasSearchText && ( +
{t('workflow.tabs.noResult')}
) } - {!tools.length && showWorkflowEmpty && ( + {!tools.length && !hasSearchText && (
- +
)} {!!tools.length && ( @@ -107,19 +117,24 @@ const Blocks = ({ isShowLetterIndex={isShowLetterIndex} hasSearchText={hasSearchText} onSelect={onSelect} + canNotSelectMultiple={canNotSelectMultiple} + onSelectMultiple={onSelectMultiple} selectedTools={selectedTools} + canChooseMCPTool={canChooseMCPTool} + indexBar={} /> ) : ( ) )} - - {isShowLetterIndex && }
) } diff --git a/web/app/components/workflow/block-selector/types.ts b/web/app/components/workflow/block-selector/types.ts index f1bdbbfbd9..c96a60f674 100644 --- a/web/app/components/workflow/block-selector/types.ts +++ b/web/app/components/workflow/block-selector/types.ts @@ -1,3 +1,5 @@ +import type { PluginMeta } from '../../plugins/types' + export enum TabsEnum { Blocks = 'blocks', Tools = 'tools', @@ -8,6 +10,7 @@ export enum ToolTypeEnum { BuiltIn = 'built-in', Custom = 'custom', Workflow = 'workflow', + MCP = 'mcp', } export enum BlockClassificationEnum { @@ -30,10 +33,12 @@ export type ToolDefaultValue = { params: Record paramSchemas: Record[] output_schema: Record + meta?: PluginMeta } export type ToolValue = { provider_name: string + provider_show_name?: string tool_name: string tool_label: string tool_description?: string diff --git a/web/app/components/workflow/block-selector/use-check-vertical-scrollbar.ts b/web/app/components/workflow/block-selector/use-check-vertical-scrollbar.ts new file mode 100644 index 0000000000..98986cf3b6 --- /dev/null +++ b/web/app/components/workflow/block-selector/use-check-vertical-scrollbar.ts @@ -0,0 +1,31 @@ +import { useEffect, useState } from 'react' + +const useCheckVerticalScrollbar = (ref: React.RefObject) => { + const [hasVerticalScrollbar, setHasVerticalScrollbar] = useState(false) + + useEffect(() => { + const elem = ref.current + if (!elem) return + + const checkScrollbar = () => { + setHasVerticalScrollbar(elem.scrollHeight > elem.clientHeight) + } + + checkScrollbar() + + const resizeObserver = new ResizeObserver(checkScrollbar) + resizeObserver.observe(elem) + + const mutationObserver = new MutationObserver(checkScrollbar) + mutationObserver.observe(elem, { childList: true, subtree: true, characterData: true }) + + return () => { + resizeObserver.disconnect() + mutationObserver.disconnect() + } + }, [ref]) + + return hasVerticalScrollbar +} + +export default useCheckVerticalScrollbar diff --git a/web/app/components/workflow/constants.ts b/web/app/components/workflow/constants.ts index 304295cfbf..0ef4dc9dea 100644 --- a/web/app/components/workflow/constants.ts +++ b/web/app/components/workflow/constants.ts @@ -480,6 +480,10 @@ export const LLM_OUTPUT_STRUCT: Var[] = [ variable: 'text', type: VarType.string, }, + { + variable: 'usage', + type: VarType.object, + }, ] export const KNOWLEDGE_RETRIEVAL_OUTPUT_STRUCT: Var[] = [ @@ -501,6 +505,10 @@ export const QUESTION_CLASSIFIER_OUTPUT_STRUCT = [ variable: 'class_name', type: VarType.string, }, + { + variable: 'usage', + type: VarType.object, + }, ] export const HTTP_REQUEST_OUTPUT_STRUCT: Var[] = [ @@ -546,6 +554,10 @@ export const PARAMETER_EXTRACTOR_COMMON_STRUCT: Var[] = [ variable: '__reason', type: VarType.string, }, + { + variable: '__usage', + type: VarType.object, + }, ] export const FILE_STRUCT: Var[] = [ diff --git a/web/app/components/workflow/hooks-store/store.ts b/web/app/components/workflow/hooks-store/store.ts index 9f5e1a6650..4e8f74c774 100644 --- a/web/app/components/workflow/hooks-store/store.ts +++ b/web/app/components/workflow/hooks-store/store.ts @@ -7,6 +7,12 @@ import { } from 'zustand' import { createStore } from 'zustand/vanilla' import { HooksStoreContext } from './provider' +import type { IOtherOptions } from '@/service/base' +import type { VarInInspect } from '@/types/workflow' +import type { + Node, + ValueSelector, +} from '@/app/components/workflow/types' type CommonHooksFnMap = { doSyncWorkflowDraft: ( @@ -22,11 +28,30 @@ type CommonHooksFnMap = { handleBackupDraft: () => void handleLoadBackupDraft: () => void handleRestoreFromPublishedWorkflow: (...args: any[]) => void - handleRun: (...args: any[]) => void + handleRun: (params: any, callback?: IOtherOptions,) => void handleStopRun: (...args: any[]) => void handleStartWorkflowRun: () => void handleWorkflowStartRunInWorkflow: () => void handleWorkflowStartRunInChatflow: () => void + fetchInspectVars: () => Promise + hasNodeInspectVars: (nodeId: string) => boolean + hasSetInspectVar: (nodeId: string, name: string, sysVars: VarInInspect[], conversationVars: VarInInspect[]) => boolean + fetchInspectVarValue: (selector: ValueSelector) => Promise + editInspectVarValue: (nodeId: string, varId: string, value: any) => Promise + renameInspectVarName: (nodeId: string, oldName: string, newName: string) => Promise + appendNodeInspectVars: (nodeId: string, payload: VarInInspect[], allNodes: Node[]) => void + deleteInspectVar: (nodeId: string, varId: string) => Promise + deleteNodeInspectorVars: (nodeId: string) => Promise + deleteAllInspectorVars: () => Promise + isInspectVarEdited: (nodeId: string, name: string) => boolean + resetToLastRunVar: (nodeId: string, varId: string) => Promise + invalidateSysVarValues: () => void + resetConversationVar: (varId: string) => Promise + invalidateConversationVarValues: () => void + configsMap?: { + conversationVarsUrl: string + systemVarsUrl: string + } } export type Shape = { @@ -45,6 +70,21 @@ export const createHooksStore = ({ handleStartWorkflowRun = noop, handleWorkflowStartRunInWorkflow = noop, handleWorkflowStartRunInChatflow = noop, + fetchInspectVars = async () => noop(), + hasNodeInspectVars = () => false, + hasSetInspectVar = () => false, + fetchInspectVarValue = async () => noop(), + editInspectVarValue = async () => noop(), + renameInspectVarName = async () => noop(), + appendNodeInspectVars = () => noop(), + deleteInspectVar = async () => noop(), + deleteNodeInspectorVars = async () => noop(), + deleteAllInspectorVars = async () => noop(), + isInspectVarEdited = () => false, + resetToLastRunVar = async () => noop(), + invalidateSysVarValues = noop, + resetConversationVar = async () => noop(), + invalidateConversationVarValues = noop, }: Partial) => { return createStore(set => ({ refreshAll: props => set(state => ({ ...state, ...props })), @@ -59,6 +99,21 @@ export const createHooksStore = ({ handleStartWorkflowRun, handleWorkflowStartRunInWorkflow, handleWorkflowStartRunInChatflow, + fetchInspectVars, + hasNodeInspectVars, + hasSetInspectVar, + fetchInspectVarValue, + editInspectVarValue, + renameInspectVarName, + appendNodeInspectVars, + deleteInspectVar, + deleteNodeInspectorVars, + deleteAllInspectorVars, + isInspectVarEdited, + resetToLastRunVar, + invalidateSysVarValues, + resetConversationVar, + invalidateConversationVarValues, })) } diff --git a/web/app/components/workflow/hooks/index.ts b/web/app/components/workflow/hooks/index.ts index fda0f50aa6..c2eebb0533 100644 --- a/web/app/components/workflow/hooks/index.ts +++ b/web/app/components/workflow/hooks/index.ts @@ -17,3 +17,5 @@ export * from './use-workflow-interactions' export * from './use-workflow-mode' export * from './use-format-time-from-now' export * from './use-workflow-refresh-draft' +export * from './use-inspect-vars-crud' +export * from './use-set-workflow-vars-with-value' diff --git a/web/app/components/workflow/hooks/use-checklist.ts b/web/app/components/workflow/hooks/use-checklist.ts index 200bee1d26..47ebaa6710 100644 --- a/web/app/components/workflow/hooks/use-checklist.ts +++ b/web/app/components/workflow/hooks/use-checklist.ts @@ -9,6 +9,7 @@ import type { CommonNodeType, Edge, Node, + ValueSelector, } from '../types' import { BlockEnum } from '../types' import { useStore } from '../store' @@ -33,6 +34,8 @@ import type { KnowledgeRetrievalNodeType } from '../nodes/knowledge-retrieval/ty import type { DataSet } from '@/models/datasets' import { fetchDatasets } from '@/service/datasets' import { MAX_TREE_DEPTH } from '@/config' +import useNodesAvailableVarList from './use-nodes-available-var-list' +import { getNodeUsedVars, isConversationVar, isENV, isSystemVar } from '../nodes/_base/components/variable/utils' export const useChecklist = (nodes: Node[], edges: Edge[]) => { const { t } = useTranslation() @@ -45,6 +48,8 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => { const { data: strategyProviders } = useStrategyProviders() const datasetsDetail = useDatasetsDetailStore(s => s.datasetsDetail) + const map = useNodesAvailableVarList(nodes) + const getCheckData = useCallback((data: CommonNodeType<{}>) => { let checkData = data if (data.type === BlockEnum.KnowledgeRetrieval) { @@ -70,6 +75,7 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => { const node = nodes[i] let toolIcon let moreDataForCheckValid + let usedVars: ValueSelector[] = [] if (node.data.type === BlockEnum.Tool) { const { provider_type } = node.data @@ -84,8 +90,7 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => { if (provider_type === CollectionType.workflow) toolIcon = workflowTools.find(tool => tool.id === node.data.provider_id)?.icon } - - if (node.data.type === BlockEnum.Agent) { + else if (node.data.type === BlockEnum.Agent) { const data = node.data as AgentNodeType const isReadyForCheckValid = !!strategyProviders const provider = strategyProviders?.find(provider => provider.declaration.identity.name === data.agent_strategy_provider_name) @@ -97,10 +102,34 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => { isReadyForCheckValid, } } + else { + usedVars = getNodeUsedVars(node).filter(v => v.length > 0) + } if (node.type === CUSTOM_NODE) { const checkData = getCheckData(node.data) - const { errorMessage } = nodesExtraData[node.data.type].checkValid(checkData, t, moreDataForCheckValid) + let { errorMessage } = nodesExtraData[node.data.type].checkValid(checkData, t, moreDataForCheckValid) + + if (!errorMessage) { + const availableVars = map[node.id].availableVars + + for (const variable of usedVars) { + const isEnv = isENV(variable) + const isConvVar = isConversationVar(variable) + const isSysVar = isSystemVar(variable) + if (!isEnv && !isConvVar && !isSysVar) { + const usedNode = availableVars.find(v => v.nodeId === variable?.[0]) + if (usedNode) { + const usedVar = usedNode.vars.find(v => v.variable === variable?.[1]) + if (!usedVar) + errorMessage = t('workflow.errorMsg.invalidVariable') + } + else { + errorMessage = t('workflow.errorMsg.invalidVariable') + } + } + } + } if (errorMessage || !validNodes.find(n => n.id === node.id)) { list.push({ diff --git a/web/app/components/workflow/hooks/use-inspect-vars-crud.ts b/web/app/components/workflow/hooks/use-inspect-vars-crud.ts index e4daaf8220..50188185c2 100644 --- a/web/app/components/workflow/hooks/use-inspect-vars-crud.ts +++ b/web/app/components/workflow/hooks/use-inspect-vars-crud.ts @@ -1,215 +1,29 @@ -import { fetchNodeInspectVars } from '@/service/workflow' -import { useStore, useWorkflowStore } from '../store' -import type { ValueSelector } from '../types' -import type { VarInInspect } from '@/types/workflow' -import { VarInInspectType } from '@/types/workflow' +import { useStore } from '../store' +import { useHooksStore } from '@/app/components/workflow/hooks-store' import { useConversationVarValues, - useDeleteAllInspectorVars, - useDeleteInspectVar, - useDeleteNodeInspectorVars, - useEditInspectorVar, - useInvalidateConversationVarValues, - useInvalidateSysVarValues, - useResetConversationVar, - useResetToLastRunValue, useSysVarValues, } from '@/service/use-workflow' -import { useCallback } from 'react' -import { isConversationVar, isENV, isSystemVar } from '../nodes/_base/components/variable/utils' -import produce from 'immer' -import type { Node } from '@/app/components/workflow/types' -import { useNodesInteractionsWithoutSync } from './use-nodes-interactions-without-sync' -import { useEdgesInteractionsWithoutSync } from './use-edges-interactions-without-sync' const useInspectVarsCrud = () => { - const workflowStore = useWorkflowStore() const nodesWithInspectVars = useStore(s => s.nodesWithInspectVars) - const { - appId, - setNodeInspectVars, - setInspectVarValue, - renameInspectVarName: renameInspectVarNameInStore, - deleteAllInspectVars: deleteAllInspectVarsInStore, - deleteNodeInspectVars: deleteNodeInspectVarsInStore, - deleteInspectVar: deleteInspectVarInStore, - setNodesWithInspectVars, - resetToLastRunVar: resetToLastRunVarInStore, - } = workflowStore.getState() - - const { data: conversationVars } = useConversationVarValues(appId) - const invalidateConversationVarValues = useInvalidateConversationVarValues(appId) - const { mutateAsync: doResetConversationVar } = useResetConversationVar(appId) - const { mutateAsync: doResetToLastRunValue } = useResetToLastRunValue(appId) - const { data: systemVars } = useSysVarValues(appId) - const invalidateSysVarValues = useInvalidateSysVarValues(appId) - - const { mutateAsync: doDeleteAllInspectorVars } = useDeleteAllInspectorVars(appId) - const { mutate: doDeleteNodeInspectorVars } = useDeleteNodeInspectorVars(appId) - const { mutate: doDeleteInspectVar } = useDeleteInspectVar(appId) - - const { mutateAsync: doEditInspectorVar } = useEditInspectorVar(appId) - const { handleCancelNodeSuccessStatus } = useNodesInteractionsWithoutSync() - const { handleEdgeCancelRunningStatus } = useEdgesInteractionsWithoutSync() - const getNodeInspectVars = useCallback((nodeId: string) => { - const node = nodesWithInspectVars.find(node => node.nodeId === nodeId) - return node - }, [nodesWithInspectVars]) - - const getVarId = useCallback((nodeId: string, varName: string) => { - const node = getNodeInspectVars(nodeId) - if (!node) - return undefined - const varId = node.vars.find((varItem) => { - return varItem.selector[1] === varName - })?.id - return varId - }, [getNodeInspectVars]) - - const getInspectVar = useCallback((nodeId: string, name: string): VarInInspect | undefined => { - const node = getNodeInspectVars(nodeId) - if (!node) - return undefined - - const variable = node.vars.find((varItem) => { - return varItem.name === name - }) - return variable - }, [getNodeInspectVars]) - - const hasSetInspectVar = useCallback((nodeId: string, name: string, sysVars: VarInInspect[], conversationVars: VarInInspect[]) => { - const isEnv = isENV([nodeId]) - if (isEnv) // always have value - return true - const isSys = isSystemVar([nodeId]) - if (isSys) - return sysVars.some(varItem => varItem.selector?.[1]?.replace('sys.', '') === name) - const isChatVar = isConversationVar([nodeId]) - if (isChatVar) - return conversationVars.some(varItem => varItem.selector?.[1] === name) - return getInspectVar(nodeId, name) !== undefined - }, [getInspectVar]) - - const hasNodeInspectVars = useCallback((nodeId: string) => { - return !!getNodeInspectVars(nodeId) - }, [getNodeInspectVars]) - - const fetchInspectVarValue = async (selector: ValueSelector) => { - const nodeId = selector[0] - const isSystemVar = nodeId === 'sys' - const isConversationVar = nodeId === 'conversation' - if (isSystemVar) { - invalidateSysVarValues() - return - } - if (isConversationVar) { - invalidateConversationVarValues() - return - } - const vars = await fetchNodeInspectVars(appId, nodeId) - setNodeInspectVars(nodeId, vars) - } - - // after last run would call this - const appendNodeInspectVars = (nodeId: string, payload: VarInInspect[], allNodes: Node[]) => { - const nodes = produce(nodesWithInspectVars, (draft) => { - const nodeInfo = allNodes.find(node => node.id === nodeId) - if (nodeInfo) { - const index = draft.findIndex(node => node.nodeId === nodeId) - if (index === -1) { - draft.unshift({ - nodeId, - nodeType: nodeInfo.data.type, - title: nodeInfo.data.title, - vars: payload, - nodePayload: nodeInfo.data, - }) - } - else { - draft[index].vars = payload - } - } - }) - setNodesWithInspectVars(nodes) - handleCancelNodeSuccessStatus(nodeId) - } - - const hasNodeInspectVar = (nodeId: string, varId: string) => { - const targetNode = nodesWithInspectVars.find(item => item.nodeId === nodeId) - if(!targetNode || !targetNode.vars) - return false - return targetNode.vars.some(item => item.id === varId) - } - - const deleteInspectVar = async (nodeId: string, varId: string) => { - if(hasNodeInspectVar(nodeId, varId)) { - await doDeleteInspectVar(varId) - deleteInspectVarInStore(nodeId, varId) - } - } - - const resetConversationVar = async (varId: string) => { - await doResetConversationVar(varId) - invalidateConversationVarValues() - } - - const deleteNodeInspectorVars = async (nodeId: string) => { - if (hasNodeInspectVars(nodeId)) { - await doDeleteNodeInspectorVars(nodeId) - deleteNodeInspectVarsInStore(nodeId) - } - } - - const deleteAllInspectorVars = async () => { - await doDeleteAllInspectorVars() - await invalidateConversationVarValues() - await invalidateSysVarValues() - deleteAllInspectVarsInStore() - handleEdgeCancelRunningStatus() - } - - const editInspectVarValue = useCallback(async (nodeId: string, varId: string, value: any) => { - await doEditInspectorVar({ - varId, - value, - }) - setInspectVarValue(nodeId, varId, value) - if (nodeId === VarInInspectType.conversation) - invalidateConversationVarValues() - if (nodeId === VarInInspectType.system) - invalidateSysVarValues() - }, [doEditInspectorVar, invalidateConversationVarValues, invalidateSysVarValues, setInspectVarValue]) - - const renameInspectVarName = async (nodeId: string, oldName: string, newName: string) => { - const varId = getVarId(nodeId, oldName) - if (!varId) - return - - const newSelector = [nodeId, newName] - await doEditInspectorVar({ - varId, - name: newName, - }) - renameInspectVarNameInStore(nodeId, varId, newSelector) - } - - const isInspectVarEdited = useCallback((nodeId: string, name: string) => { - const inspectVar = getInspectVar(nodeId, name) - if (!inspectVar) - return false - - return inspectVar.edited - }, [getInspectVar]) - - const resetToLastRunVar = async (nodeId: string, varId: string) => { - const isSysVar = nodeId === 'sys' - const data = await doResetToLastRunValue(varId) - - if(isSysVar) - invalidateSysVarValues() - else - resetToLastRunVarInStore(nodeId, varId, data.value) - } + const configsMap = useHooksStore(s => s.configsMap) + const { data: conversationVars } = useConversationVarValues(configsMap?.conversationVarsUrl) + const { data: systemVars } = useSysVarValues(configsMap?.systemVarsUrl) + const hasNodeInspectVars = useHooksStore(s => s.hasNodeInspectVars) + const hasSetInspectVar = useHooksStore(s => s.hasSetInspectVar) + const fetchInspectVarValue = useHooksStore(s => s.fetchInspectVarValue) + const editInspectVarValue = useHooksStore(s => s.editInspectVarValue) + const renameInspectVarName = useHooksStore(s => s.renameInspectVarName) + const appendNodeInspectVars = useHooksStore(s => s.appendNodeInspectVars) + const deleteInspectVar = useHooksStore(s => s.deleteInspectVar) + const deleteNodeInspectorVars = useHooksStore(s => s.deleteNodeInspectorVars) + const deleteAllInspectorVars = useHooksStore(s => s.deleteAllInspectorVars) + const isInspectVarEdited = useHooksStore(s => s.isInspectVarEdited) + const resetToLastRunVar = useHooksStore(s => s.resetToLastRunVar) + const invalidateSysVarValues = useHooksStore(s => s.invalidateSysVarValues) + const resetConversationVar = useHooksStore(s => s.resetConversationVar) + const invalidateConversationVarValues = useHooksStore(s => s.invalidateConversationVarValues) return { conversationVars: conversationVars || [], diff --git a/web/app/components/workflow/hooks/use-nodes-available-var-list.ts b/web/app/components/workflow/hooks/use-nodes-available-var-list.ts new file mode 100644 index 0000000000..5efe2519ef --- /dev/null +++ b/web/app/components/workflow/hooks/use-nodes-available-var-list.ts @@ -0,0 +1,75 @@ +import { + useIsChatMode, + useWorkflow, + useWorkflowVariables, +} from '@/app/components/workflow/hooks' +import { BlockEnum, type Node, type NodeOutPutVar, type ValueSelector, type Var } from '@/app/components/workflow/types' +type Params = { + onlyLeafNodeVar?: boolean + hideEnv?: boolean + hideChatVar?: boolean + filterVar: (payload: Var, selector: ValueSelector) => boolean + passedInAvailableNodes?: Node[] +} + +const getNodeInfo = (nodeId: string, nodes: Node[]) => { + const allNodes = nodes + const node = allNodes.find(n => n.id === nodeId) + const isInIteration = !!node?.data.isInIteration + const isInLoop = !!node?.data.isInLoop + const parentNodeId = node?.parentId + const parentNode = allNodes.find(n => n.id === parentNodeId) + return { + node, + isInIteration, + isInLoop, + parentNode, + } +} + +// TODO: loop type? +const useNodesAvailableVarList = (nodes: Node[], { + onlyLeafNodeVar, + filterVar, + hideEnv = false, + hideChatVar = false, + passedInAvailableNodes, +}: Params = { + onlyLeafNodeVar: false, + filterVar: () => true, + }) => { + const { getTreeLeafNodes, getBeforeNodesInSameBranchIncludeParent } = useWorkflow() + const { getNodeAvailableVars } = useWorkflowVariables() + const isChatMode = useIsChatMode() + + const nodeAvailabilityMap: { [key: string ]: { availableVars: NodeOutPutVar[], availableNodes: Node[] } } = {} + + nodes.forEach((node) => { + const nodeId = node.id + const availableNodes = passedInAvailableNodes || (onlyLeafNodeVar ? getTreeLeafNodes(nodeId) : getBeforeNodesInSameBranchIncludeParent(nodeId)) + if (node.data.type === BlockEnum.Loop) + availableNodes.push(node) + + const { + parentNode: iterationNode, + } = getNodeInfo(nodeId, nodes) + + const availableVars = getNodeAvailableVars({ + parentNode: iterationNode, + beforeNodes: availableNodes, + isChatMode, + filterVar, + hideEnv, + hideChatVar, + }) + const result = { + node, + availableVars, + availableNodes, + } + nodeAvailabilityMap[nodeId] = result + }) + return nodeAvailabilityMap +} + +export default useNodesAvailableVarList diff --git a/web/app/components/workflow/hooks/use-nodes-data.ts b/web/app/components/workflow/hooks/use-nodes-data.ts index aeb45ddb93..7df6b2ffd0 100644 --- a/web/app/components/workflow/hooks/use-nodes-data.ts +++ b/web/app/components/workflow/hooks/use-nodes-data.ts @@ -34,15 +34,14 @@ export const useNodesExtraData = () => { export const useAvailableBlocks = (nodeType?: BlockEnum, isInIteration?: boolean, isInLoop?: boolean) => { const nodesExtraData = useNodesExtraData() const availablePrevBlocks = useMemo(() => { - if (!nodeType) + if (!nodeType || !nodesExtraData[nodeType]) return [] return nodesExtraData[nodeType].availablePrevNodes || [] }, [nodeType, nodesExtraData]) const availableNextBlocks = useMemo(() => { - if (!nodeType) + if (!nodeType || !nodesExtraData[nodeType]) return [] - return nodesExtraData[nodeType].availableNextNodes || [] }, [nodeType, nodesExtraData]) @@ -55,10 +54,7 @@ export const useAvailableBlocks = (nodeType?: BlockEnum, isInIteration?: boolean if (isInLoop && (nType === BlockEnum.Iteration || nType === BlockEnum.Loop || nType === BlockEnum.End)) return false - if (!isInLoop && nType === BlockEnum.LoopEnd) - return false - - return true + return !(!isInLoop && nType === BlockEnum.LoopEnd) }), availableNextBlocks: availableNextBlocks.filter((nType) => { if (isInIteration && (nType === BlockEnum.Iteration || nType === BlockEnum.Loop || nType === BlockEnum.End)) @@ -67,10 +63,7 @@ export const useAvailableBlocks = (nodeType?: BlockEnum, isInIteration?: boolean if (isInLoop && (nType === BlockEnum.Iteration || nType === BlockEnum.Loop || nType === BlockEnum.End)) return false - if (!isInLoop && nType === BlockEnum.LoopEnd) - return false - - return true + return !(!isInLoop && nType === BlockEnum.LoopEnd) }), } }, [isInIteration, availablePrevBlocks, availableNextBlocks, isInLoop]) diff --git a/web/app/components/workflow/hooks/use-set-workflow-vars-with-value.ts b/web/app/components/workflow/hooks/use-set-workflow-vars-with-value.ts new file mode 100644 index 0000000000..a04c2de305 --- /dev/null +++ b/web/app/components/workflow/hooks/use-set-workflow-vars-with-value.ts @@ -0,0 +1,9 @@ +import { useHooksStore } from '@/app/components/workflow/hooks-store' + +export const useSetWorkflowVarsWithValue = () => { + const fetchInspectVars = useHooksStore(s => s.fetchInspectVars) + + return { + fetchInspectVars, + } +} diff --git a/web/app/components/workflow/hooks/use-workflow.ts b/web/app/components/workflow/hooks/use-workflow.ts index 1b98178152..8bc9d3436f 100644 --- a/web/app/components/workflow/hooks/use-workflow.ts +++ b/web/app/components/workflow/hooks/use-workflow.ts @@ -40,6 +40,7 @@ import { useStore as useAppStore } from '@/app/components/app/store' import { fetchAllBuiltInTools, fetchAllCustomTools, + fetchAllMCPTools, fetchAllWorkflowTools, } from '@/service/tools' import { CollectionType } from '@/app/components/tools/types' @@ -445,6 +446,13 @@ export const useFetchToolsData = () => { workflowTools: workflowTools || [], }) } + if(type === 'mcp') { + const mcpTools = await fetchAllMCPTools() + + workflowStore.setState({ + mcpTools: mcpTools || [], + }) + } }, [workflowStore]) return { @@ -491,6 +499,8 @@ export const useToolIcon = (data: Node['data']) => { const buildInTools = useStore(s => s.buildInTools) const customTools = useStore(s => s.customTools) const workflowTools = useStore(s => s.workflowTools) + const mcpTools = useStore(s => s.mcpTools) + const toolIcon = useMemo(() => { if(!data) return '' @@ -500,11 +510,13 @@ export const useToolIcon = (data: Node['data']) => { targetTools = buildInTools else if (data.provider_type === CollectionType.custom) targetTools = customTools + else if (data.provider_type === CollectionType.mcp) + targetTools = mcpTools else targetTools = workflowTools return targetTools.find(toolWithProvider => canFindTool(toolWithProvider.id, data.provider_id))?.icon } - }, [data, buildInTools, customTools, workflowTools]) + }, [data, buildInTools, customTools, mcpTools, workflowTools]) return toolIcon } diff --git a/web/app/components/workflow/index.tsx b/web/app/components/workflow/index.tsx index 429d07853d..8ea861ebb4 100644 --- a/web/app/components/workflow/index.tsx +++ b/web/app/components/workflow/index.tsx @@ -42,6 +42,7 @@ import { useNodesSyncDraft, usePanelInteractions, useSelectionInteractions, + useSetWorkflowVarsWithValue, useShortcuts, useWorkflow, useWorkflowReadOnly, @@ -82,7 +83,6 @@ import Confirm from '@/app/components/base/confirm' import DatasetsDetailProvider from './datasets-detail-store/provider' import { HooksStoreContextProvider } from './hooks-store' import type { Shape as HooksStoreShape } from './hooks-store' -import useSetWorkflowVarsWithValue from '../workflow-app/hooks/use-fetch-workflow-inspect-vars' const nodeTypes = { [CUSTOM_NODE]: CustomNode, @@ -234,6 +234,7 @@ export const Workflow: FC = memo(({ handleFetchAllTools('builtin') handleFetchAllTools('custom') handleFetchAllTools('workflow') + handleFetchAllTools('mcp') }, [handleFetchAllTools]) const { diff --git a/web/app/components/workflow/nodes/_base/components/agent-strategy-selector.tsx b/web/app/components/workflow/nodes/_base/components/agent-strategy-selector.tsx index dd6a1c6a22..ba5281870f 100644 --- a/web/app/components/workflow/nodes/_base/components/agent-strategy-selector.tsx +++ b/web/app/components/workflow/nodes/_base/components/agent-strategy-selector.tsx @@ -22,6 +22,7 @@ import type { ListRef } from '@/app/components/workflow/block-selector/market-pl import PluginList, { type ListProps } from '@/app/components/workflow/block-selector/market-place-plugin/list' import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/hooks' import { ToolTipContent } from '@/app/components/base/tooltip/content' +import { useGlobalPublicStore } from '@/context/global-public-context' const DEFAULT_TAGS: ListProps['tags'] = [] @@ -67,6 +68,7 @@ function formatStrategy(input: StrategyPluginDetail[], getIcon: (i: string) => s icon: getIcon(item.declaration.identity.icon), label: item.declaration.identity.label as any, type: CollectionType.all, + meta: item.meta, tools: item.declaration.strategies.map(strategy => ({ name: strategy.identity.name, author: strategy.identity.author, @@ -88,10 +90,13 @@ function formatStrategy(input: StrategyPluginDetail[], getIcon: (i: string) => s export type AgentStrategySelectorProps = { value?: Strategy, onChange: (value?: Strategy) => void, + canChooseMCPTool: boolean, } export const AgentStrategySelector = memo((props: AgentStrategySelectorProps) => { - const { value, onChange } = props + const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures) + + const { value, onChange, canChooseMCPTool } = props const [open, setOpen] = useState(false) const [viewType, setViewType] = useState(ViewType.flat) const [query, setQuery] = useState('') @@ -132,6 +137,7 @@ export const AgentStrategySelector = memo((props: AgentStrategySelectorProps) => } = useMarketplacePlugins() useEffect(() => { + if (!enable_marketplace) return if (query) { fetchPlugins({ query, @@ -158,7 +164,7 @@ export const AgentStrategySelector = memo((props: AgentStrategySelectorProps) => alt='icon' />
}

{value?.agent_strategy_label || t('workflow.nodes.agent.strategy.selectTip')}

@@ -210,19 +216,25 @@ export const AgentStrategySelector = memo((props: AgentStrategySelectorProps) => agent_strategy_label: tool!.tool_label, agent_output_schema: tool!.output_schema, plugin_unique_identifier: tool!.provider_id, + meta: tool!.meta, }) setOpen(false) }} className='h-full max-h-full max-w-none overflow-y-auto' - indexBarClassName='top-0 xl:top-36' showWorkflowEmpty={false} hasSearchText={false} /> - + {enable_marketplace && + />}
diff --git a/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx b/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx index 4ca8746137..31aa91cfdb 100644 --- a/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx +++ b/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx @@ -19,6 +19,8 @@ import { useWorkflowStore } from '../../../store' import { useRenderI18nObject } from '@/hooks/use-i18n' import type { NodeOutPutVar } from '../../../types' import type { Node } from 'reactflow' +import type { PluginMeta } from '@/app/components/plugins/types' +import { noop } from 'lodash' import { useDocLink } from '@/context/i18n' export type Strategy = { @@ -27,6 +29,7 @@ export type Strategy = { agent_strategy_label: string agent_output_schema: Record plugin_unique_identifier: string + meta?: PluginMeta } export type AgentStrategyProps = { @@ -38,6 +41,7 @@ export type AgentStrategyProps = { nodeOutputVars?: NodeOutPutVar[], availableNodes?: Node[], nodeId?: string + canChooseMCPTool: boolean } type CustomSchema = Omit & { type: Type } & Field @@ -48,7 +52,7 @@ type MultipleToolSelectorSchema = CustomSchema<'array[tools]'> type CustomField = ToolSelectorSchema | MultipleToolSelectorSchema export const AgentStrategy = memo((props: AgentStrategyProps) => { - const { strategy, onStrategyChange, formSchema, formValue, onFormValueChange, nodeOutputVars, availableNodes, nodeId } = props + const { strategy, onStrategyChange, formSchema, formValue, onFormValueChange, nodeOutputVars, availableNodes, nodeId, canChooseMCPTool } = props const { t } = useTranslation() const docLink = useDocLink() const defaultModel = useDefaultModel(ModelTypeEnum.textGeneration) @@ -57,6 +61,7 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => { const { setControlPromptEditorRerenderKey, } = workflowStore.getState() + const override: ComponentProps>['override'] = [ [FormTypeEnum.textNumber, FormTypeEnum.textInput], (schema, props) => { @@ -168,6 +173,8 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => { value={value} onSelect={item => onChange(item)} onDelete={() => onChange(null)} + canChooseMCPTool={canChooseMCPTool} + onSelectMultiple={noop} /> ) @@ -189,13 +196,14 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => { onChange={onChange} supportCollapse required={schema.required} + canChooseMCPTool={canChooseMCPTool} /> ) } } } return
- + { strategy ?
@@ -215,6 +223,7 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => { nodeId={nodeId} nodeOutputVars={nodeOutputVars || []} availableNodes={availableNodes || []} + canChooseMCPTool={canChooseMCPTool} />
: = ({ return ( -
+
{title}
{ diff --git a/web/app/components/workflow/nodes/_base/components/editor/code-editor/index.tsx b/web/app/components/workflow/nodes/_base/components/editor/code-editor/index.tsx index 3540c60a39..748698747c 100644 --- a/web/app/components/workflow/nodes/_base/components/editor/code-editor/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/editor/code-editor/index.tsx @@ -23,7 +23,7 @@ export type Props = { value?: string | object placeholder?: React.JSX.Element | string onChange?: (value: string) => void - title?: React.JSX.Element + title?: string | React.JSX.Element language: CodeLanguage headerRight?: React.JSX.Element readOnly?: boolean diff --git a/web/app/components/workflow/nodes/_base/components/form-input-boolean.tsx b/web/app/components/workflow/nodes/_base/components/form-input-boolean.tsx new file mode 100644 index 0000000000..07c3a087b9 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/form-input-boolean.tsx @@ -0,0 +1,35 @@ +'use client' +import type { FC } from 'react' +import cn from '@/utils/classnames' + +type Props = { + value: boolean + onChange: (value: boolean) => void +} + +const FormInputBoolean: FC = ({ + value, + onChange, +}) => { + return ( +
+
onChange(true)} + >True
+
onChange(false)} + >False
+
+ ) +} +export default FormInputBoolean diff --git a/web/app/components/workflow/nodes/_base/components/form-input-item.tsx b/web/app/components/workflow/nodes/_base/components/form-input-item.tsx new file mode 100644 index 0000000000..6f8bd17a96 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/form-input-item.tsx @@ -0,0 +1,279 @@ +'use client' +import type { FC } from 'react' +import type { ToolVarInputs } from '@/app/components/workflow/nodes/tool/types' +import type { CredentialFormSchema } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' +import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types' +import { VarType } from '@/app/components/workflow/types' + +import type { ToolWithProvider, ValueSelector, Var } from '@/app/components/workflow/types' +import FormInputTypeSwitch from './form-input-type-switch' +import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list' +import Input from '@/app/components/base/input' +import { SimpleSelect } from '@/app/components/base/select' +import MixedVariableTextInput from '@/app/components/workflow/nodes/tool/components/mixed-variable-text-input' +import FormInputBoolean from './form-input-boolean' +import AppSelector from '@/app/components/plugins/plugin-detail-panel/app-selector' +import ModelParameterModal from '@/app/components/plugins/plugin-detail-panel/model-selector' +import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker' +import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor' +import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' +import cn from '@/utils/classnames' +import type { Tool } from '@/app/components/tools/types' + +type Props = { + readOnly: boolean + nodeId: string + schema: CredentialFormSchema + value: ToolVarInputs + onChange: (value: any) => void + inPanel?: boolean + currentTool?: Tool + currentProvider?: ToolWithProvider +} + +const FormInputItem: FC = ({ + readOnly, + nodeId, + schema, + value, + onChange, + inPanel, + currentTool, + currentProvider, +}) => { + const language = useLanguage() + + const { + placeholder, + variable, + type, + default: defaultValue, + options, + scope, + } = schema as any + const varInput = value[variable] + const isString = type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput + const isNumber = type === FormTypeEnum.textNumber + const isObject = type === FormTypeEnum.object + const isArray = type === FormTypeEnum.array + const isShowJSONEditor = isObject || isArray + const isFile = type === FormTypeEnum.file || type === FormTypeEnum.files + const isBoolean = type === FormTypeEnum.boolean + const isSelect = type === FormTypeEnum.select || type === FormTypeEnum.dynamicSelect + const isAppSelector = type === FormTypeEnum.appSelector + const isModelSelector = type === FormTypeEnum.modelSelector + const showTypeSwitch = isNumber || isObject || isArray + const isConstant = varInput?.type === VarKindType.constant || !varInput?.type + const showVariableSelector = isFile || varInput?.type === VarKindType.variable + + const { availableVars, availableNodesWithParent } = useAvailableVarList(nodeId, { + onlyLeafNodeVar: false, + filterVar: (varPayload: Var) => { + return [VarType.string, VarType.number, VarType.secret].includes(varPayload.type) + }, + }) + + const targetVarType = () => { + if (isString) + return VarType.string + else if (isNumber) + return VarType.number + else if (type === FormTypeEnum.files) + return VarType.arrayFile + else if (type === FormTypeEnum.file) + return VarType.file + // else if (isSelect) + // return VarType.select + // else if (isAppSelector) + // return VarType.appSelector + // else if (isModelSelector) + // return VarType.modelSelector + // else if (isBoolean) + // return VarType.boolean + else if (isObject) + return VarType.object + else if (isArray) + return VarType.arrayObject + else + return VarType.string + } + + const getFilterVar = () => { + if (isNumber) + return (varPayload: any) => varPayload.type === VarType.number + else if (isString) + return (varPayload: any) => [VarType.string, VarType.number, VarType.secret].includes(varPayload.type) + else if (isFile) + return (varPayload: any) => [VarType.file, VarType.arrayFile].includes(varPayload.type) + else if (isBoolean) + return (varPayload: any) => varPayload.type === VarType.boolean + else if (isObject) + return (varPayload: any) => varPayload.type === VarType.object + else if (isArray) + return (varPayload: any) => [VarType.array, VarType.arrayString, VarType.arrayNumber, VarType.arrayObject].includes(varPayload.type) + return undefined + } + + const getVarKindType = () => { + if (isFile) + return VarKindType.variable + if (isSelect || isBoolean || isNumber || isArray || isObject) + return VarKindType.constant + if (isString) + return VarKindType.mixed + } + + const handleTypeChange = (newType: string) => { + if (newType === VarKindType.variable) { + onChange({ + ...value, + [variable]: { + ...varInput, + type: VarKindType.variable, + value: '', + }, + }) + } + else { + onChange({ + ...value, + [variable]: { + ...varInput, + type: VarKindType.constant, + value: defaultValue, + }, + }) + } + } + + const handleValueChange = (newValue: any) => { + onChange({ + ...value, + [variable]: { + ...varInput, + type: getVarKindType(), + value: isNumber ? Number.parseFloat(newValue) : newValue, + }, + }) + } + + const handleAppOrModelSelect = (newValue: any) => { + onChange({ + ...value, + [variable]: { + ...varInput, + ...newValue, + }, + }) + } + + const handleVariableSelectorChange = (newValue: ValueSelector | string, variable: string) => { + onChange({ + ...value, + [variable]: { + ...varInput, + type: VarKindType.variable, + value: newValue || '', + }, + }) + } + + return ( +
+ {showTypeSwitch && ( + + )} + {isString && ( + + )} + {isNumber && isConstant && ( + handleValueChange(e.target.value)} + placeholder={placeholder?.[language] || placeholder?.en_US} + /> + )} + {isBoolean && ( + + )} + {isSelect && ( + { + if (option.show_on.length) + return option.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value) + + return true + }).map((option: { value: any; label: { [x: string]: any; en_US: any } }) => ({ value: option.value, name: option.label[language] || option.label.en_US }))} + onSelect={item => handleValueChange(item.value as string)} + placeholder={placeholder?.[language] || placeholder?.en_US} + /> + )} + {isShowJSONEditor && isConstant && ( +
+ {placeholder?.[language] || placeholder?.en_US}
} + /> +
+ )} + {isAppSelector && ( + + )} + {isModelSelector && isConstant && ( + + )} + {showVariableSelector && ( + handleVariableSelectorChange(value, variable)} + filterVar={getFilterVar()} + schema={schema} + valueTypePlaceHolder={targetVarType()} + currentTool={currentTool} + currentProvider={currentProvider} + /> + )} +
+ ) +} +export default FormInputItem diff --git a/web/app/components/workflow/nodes/_base/components/form-input-type-switch.tsx b/web/app/components/workflow/nodes/_base/components/form-input-type-switch.tsx new file mode 100644 index 0000000000..391e204844 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/form-input-type-switch.tsx @@ -0,0 +1,47 @@ +'use client' +import type { FC } from 'react' +import { useTranslation } from 'react-i18next' +import { + RiEditLine, +} from '@remixicon/react' +import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development' +import Tooltip from '@/app/components/base/tooltip' +import { VarType } from '@/app/components/workflow/nodes/tool/types' +import cn from '@/utils/classnames' + +type Props = { + value: VarType + onChange: (value: VarType) => void +} + +const FormInputTypeSwitch: FC = ({ + value, + onChange, +}) => { + const { t } = useTranslation() + return ( +
+ +
onChange(VarType.variable)} + > + +
+
+ +
onChange(VarType.constant)} + > + +
+
+
+ ) +} +export default FormInputTypeSwitch diff --git a/web/app/components/workflow/nodes/_base/components/mcp-tool-not-support-tooltip.tsx b/web/app/components/workflow/nodes/_base/components/mcp-tool-not-support-tooltip.tsx new file mode 100644 index 0000000000..8117f7502f --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/mcp-tool-not-support-tooltip.tsx @@ -0,0 +1,22 @@ +'use client' +import Tooltip from '@/app/components/base/tooltip' +import { RiAlertFill } from '@remixicon/react' +import type { FC } from 'react' +import React from 'react' +import { useTranslation } from 'react-i18next' + +const McpToolNotSupportTooltip: FC = () => { + const { t } = useTranslation() + return ( + + {t('plugin.detailPanel.toolSelector.unsupportedMCPTool')} +
+ } + > + + + ) +} +export default React.memo(McpToolNotSupportTooltip) diff --git a/web/app/components/workflow/nodes/_base/components/next-step/index.tsx b/web/app/components/workflow/nodes/_base/components/next-step/index.tsx index afb642955c..25d1a0aa63 100644 --- a/web/app/components/workflow/nodes/_base/components/next-step/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/next-step/index.tsx @@ -1,10 +1,10 @@ import { memo, useMemo } from 'react' import { useTranslation } from 'react-i18next' +import { isEqual } from 'lodash-es' import { getConnectedEdges, getOutgoers, - useEdges, - useStoreApi, + useStore, } from 'reactflow' import { useToolIcon } from '../../../../hooks' import BlockIcon from '../../../../block-icon' @@ -26,12 +26,21 @@ const NextStep = ({ const { t } = useTranslation() const data = selectedNode.data const toolIcon = useToolIcon(data) - const store = useStoreApi() const branches = useMemo(() => { return data._targetBranches || [] }, [data]) - const edges = useEdges() - const outgoers = getOutgoers(selectedNode as Node, store.getState().getNodes(), edges) + const edges = useStore(s => s.edges.map(edge => ({ + id: edge.id, + source: edge.source, + sourceHandle: edge.sourceHandle, + target: edge.target, + targetHandle: edge.targetHandle, + })), isEqual) + const nodes = useStore(s => s.getNodes().map(node => ({ + id: node.id, + data: node.data, + })), isEqual) + const outgoers = getOutgoers(selectedNode as Node, nodes as Node[], edges) const connectedEdges = getConnectedEdges([selectedNode] as Node[], edges).filter(edge => edge.source === selectedNode!.id) const list = useMemo(() => { diff --git a/web/app/components/workflow/nodes/_base/components/node-position.tsx b/web/app/components/workflow/nodes/_base/components/node-position.tsx index 404648dfa6..e844726b4f 100644 --- a/web/app/components/workflow/nodes/_base/components/node-position.tsx +++ b/web/app/components/workflow/nodes/_base/components/node-position.tsx @@ -1,30 +1,39 @@ import { memo } from 'react' import { useTranslation } from 'react-i18next' +import { useShallow } from 'zustand/react/shallow' import { RiCrosshairLine } from '@remixicon/react' -import type { XYPosition } from 'reactflow' -import { useReactFlow, useStoreApi } from 'reactflow' +import { useReactFlow, useStore } from 'reactflow' import TooltipPlus from '@/app/components/base/tooltip' import { useNodesSyncDraft } from '@/app/components/workflow-app/hooks' type NodePositionProps = { - nodePosition: XYPosition, - nodeWidth?: number | null, - nodeHeight?: number | null, + nodeId: string } const NodePosition = ({ - nodePosition, - nodeWidth, - nodeHeight, + nodeId, }: NodePositionProps) => { const { t } = useTranslation() const reactflow = useReactFlow() - const store = useStoreApi() const { doSyncWorkflowDraft } = useNodesSyncDraft() + const { + nodePosition, + nodeWidth, + nodeHeight, + } = useStore(useShallow((s) => { + const nodes = s.getNodes() + const currentNode = nodes.find(node => node.id === nodeId)! + + return { + nodePosition: currentNode.position, + nodeWidth: currentNode.width, + nodeHeight: currentNode.height, + } + })) + const transform = useStore(s => s.transform) if (!nodePosition || !nodeWidth || !nodeHeight) return null const workflowContainer = document.getElementById('workflow-container') - const { transform } = store.getState() const zoom = transform[2] const { clientWidth, clientHeight } = workflowContainer! diff --git a/web/app/components/workflow/nodes/_base/components/setting-item.tsx b/web/app/components/workflow/nodes/_base/components/setting-item.tsx index 134bf4a551..abbfaef490 100644 --- a/web/app/components/workflow/nodes/_base/components/setting-item.tsx +++ b/web/app/components/workflow/nodes/_base/components/setting-item.tsx @@ -13,7 +13,7 @@ export const SettingItem = memo(({ label, children, status, tooltip }: SettingIt const indicator: ComponentProps['color'] = status === 'error' ? 'red' : status === 'warning' ? 'yellow' : undefined const needTooltip = ['error', 'warning'].includes(status as any) return
-
+
{label}
diff --git a/web/app/components/workflow/nodes/_base/components/variable/output-var-list.tsx b/web/app/components/workflow/nodes/_base/components/variable/output-var-list.tsx index dba93aaf97..a7c9a9d172 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/output-var-list.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/output-var-list.tsx @@ -8,7 +8,7 @@ import RemoveButton from '../remove-button' import VarTypePicker from './var-type-picker' import Input from '@/app/components/base/input' import type { VarType } from '@/app/components/workflow/types' -import { checkKeys } from '@/utils/var' +import { checkKeys, replaceSpaceWithUnderscreInVarNameInput } from '@/utils/var' import Toast from '@/app/components/base/toast' type Props = { @@ -37,6 +37,8 @@ const OutputVarList: FC = ({ const handleVarNameChange = useCallback((index: number) => { return (e: React.ChangeEvent) => { const oldKey = list[index].variable + + replaceSpaceWithUnderscreInVarNameInput(e.target) const newKey = e.target.value const { isValid, errorKey, errorMessageKey } = checkKeys([newKey], true) diff --git a/web/app/components/workflow/nodes/_base/components/variable/utils.ts b/web/app/components/workflow/nodes/_base/components/variable/utils.ts index a69f9a51a7..ac95f54757 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/utils.ts +++ b/web/app/components/workflow/nodes/_base/components/variable/utils.ts @@ -462,6 +462,7 @@ const formatItem = ( return { variable: `env.${env.name}`, type: env.value_type, + description: env.description, } }) as Var[] break @@ -472,7 +473,7 @@ const formatItem = ( return { variable: `conversation.${chatVar.name}`, type: chatVar.value_type, - des: chatVar.description, + description: chatVar.description, } }) as Var[] break @@ -948,9 +949,7 @@ export const getNodeUsedVars = (node: Node): ValueSelector[] => { break } case BlockEnum.Answer: { - res = (data as AnswerNodeType).variables?.map((v) => { - return v.value_selector - }) + res = matchNotSystemVars([(data as AnswerNodeType).answer]) break } case BlockEnum.LLM: { @@ -977,6 +976,7 @@ export const getNodeUsedVars = (node: Node): ValueSelector[] => { res = (data as IfElseNodeType).conditions?.map((c) => { return c.variable_selector || [] }) || [] + res.push(...((data as IfElseNodeType).cases || []).flatMap(c => (c.conditions || [])).map(c => c.variable_selector || [])) break } case BlockEnum.Code: { @@ -996,6 +996,9 @@ export const getNodeUsedVars = (node: Node): ValueSelector[] => { res = [payload.query_variable_selector] const varInInstructions = matchNotSystemVars([payload.instruction || '']) res.push(...varInInstructions) + + const classes = payload.classes.map(c => c.name) + res.push(...matchNotSystemVars(classes)) break } case BlockEnum.HttpRequest: { diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-list.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-list.tsx index fe489a5b80..9eb34ac7f2 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-list.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-list.tsx @@ -8,7 +8,7 @@ import VarReferencePicker from './var-reference-picker' import Input from '@/app/components/base/input' import type { ValueSelector, Var, Variable } from '@/app/components/workflow/types' import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types' -import { checkKeys } from '@/utils/var' +import { checkKeys, replaceSpaceWithUnderscreInVarNameInput } from '@/utils/var' import Toast from '@/app/components/base/toast' type Props = { @@ -38,6 +38,8 @@ const VarList: FC = ({ const handleVarNameChange = useCallback((index: number) => { return (e: React.ChangeEvent) => { + replaceSpaceWithUnderscreInVarNameInput(e.target) + const newKey = e.target.value const { isValid, errorKey, errorMessageKey } = checkKeys([newKey], true) if (!isValid) { @@ -73,8 +75,16 @@ const VarList: FC = ({ if (isSupportConstantValue) draft[index].variable_type = VarKindType.variable - if (!draft[index].variable) - draft[index].variable = value[value.length - 1] + if (!draft[index].variable) { + const variables = draft.map(v => v.variable) + let newVarName = value[value.length - 1] + let count = 1 + while (variables.includes(newVarName)) { + newVarName = `${value[value.length - 1]}_${count}` + count++ + } + draft[index].variable = newVarName + } } else { draft[index].variable_type = VarKindType.constant diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx index 23ccea2572..e6f3ce1fa1 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx @@ -528,6 +528,7 @@ const VarReferencePicker: FC = ({ onChange={handleVarReferenceChange} itemWidth={isAddBtnTrigger ? 260 : (minWidth || triggerWidth)} isSupportFileVar={isSupportFileVar} + zIndex={zIndex} /> )} diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-popup.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-popup.tsx index 9398ae7361..3746a85441 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-popup.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-popup.tsx @@ -13,6 +13,7 @@ type Props = { onChange: (value: ValueSelector, varDetail: Var) => void itemWidth?: number isSupportFileVar?: boolean + zIndex?: number } const VarReferencePopup: FC = ({ vars, @@ -20,6 +21,7 @@ const VarReferencePopup: FC = ({ onChange, itemWidth, isSupportFileVar = true, + zIndex, }) => { const { t } = useTranslation() const docLink = useDocLink() @@ -60,6 +62,7 @@ const VarReferencePopup: FC = ({ onChange={onChange} itemWidth={itemWidth} isSupportFileVar={isSupportFileVar} + zIndex={zIndex} /> }
diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx index 27063a2ba3..303840d8e7 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx @@ -46,6 +46,7 @@ type ItemProps = { isSupportFileVar?: boolean isException?: boolean isLoopVar?: boolean + zIndex?: number } const objVarTypes = [VarType.object, VarType.file] @@ -60,6 +61,7 @@ const Item: FC = ({ isSupportFileVar, isException, isLoopVar, + zIndex, }) => { const isStructureOutput = itemData.type === VarType.object && (itemData.children as StructuredOutput)?.schema?.properties const isFile = itemData.type === VarType.file && !isStructureOutput @@ -171,7 +173,7 @@ const Item: FC = ({
{(isStructureOutput || isObj) && ( void onBlur?: () => void + zIndex?: number autoFocus?: boolean } const VarReferenceVars: FC = ({ @@ -272,6 +275,7 @@ const VarReferenceVars: FC = ({ maxHeightClass, onClose, onBlur, + zIndex, autoFocus = true, }) => { const { t } = useTranslation() @@ -357,6 +361,7 @@ const VarReferenceVars: FC = ({ isSupportFileVar={isSupportFileVar} isException={v.isException} isLoopVar={item.isLoop} + zIndex={zIndex} /> ))}
)) diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx index a47bb226b2..164369e64c 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx @@ -62,15 +62,14 @@ import { Stop } from '@/app/components/base/icons/src/vender/line/mediaAndDevice type BasePanelProps = { children: ReactNode -} & Node + id: Node['id'] + data: Node['data'] +} const BasePanel: FC = ({ id, data, children, - position, - width, - height, }) => { const { t } = useTranslation() const { showMessageLogModal } = useAppStore(useShallow(state => ({ @@ -330,7 +329,7 @@ const BasePanel: FC = ({ ) } - +
diff --git a/web/app/components/workflow/nodes/_base/node.tsx b/web/app/components/workflow/nodes/_base/node.tsx index 27d6adc62b..b969702dd7 100644 --- a/web/app/components/workflow/nodes/_base/node.tsx +++ b/web/app/components/workflow/nodes/_base/node.tsx @@ -32,6 +32,7 @@ import { import { useNodeIterationInteractions } from '../iteration/use-interactions' import { useNodeLoopInteractions } from '../loop/use-interactions' import type { IterationNodeType } from '../iteration/types' +import CopyID from '../tool/components/copy-id' import { NodeSourceHandle, NodeTargetHandle, @@ -48,7 +49,9 @@ import useInspectVarsCrud from '../../hooks/use-inspect-vars-crud' type BaseNodeProps = { children: ReactElement -} & NodeProps + id: NodeProps['id'] + data: NodeProps['data'] +} const BaseNode: FC = ({ id, @@ -319,6 +322,11 @@ const BaseNode: FC = ({
) } + {data.type === BlockEnum.Tool && ( +
+ +
+ )}
) diff --git a/web/app/components/workflow/nodes/agent/components/tool-icon.tsx b/web/app/components/workflow/nodes/agent/components/tool-icon.tsx index b94258855a..4ff0cd780d 100644 --- a/web/app/components/workflow/nodes/agent/components/tool-icon.tsx +++ b/web/app/components/workflow/nodes/agent/components/tool-icon.tsx @@ -2,10 +2,11 @@ import Tooltip from '@/app/components/base/tooltip' import Indicator from '@/app/components/header/indicator' import classNames from '@/utils/classnames' import { memo, useMemo, useRef, useState } from 'react' -import { useAllBuiltInTools, useAllCustomTools, useAllWorkflowTools } from '@/service/use-tools' +import { useAllBuiltInTools, useAllCustomTools, useAllMCPTools, useAllWorkflowTools } from '@/service/use-tools' import { getIconFromMarketPlace } from '@/utils/get-icon' import { useTranslation } from 'react-i18next' import { Group } from '@/app/components/base/icons/src/vender/other' +import AppIcon from '@/app/components/base/app-icon' type Status = 'not-installed' | 'not-authorized' | undefined @@ -19,19 +20,21 @@ export const ToolIcon = memo(({ providerName }: ToolIconProps) => { const { data: buildInTools } = useAllBuiltInTools() const { data: customTools } = useAllCustomTools() const { data: workflowTools } = useAllWorkflowTools() - const isDataReady = !!buildInTools && !!customTools && !!workflowTools + const { data: mcpTools } = useAllMCPTools() + const isDataReady = !!buildInTools && !!customTools && !!workflowTools && !!mcpTools const currentProvider = useMemo(() => { - const mergedTools = [...(buildInTools || []), ...(customTools || []), ...(workflowTools || [])] + const mergedTools = [...(buildInTools || []), ...(customTools || []), ...(workflowTools || []), ...(mcpTools || [])] return mergedTools.find((toolWithProvider) => { - return toolWithProvider.name === providerName + return toolWithProvider.name === providerName || toolWithProvider.id === providerName }) - }, [buildInTools, customTools, providerName, workflowTools]) + }, [buildInTools, customTools, providerName, workflowTools, mcpTools]) + const providerNameParts = providerName.split('/') const author = providerNameParts[0] const name = providerNameParts[1] const icon = useMemo(() => { if (!isDataReady) return '' - if (currentProvider) return currentProvider.icon as string + if (currentProvider) return currentProvider.icon const iconFromMarketPlace = getIconFromMarketPlace(`${author}/${name}`) return iconFromMarketPlace }, [author, currentProvider, name, isDataReady]) @@ -58,23 +61,36 @@ export const ToolIcon = memo(({ providerName }: ToolIconProps) => { >
- {(!iconFetchError && isDataReady) - - ? tool icon setIconFetchError(true)} - /> - : - } + {(() => { + if (iconFetchError || !icon) + return + if (typeof icon === 'string') { + return tool icon setIconFetchError(true)} + /> + } + if (typeof icon === 'object') { + return + } + return + })()} {indicator && }
diff --git a/web/app/components/workflow/nodes/agent/default.ts b/web/app/components/workflow/nodes/agent/default.ts index d80def7bd2..4f68cfe87c 100644 --- a/web/app/components/workflow/nodes/agent/default.ts +++ b/web/app/components/workflow/nodes/agent/default.ts @@ -7,6 +7,7 @@ import { renderI18nObject } from '@/i18n' const nodeDefault: NodeDefault = { defaultValue: { + version: '2', }, getAvailablePrevNodes(isChatMode) { return isChatMode @@ -60,15 +61,28 @@ const nodeDefault: NodeDefault = { const schemas = toolValue.schemas || [] const userSettings = toolValue.settings const reasoningConfig = toolValue.parameters + const version = payload.version schemas.forEach((schema: any) => { if (schema?.required) { - if (schema.form === 'form' && !userSettings[schema.name]?.value) { + if (schema.form === 'form' && !version && !userSettings[schema.name]?.value) { return { isValid: false, errorMessage: t('workflow.errorMsg.toolParameterRequired', { field: renderI18nObject(param.label, language), param: renderI18nObject(schema.label, language) }), } } - if (schema.form === 'llm' && reasoningConfig[schema.name].auto === 0 && !userSettings[schema.name]?.value) { + if (schema.form === 'form' && version && !userSettings[schema.name]?.value.value) { + return { + isValid: false, + errorMessage: t('workflow.errorMsg.toolParameterRequired', { field: renderI18nObject(param.label, language), param: renderI18nObject(schema.label, language) }), + } + } + if (schema.form === 'llm' && !version && reasoningConfig[schema.name].auto === 0 && !reasoningConfig[schema.name]?.value) { + return { + isValid: false, + errorMessage: t('workflow.errorMsg.toolParameterRequired', { field: renderI18nObject(param.label, language), param: renderI18nObject(schema.label, language) }), + } + } + if (schema.form === 'llm' && version && reasoningConfig[schema.name].auto === 0 && !reasoningConfig[schema.name]?.value.value) { return { isValid: false, errorMessage: t('workflow.errorMsg.toolParameterRequired', { field: renderI18nObject(param.label, language), param: renderI18nObject(schema.label, language) }), diff --git a/web/app/components/workflow/nodes/agent/node.tsx b/web/app/components/workflow/nodes/agent/node.tsx index d2267fd00f..a2190317af 100644 --- a/web/app/components/workflow/nodes/agent/node.tsx +++ b/web/app/components/workflow/nodes/agent/node.tsx @@ -104,7 +104,7 @@ const AgentNode: FC> = (props) => { {t('workflow.nodes.agent.toolbox')} }>
- {tools.map(tool => )} + {tools.map((tool, i) => )}
}
diff --git a/web/app/components/workflow/nodes/agent/panel.tsx b/web/app/components/workflow/nodes/agent/panel.tsx index 391383031f..6741453944 100644 --- a/web/app/components/workflow/nodes/agent/panel.tsx +++ b/web/app/components/workflow/nodes/agent/panel.tsx @@ -38,11 +38,11 @@ const AgentPanel: FC> = (props) => { readOnly, outputSchema, handleMemoryChange, + canChooseMCPTool, } = useConfig(props.id, props.data) const { t } = useTranslation() const resetEditor = useStore(s => s.setControlPromptEditorRerenderKey) - return
> = (props) => { agent_strategy_label: inputs.agent_strategy_label!, agent_output_schema: inputs.output_schema, plugin_unique_identifier: inputs.plugin_unique_identifier!, + meta: inputs.meta, } : undefined} onStrategyChange={(strategy) => { setInputs({ @@ -65,6 +66,7 @@ const AgentPanel: FC> = (props) => { agent_strategy_label: strategy?.agent_strategy_label, output_schema: strategy!.agent_output_schema, plugin_unique_identifier: strategy!.plugin_unique_identifier, + meta: strategy?.meta, }) resetEditor(Date.now()) }} @@ -74,6 +76,7 @@ const AgentPanel: FC> = (props) => { nodeOutputVars={availableVars} availableNodes={availableNodesWithParent} nodeId={props.id} + canChooseMCPTool={canChooseMCPTool} />
diff --git a/web/app/components/workflow/nodes/agent/types.ts b/web/app/components/workflow/nodes/agent/types.ts index ca8bb5e71d..5a13a4a4f3 100644 --- a/web/app/components/workflow/nodes/agent/types.ts +++ b/web/app/components/workflow/nodes/agent/types.ts @@ -1,14 +1,17 @@ import type { CommonNodeType, Memory } from '@/app/components/workflow/types' import type { ToolVarInputs } from '../tool/types' +import type { PluginMeta } from '@/app/components/plugins/types' export type AgentNodeType = CommonNodeType & { agent_strategy_provider_name?: string agent_strategy_name?: string agent_strategy_label?: string agent_parameters?: ToolVarInputs + meta?: PluginMeta output_schema: Record plugin_unique_identifier?: string memory?: Memory + version?: string } export enum AgentFeature { diff --git a/web/app/components/workflow/nodes/agent/use-config.ts b/web/app/components/workflow/nodes/agent/use-config.ts index c3e07e4e60..50faf03040 100644 --- a/web/app/components/workflow/nodes/agent/use-config.ts +++ b/web/app/components/workflow/nodes/agent/use-config.ts @@ -6,13 +6,16 @@ import { useIsChatMode, useNodesReadOnly, } from '@/app/components/workflow/hooks' -import { useCallback, useMemo } from 'react' +import { useCallback, useEffect, useMemo } from 'react' import { type ToolVarInputs, VarType } from '../tool/types' import { useCheckInstalled, useFetchPluginsInMarketPlaceByIds } from '@/service/use-plugins' import type { Memory, Var } from '../../types' import { VarType as VarKindType } from '../../types' import useAvailableVarList from '../_base/hooks/use-available-var-list' import produce from 'immer' +import { isSupportMCP } from '@/utils/plugin-version-feature' +import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { generateAgentToolValue, toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema' export type StrategyStatus = { plugin: { @@ -85,11 +88,12 @@ const useConfig = (id: string, payload: AgentNodeType) => { }) const formData = useMemo(() => { const paramNameList = (currentStrategy?.parameters || []).map(item => item.name) - return Object.fromEntries( + const res = Object.fromEntries( Object.entries(inputs.agent_parameters || {}).filter(([name]) => paramNameList.includes(name)).map(([key, value]) => { return [key, value.value] }), ) + return res }, [inputs.agent_parameters, currentStrategy?.parameters]) const onFormChange = (value: Record) => { const res: ToolVarInputs = {} @@ -105,6 +109,42 @@ const useConfig = (id: string, payload: AgentNodeType) => { }) } + const formattingToolData = (data: any) => { + const settingValues = generateAgentToolValue(data.settings, toolParametersToFormSchemas(data.schemas.filter((param: { form: string }) => param.form !== 'llm') as any)) + const paramValues = generateAgentToolValue(data.parameters, toolParametersToFormSchemas(data.schemas.filter((param: { form: string }) => param.form === 'llm') as any), true) + const res = produce(data, (draft: any) => { + draft.settings = settingValues + draft.parameters = paramValues + }) + return res + } + + const formattingLegacyData = () => { + if (inputs.version) + return inputs + const newData = produce(inputs, (draft) => { + const schemas = currentStrategy?.parameters || [] + Object.keys(draft.agent_parameters || {}).forEach((key) => { + const targetSchema = schemas.find(schema => schema.name === key) + if (targetSchema?.type === FormTypeEnum.toolSelector) + draft.agent_parameters![key].value = formattingToolData(draft.agent_parameters![key].value) + if (targetSchema?.type === FormTypeEnum.multiToolSelector) + draft.agent_parameters![key].value = draft.agent_parameters![key].value.map((tool: any) => formattingToolData(tool)) + }) + draft.version = '2' + }) + return newData + } + + // formatting legacy data + useEffect(() => { + if (!currentStrategy) + return + const newData = formattingLegacyData() + setInputs(newData) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentStrategy]) + // vars const filterMemoryPromptVar = useCallback((varPayload: Var) => { @@ -172,6 +212,7 @@ const useConfig = (id: string, payload: AgentNodeType) => { outputSchema, handleMemoryChange, isChatMode, + canChooseMCPTool: isSupportMCP(inputs.meta?.version), } } diff --git a/web/app/components/workflow/nodes/http/default.ts b/web/app/components/workflow/nodes/http/default.ts index 1bd584eeb9..3f9df0178d 100644 --- a/web/app/components/workflow/nodes/http/default.ts +++ b/web/app/components/workflow/nodes/http/default.ts @@ -22,6 +22,7 @@ const nodeDefault: NodeDefault = { type: BodyType.none, data: [], }, + ssl_verify: true, timeout: { max_connect_timeout: 0, max_read_timeout: 0, diff --git a/web/app/components/workflow/nodes/http/node.tsx b/web/app/components/workflow/nodes/http/node.tsx index ad4b5f58ff..aa1912bd59 100644 --- a/web/app/components/workflow/nodes/http/node.tsx +++ b/web/app/components/workflow/nodes/http/node.tsx @@ -13,7 +13,7 @@ const Node: FC> = ({ return (
-
+
{method}
> = ({ showCurlPanel, hideCurlPanel, handleCurlImport, + handleSSLVerifyChange, } = useConfig(id, data) // To prevent prompt editor in body not update data. if (!isDataReady) @@ -124,6 +126,18 @@ const Panel: FC> = ({ onChange={setBody} /> + + }> +
{ setInputs(newInputs) }, [inputs, setInputs]) + const handleSSLVerifyChange = useCallback((checked: boolean) => { + const newInputs = produce(inputs, (draft: HttpNodeType) => { + draft.ssl_verify = checked + }) + setInputs(newInputs) + }, [inputs, setInputs]) + return { readOnly, isDataReady, @@ -164,6 +171,8 @@ const useConfig = (id: string, payload: HttpNodeType) => { toggleIsParamKeyValueEdit, // body setBody, + // ssl verify + handleSSLVerifyChange, // authorization isShowAuthorization, showAuthorization, diff --git a/web/app/components/workflow/nodes/index.tsx b/web/app/components/workflow/nodes/index.tsx index d120ed8d37..8458051da2 100644 --- a/web/app/components/workflow/nodes/index.tsx +++ b/web/app/components/workflow/nodes/index.tsx @@ -14,11 +14,14 @@ import BasePanel from './_base/components/workflow-panel' const CustomNode = (props: NodeProps) => { const nodeData = props.data - const NodeComponent = NodeComponentMap[nodeData.type] + const NodeComponent = useMemo(() => NodeComponentMap[nodeData.type], [nodeData.type]) return ( <> - + @@ -26,7 +29,12 @@ const CustomNode = (props: NodeProps) => { } CustomNode.displayName = 'CustomNode' -export const Panel = memo((props: Node) => { +export type PanelProps = { + type: Node['type'] + id: Node['id'] + data: Node['data'] +} +export const Panel = memo((props: PanelProps) => { const nodeClass = props.type const nodeData = props.data const PanelComponent = useMemo(() => { @@ -38,7 +46,11 @@ export const Panel = memo((props: Node) => { if (nodeClass === CUSTOM_NODE) { return ( - + ) diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/code-editor.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/code-editor.tsx index 83c5d60573..920757c6a6 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/code-editor.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/code-editor.tsx @@ -98,7 +98,7 @@ const CodeEditor: FC = ({ }, []) return ( -
+
{!hideTopMenu && (
diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/hooks.ts b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/hooks.ts index 470a322b13..eb3dff83d8 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/hooks.ts +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/hooks.ts @@ -6,6 +6,7 @@ import type { EditData } from './edit-card' import { ArrayType, type Field, Type } from '../../../types' import Toast from '@/app/components/base/toast' import { findPropertyWithPath } from '../../../utils' +import _ from 'lodash' type ChangeEventParams = { path: string[], @@ -19,7 +20,8 @@ type AddEventParams = { } export const useSchemaNodeOperations = (props: VisualEditorProps) => { - const { schema: jsonSchema, onChange } = props + const { schema: jsonSchema, onChange: doOnChange } = props + const onChange = doOnChange || _.noop const backupSchema = useVisualEditorStore(state => state.backupSchema) const setBackupSchema = useVisualEditorStore(state => state.setBackupSchema) const isAddingNewField = useVisualEditorStore(state => state.isAddingNewField) diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/index.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/index.tsx index 1df42532a6..d96f856bbb 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/index.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/index.tsx @@ -2,24 +2,29 @@ import type { FC } from 'react' import type { SchemaRoot } from '../../../types' import SchemaNode from './schema-node' import { useSchemaNodeOperations } from './hooks' +import cn from '@/utils/classnames' export type VisualEditorProps = { + className?: string schema: SchemaRoot - onChange: (schema: SchemaRoot) => void + rootName?: string + readOnly?: boolean + onChange?: (schema: SchemaRoot) => void } const VisualEditor: FC = (props) => { - const { schema } = props + const { className, schema, readOnly } = props useSchemaNodeOperations(props) return ( -
+
) diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/schema-node.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/schema-node.tsx index 70a6b861ad..36671ab050 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/schema-node.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/schema-node.tsx @@ -19,6 +19,7 @@ type SchemaNodeProps = { path: string[] parentPath?: string[] depth: number + readOnly?: boolean } // Support 10 levels of indentation @@ -57,6 +58,7 @@ const SchemaNode: FC = ({ path, parentPath, depth, + readOnly, }) => { const [isExpanded, setIsExpanded] = useState(true) const hoveringProperty = useVisualEditorStore(state => state.hoveringProperty) @@ -77,11 +79,13 @@ const SchemaNode: FC = ({ } const handleMouseEnter = () => { + if(readOnly) return if (advancedEditing || isAddingNewField) return setHoveringPropertyDebounced(path.join('.')) } const handleMouseLeave = () => { + if(readOnly) return if (advancedEditing || isAddingNewField) return setHoveringPropertyDebounced(null) } @@ -91,7 +95,7 @@ const SchemaNode: FC = ({
{depth > 0 && hasChildren && (
= ({ )} { - depth === 0 && !isAddingNewField && ( + !readOnly && depth === 0 && !isAddingNewField && ( ) } diff --git a/web/app/components/workflow/nodes/llm/panel.tsx b/web/app/components/workflow/nodes/llm/panel.tsx index 2a71dffa11..471d65ef20 100644 --- a/web/app/components/workflow/nodes/llm/panel.tsx +++ b/web/app/components/workflow/nodes/llm/panel.tsx @@ -282,6 +282,11 @@ const Panel: FC> = ({ type='string' description={t(`${i18nPrefix}.outputVars.output`)} /> + {inputs.structured_output_enabled && ( <> diff --git a/web/app/components/workflow/nodes/loop/components/loop-variables/item.tsx b/web/app/components/workflow/nodes/loop/components/loop-variables/item.tsx index d33e9361ad..0e8650d743 100644 --- a/web/app/components/workflow/nodes/loop/components/loop-variables/item.tsx +++ b/web/app/components/workflow/nodes/loop/components/loop-variables/item.tsx @@ -10,6 +10,8 @@ import type { LoopVariable, LoopVariablesComponentShape, } from '@/app/components/workflow/nodes/loop/types' +import { checkKeys, replaceSpaceWithUnderscreInVarNameInput } from '@/utils/var' +import Toast from '@/app/components/base/toast' type ItemProps = { item: LoopVariable @@ -21,7 +23,22 @@ const Item = ({ handleUpdateLoopVariable, }: ItemProps) => { const { t } = useTranslation() + + const checkVariableName = (value: string) => { + const { isValid, errorMessageKey } = checkKeys([value], false) + if (!isValid) { + Toast.notify({ + type: 'error', + message: t(`appDebug.varKeyError.${errorMessageKey}`, { key: t('workflow.env.modal.name') }), + }) + return false + } + return true + } const handleUpdateItemLabel = useCallback((e: any) => { + replaceSpaceWithUnderscreInVarNameInput(e.target) + if (!!e.target.value && !checkVariableName(e.target.value)) + return handleUpdateLoopVariable(item.id, { label: e.target.value }) }, [item.id, handleUpdateLoopVariable]) @@ -44,6 +61,7 @@ const Item = ({ checkVariableName(e.target.value)} autoFocus={!item.label} placeholder={t('workflow.nodes.loop.variableName')} /> diff --git a/web/app/components/workflow/nodes/parameter-extractor/panel.tsx b/web/app/components/workflow/nodes/parameter-extractor/panel.tsx index e86a2e3764..a169217609 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/panel.tsx +++ b/web/app/components/workflow/nodes/parameter-extractor/panel.tsx @@ -190,12 +190,17 @@ const Panel: FC> = ({ + diff --git a/web/app/components/workflow/nodes/question-classifier/components/class-item.tsx b/web/app/components/workflow/nodes/question-classifier/components/class-item.tsx index 6065037322..478ac925d6 100644 --- a/web/app/components/workflow/nodes/question-classifier/components/class-item.tsx +++ b/web/app/components/workflow/nodes/question-classifier/components/class-item.tsx @@ -1,15 +1,18 @@ 'use client' import type { FC } from 'react' -import React, { useCallback } from 'react' +import React, { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import type { Topic } from '../types' import Editor from '@/app/components/workflow/nodes/_base/components/prompt/editor' import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list' import type { ValueSelector, Var } from '@/app/components/workflow/types' +import { uniqueId } from 'lodash-es' const i18nPrefix = 'workflow.nodes.questionClassifiers' type Props = { + className?: string + headerClassName?: string nodeId: string payload: Topic onChange: (payload: Topic) => void @@ -20,6 +23,8 @@ type Props = { } const ClassItem: FC = ({ + className, + headerClassName, nodeId, payload, onChange, @@ -29,6 +34,11 @@ const ClassItem: FC = ({ filterVar, }) => { const { t } = useTranslation() + const [instanceId, setInstanceId] = useState(uniqueId()) + + useEffect(() => { + setInstanceId(`${nodeId}-${uniqueId()}`) + }, [nodeId]) const handleNameChange = useCallback((value: string) => { onChange({ ...payload, name: value }) @@ -43,6 +53,8 @@ const ClassItem: FC = ({ return ( = ({ nodesOutputVars={availableVars} availableNodes={availableNodesWithParent} readOnly={readonly} // ? + instanceId={instanceId} justVar // ? isSupportFileVar // ? /> diff --git a/web/app/components/workflow/nodes/question-classifier/components/class-list.tsx b/web/app/components/workflow/nodes/question-classifier/components/class-list.tsx index f152917ed4..d0297cfd74 100644 --- a/web/app/components/workflow/nodes/question-classifier/components/class-list.tsx +++ b/web/app/components/workflow/nodes/question-classifier/components/class-list.tsx @@ -8,6 +8,9 @@ import AddButton from '../../_base/components/add-button' import Item from './class-item' import type { Topic } from '@/app/components/workflow/nodes/question-classifier/types' import type { ValueSelector, Var } from '@/app/components/workflow/types' +import { ReactSortable } from 'react-sortablejs' +import { noop } from 'lodash-es' +import cn from '@/utils/classnames' const i18nPrefix = 'workflow.nodes.questionClassifiers' @@ -17,6 +20,7 @@ type Props = { onChange: (list: Topic[]) => void readonly?: boolean filterVar: (payload: Var, valueSelector: ValueSelector) => boolean + handleSortTopic?: (newTopics: (Topic & { id: string })[]) => void } const ClassList: FC = ({ @@ -25,6 +29,7 @@ const ClassList: FC = ({ onChange, readonly, filterVar, + handleSortTopic = noop, }) => { const { t } = useTranslation() const { handleEdgeDeleteByDeleteBranch } = useEdgesInteractions() @@ -55,22 +60,48 @@ const ClassList: FC = ({ } }, [list, onChange, handleEdgeDeleteByDeleteBranch, nodeId]) + const topicCount = list.length + const handleSideWidth = 3 // Todo Remove; edit topic name return ( -
+ ({ ...item }))} + setList={handleSortTopic} + handle='.handle' + ghostClass='bg-components-panel-bg' + animation={150} + disabled={readonly} + className='space-y-2' + > { list.map((item, index) => { + const canDrag = (() => { + if (readonly) + return false + + return topicCount >= 2 + })() return ( - +
+
+ +
+
) }) } @@ -81,7 +112,7 @@ const ClassList: FC = ({ /> )} -
+ ) } export default React.memo(ClassList) diff --git a/web/app/components/workflow/nodes/question-classifier/panel.tsx b/web/app/components/workflow/nodes/question-classifier/panel.tsx index 8f6f5eb76d..8e27f5dceb 100644 --- a/web/app/components/workflow/nodes/question-classifier/panel.tsx +++ b/web/app/components/workflow/nodes/question-classifier/panel.tsx @@ -40,6 +40,7 @@ const Panel: FC> = ({ handleVisionResolutionChange, handleVisionResolutionEnabledChange, filterVar, + handleSortTopic, } = useConfig(id, data) const model = inputs.model @@ -99,6 +100,7 @@ const Panel: FC> = ({ onChange={handleTopicsChange} readonly={readOnly} filterVar={filterVar} + handleSortTopic={handleSortTopic} /> @@ -129,6 +131,11 @@ const Panel: FC> = ({ type='string' description={t(`${i18nPrefix}.outputVars.className`)} /> +
diff --git a/web/app/components/workflow/nodes/question-classifier/use-config.ts b/web/app/components/workflow/nodes/question-classifier/use-config.ts index 8eacf5b43f..a4acf5b7f6 100644 --- a/web/app/components/workflow/nodes/question-classifier/use-config.ts +++ b/web/app/components/workflow/nodes/question-classifier/use-config.ts @@ -9,13 +9,15 @@ import { import { useStore } from '../../store' import useAvailableVarList from '../_base/hooks/use-available-var-list' import useConfigVision from '../../hooks/use-config-vision' -import type { QuestionClassifierNodeType } from './types' +import type { QuestionClassifierNodeType, Topic } from './types' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { checkHasQueryBlock } from '@/app/components/base/prompt-editor/constants' +import { useUpdateNodeInternals } from 'reactflow' const useConfig = (id: string, payload: QuestionClassifierNodeType) => { + const updateNodeInternals = useUpdateNodeInternals() const { nodesReadOnly: readOnly } = useNodesReadOnly() const isChatMode = useIsChatMode() const defaultConfig = useStore(s => s.nodesDefaultConfigs)[payload.type] @@ -166,6 +168,17 @@ const useConfig = (id: string, payload: QuestionClassifierNodeType) => { return varPayload.type === VarType.string }, []) + const handleSortTopic = useCallback((newTopics: (Topic & { id: string })[]) => { + const newInputs = produce(inputs, (draft) => { + draft.classes = newTopics.filter(Boolean).map(item => ({ + id: item.id, + name: item.name, + })) + }) + setInputs(newInputs) + updateNodeInternals(id) + }, [id, inputs, setInputs, updateNodeInternals]) + return { readOnly, inputs, @@ -185,6 +198,7 @@ const useConfig = (id: string, payload: QuestionClassifierNodeType) => { isVisionModel, handleVisionResolutionEnabledChange, handleVisionResolutionChange, + handleSortTopic, } } diff --git a/web/app/components/workflow/nodes/tool/components/copy-id.tsx b/web/app/components/workflow/nodes/tool/components/copy-id.tsx new file mode 100644 index 0000000000..3a633e1d2e --- /dev/null +++ b/web/app/components/workflow/nodes/tool/components/copy-id.tsx @@ -0,0 +1,51 @@ +'use client' +import React, { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { RiFileCopyLine } from '@remixicon/react' +import copy from 'copy-to-clipboard' +import { debounce } from 'lodash-es' +import Tooltip from '@/app/components/base/tooltip' + +type Props = { + content: string +} + +const prefixEmbedded = 'appOverview.overview.appInfo.embedded' + +const CopyFeedbackNew = ({ content }: Props) => { + const { t } = useTranslation() + const [isCopied, setIsCopied] = useState(false) + + const onClickCopy = debounce(() => { + copy(content) + setIsCopied(true) + }, 100) + + const onMouseLeave = debounce(() => { + setIsCopied(false) + }, 100) + + return ( +
e.stopPropagation()} onMouseLeave={onMouseLeave}> + +
+
{content}
+ +
+
+
+ ) +} + +export default CopyFeedbackNew diff --git a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx new file mode 100644 index 0000000000..6680c8ebb6 --- /dev/null +++ b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx @@ -0,0 +1,62 @@ +import { + memo, +} from 'react' +import { useTranslation } from 'react-i18next' +import PromptEditor from '@/app/components/base/prompt-editor' +import Placeholder from './placeholder' +import type { + Node, + NodeOutPutVar, +} from '@/app/components/workflow/types' +import { BlockEnum } from '@/app/components/workflow/types' +import cn from '@/utils/classnames' + +type MixedVariableTextInputProps = { + readOnly?: boolean + nodesOutputVars?: NodeOutPutVar[] + availableNodes?: Node[] + value?: string + onChange?: (text: string) => void +} +const MixedVariableTextInput = ({ + readOnly = false, + nodesOutputVars, + availableNodes = [], + value = '', + onChange, +}: MixedVariableTextInputProps) => { + const { t } = useTranslation() + return ( + { + acc[node.id] = { + title: node.data.title, + type: node.data.type, + } + if (node.data.type === BlockEnum.Start) { + acc.sys = { + title: t('workflow.blocks.start'), + type: BlockEnum.Start, + } + } + return acc + }, {} as any), + }} + placeholder={} + onChange={onChange} + /> + ) +} + +export default memo(MixedVariableTextInput) diff --git a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/placeholder.tsx b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/placeholder.tsx new file mode 100644 index 0000000000..3337d6ae66 --- /dev/null +++ b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/placeholder.tsx @@ -0,0 +1,51 @@ +import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { FOCUS_COMMAND } from 'lexical' +import { $insertNodes } from 'lexical' +import { CustomTextNode } from '@/app/components/base/prompt-editor/plugins/custom-text/node' +import Badge from '@/app/components/base/badge' + +const Placeholder = () => { + const { t } = useTranslation() + const [editor] = useLexicalComposerContext() + + const handleInsert = useCallback((text: string) => { + editor.update(() => { + const textNode = new CustomTextNode(text) + $insertNodes([textNode]) + }) + editor.dispatchCommand(FOCUS_COMMAND, undefined as any) + }, [editor]) + + return ( +
{ + e.stopPropagation() + handleInsert('') + }} + > +
+ {t('workflow.nodes.tool.insertPlaceholder1')} +
/
+
{ + e.stopPropagation() + handleInsert('/') + })} + > + {t('workflow.nodes.tool.insertPlaceholder2')} +
+
+ +
+ ) +} + +export default Placeholder diff --git a/web/app/components/workflow/nodes/tool/components/tool-form/index.tsx b/web/app/components/workflow/nodes/tool/components/tool-form/index.tsx new file mode 100644 index 0000000000..a867797473 --- /dev/null +++ b/web/app/components/workflow/nodes/tool/components/tool-form/index.tsx @@ -0,0 +1,51 @@ +'use client' +import type { FC } from 'react' +import type { ToolVarInputs } from '../../types' +import type { CredentialFormSchema } from '@/app/components/header/account-setting/model-provider-page/declarations' +import ToolFormItem from './item' +import type { ToolWithProvider } from '@/app/components/workflow/types' +import type { Tool } from '@/app/components/tools/types' + +type Props = { + readOnly: boolean + nodeId: string + schema: CredentialFormSchema[] + value: ToolVarInputs + onChange: (value: ToolVarInputs) => void + onOpen?: (index: number) => void + inPanel?: boolean + currentTool?: Tool + currentProvider?: ToolWithProvider +} + +const ToolForm: FC = ({ + readOnly, + nodeId, + schema, + value, + onChange, + inPanel, + currentTool, + currentProvider, +}) => { + return ( +
+ { + schema.map((schema, index) => ( + + )) + } +
+ ) +} +export default ToolForm diff --git a/web/app/components/workflow/nodes/tool/components/tool-form/item.tsx b/web/app/components/workflow/nodes/tool/components/tool-form/item.tsx new file mode 100644 index 0000000000..11de42fe56 --- /dev/null +++ b/web/app/components/workflow/nodes/tool/components/tool-form/item.tsx @@ -0,0 +1,105 @@ +'use client' +import type { FC } from 'react' +import { + RiBracesLine, +} from '@remixicon/react' +import type { ToolVarInputs } from '../../types' +import type { CredentialFormSchema } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' +import Button from '@/app/components/base/button' +import Tooltip from '@/app/components/base/tooltip' +import FormInputItem from '@/app/components/workflow/nodes/_base/components/form-input-item' +import { useBoolean } from 'ahooks' +import SchemaModal from '@/app/components/plugins/plugin-detail-panel/tool-selector/schema-modal' +import type { ToolWithProvider } from '@/app/components/workflow/types' +import type { Tool } from '@/app/components/tools/types' + +type Props = { + readOnly: boolean + nodeId: string + schema: CredentialFormSchema + value: ToolVarInputs + onChange: (value: ToolVarInputs) => void + inPanel?: boolean + currentTool?: Tool + currentProvider?: ToolWithProvider +} + +const ToolFormItem: FC = ({ + readOnly, + nodeId, + schema, + value, + onChange, + inPanel, + currentTool, + currentProvider, +}) => { + const language = useLanguage() + const { name, label, type, required, tooltip, input_schema } = schema + const showSchemaButton = type === FormTypeEnum.object || type === FormTypeEnum.array + const showDescription = type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput + const [isShowSchema, { + setTrue: showSchema, + setFalse: hideSchema, + }] = useBoolean(false) + return ( +
+
+
+
{label[language] || label.en_US}
+ {required && ( +
*
+ )} + {!showDescription && tooltip && ( + + {tooltip[language] || tooltip.en_US} +
} + triggerClassName='ml-1 w-4 h-4' + asChild={false} + /> + )} + {showSchemaButton && ( + <> +
·
+ + + )} +
+ {showDescription && tooltip && ( +
{tooltip[language] || tooltip.en_US}
+ )} +
+ + + {isShowSchema && ( + + )} +
+ ) +} +export default ToolFormItem diff --git a/web/app/components/workflow/nodes/tool/default.ts b/web/app/components/workflow/nodes/tool/default.ts index f245929684..1fdb9eed2d 100644 --- a/web/app/components/workflow/nodes/tool/default.ts +++ b/web/app/components/workflow/nodes/tool/default.ts @@ -10,6 +10,7 @@ const nodeDefault: NodeDefault = { defaultValue: { tool_parameters: {}, tool_configurations: {}, + version: '2', }, getAvailablePrevNodes(isChatMode: boolean) { const nodes = isChatMode @@ -55,6 +56,8 @@ const nodeDefault: NodeDefault = { const value = payload.tool_configurations[field.variable] if (!errorMessages && (value === undefined || value === null || value === '')) errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: field.label[language] }) + if (!errorMessages && typeof value === 'object' && !!value.type && (value.value === undefined || value.value === null || value.value === '' || (Array.isArray(value.value) && value.value.length === 0))) + errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: field.label[language] }) }) } diff --git a/web/app/components/workflow/nodes/tool/node.tsx b/web/app/components/workflow/nodes/tool/node.tsx index f3cb4d9fae..e15ddcaaaa 100644 --- a/web/app/components/workflow/nodes/tool/node.tsx +++ b/web/app/components/workflow/nodes/tool/node.tsx @@ -21,14 +21,14 @@ const Node: FC> = ({
{key}
- {typeof tool_configurations[key] === 'string' && ( + {typeof tool_configurations[key].value === 'string' && (
- {paramSchemas?.find(i => i.name === key)?.type === FormTypeEnum.secretInput ? '********' : tool_configurations[key]} + {paramSchemas?.find(i => i.name === key)?.type === FormTypeEnum.secretInput ? '********' : tool_configurations[key].value}
)} - {typeof tool_configurations[key] === 'number' && ( + {typeof tool_configurations[key].value === 'number' && (
- {tool_configurations[key]} + {tool_configurations[key].value}
)} {typeof tool_configurations[key] !== 'string' && tool_configurations[key]?.type === FormTypeEnum.modelSelector && ( @@ -36,11 +36,6 @@ const Node: FC> = ({ {tool_configurations[key].model}
)} - {/* {typeof tool_configurations[key] !== 'string' && tool_configurations[key]?.type === FormTypeEnum.appSelector && ( -
- {tool_configurations[key].app_id} -
- )} */}
))} diff --git a/web/app/components/workflow/nodes/tool/panel.tsx b/web/app/components/workflow/nodes/tool/panel.tsx index 038159870e..936f730a46 100644 --- a/web/app/components/workflow/nodes/tool/panel.tsx +++ b/web/app/components/workflow/nodes/tool/panel.tsx @@ -4,11 +4,10 @@ import { useTranslation } from 'react-i18next' import Split from '../_base/components/split' import type { ToolNodeType } from './types' import useConfig from './use-config' -import InputVarList from './components/input-var-list' +import ToolForm from './components/tool-form' import Button from '@/app/components/base/button' import Field from '@/app/components/workflow/nodes/_base/components/field' import type { NodePanelProps } from '@/app/components/workflow/types' -import Form from '@/app/components/header/account-setting/model-provider-page/model-modal/Form' import ConfigCredential from '@/app/components/tools/setting/build-in/config-credentials' import Loading from '@/app/components/base/loading' import OutputVars, { VarItem } from '@/app/components/workflow/nodes/_base/components/output-vars' @@ -28,8 +27,6 @@ const Panel: FC> = ({ inputs, toolInputVarSchema, setInputVar, - handleOnVarOpen, - filterVar, toolSettingSchema, toolSettingValue, setToolSettingValue, @@ -45,6 +42,8 @@ const Panel: FC> = ({ currTool, } = useConfig(id, data) + const [collapsed, setCollapsed] = React.useState(false) + if (isLoading) { return
@@ -66,21 +65,19 @@ const Panel: FC> = ({
)} - {!isShowAuthBtn && <> -
+ {!isShowAuthBtn && ( +
{toolInputVarSchema.length > 0 && ( - @@ -88,24 +85,29 @@ const Panel: FC> = ({ )} {toolInputVarSchema.length > 0 && toolSettingSchema.length > 0 && ( - + )} - + {toolSettingSchema.length > 0 && ( + <> + + + + + + )}
- } + )} {showSetAuth && ( output_schema: Record paramSchemas?: Record[] + version?: string } diff --git a/web/app/components/workflow/nodes/tool/use-config.ts b/web/app/components/workflow/nodes/tool/use-config.ts index b83ae8a07f..ea8d0e21ca 100644 --- a/web/app/components/workflow/nodes/tool/use-config.ts +++ b/web/app/components/workflow/nodes/tool/use-config.ts @@ -8,10 +8,12 @@ import { useLanguage } from '@/app/components/header/account-setting/model-provi import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' import { CollectionType } from '@/app/components/tools/types' import { updateBuiltInToolCredential } from '@/service/tools' -import { addDefaultValue, toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema' +import { + getConfiguredValue, + toolParametersToFormSchemas, +} from '@/app/components/tools/utils/to-form-schema' import Toast from '@/app/components/base/toast' -import { VarType as VarVarType } from '@/app/components/workflow/types' -import type { InputVar, Var } from '@/app/components/workflow/types' +import type { InputVar } from '@/app/components/workflow/types' import { useFetchToolsData, useNodesReadOnly, @@ -26,17 +28,18 @@ const useConfig = (id: string, payload: ToolNodeType) => { const language = useLanguage() const { inputs, setInputs: doSetInputs } = useNodeCrud(id, payload) /* - * tool_configurations: tool setting, not dynamic setting - * tool_parameters: tool dynamic setting(by user) + * tool_configurations: tool setting, not dynamic setting (form type = form) + * tool_parameters: tool dynamic setting(form type = llm) * output_schema: tool dynamic output */ - const { provider_id, provider_type, tool_name, tool_configurations, output_schema } = inputs + const { provider_id, provider_type, tool_name, tool_configurations, output_schema, tool_parameters } = inputs const isBuiltIn = provider_type === CollectionType.builtIn const buildInTools = useStore(s => s.buildInTools) const customTools = useStore(s => s.customTools) const workflowTools = useStore(s => s.workflowTools) + const mcpTools = useStore(s => s.mcpTools) - const currentTools = (() => { + const currentTools = useMemo(() => { switch (provider_type) { case CollectionType.builtIn: return buildInTools @@ -44,10 +47,12 @@ const useConfig = (id: string, payload: ToolNodeType) => { return customTools case CollectionType.workflow: return workflowTools + case CollectionType.mcp: + return mcpTools default: return [] } - })() + }, [buildInTools, customTools, mcpTools, provider_type, workflowTools]) const currCollection = currentTools.find(item => canFindTool(item.id, provider_id)) // Auth @@ -91,10 +96,10 @@ const useConfig = (id: string, payload: ToolNodeType) => { const value = newConfig[key] if (schema?.type === 'boolean') { if (typeof value === 'string') - newConfig[key] = Number.parseInt(value, 10) + newConfig[key] = value === 'true' || value === '1' - if (typeof value === 'boolean') - newConfig[key] = value ? 1 : 0 + if (typeof value === 'number') + newConfig[key] = value === 1 } if (schema?.type === 'number-input') { @@ -107,12 +112,11 @@ const useConfig = (id: string, payload: ToolNodeType) => { doSetInputs(newInputs) }, [doSetInputs, formSchemas, hasShouldTransferTypeSettingInput]) const [notSetDefaultValue, setNotSetDefaultValue] = useState(false) - const toolSettingValue = (() => { + const toolSettingValue = useMemo(() => { if (notSetDefaultValue) return tool_configurations - - return addDefaultValue(tool_configurations, toolSettingSchema) - })() + return getConfiguredValue(tool_configurations, toolSettingSchema) + }, [notSetDefaultValue, toolSettingSchema, tool_configurations]) const setToolSettingValue = useCallback((value: Record) => { setNotSetDefaultValue(true) setInputs({ @@ -121,16 +125,20 @@ const useConfig = (id: string, payload: ToolNodeType) => { }) }, [inputs, setInputs]) + const formattingParameters = () => { + const inputsWithDefaultValue = produce(inputs, (draft) => { + if (!draft.tool_configurations || Object.keys(draft.tool_configurations).length === 0) + draft.tool_configurations = getConfiguredValue(tool_configurations, toolSettingSchema) + if (!draft.tool_parameters || Object.keys(draft.tool_parameters).length === 0) + draft.tool_parameters = getConfiguredValue(tool_parameters, toolInputVarSchema) + }) + return inputsWithDefaultValue + } + useEffect(() => { if (!currTool) return - const inputsWithDefaultValue = produce(inputs, (draft) => { - if (!draft.tool_configurations || Object.keys(draft.tool_configurations).length === 0) - draft.tool_configurations = addDefaultValue(tool_configurations, toolSettingSchema) - - if (!draft.tool_parameters) - draft.tool_parameters = {} - }) + const inputsWithDefaultValue = formattingParameters() setInputs(inputsWithDefaultValue) // eslint-disable-next-line react-hooks/exhaustive-deps }, [currTool]) @@ -143,19 +151,6 @@ const useConfig = (id: string, payload: ToolNodeType) => { }) }, [inputs, setInputs]) - const [currVarIndex, setCurrVarIndex] = useState(-1) - const currVarType = toolInputVarSchema[currVarIndex]?._type - const handleOnVarOpen = useCallback((index: number) => { - setCurrVarIndex(index) - }, []) - - const filterVar = useCallback((varPayload: Var) => { - if (currVarType) - return varPayload.type === currVarType - - return varPayload.type !== VarVarType.arrayFile - }, [currVarType]) - const isLoading = currTool && (isBuiltIn ? !currCollection : false) const getMoreDataForCheckValid = () => { @@ -220,8 +215,6 @@ const useConfig = (id: string, payload: ToolNodeType) => { setToolSettingValue, toolInputVarSchema, setInputVar, - handleOnVarOpen, - filterVar, currCollection, isShowAuthBtn, showSetAuth, diff --git a/web/app/components/workflow/nodes/tool/use-single-run-form-params.ts b/web/app/components/workflow/nodes/tool/use-single-run-form-params.ts index 295cf02639..6fc79beebe 100644 --- a/web/app/components/workflow/nodes/tool/use-single-run-form-params.ts +++ b/web/app/components/workflow/nodes/tool/use-single-run-form-params.ts @@ -34,7 +34,12 @@ const useSingleRunFormParams = ({ const hadVarParams = Object.keys(inputs.tool_parameters) .filter(key => inputs.tool_parameters[key].type !== VarType.constant) .map(k => inputs.tool_parameters[k]) - const varInputs = getInputVars(hadVarParams.map((p) => { + + const hadVarSettings = Object.keys(inputs.tool_configurations) + .filter(key => typeof inputs.tool_configurations[key] === 'object' && inputs.tool_configurations[key].type && inputs.tool_configurations[key].type !== VarType.constant) + .map(k => inputs.tool_configurations[k]) + + const varInputs = getInputVars([...hadVarParams, ...hadVarSettings].map((p) => { if (p.type === VarType.variable) { // handle the old wrong value not crash the page if (!(p.value as any).join) @@ -55,8 +60,11 @@ const useSingleRunFormParams = ({ const res = produce(inputVarValues, (draft) => { Object.keys(inputs.tool_parameters).forEach((key: string) => { const { type, value } = inputs.tool_parameters[key] - if (type === VarType.constant && (value === undefined || value === null)) + if (type === VarType.constant && (value === undefined || value === null)) { + if(!draft.tool_parameters || !draft.tool_parameters[key]) + return draft[key] = value + } }) }) return res diff --git a/web/app/components/workflow/nodes/variable-assigner/components/var-group-item.tsx b/web/app/components/workflow/nodes/variable-assigner/components/var-group-item.tsx index cf9d4152a4..60be8a0842 100644 --- a/web/app/components/workflow/nodes/variable-assigner/components/var-group-item.tsx +++ b/web/app/components/workflow/nodes/variable-assigner/components/var-group-item.tsx @@ -15,7 +15,7 @@ import { VarType } from '@/app/components/workflow/types' import type { NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types' import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types' import { Folder } from '@/app/components/base/icons/src/vender/line/files' -import { checkKeys } from '@/utils/var' +import { checkKeys, replaceSpaceWithUnderscreInVarNameInput } from '@/utils/var' import Toast from '@/app/components/base/toast' const i18nPrefix = 'workflow.nodes.variableAssigner' @@ -89,6 +89,7 @@ const VarGroupItem: FC = ({ }] = useBoolean(false) const handleGroupNameChange = useCallback((e: ChangeEvent) => { + replaceSpaceWithUnderscreInVarNameInput(e.target) const value = e.target.value const { isValid, errorKey, errorMessageKey } = checkKeys([value], false) if (!isValid) { diff --git a/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.tsx b/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.tsx index 3240496b62..869317ca6a 100644 --- a/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.tsx +++ b/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.tsx @@ -16,7 +16,7 @@ import type { ConversationVariable } from '@/app/components/workflow/types' import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' import { ChatVarType } from '@/app/components/workflow/panel/chat-variable-panel/type' import cn from '@/utils/classnames' -import { checkKeys } from '@/utils/var' +import { checkKeys, replaceSpaceWithUnderscreInVarNameInput } from '@/utils/var' export type ModalPropsType = { chatVar?: ConversationVariable @@ -80,7 +80,7 @@ const ChatVariableModal = ({ const [objectValue, setObjectValue] = React.useState([DEFAULT_OBJECT_VALUE]) const [editorContent, setEditorContent] = React.useState() const [editInJSON, setEditInJSON] = React.useState(false) - const [des, setDes] = React.useState('') + const [description, setDescription] = React.useState('') const editorMinHeight = useMemo(() => { if (type === ChatVarType.ArrayObject) @@ -143,6 +143,13 @@ const ChatVariableModal = ({ return true } + const handleVarNameChange = (e: React.ChangeEvent) => { + replaceSpaceWithUnderscreInVarNameInput(e.target) + if (!!e.target.value && !checkVariableName(e.target.value)) + return + setName(e.target.value || '') + } + const handleTypeChange = (v: ChatVarType) => { setValue(undefined) setEditorContent(undefined) @@ -230,7 +237,7 @@ const ChatVariableModal = ({ name, value_type: type, value: formatValue(value), - description: des, + description, }) onClose() } @@ -240,7 +247,7 @@ const ChatVariableModal = ({ setName(chatVar.name) setType(chatVar.value_type) setValue(chatVar.value) - setDes(chatVar.description) + setDescription(chatVar.description) setObjectValue(getObjectValue()) if (chatVar.value_type === ChatVarType.ArrayObject) { setEditorContent(JSON.stringify(chatVar.value)) @@ -275,7 +282,7 @@ const ChatVariableModal = ({ setName(e.target.value || '')} + onChange={handleVarNameChange} onBlur={e => checkVariableName(e.target.value)} type='text' /> @@ -378,9 +385,9 @@ const ChatVariableModal = ({