Merge branch 'feat/plugins' of https://github.com/langgenius/dify into feat/plugins

pull/12372/head
AkaraChen 1 year ago
commit 870779534f

@ -85,11 +85,11 @@ ignore = [
] ]
"tests/*" = [ "tests/*" = [
"F811", # redefined-while-unused "F811", # redefined-while-unused
"F401", # unused-import
] ]
[lint.pyflakes] [lint.pyflakes]
extend-generics = [ allowed-unused-imports = [
"_pytest.monkeypatch", "_pytest.monkeypatch",
"tests.integration_tests", "tests.integration_tests",
"tests.unit_tests",
] ]

@ -55,7 +55,7 @@ RUN apt-get update \
&& echo "deb http://deb.debian.org/debian testing main" > /etc/apt/sources.list \ && echo "deb http://deb.debian.org/debian testing main" > /etc/apt/sources.list \
&& apt-get update \ && apt-get update \
# For Security # For Security
&& apt-get install -y --no-install-recommends expat=2.6.4-1 libldap-2.5-0=2.5.18+dfsg-3+b1 perl=5.40.0-8 libsqlite3-0=3.46.1-1 zlib1g=1:1.3.dfsg+really1.3.1-1+b1 \ && apt-get install -y --no-install-recommends expat=2.6.4-1 libldap-2.5-0=2.5.19+dfsg-1 perl=5.40.0-8 libsqlite3-0=3.46.1-1 zlib1g=1:1.3.dfsg+really1.3.1-1+b1 \
# install a chinese font to support the use of tools like matplotlib # install a chinese font to support the use of tools like matplotlib
&& apt-get install -y fonts-noto-cjk \ && apt-get install -y fonts-noto-cjk \
&& apt-get autoremove -y \ && apt-get autoremove -y \

@ -1,12 +1,8 @@
from libs import version_utils import os
import sys
# preparation before creating app
version_utils.check_supported_python_version()
def is_db_command(): def is_db_command():
import sys
if len(sys.argv) > 1 and sys.argv[0].endswith("flask") and sys.argv[1] == "db": if len(sys.argv) > 1 and sys.argv[0].endswith("flask") and sys.argv[1] == "db":
return True return True
return False return False
@ -18,10 +14,18 @@ if is_db_command():
app = create_migrations_app() app = create_migrations_app()
else: else:
from app_factory import create_app if os.environ.get("FLASK_DEBUG", "False") != "True":
from libs import threadings_utils from gevent import monkey # type: ignore
# gevent
monkey.patch_all()
threadings_utils.apply_gevent_threading_patch() from grpc.experimental import gevent as grpc_gevent # type: ignore
# grpc gevent
grpc_gevent.init_gevent()
from app_factory import create_app
app = create_app() app = create_app()
celery = app.extensions["celery"] celery = app.extensions["celery"]

@ -765,6 +765,13 @@ class LoginConfig(BaseSettings):
) )
class AccountConfig(BaseSettings):
ACCOUNT_DELETION_TOKEN_EXPIRY_MINUTES: PositiveInt = Field(
description="Duration in minutes for which a account deletion token remains valid",
default=5,
)
class FeatureConfig( class FeatureConfig(
# place the configs in alphabet order # place the configs in alphabet order
AppExecutionConfig, AppExecutionConfig,
@ -792,6 +799,7 @@ class FeatureConfig(
WorkflowNodeExecutionConfig, WorkflowNodeExecutionConfig,
WorkspaceConfig, WorkspaceConfig,
LoginConfig, LoginConfig,
AccountConfig,
# hosted services config # hosted services config
HostedServiceConfig, HostedServiceConfig,
CeleryBeatConfig, CeleryBeatConfig,

@ -2,7 +2,7 @@ import json
import logging import logging
from flask import abort, request from flask import abort, request
from flask_restful import Resource, marshal_with, reqparse # type: ignore from flask_restful import Resource, inputs, marshal_with, reqparse # type: ignore
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound from werkzeug.exceptions import Forbidden, InternalServerError, NotFound
import services import services
@ -14,7 +14,7 @@ from controllers.console.wraps import account_initialization_required, setup_req
from core.app.apps.base_app_queue_manager import AppQueueManager from core.app.apps.base_app_queue_manager import AppQueueManager
from core.app.entities.app_invoke_entities import InvokeFrom from core.app.entities.app_invoke_entities import InvokeFrom
from factories import variable_factory from factories import variable_factory
from fields.workflow_fields import workflow_fields from fields.workflow_fields import workflow_fields, workflow_pagination_fields
from fields.workflow_run_fields import workflow_run_node_execution_fields from fields.workflow_run_fields import workflow_run_node_execution_fields
from libs import helper from libs import helper
from libs.helper import TimestampField, uuid_value from libs.helper import TimestampField, uuid_value
@ -440,6 +440,31 @@ class WorkflowConfigApi(Resource):
} }
class PublishedAllWorkflowApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
@marshal_with(workflow_pagination_fields)
def get(self, app_model: App):
"""
Get published workflows
"""
if not current_user.is_editor:
raise Forbidden()
parser = reqparse.RequestParser()
parser.add_argument("page", type=inputs.int_range(1, 99999), required=False, default=1, location="args")
parser.add_argument("limit", type=inputs.int_range(1, 100), required=False, default=20, location="args")
args = parser.parse_args()
page = args.get("page")
limit = args.get("limit")
workflow_service = WorkflowService()
workflows, has_more = workflow_service.get_all_published_workflow(app_model=app_model, page=page, limit=limit)
return {"items": workflows, "page": page, "limit": limit, "has_more": has_more}
api.add_resource(DraftWorkflowApi, "/apps/<uuid:app_id>/workflows/draft") api.add_resource(DraftWorkflowApi, "/apps/<uuid:app_id>/workflows/draft")
api.add_resource(WorkflowConfigApi, "/apps/<uuid:app_id>/workflows/draft/config") api.add_resource(WorkflowConfigApi, "/apps/<uuid:app_id>/workflows/draft/config")
api.add_resource(AdvancedChatDraftWorkflowRunApi, "/apps/<uuid:app_id>/advanced-chat/workflows/draft/run") api.add_resource(AdvancedChatDraftWorkflowRunApi, "/apps/<uuid:app_id>/advanced-chat/workflows/draft/run")
@ -454,6 +479,7 @@ api.add_resource(
WorkflowDraftRunIterationNodeApi, "/apps/<uuid:app_id>/workflows/draft/iteration/nodes/<string:node_id>/run" WorkflowDraftRunIterationNodeApi, "/apps/<uuid:app_id>/workflows/draft/iteration/nodes/<string:node_id>/run"
) )
api.add_resource(PublishedWorkflowApi, "/apps/<uuid:app_id>/workflows/publish") api.add_resource(PublishedWorkflowApi, "/apps/<uuid:app_id>/workflows/publish")
api.add_resource(PublishedAllWorkflowApi, "/apps/<uuid:app_id>/workflows")
api.add_resource(DefaultBlockConfigsApi, "/apps/<uuid:app_id>/workflows/default-workflow-block-configs") api.add_resource(DefaultBlockConfigsApi, "/apps/<uuid:app_id>/workflows/default-workflow-block-configs")
api.add_resource( api.add_resource(
DefaultBlockConfigApi, "/apps/<uuid:app_id>/workflows/default-workflow-block-configs/<string:block_type>" DefaultBlockConfigApi, "/apps/<uuid:app_id>/workflows/default-workflow-block-configs/<string:block_type>"

@ -53,3 +53,9 @@ class EmailCodeLoginRateLimitExceededError(BaseHTTPException):
error_code = "email_code_login_rate_limit_exceeded" error_code = "email_code_login_rate_limit_exceeded"
description = "Too many login emails have been sent. Please try again in 5 minutes." description = "Too many login emails have been sent. Please try again in 5 minutes."
code = 429 code = 429
class EmailCodeAccountDeletionRateLimitExceededError(BaseHTTPException):
error_code = "email_code_account_deletion_rate_limit_exceeded"
description = "Too many account deletion emails have been sent. Please try again in 5 minutes."
code = 429

@ -6,13 +6,8 @@ from flask_restful import Resource, reqparse # type: ignore
from constants.languages import languages from constants.languages import languages
from controllers.console import api from controllers.console import api
from controllers.console.auth.error import ( from controllers.console.auth.error import EmailCodeError, InvalidEmailError, InvalidTokenError, PasswordMismatchError
EmailCodeError, from controllers.console.error import AccountInFreezeError, AccountNotFound, EmailSendIpLimitError
InvalidEmailError,
InvalidTokenError,
PasswordMismatchError,
)
from controllers.console.error import AccountNotFound, EmailSendIpLimitError
from controllers.console.wraps import setup_required from controllers.console.wraps import setup_required
from events.tenant_event import tenant_was_created from events.tenant_event import tenant_was_created
from extensions.ext_database import db from extensions.ext_database import db
@ -20,6 +15,7 @@ from libs.helper import email, extract_remote_ip
from libs.password import hash_password, valid_password from libs.password import hash_password, valid_password
from models.account import Account from models.account import Account
from services.account_service import AccountService, TenantService from services.account_service import AccountService, TenantService
from services.errors.account import AccountRegisterError
from services.errors.workspace import WorkSpaceNotAllowedCreateError from services.errors.workspace import WorkSpaceNotAllowedCreateError
from services.feature_service import FeatureService from services.feature_service import FeatureService
@ -129,6 +125,8 @@ class ForgotPasswordResetApi(Resource):
) )
except WorkSpaceNotAllowedCreateError: except WorkSpaceNotAllowedCreateError:
pass pass
except AccountRegisterError as are:
raise AccountInFreezeError()
return {"result": "success"} return {"result": "success"}

@ -5,6 +5,7 @@ from flask import request
from flask_restful import Resource, reqparse # type: ignore from flask_restful import Resource, reqparse # type: ignore
import services import services
from configs import dify_config
from constants.languages import languages from constants.languages import languages
from controllers.console import api from controllers.console import api
from controllers.console.auth.error import ( from controllers.console.auth.error import (
@ -16,6 +17,7 @@ from controllers.console.auth.error import (
) )
from controllers.console.error import ( from controllers.console.error import (
AccountBannedError, AccountBannedError,
AccountInFreezeError,
AccountNotFound, AccountNotFound,
EmailSendIpLimitError, EmailSendIpLimitError,
NotAllowedCreateWorkspace, NotAllowedCreateWorkspace,
@ -26,6 +28,8 @@ from libs.helper import email, extract_remote_ip
from libs.password import valid_password from libs.password import valid_password
from models.account import Account from models.account import Account
from services.account_service import AccountService, RegisterService, TenantService from services.account_service import AccountService, RegisterService, TenantService
from services.billing_service import BillingService
from services.errors.account import AccountRegisterError
from services.errors.workspace import WorkSpaceNotAllowedCreateError from services.errors.workspace import WorkSpaceNotAllowedCreateError
from services.feature_service import FeatureService from services.feature_service import FeatureService
@ -44,6 +48,9 @@ class LoginApi(Resource):
parser.add_argument("language", type=str, required=False, default="en-US", location="json") parser.add_argument("language", type=str, required=False, default="en-US", location="json")
args = parser.parse_args() args = parser.parse_args()
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(args["email"]):
raise AccountInFreezeError()
is_login_error_rate_limit = AccountService.is_login_error_rate_limit(args["email"]) is_login_error_rate_limit = AccountService.is_login_error_rate_limit(args["email"])
if is_login_error_rate_limit: if is_login_error_rate_limit:
raise EmailPasswordLoginLimitError() raise EmailPasswordLoginLimitError()
@ -113,8 +120,10 @@ class ResetPasswordSendEmailApi(Resource):
language = "zh-Hans" language = "zh-Hans"
else: else:
language = "en-US" language = "en-US"
try:
account = AccountService.get_user_through_email(args["email"]) account = AccountService.get_user_through_email(args["email"])
except AccountRegisterError as are:
raise AccountInFreezeError()
if account is None: if account is None:
if FeatureService.get_system_features().is_allow_register: if FeatureService.get_system_features().is_allow_register:
token = AccountService.send_reset_password_email(email=args["email"], language=language) token = AccountService.send_reset_password_email(email=args["email"], language=language)
@ -142,8 +151,11 @@ class EmailCodeLoginSendEmailApi(Resource):
language = "zh-Hans" language = "zh-Hans"
else: else:
language = "en-US" language = "en-US"
try:
account = AccountService.get_user_through_email(args["email"])
except AccountRegisterError as are:
raise AccountInFreezeError()
account = AccountService.get_user_through_email(args["email"])
if account is None: if account is None:
if FeatureService.get_system_features().is_allow_register: if FeatureService.get_system_features().is_allow_register:
token = AccountService.send_email_code_login_email(email=args["email"], language=language) token = AccountService.send_email_code_login_email(email=args["email"], language=language)
@ -177,7 +189,10 @@ class EmailCodeLoginApi(Resource):
raise EmailCodeError() raise EmailCodeError()
AccountService.revoke_email_code_login_token(args["token"]) AccountService.revoke_email_code_login_token(args["token"])
account = AccountService.get_user_through_email(user_email) try:
account = AccountService.get_user_through_email(user_email)
except AccountRegisterError as are:
raise AccountInFreezeError()
if account: if account:
tenant = TenantService.get_join_tenants(account) tenant = TenantService.get_join_tenants(account)
if not tenant: if not tenant:
@ -196,6 +211,8 @@ class EmailCodeLoginApi(Resource):
) )
except WorkSpaceNotAllowedCreateError: except WorkSpaceNotAllowedCreateError:
return NotAllowedCreateWorkspace() return NotAllowedCreateWorkspace()
except AccountRegisterError as are:
raise AccountInFreezeError()
token_pair = AccountService.login(account, ip_address=extract_remote_ip(request)) token_pair = AccountService.login(account, ip_address=extract_remote_ip(request))
AccountService.reset_login_error_rate_limit(args["email"]) AccountService.reset_login_error_rate_limit(args["email"])
return {"result": "success", "data": token_pair.model_dump()} return {"result": "success", "data": token_pair.model_dump()}

@ -16,7 +16,7 @@ from libs.oauth import GitHubOAuth, GoogleOAuth, OAuthUserInfo
from models import Account from models import Account
from models.account import AccountStatus from models.account import AccountStatus
from services.account_service import AccountService, RegisterService, TenantService from services.account_service import AccountService, RegisterService, TenantService
from services.errors.account import AccountNotFoundError from services.errors.account import AccountNotFoundError, AccountRegisterError
from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkSpaceNotFoundError from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkSpaceNotFoundError
from services.feature_service import FeatureService from services.feature_service import FeatureService
@ -99,6 +99,8 @@ class OAuthCallback(Resource):
f"{dify_config.CONSOLE_WEB_URL}/signin" f"{dify_config.CONSOLE_WEB_URL}/signin"
"?message=Workspace not found, please contact system admin to invite you to join in a workspace." "?message=Workspace not found, please contact system admin to invite you to join in a workspace."
) )
except AccountRegisterError as e:
return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message={e.description}")
# Check account status # Check account status
if account.status == AccountStatus.BANNED.value: if account.status == AccountStatus.BANNED.value:

@ -92,3 +92,12 @@ class UnauthorizedAndForceLogout(BaseHTTPException):
error_code = "unauthorized_and_force_logout" error_code = "unauthorized_and_force_logout"
description = "Unauthorized and force logout." description = "Unauthorized and force logout."
code = 401 code = 401
class AccountInFreezeError(BaseHTTPException):
error_code = "account_in_freeze"
code = 400
description = (
"This email account has been deleted within the past 30 days"
"and is temporarily unavailable for new account registration."
)

@ -66,10 +66,17 @@ class MessageFeedbackApi(InstalledAppResource):
parser = reqparse.RequestParser() parser = reqparse.RequestParser()
parser.add_argument("rating", type=str, choices=["like", "dislike", None], location="json") parser.add_argument("rating", type=str, choices=["like", "dislike", None], location="json")
parser.add_argument("content", type=str, location="json")
args = parser.parse_args() args = parser.parse_args()
try: try:
MessageService.create_feedback(app_model, message_id, current_user, args.get("rating"), args.get("content")) MessageService.create_feedback(
app_model=app_model,
message_id=message_id,
user=current_user,
rating=args.get("rating"),
content=args.get("content"),
)
except services.errors.message.MessageNotExistsError: except services.errors.message.MessageNotExistsError:
raise NotFound("Message Not Exists.") raise NotFound("Message Not Exists.")

@ -11,6 +11,7 @@ from controllers.console import api
from controllers.console.workspace.error import ( from controllers.console.workspace.error import (
AccountAlreadyInitedError, AccountAlreadyInitedError,
CurrentPasswordIncorrectError, CurrentPasswordIncorrectError,
InvalidAccountDeletionCodeError,
InvalidInvitationCodeError, InvalidInvitationCodeError,
RepeatPasswordNotMatchError, RepeatPasswordNotMatchError,
) )
@ -21,6 +22,7 @@ from libs.helper import TimestampField, timezone
from libs.login import login_required from libs.login import login_required
from models import AccountIntegrate, InvitationCode from models import AccountIntegrate, InvitationCode
from services.account_service import AccountService from services.account_service import AccountService
from services.billing_service import BillingService
from services.errors.account import CurrentPasswordIncorrectError as ServiceCurrentPasswordIncorrectError from services.errors.account import CurrentPasswordIncorrectError as ServiceCurrentPasswordIncorrectError
@ -242,6 +244,54 @@ class AccountIntegrateApi(Resource):
return {"data": integrate_data} return {"data": integrate_data}
class AccountDeleteVerifyApi(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self):
account = current_user
token, code = AccountService.generate_account_deletion_verification_code(account)
AccountService.send_account_deletion_verification_email(account, code)
return {"result": "success", "data": token}
class AccountDeleteApi(Resource):
@setup_required
@login_required
@account_initialization_required
def post(self):
account = current_user
parser = reqparse.RequestParser()
parser.add_argument("token", type=str, required=True, location="json")
parser.add_argument("code", type=str, required=True, location="json")
args = parser.parse_args()
if not AccountService.verify_account_deletion_code(args["token"], args["code"]):
raise InvalidAccountDeletionCodeError()
AccountService.delete_account(account)
return {"result": "success"}
class AccountDeleteUpdateFeedbackApi(Resource):
@setup_required
def post(self):
account = current_user
parser = reqparse.RequestParser()
parser.add_argument("email", type=str, required=True, location="json")
parser.add_argument("feedback", type=str, required=True, location="json")
args = parser.parse_args()
BillingService.update_account_deletion_feedback(args["email"], args["feedback"])
return {"result": "success"}
# Register API resources # Register API resources
api.add_resource(AccountInitApi, "/account/init") api.add_resource(AccountInitApi, "/account/init")
api.add_resource(AccountProfileApi, "/account/profile") api.add_resource(AccountProfileApi, "/account/profile")
@ -252,5 +302,8 @@ api.add_resource(AccountInterfaceThemeApi, "/account/interface-theme")
api.add_resource(AccountTimezoneApi, "/account/timezone") api.add_resource(AccountTimezoneApi, "/account/timezone")
api.add_resource(AccountPasswordApi, "/account/password") api.add_resource(AccountPasswordApi, "/account/password")
api.add_resource(AccountIntegrateApi, "/account/integrates") api.add_resource(AccountIntegrateApi, "/account/integrates")
api.add_resource(AccountDeleteVerifyApi, "/account/delete/verify")
api.add_resource(AccountDeleteApi, "/account/delete")
api.add_resource(AccountDeleteUpdateFeedbackApi, "/account/delete/feedback")
# api.add_resource(AccountEmailApi, '/account/email') # api.add_resource(AccountEmailApi, '/account/email')
# api.add_resource(AccountEmailVerifyApi, '/account/email-verify') # api.add_resource(AccountEmailVerifyApi, '/account/email-verify')

@ -35,3 +35,9 @@ class AccountNotInitializedError(BaseHTTPException):
error_code = "account_not_initialized" error_code = "account_not_initialized"
description = "The account has not been initialized yet. Please proceed with the initialization process first." description = "The account has not been initialized yet. Please proceed with the initialization process first."
code = 400 code = 400
class InvalidAccountDeletionCodeError(BaseHTTPException):
error_code = "invalid_account_deletion_code"
description = "Invalid account deletion code."
code = 400

@ -122,7 +122,7 @@ class MemberUpdateRoleApi(Resource):
return {"code": "invalid-role", "message": "Invalid role"}, 400 return {"code": "invalid-role", "message": "Invalid role"}, 400
member = db.session.get(Account, str(member_id)) member = db.session.get(Account, str(member_id))
if member: if not member:
abort(404) abort(404)
try: try:

@ -108,7 +108,13 @@ class MessageFeedbackApi(Resource):
args = parser.parse_args() args = parser.parse_args()
try: try:
MessageService.create_feedback(app_model, message_id, end_user, args.get("rating"), args.get("content")) MessageService.create_feedback(
app_model=app_model,
message_id=message_id,
user=end_user,
rating=args.get("rating"),
content=args.get("content"),
)
except services.errors.message.MessageNotExistsError: except services.errors.message.MessageNotExistsError:
raise NotFound("Message Not Exists.") raise NotFound("Message Not Exists.")

@ -8,12 +8,16 @@ from werkzeug.exceptions import NotFound
import services.dataset_service import services.dataset_service
from controllers.common.errors import FilenameNotExistsError from controllers.common.errors import FilenameNotExistsError
from controllers.service_api import api from controllers.service_api import api
from controllers.service_api.app.error import ProviderNotInitializeError from controllers.service_api.app.error import (
FileTooLargeError,
NoFileUploadedError,
ProviderNotInitializeError,
TooManyFilesError,
UnsupportedFileTypeError,
)
from controllers.service_api.dataset.error import ( from controllers.service_api.dataset.error import (
ArchivedDocumentImmutableError, ArchivedDocumentImmutableError,
DocumentIndexingError, DocumentIndexingError,
NoFileUploadedError,
TooManyFilesError,
) )
from controllers.service_api.wraps import DatasetApiResource, cloud_edition_billing_resource_check from controllers.service_api.wraps import DatasetApiResource, cloud_edition_billing_resource_check
from core.errors.error import ProviderTokenNotInitError from core.errors.error import ProviderTokenNotInitError
@ -238,13 +242,18 @@ class DocumentUpdateByFileApi(DatasetApiResource):
if not file.filename: if not file.filename:
raise FilenameNotExistsError raise FilenameNotExistsError
upload_file = FileService.upload_file( try:
filename=file.filename, upload_file = FileService.upload_file(
content=file.read(), filename=file.filename,
mimetype=file.mimetype, content=file.read(),
user=current_user, mimetype=file.mimetype,
source="datasets", user=current_user,
) source="datasets",
)
except services.errors.file.FileTooLargeError as file_too_large_error:
raise FileTooLargeError(file_too_large_error.description)
except services.errors.file.UnsupportedFileTypeError:
raise UnsupportedFileTypeError()
data_source = {"type": "upload_file", "info_list": {"file_info_list": {"file_ids": [upload_file.id]}}} data_source = {"type": "upload_file", "info_list": {"file_info_list": {"file_ids": [upload_file.id]}}}
args["data_source"] = data_source args["data_source"] = data_source
# validate args # validate args

@ -339,13 +339,13 @@ class BaseAgentRunner(AppRunner):
raise ValueError(f"Agent thought {agent_thought.id} not found") raise ValueError(f"Agent thought {agent_thought.id} not found")
agent_thought = queried_thought agent_thought = queried_thought
if thought is not None: if thought:
agent_thought.thought = thought agent_thought.thought = thought
if tool_name is not None: if tool_name:
agent_thought.tool = tool_name agent_thought.tool = tool_name
if tool_input is not None: if tool_input:
if isinstance(tool_input, dict): if isinstance(tool_input, dict):
try: try:
tool_input = json.dumps(tool_input, ensure_ascii=False) tool_input = json.dumps(tool_input, ensure_ascii=False)
@ -354,7 +354,7 @@ class BaseAgentRunner(AppRunner):
agent_thought.tool_input = tool_input agent_thought.tool_input = tool_input
if observation is not None: if observation:
if isinstance(observation, dict): if isinstance(observation, dict):
try: try:
observation = json.dumps(observation, ensure_ascii=False) observation = json.dumps(observation, ensure_ascii=False)
@ -363,7 +363,7 @@ class BaseAgentRunner(AppRunner):
agent_thought.observation = observation agent_thought.observation = observation
if answer is not None: if answer:
agent_thought.answer = answer agent_thought.answer = answer
if messages_ids is not None and len(messages_ids) > 0: if messages_ids is not None and len(messages_ids) > 0:

@ -274,7 +274,7 @@ class WorkflowCycleManage:
self, *, session: Session, workflow_run: WorkflowRun, event: QueueNodeStartedEvent self, *, session: Session, workflow_run: WorkflowRun, event: QueueNodeStartedEvent
) -> WorkflowNodeExecution: ) -> WorkflowNodeExecution:
workflow_node_execution = WorkflowNodeExecution() workflow_node_execution = WorkflowNodeExecution()
workflow_node_execution.id = event.node_execution_id workflow_node_execution.id = str(uuid4())
workflow_node_execution.tenant_id = workflow_run.tenant_id workflow_node_execution.tenant_id = workflow_run.tenant_id
workflow_node_execution.app_id = workflow_run.app_id workflow_node_execution.app_id = workflow_run.app_id
workflow_node_execution.workflow_id = workflow_run.workflow_id workflow_node_execution.workflow_id = workflow_run.workflow_id
@ -391,7 +391,7 @@ class WorkflowCycleManage:
execution_metadata = json.dumps(merged_metadata) execution_metadata = json.dumps(merged_metadata)
workflow_node_execution = WorkflowNodeExecution() workflow_node_execution = WorkflowNodeExecution()
workflow_node_execution.id = event.node_execution_id workflow_node_execution.id = str(uuid4())
workflow_node_execution.tenant_id = workflow_run.tenant_id workflow_node_execution.tenant_id = workflow_run.tenant_id
workflow_node_execution.app_id = workflow_run.app_id workflow_node_execution.app_id = workflow_run.app_id
workflow_node_execution.workflow_id = workflow_run.workflow_id workflow_node_execution.workflow_id = workflow_run.workflow_id
@ -824,7 +824,7 @@ class WorkflowCycleManage:
return workflow_run return workflow_run
def _get_workflow_node_execution(self, session: Session, node_execution_id: str) -> WorkflowNodeExecution: def _get_workflow_node_execution(self, session: Session, node_execution_id: str) -> WorkflowNodeExecution:
stmt = select(WorkflowNodeExecution).where(WorkflowNodeExecution.id == node_execution_id) stmt = select(WorkflowNodeExecution).where(WorkflowNodeExecution.node_execution_id == node_execution_id)
workflow_node_execution = session.scalar(stmt) workflow_node_execution = session.scalar(stmt)
if not workflow_node_execution: if not workflow_node_execution:
raise WorkflowNodeExecutionNotFoundError(node_execution_id) raise WorkflowNodeExecutionNotFoundError(node_execution_id)

@ -122,6 +122,7 @@ class _CommonWenxin:
"bge-large-zh": "https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop/embeddings/bge_large_zh", "bge-large-zh": "https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop/embeddings/bge_large_zh",
"tao-8k": "https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop/embeddings/tao_8k", "tao-8k": "https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop/embeddings/tao_8k",
"bce-reranker-base_v1": "https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop/reranker/bce_reranker_base", "bce-reranker-base_v1": "https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop/reranker/bce_reranker_base",
"ernie-lite-pro-128k": "https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop/chat/ernie-lite-pro-128k",
} }
function_calling_supports = [ function_calling_supports = [

@ -0,0 +1,42 @@
model: ernie-lite-pro-128k
label:
en_US: Ernie-Lite-Pro-128K
model_type: llm
features:
- agent-thought
model_properties:
mode: chat
context_size: 128000
parameter_rules:
- name: temperature
use_template: temperature
min: 0.1
max: 1.0
default: 0.8
- name: top_p
use_template: top_p
- name: min_output_tokens
label:
en_US: "Min Output Tokens"
zh_Hans: "最小输出Token数"
use_template: max_tokens
min: 2
max: 2048
help:
zh_Hans: 指定模型最小输出token数
en_US: Specifies the lower limit on the length of generated results.
- name: max_output_tokens
label:
en_US: "Max Output Tokens"
zh_Hans: "最大输出Token数"
use_template: max_tokens
min: 2
max: 2048
default: 2048
help:
zh_Hans: 指定模型最大输出token数
en_US: Specifies the upper limit on the length of generated results. If the generated results are truncated, you can increase this parameter.
- name: presence_penalty
use_template: presence_penalty
- name: frequency_penalty
use_template: frequency_penalty

@ -1,5 +1,5 @@
import re import re
from typing import Optional from typing import Optional, cast
class JiebaKeywordTableHandler: class JiebaKeywordTableHandler:
@ -8,18 +8,20 @@ class JiebaKeywordTableHandler:
from core.rag.datasource.keyword.jieba.stopwords import STOPWORDS from core.rag.datasource.keyword.jieba.stopwords import STOPWORDS
jieba.analyse.default_tfidf.stop_words = STOPWORDS jieba.analyse.default_tfidf.stop_words = STOPWORDS # type: ignore
def extract_keywords(self, text: str, max_keywords_per_chunk: Optional[int] = 10) -> set[str]: def extract_keywords(self, text: str, max_keywords_per_chunk: Optional[int] = 10) -> set[str]:
"""Extract keywords with JIEBA tfidf.""" """Extract keywords with JIEBA tfidf."""
import jieba # type: ignore import jieba.analyse # type: ignore
keywords = jieba.analyse.extract_tags( keywords = jieba.analyse.extract_tags(
sentence=text, sentence=text,
topK=max_keywords_per_chunk, topK=max_keywords_per_chunk,
) )
# jieba.analyse.extract_tags returns list[Any] when withFlag is False by default.
keywords = cast(list[str], keywords)
return set(self._expand_tokens_with_subtokens(keywords)) return set(self._expand_tokens_with_subtokens(set(keywords)))
def _expand_tokens_with_subtokens(self, tokens: set[str]) -> set[str]: def _expand_tokens_with_subtokens(self, tokens: set[str]) -> set[str]:
"""Get subtokens from a list of tokens., filtering for stopwords.""" """Get subtokens from a list of tokens., filtering for stopwords."""

@ -138,17 +138,24 @@ class NotionExtractor(BaseExtractor):
block_url = BLOCK_CHILD_URL_TMPL.format(block_id=page_id) block_url = BLOCK_CHILD_URL_TMPL.format(block_id=page_id)
while True: while True:
query_dict: dict[str, Any] = {} if not start_cursor else {"start_cursor": start_cursor} query_dict: dict[str, Any] = {} if not start_cursor else {"start_cursor": start_cursor}
res = requests.request( try:
"GET", res = requests.request(
block_url, "GET",
headers={ block_url,
"Authorization": "Bearer " + self._notion_access_token, headers={
"Content-Type": "application/json", "Authorization": "Bearer " + self._notion_access_token,
"Notion-Version": "2022-06-28", "Content-Type": "application/json",
}, "Notion-Version": "2022-06-28",
params=query_dict, },
) params=query_dict,
data = res.json() )
if res.status_code != 200:
raise ValueError(f"Error fetching Notion block data: {res.text}")
data = res.json()
except requests.RequestException as e:
raise ValueError("Error fetching Notion block data") from e
if "results" not in data or not isinstance(data["results"], list):
raise ValueError("Error fetching Notion block data")
for result in data["results"]: for result in data["results"]:
result_type = result["type"] result_type = result["type"]
result_obj = result[result_type] result_obj = result[result_type]

@ -31,3 +31,7 @@ class ToolApiSchemaError(ValueError):
class ToolEngineInvokeError(Exception): class ToolEngineInvokeError(Exception):
meta: ToolInvokeMeta meta: ToolInvokeMeta
def __init__(self, meta, **kwargs):
self.meta = meta
super().__init__(**kwargs)

@ -21,7 +21,7 @@ class BedrockRetrieveTool(BuiltinTool):
retrieval_configuration = {"vectorSearchConfiguration": {"numberOfResults": num_results}} retrieval_configuration = {"vectorSearchConfiguration": {"numberOfResults": num_results}}
# 如果有元数据过滤条件,则添加到检索配置中 # Add metadata filter to retrieval configuration if present
if metadata_filter: if metadata_filter:
retrieval_configuration["vectorSearchConfiguration"]["filter"] = metadata_filter retrieval_configuration["vectorSearchConfiguration"]["filter"] = metadata_filter
@ -77,7 +77,7 @@ class BedrockRetrieveTool(BuiltinTool):
if not query: if not query:
return self.create_text_message("Please input query") return self.create_text_message("Please input query")
# 获取元数据过滤条件(如果存在) # Get metadata filter conditions (if they exist)
metadata_filter_str = tool_parameters.get("metadata_filter") metadata_filter_str = tool_parameters.get("metadata_filter")
metadata_filter = json.loads(metadata_filter_str) if metadata_filter_str else None metadata_filter = json.loads(metadata_filter_str) if metadata_filter_str else None
@ -86,7 +86,7 @@ class BedrockRetrieveTool(BuiltinTool):
query_input=query, query_input=query,
knowledge_base_id=self.knowledge_base_id, knowledge_base_id=self.knowledge_base_id,
num_results=self.topk, num_results=self.topk,
metadata_filter=metadata_filter, # 将元数据过滤条件传递给检索方法 metadata_filter=metadata_filter,
) )
line = 5 line = 5
@ -109,7 +109,7 @@ class BedrockRetrieveTool(BuiltinTool):
if not parameters.get("query"): if not parameters.get("query"):
raise ValueError("query is required") raise ValueError("query is required")
# 可选:可以验证元数据过滤条件是否为有效的 JSON 字符串(如果提供) # Optional: Validate if metadata filter is a valid JSON string (if provided)
metadata_filter_str = parameters.get("metadata_filter") metadata_filter_str = parameters.get("metadata_filter")
if metadata_filter_str and not isinstance(json.loads(metadata_filter_str), dict): if metadata_filter_str and not isinstance(json.loads(metadata_filter_str), dict):
raise ValueError("metadata_filter must be a valid JSON object") raise ValueError("metadata_filter must be a valid JSON object")

@ -73,9 +73,9 @@ parameters:
llm_description: AWS region where the Bedrock Knowledge Base is located llm_description: AWS region where the Bedrock Knowledge Base is located
form: form form: form
- name: metadata_filter - name: metadata_filter # Additional parameter for metadata filtering
type: string type: string # String type, expects JSON-formatted filter conditions
required: false required: false # Optional field - can be omitted
label: label:
en_US: Metadata Filter en_US: Metadata Filter
zh_Hans: 元数据过滤器 zh_Hans: 元数据过滤器

@ -6,8 +6,8 @@ import boto3
from core.tools.entities.tool_entities import ToolInvokeMessage from core.tools.entities.tool_entities import ToolInvokeMessage
from core.tools.tool.builtin_tool import BuiltinTool from core.tools.tool.builtin_tool import BuiltinTool
# 定义标签映射 # Define label mappings
LABEL_MAPPING = {"LABEL_0": "SAFE", "LABEL_1": "NO_SAFE"} LABEL_MAPPING = {0: "SAFE", 1: "NO_SAFE"}
class ContentModerationTool(BuiltinTool): class ContentModerationTool(BuiltinTool):
@ -28,12 +28,12 @@ class ContentModerationTool(BuiltinTool):
# Handle nested JSON if present # Handle nested JSON if present
if isinstance(json_obj, dict) and "body" in json_obj: if isinstance(json_obj, dict) and "body" in json_obj:
body_content = json.loads(json_obj["body"]) body_content = json.loads(json_obj["body"])
raw_label = body_content.get("label") prediction_result = body_content.get("prediction")
else: else:
raw_label = json_obj.get("label") prediction_result = json_obj.get("prediction")
# 映射标签并返回 # Map labels and return
result = LABEL_MAPPING.get(raw_label, "NO_SAFE") # 如果映射中没有找到,默认返回NO_SAFE result = LABEL_MAPPING.get(prediction_result, "NO_SAFE") # If not found in mapping, default to NO_SAFE
return result return result
def _invoke( def _invoke(

@ -10,8 +10,7 @@ from core.tools.tool.builtin_tool import BuiltinTool
class SageMakerReRankTool(BuiltinTool): class SageMakerReRankTool(BuiltinTool):
sagemaker_client: Any = None sagemaker_client: Any = None
sagemaker_endpoint: str | None = None sagemaker_endpoint: str = None
topk: int | None = None
def _sagemaker_rerank(self, query_input: str, docs: list[str], rerank_endpoint: str): def _sagemaker_rerank(self, query_input: str, docs: list[str], rerank_endpoint: str):
inputs = [query_input] * len(docs) inputs = [query_input] * len(docs)
@ -47,8 +46,7 @@ class SageMakerReRankTool(BuiltinTool):
self.sagemaker_endpoint = tool_parameters.get("sagemaker_endpoint") self.sagemaker_endpoint = tool_parameters.get("sagemaker_endpoint")
line = 2 line = 2
if not self.topk: topk = tool_parameters.get("topk", 5)
self.topk = tool_parameters.get("topk", 5)
line = 3 line = 3
query = tool_parameters.get("query", "") query = tool_parameters.get("query", "")
@ -75,7 +73,7 @@ class SageMakerReRankTool(BuiltinTool):
sorted_candidate_docs = sorted(candidate_docs, key=operator.itemgetter("score"), reverse=True) sorted_candidate_docs = sorted(candidate_docs, key=operator.itemgetter("score"), reverse=True)
line = 9 line = 9
return [self.create_json_message(res) for res in sorted_candidate_docs[: self.topk]] return [self.create_json_message(res) for res in sorted_candidate_docs[:topk]]
except Exception as e: except Exception as e:
return self.create_text_message(f"Exception {str(e)}, line : {line}") return self.create_text_message(f"Exception {str(e)}, line : {line}")

@ -125,7 +125,7 @@ class ComfyUiClient:
for output in history["outputs"].values(): for output in history["outputs"].values():
for img in output.get("images", []): for img in output.get("images", []):
image_data = self.get_image(img["filename"], img["subfolder"], img["type"]) image_data = self.get_image(img["filename"], img["subfolder"], img["type"])
images.append(image_data) images.append((image_data, img["filename"]))
return images return images
finally: finally:
ws.close() ws.close()

@ -1,4 +1,5 @@
import json import json
import mimetypes
from typing import Any from typing import Any
from core.file import FileType from core.file import FileType
@ -75,10 +76,12 @@ class ComfyUIWorkflowTool(BuiltinTool):
images = comfyui.generate_image_by_prompt(prompt) images = comfyui.generate_image_by_prompt(prompt)
result = [] result = []
for img in images: for image_data, filename in images:
result.append( result.append(
self.create_blob_message( self.create_blob_message(
blob=img, meta={"mime_type": "image/png"}, save_as=self.VariableKey.IMAGE.value blob=image_data,
meta={"mime_type": mimetypes.guess_type(filename)[0]},
save_as=self.VariableKey.IMAGE.value,
) )
) )
return result return result

@ -1,12 +1,13 @@
import json import json
import logging import logging
from copy import deepcopy from copy import deepcopy
from typing import Any, Optional, Union from typing import Any, Optional, Union, cast
from core.file import FILE_MODEL_IDENTITY, File, FileTransferMethod from core.file import FILE_MODEL_IDENTITY, File, FileTransferMethod
from core.tools.entities.tool_entities import ToolInvokeMessage, ToolParameter, ToolProviderType from core.tools.entities.tool_entities import ToolInvokeMessage, ToolParameter, ToolProviderType
from core.tools.tool.tool import Tool from core.tools.tool.tool import Tool
from extensions.ext_database import db from extensions.ext_database import db
from factories.file_factory import build_from_mapping
from models.account import Account from models.account import Account
from models.model import App, EndUser from models.model import App, EndUser
from models.workflow import Workflow from models.workflow import Workflow
@ -194,10 +195,18 @@ class WorkflowTool(Tool):
if isinstance(value, list): if isinstance(value, list):
for item in value: for item in value:
if isinstance(item, dict) and item.get("dify_model_identity") == FILE_MODEL_IDENTITY: if isinstance(item, dict) and item.get("dify_model_identity") == FILE_MODEL_IDENTITY:
file = File.model_validate(item) item["tool_file_id"] = item.get("related_id")
file = build_from_mapping(
mapping=item,
tenant_id=str(cast(Tool.Runtime, self.runtime).tenant_id),
)
files.append(file) files.append(file)
elif isinstance(value, dict) and value.get("dify_model_identity") == FILE_MODEL_IDENTITY: elif isinstance(value, dict) and value.get("dify_model_identity") == FILE_MODEL_IDENTITY:
file = File.model_validate(value) value["tool_file_id"] = value.get("related_id")
file = build_from_mapping(
mapping=value,
tenant_id=str(cast(Tool.Runtime, self.runtime).tenant_id),
)
files.append(file) files.append(file)
result[key] = value result[key] = value

@ -613,10 +613,10 @@ class Graph(BaseModel):
for (node_id, node_id2), branch_node_ids in duplicate_end_node_ids.items(): for (node_id, node_id2), branch_node_ids in duplicate_end_node_ids.items():
# check which node is after # check which node is after
if cls._is_node2_after_node1(node1_id=node_id, node2_id=node_id2, edge_mapping=edge_mapping): if cls._is_node2_after_node1(node1_id=node_id, node2_id=node_id2, edge_mapping=edge_mapping):
if node_id in merge_branch_node_ids: if node_id in merge_branch_node_ids and node_id2 in merge_branch_node_ids:
del merge_branch_node_ids[node_id2] del merge_branch_node_ids[node_id2]
elif cls._is_node2_after_node1(node1_id=node_id2, node2_id=node_id, edge_mapping=edge_mapping): elif cls._is_node2_after_node1(node1_id=node_id2, node2_id=node_id, edge_mapping=edge_mapping):
if node_id2 in merge_branch_node_ids: if node_id in merge_branch_node_ids and node_id2 in merge_branch_node_ids:
del merge_branch_node_ids[node_id] del merge_branch_node_ids[node_id]
branches_merge_node_ids: dict[str, str] = {} branches_merge_node_ids: dict[str, str] = {}

@ -15,11 +15,11 @@ def handle(sender, **kwargs):
app_dataset_joins = db.session.query(AppDatasetJoin).filter(AppDatasetJoin.app_id == app.id).all() app_dataset_joins = db.session.query(AppDatasetJoin).filter(AppDatasetJoin.app_id == app.id).all()
removed_dataset_ids: set[int] = set() removed_dataset_ids: set[str] = set()
if not app_dataset_joins: if not app_dataset_joins:
added_dataset_ids = dataset_ids added_dataset_ids = dataset_ids
else: else:
old_dataset_ids: set[int] = set() old_dataset_ids: set[str] = set()
old_dataset_ids.update(app_dataset_join.dataset_id for app_dataset_join in app_dataset_joins) old_dataset_ids.update(app_dataset_join.dataset_id for app_dataset_join in app_dataset_joins)
added_dataset_ids = dataset_ids - old_dataset_ids added_dataset_ids = dataset_ids - old_dataset_ids
@ -39,8 +39,8 @@ def handle(sender, **kwargs):
db.session.commit() db.session.commit()
def get_dataset_ids_from_model_config(app_model_config: AppModelConfig) -> set[int]: def get_dataset_ids_from_model_config(app_model_config: AppModelConfig) -> set[str]:
dataset_ids: set[int] = set() dataset_ids: set[str] = set()
if not app_model_config: if not app_model_config:
return dataset_ids return dataset_ids

@ -17,11 +17,11 @@ def handle(sender, **kwargs):
dataset_ids = get_dataset_ids_from_workflow(published_workflow) dataset_ids = get_dataset_ids_from_workflow(published_workflow)
app_dataset_joins = db.session.query(AppDatasetJoin).filter(AppDatasetJoin.app_id == app.id).all() app_dataset_joins = db.session.query(AppDatasetJoin).filter(AppDatasetJoin.app_id == app.id).all()
removed_dataset_ids: set[int] = set() removed_dataset_ids: set[str] = set()
if not app_dataset_joins: if not app_dataset_joins:
added_dataset_ids = dataset_ids added_dataset_ids = dataset_ids
else: else:
old_dataset_ids: set[int] = set() old_dataset_ids: set[str] = set()
old_dataset_ids.update(app_dataset_join.dataset_id for app_dataset_join in app_dataset_joins) old_dataset_ids.update(app_dataset_join.dataset_id for app_dataset_join in app_dataset_joins)
added_dataset_ids = dataset_ids - old_dataset_ids added_dataset_ids = dataset_ids - old_dataset_ids
@ -41,8 +41,8 @@ def handle(sender, **kwargs):
db.session.commit() db.session.commit()
def get_dataset_ids_from_workflow(published_workflow: Workflow) -> set[int]: def get_dataset_ids_from_workflow(published_workflow: Workflow) -> set[str]:
dataset_ids: set[int] = set() dataset_ids: set[str] = set()
graph = published_workflow.graph_dict graph = published_workflow.graph_dict
if not graph: if not graph:
return dataset_ids return dataset_ids
@ -60,7 +60,7 @@ def get_dataset_ids_from_workflow(published_workflow: Workflow) -> set[int]:
for node in knowledge_retrieval_nodes: for node in knowledge_retrieval_nodes:
try: try:
node_data = KnowledgeRetrievalNodeData(**node.get("data", {})) node_data = KnowledgeRetrievalNodeData(**node.get("data", {}))
dataset_ids.update(int(dataset_id) for dataset_id in node_data.dataset_ids) dataset_ids.update(dataset_id for dataset_id in node_data.dataset_ids)
except Exception as e: except Exception as e:
continue continue

@ -69,6 +69,7 @@ def init_app(app: DifyApp) -> Celery:
"schedule.create_tidb_serverless_task", "schedule.create_tidb_serverless_task",
"schedule.update_tidb_serverless_status_task", "schedule.update_tidb_serverless_status_task",
"schedule.clean_messages", "schedule.clean_messages",
"schedule.mail_clean_document_notify_task",
] ]
day = dify_config.CELERY_BEAT_SCHEDULER_TIME day = dify_config.CELERY_BEAT_SCHEDULER_TIME
beat_schedule = { beat_schedule = {
@ -92,6 +93,11 @@ def init_app(app: DifyApp) -> Celery:
"task": "schedule.clean_messages.clean_messages", "task": "schedule.clean_messages.clean_messages",
"schedule": timedelta(days=day), "schedule": timedelta(days=day),
}, },
# every Monday
"mail_clean_document_notify_task": {
"task": "schedule.mail_clean_document_notify_task.mail_clean_document_notify_task",
"schedule": crontab(minute="0", hour="10", day_of_week="1"),
},
} }
celery_app.conf.update(beat_schedule=beat_schedule, imports=imports) celery_app.conf.update(beat_schedule=beat_schedule, imports=imports)

@ -45,6 +45,7 @@ workflow_fields = {
"graph": fields.Raw(attribute="graph_dict"), "graph": fields.Raw(attribute="graph_dict"),
"features": fields.Raw(attribute="features_dict"), "features": fields.Raw(attribute="features_dict"),
"hash": fields.String(attribute="unique_hash"), "hash": fields.String(attribute="unique_hash"),
"version": fields.String(attribute="version"),
"created_by": fields.Nested(simple_account_fields, attribute="created_by_account"), "created_by": fields.Nested(simple_account_fields, attribute="created_by_account"),
"created_at": TimestampField, "created_at": TimestampField,
"updated_by": fields.Nested(simple_account_fields, attribute="updated_by_account", allow_null=True), "updated_by": fields.Nested(simple_account_fields, attribute="updated_by_account", allow_null=True),
@ -61,3 +62,10 @@ workflow_partial_fields = {
"updated_by": fields.String, "updated_by": fields.String,
"updated_at": TimestampField, "updated_at": TimestampField,
} }
workflow_pagination_fields = {
"items": fields.List(fields.Nested(workflow_fields), attribute="items"),
"page": fields.Integer,
"limit": fields.Integer(attribute="limit"),
"has_more": fields.Boolean(attribute="has_more"),
}

@ -1,19 +0,0 @@
from configs import dify_config
def apply_gevent_threading_patch():
"""
Run threading patch by gevent
to make standard library threading compatible.
Patching should be done as early as possible in the lifecycle of the program.
:return:
"""
if not dify_config.DEBUG:
from gevent import monkey # type: ignore
from grpc.experimental import gevent as grpc_gevent # type: ignore
# gevent
monkey.patch_all()
# grpc gevent
grpc_gevent.init_gevent()

@ -1,12 +0,0 @@
import sys
def check_supported_python_version():
python_version = sys.version_info
if not ((3, 11) <= python_version < (3, 13)):
print(
"Aborted to launch the service "
f" with unsupported Python version {python_version.major}.{python_version.minor}."
" Please ensure Python 3.11 or 3.12."
)
raise SystemExit(1)

@ -1,2 +1 @@
Single-database configuration for Flask. Single-database configuration for Flask.

@ -414,6 +414,18 @@ class WorkflowRun(db.Model): # type: ignore[name-defined]
finished_at = db.Column(db.DateTime) finished_at = db.Column(db.DateTime)
exceptions_count = db.Column(db.Integer, server_default=db.text("0")) exceptions_count = db.Column(db.Integer, server_default=db.text("0"))
@property
def created_by_account(self):
created_by_role = CreatedByRole(self.created_by_role)
return db.session.get(Account, self.created_by) if created_by_role == CreatedByRole.ACCOUNT else None
@property
def created_by_end_user(self):
from models.model import EndUser
created_by_role = CreatedByRole(self.created_by_role)
return db.session.get(EndUser, self.created_by) if created_by_role == CreatedByRole.END_USER else None
@property @property
def graph_dict(self): def graph_dict(self):
return json.loads(self.graph) if self.graph else {} return json.loads(self.graph) if self.graph else {}

@ -28,7 +28,6 @@ def clean_messages():
plan_sandbox_clean_message_day = datetime.datetime.now() - datetime.timedelta( plan_sandbox_clean_message_day = datetime.datetime.now() - datetime.timedelta(
days=dify_config.PLAN_SANDBOX_CLEAN_MESSAGE_DAY_SETTING days=dify_config.PLAN_SANDBOX_CLEAN_MESSAGE_DAY_SETTING
) )
page = 1
while True: while True:
try: try:
# Main query with join and filter # Main query with join and filter
@ -79,4 +78,4 @@ def clean_messages():
db.session.query(Message).filter(Message.id == message.id).delete() db.session.query(Message).filter(Message.id == message.id).delete()
db.session.commit() db.session.commit()
end_at = time.perf_counter() end_at = time.perf_counter()
click.echo(click.style("Cleaned unused dataset from db success latency: {}".format(end_at - start_at), fg="green")) click.echo(click.style("Cleaned messages from db success latency: {}".format(end_at - start_at), fg="green"))

@ -3,14 +3,18 @@ import time
from collections import defaultdict from collections import defaultdict
import click import click
from celery import shared_task # type: ignore from flask import render_template # type: ignore
import app
from configs import dify_config
from extensions.ext_database import db
from extensions.ext_mail import mail from extensions.ext_mail import mail
from models.account import Account, Tenant, TenantAccountJoin from models.account import Account, Tenant, TenantAccountJoin
from models.dataset import Dataset, DatasetAutoDisableLog from models.dataset import Dataset, DatasetAutoDisableLog
from services.feature_service import FeatureService
@shared_task(queue="mail") @app.celery.task(queue="dataset")
def send_document_clean_notify_task(): def send_document_clean_notify_task():
""" """
Async Send document clean notify mail Async Send document clean notify mail
@ -29,35 +33,58 @@ def send_document_clean_notify_task():
# group by tenant_id # group by tenant_id
dataset_auto_disable_logs_map: dict[str, list[DatasetAutoDisableLog]] = defaultdict(list) dataset_auto_disable_logs_map: dict[str, list[DatasetAutoDisableLog]] = defaultdict(list)
for dataset_auto_disable_log in dataset_auto_disable_logs: for dataset_auto_disable_log in dataset_auto_disable_logs:
if dataset_auto_disable_log.tenant_id not in dataset_auto_disable_logs_map:
dataset_auto_disable_logs_map[dataset_auto_disable_log.tenant_id] = []
dataset_auto_disable_logs_map[dataset_auto_disable_log.tenant_id].append(dataset_auto_disable_log) dataset_auto_disable_logs_map[dataset_auto_disable_log.tenant_id].append(dataset_auto_disable_log)
url = f"{dify_config.CONSOLE_WEB_URL}/datasets"
for tenant_id, tenant_dataset_auto_disable_logs in dataset_auto_disable_logs_map.items(): for tenant_id, tenant_dataset_auto_disable_logs in dataset_auto_disable_logs_map.items():
knowledge_details = [] features = FeatureService.get_features(tenant_id)
tenant = Tenant.query.filter(Tenant.id == tenant_id).first() plan = features.billing.subscription.plan
if not tenant: if plan != "sandbox":
continue knowledge_details = []
current_owner_join = TenantAccountJoin.query.filter_by(tenant_id=tenant.id, role="owner").first() # check tenant
if not current_owner_join: tenant = Tenant.query.filter(Tenant.id == tenant_id).first()
continue if not tenant:
account = Account.query.filter(Account.id == current_owner_join.account_id).first() continue
if not account: # check current owner
continue current_owner_join = TenantAccountJoin.query.filter_by(tenant_id=tenant.id, role="owner").first()
if not current_owner_join:
dataset_auto_dataset_map = {} # type: ignore continue
for dataset_auto_disable_log in tenant_dataset_auto_disable_logs: account = Account.query.filter(Account.id == current_owner_join.account_id).first()
dataset_auto_dataset_map[dataset_auto_disable_log.dataset_id].append( if not account:
dataset_auto_disable_log.document_id continue
)
for dataset_id, document_ids in dataset_auto_dataset_map.items(): dataset_auto_dataset_map = {} # type: ignore
dataset = Dataset.query.filter(Dataset.id == dataset_id).first() for dataset_auto_disable_log in tenant_dataset_auto_disable_logs:
if dataset: if dataset_auto_disable_log.dataset_id not in dataset_auto_dataset_map:
document_count = len(document_ids) dataset_auto_dataset_map[dataset_auto_disable_log.dataset_id] = []
knowledge_details.append(f"<li>Knowledge base {dataset.name}: {document_count} documents</li>") dataset_auto_dataset_map[dataset_auto_disable_log.dataset_id].append(
dataset_auto_disable_log.document_id
)
for dataset_id, document_ids in dataset_auto_dataset_map.items():
dataset = Dataset.query.filter(Dataset.id == dataset_id).first()
if dataset:
document_count = len(document_ids)
knowledge_details.append(rf"Knowledge base {dataset.name}: {document_count} documents")
if knowledge_details:
html_content = render_template(
"clean_document_job_mail_template-US.html",
userName=account.email,
knowledge_details=knowledge_details,
url=url,
)
mail.send(
to=account.email, subject="Dify Knowledge base auto disable notification", html=html_content
)
# update notified to True
for dataset_auto_disable_log in tenant_dataset_auto_disable_logs:
dataset_auto_disable_log.notified = True
db.session.commit()
end_at = time.perf_counter() end_at = time.perf_counter()
logging.info( logging.info(
click.style("Send document clean notify mail succeeded: latency: {}".format(end_at - start_at), fg="green") click.style("Send document clean notify mail succeeded: latency: {}".format(end_at - start_at), fg="green")
) )
except Exception: except Exception:
logging.exception("Send invite member mail to failed") logging.exception("Send document clean notify mail failed")

@ -32,6 +32,7 @@ from models.account import (
TenantStatus, TenantStatus,
) )
from models.model import DifySetup from models.model import DifySetup
from services.billing_service import BillingService
from services.errors.account import ( from services.errors.account import (
AccountAlreadyInTenantError, AccountAlreadyInTenantError,
AccountLoginError, AccountLoginError,
@ -50,6 +51,8 @@ from services.errors.account import (
) )
from services.errors.workspace import WorkSpaceNotAllowedCreateError from services.errors.workspace import WorkSpaceNotAllowedCreateError
from services.feature_service import FeatureService from services.feature_service import FeatureService
from tasks.delete_account_task import delete_account_task
from tasks.mail_account_deletion_task import send_account_deletion_verification_code
from tasks.mail_email_code_login import send_email_code_login_mail_task from tasks.mail_email_code_login import send_email_code_login_mail_task
from tasks.mail_invite_member_task import send_invite_member_mail_task from tasks.mail_invite_member_task import send_invite_member_mail_task
from tasks.mail_reset_password_task import send_reset_password_mail_task from tasks.mail_reset_password_task import send_reset_password_mail_task
@ -70,6 +73,9 @@ class AccountService:
email_code_login_rate_limiter = RateLimiter( email_code_login_rate_limiter = RateLimiter(
prefix="email_code_login_rate_limit", max_attempts=1, time_window=60 * 1 prefix="email_code_login_rate_limit", max_attempts=1, time_window=60 * 1
) )
email_code_account_deletion_rate_limiter = RateLimiter(
prefix="email_code_account_deletion_rate_limit", max_attempts=1, time_window=60 * 1
)
LOGIN_MAX_ERROR_LIMITS = 5 LOGIN_MAX_ERROR_LIMITS = 5
@staticmethod @staticmethod
@ -201,6 +207,15 @@ class AccountService:
from controllers.console.error import AccountNotFound from controllers.console.error import AccountNotFound
raise AccountNotFound() raise AccountNotFound()
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(email):
raise AccountRegisterError(
description=(
"This email account has been deleted within the past "
"30 days and is temporarily unavailable for new account registration"
)
)
account = Account() account = Account()
account.email = email account.email = email
account.name = name account.name = name
@ -240,6 +255,42 @@ class AccountService:
return account return account
@staticmethod
def generate_account_deletion_verification_code(account: Account) -> tuple[str, str]:
code = "".join([str(random.randint(0, 9)) for _ in range(6)])
token = TokenManager.generate_token(
account=account, token_type="account_deletion", additional_data={"code": code}
)
return token, code
@classmethod
def send_account_deletion_verification_email(cls, account: Account, code: str):
email = account.email
if cls.email_code_account_deletion_rate_limiter.is_rate_limited(email):
from controllers.console.auth.error import EmailCodeAccountDeletionRateLimitExceededError
raise EmailCodeAccountDeletionRateLimitExceededError()
send_account_deletion_verification_code.delay(to=email, code=code)
cls.email_code_account_deletion_rate_limiter.increment_rate_limit(email)
@staticmethod
def verify_account_deletion_code(token: str, code: str) -> bool:
token_data = TokenManager.get_token_data(token, "account_deletion")
if token_data is None:
return False
if token_data["code"] != code:
return False
return True
@staticmethod
def delete_account(account: Account) -> None:
"""Delete account. This method only adds a task to the queue for deletion."""
delete_account_task.delay(account.id)
@staticmethod @staticmethod
def link_account_integrate(provider: str, open_id: str, account: Account) -> None: def link_account_integrate(provider: str, open_id: str, account: Account) -> None:
"""Link account integrate""" """Link account integrate"""
@ -379,6 +430,7 @@ class AccountService:
def send_email_code_login_email( def send_email_code_login_email(
cls, account: Optional[Account] = None, email: Optional[str] = None, language: Optional[str] = "en-US" cls, account: Optional[Account] = None, email: Optional[str] = None, language: Optional[str] = "en-US"
): ):
email = account.email if account else email
if email is None: if email is None:
raise ValueError("Email must be provided.") raise ValueError("Email must be provided.")
if cls.email_code_login_rate_limiter.is_rate_limited(email): if cls.email_code_login_rate_limiter.is_rate_limited(email):
@ -408,6 +460,14 @@ class AccountService:
@classmethod @classmethod
def get_user_through_email(cls, email: str): def get_user_through_email(cls, email: str):
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(email):
raise AccountRegisterError(
description=(
"This email account has been deleted within the past "
"30 days and is temporarily unavailable for new account registration"
)
)
account = db.session.query(Account).filter(Account.email == email).first() account = db.session.query(Account).filter(Account.email == email).first()
if not account: if not account:
return None return None
@ -824,6 +884,10 @@ class RegisterService:
db.session.commit() db.session.commit()
except WorkSpaceNotAllowedCreateError: except WorkSpaceNotAllowedCreateError:
db.session.rollback() db.session.rollback()
except AccountRegisterError as are:
db.session.rollback()
logging.exception("Register failed")
raise are
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
logging.exception("Register failed") logging.exception("Register failed")

@ -176,6 +176,9 @@ class AppDslService:
data["kind"] = "app" data["kind"] = "app"
imported_version = data.get("version", "0.1.0") imported_version = data.get("version", "0.1.0")
# check if imported_version is a float-like string
if not isinstance(imported_version, str):
raise ValueError(f"Invalid version type, expected str, got {type(imported_version)}")
status = _check_version_compatibility(imported_version) status = _check_version_compatibility(imported_version)
# Extract app data # Extract app data

@ -139,7 +139,7 @@ class AudioService:
return Response(stream_with_context(response), content_type="audio/mpeg") return Response(stream_with_context(response), content_type="audio/mpeg")
return response return response
else: else:
if not text: if text is None:
raise ValueError("Text is required") raise ValueError("Text is required")
response = invoke_tts(text, app_model, voice) response = invoke_tts(text, app_model, voice)
if isinstance(response, Generator): if isinstance(response, Generator):

@ -70,3 +70,24 @@ class BillingService:
if not TenantAccountRole.is_privileged_role(join.role): if not TenantAccountRole.is_privileged_role(join.role):
raise ValueError("Only team owner or team admin can perform this action") raise ValueError("Only team owner or team admin can perform this action")
@classmethod
def delete_account(cls, account_id: str):
"""Delete account."""
params = {"account_id": account_id}
return cls._send_request("DELETE", "/account/", params=params)
@classmethod
def is_email_in_freeze(cls, email: str) -> bool:
params = {"email": email}
try:
response = cls._send_request("GET", "/account/in-freeze", params=params)
return bool(response.get("data", False))
except Exception:
return False
@classmethod
def update_account_deletion_feedback(cls, email: str, feedback: str):
"""Update account deletion feedback."""
json = {"email": email, "feedback": feedback}
return cls._send_request("POST", "/account/delete-feedback", json=json)

@ -86,25 +86,30 @@ class DatasetService:
else: else:
return [], 0 return [], 0
else: else:
# show all datasets that the user has permission to access if user.current_role not in (TenantAccountRole.OWNER, TenantAccountRole.ADMIN):
if permitted_dataset_ids: # show all datasets that the user has permission to access
query = query.filter( if permitted_dataset_ids:
db.or_( query = query.filter(
Dataset.permission == DatasetPermissionEnum.ALL_TEAM, db.or_(
db.and_(Dataset.permission == DatasetPermissionEnum.ONLY_ME, Dataset.created_by == user.id), Dataset.permission == DatasetPermissionEnum.ALL_TEAM,
db.and_( db.and_(
Dataset.permission == DatasetPermissionEnum.PARTIAL_TEAM, Dataset.permission == DatasetPermissionEnum.ONLY_ME, Dataset.created_by == user.id
Dataset.id.in_(permitted_dataset_ids), ),
), db.and_(
Dataset.permission == DatasetPermissionEnum.PARTIAL_TEAM,
Dataset.id.in_(permitted_dataset_ids),
),
)
) )
) else:
else: query = query.filter(
query = query.filter( db.or_(
db.or_( Dataset.permission == DatasetPermissionEnum.ALL_TEAM,
Dataset.permission == DatasetPermissionEnum.ALL_TEAM, db.and_(
db.and_(Dataset.permission == DatasetPermissionEnum.ONLY_ME, Dataset.created_by == user.id), Dataset.permission == DatasetPermissionEnum.ONLY_ME, Dataset.created_by == user.id
),
)
) )
)
else: else:
# if no user, only show datasets that are shared with all team members # if no user, only show datasets that are shared with all team members
query = query.filter(Dataset.permission == DatasetPermissionEnum.ALL_TEAM) query = query.filter(Dataset.permission == DatasetPermissionEnum.ALL_TEAM)
@ -377,14 +382,19 @@ class DatasetService:
if dataset.tenant_id != user.current_tenant_id: if dataset.tenant_id != user.current_tenant_id:
logging.debug(f"User {user.id} does not have permission to access dataset {dataset.id}") logging.debug(f"User {user.id} does not have permission to access dataset {dataset.id}")
raise NoPermissionError("You do not have permission to access this dataset.") raise NoPermissionError("You do not have permission to access this dataset.")
if dataset.permission == DatasetPermissionEnum.ONLY_ME and dataset.created_by != user.id: if user.current_role not in (TenantAccountRole.OWNER, TenantAccountRole.ADMIN):
logging.debug(f"User {user.id} does not have permission to access dataset {dataset.id}") if dataset.permission == DatasetPermissionEnum.ONLY_ME and dataset.created_by != user.id:
raise NoPermissionError("You do not have permission to access this dataset.")
if dataset.permission == "partial_members":
user_permission = DatasetPermission.query.filter_by(dataset_id=dataset.id, account_id=user.id).first()
if not user_permission and dataset.tenant_id != user.current_tenant_id and dataset.created_by != user.id:
logging.debug(f"User {user.id} does not have permission to access dataset {dataset.id}") logging.debug(f"User {user.id} does not have permission to access dataset {dataset.id}")
raise NoPermissionError("You do not have permission to access this dataset.") raise NoPermissionError("You do not have permission to access this dataset.")
if dataset.permission == "partial_members":
user_permission = DatasetPermission.query.filter_by(dataset_id=dataset.id, account_id=user.id).first()
if (
not user_permission
and dataset.tenant_id != user.current_tenant_id
and dataset.created_by != user.id
):
logging.debug(f"User {user.id} does not have permission to access dataset {dataset.id}")
raise NoPermissionError("You do not have permission to access this dataset.")
@staticmethod @staticmethod
def check_dataset_operator_permission(user: Optional[Account] = None, dataset: Optional[Dataset] = None): def check_dataset_operator_permission(user: Optional[Account] = None, dataset: Optional[Dataset] = None):
@ -394,15 +404,16 @@ class DatasetService:
if not user: if not user:
raise ValueError("User not found") raise ValueError("User not found")
if dataset.permission == DatasetPermissionEnum.ONLY_ME: if user.current_role not in (TenantAccountRole.OWNER, TenantAccountRole.ADMIN):
if dataset.created_by != user.id: if dataset.permission == DatasetPermissionEnum.ONLY_ME:
raise NoPermissionError("You do not have permission to access this dataset.") if dataset.created_by != user.id:
raise NoPermissionError("You do not have permission to access this dataset.")
elif dataset.permission == DatasetPermissionEnum.PARTIAL_TEAM: elif dataset.permission == DatasetPermissionEnum.PARTIAL_TEAM:
if not any( if not any(
dp.dataset_id == dataset.id for dp in DatasetPermission.query.filter_by(account_id=user.id).all() dp.dataset_id == dataset.id for dp in DatasetPermission.query.filter_by(account_id=user.id).all()
): ):
raise NoPermissionError("You do not have permission to access this dataset.") raise NoPermissionError("You do not have permission to access this dataset.")
@staticmethod @staticmethod
def get_dataset_queries(dataset_id: str, page: int, per_page: int): def get_dataset_queries(dataset_id: str, page: int, per_page: int):
@ -441,7 +452,7 @@ class DatasetService:
class DocumentService: class DocumentService:
DEFAULT_RULES = { DEFAULT_RULES: dict[str, Any] = {
"mode": "custom", "mode": "custom",
"rules": { "rules": {
"pre_processing_rules": [ "pre_processing_rules": [
@ -455,7 +466,7 @@ class DocumentService:
}, },
} }
DOCUMENT_METADATA_SCHEMA = { DOCUMENT_METADATA_SCHEMA: dict[str, Any] = {
"book": { "book": {
"title": str, "title": str,
"language": str, "language": str,

@ -1,6 +1,6 @@
from typing import Optional from typing import Optional
class BaseServiceError(Exception): class BaseServiceError(ValueError):
def __init__(self, description: Optional[str] = None): def __init__(self, description: Optional[str] = None):
self.description = description self.description = description

@ -152,6 +152,7 @@ class MessageService:
@classmethod @classmethod
def create_feedback( def create_feedback(
cls, cls,
*,
app_model: App, app_model: App,
message_id: str, message_id: str,
user: Optional[Union[Account, EndUser]], user: Optional[Union[Account, EndUser]],

@ -425,7 +425,7 @@ class ApiToolManageService:
"tenant_id": tenant_id, "tenant_id": tenant_id,
} }
) )
result = tool.validate_credentials(credentials, parameters) result = runtime_tool.validate_credentials(credentials, parameters)
except Exception as e: except Exception as e:
return {"error": str(e)} return {"error": str(e)}

@ -5,6 +5,8 @@ from datetime import UTC, datetime
from typing import Any, Optional, cast from typing import Any, Optional, cast
from uuid import uuid4 from uuid import uuid4
from sqlalchemy import desc
from core.app.apps.advanced_chat.app_config_manager import AdvancedChatAppConfigManager from core.app.apps.advanced_chat.app_config_manager import AdvancedChatAppConfigManager
from core.app.apps.workflow.app_config_manager import WorkflowAppConfigManager from core.app.apps.workflow.app_config_manager import WorkflowAppConfigManager
from core.model_runtime.utils.encoders import jsonable_encoder from core.model_runtime.utils.encoders import jsonable_encoder
@ -76,6 +78,28 @@ class WorkflowService:
return workflow return workflow
def get_all_published_workflow(self, app_model: App, page: int, limit: int) -> tuple[list[Workflow], bool]:
"""
Get published workflow with pagination
"""
if not app_model.workflow_id:
return [], False
workflows = (
db.session.query(Workflow)
.filter(Workflow.app_id == app_model.id)
.order_by(desc(Workflow.version))
.offset((page - 1) * limit)
.limit(limit + 1)
.all()
)
has_more = len(workflows) > limit
if has_more:
workflows = workflows[:-1]
return workflows, has_more
def sync_draft_workflow( def sync_draft_workflow(
self, self,
*, *,

@ -38,7 +38,11 @@ def add_document_to_index_task(dataset_document_id: str):
try: try:
segments = ( segments = (
db.session.query(DocumentSegment) db.session.query(DocumentSegment)
.filter(DocumentSegment.document_id == dataset_document.id, DocumentSegment.enabled == True) .filter(
DocumentSegment.document_id == dataset_document.id,
DocumentSegment.enabled == False,
DocumentSegment.status == "completed",
)
.order_by(DocumentSegment.position.asc()) .order_by(DocumentSegment.position.asc())
.all() .all()
) )
@ -85,6 +89,16 @@ def add_document_to_index_task(dataset_document_id: str):
db.session.query(DatasetAutoDisableLog).filter( db.session.query(DatasetAutoDisableLog).filter(
DatasetAutoDisableLog.document_id == dataset_document.id DatasetAutoDisableLog.document_id == dataset_document.id
).delete() ).delete()
# update segment to enable
db.session.query(DocumentSegment).filter(DocumentSegment.document_id == dataset_document.id).update(
{
DocumentSegment.enabled: True,
DocumentSegment.disabled_at: None,
DocumentSegment.disabled_by: None,
DocumentSegment.updated_at: datetime.datetime.now(datetime.UTC).replace(tzinfo=None),
}
)
db.session.commit() db.session.commit()
end_at = time.perf_counter() end_at = time.perf_counter()

@ -0,0 +1,26 @@
import logging
from celery import shared_task # type: ignore
from extensions.ext_database import db
from models.account import Account
from services.billing_service import BillingService
from tasks.mail_account_deletion_task import send_deletion_success_task
logger = logging.getLogger(__name__)
@shared_task(queue="dataset")
def delete_account_task(account_id):
account = db.session.query(Account).filter(Account.id == account_id).first()
try:
BillingService.delete_account(account_id)
except Exception as e:
logger.exception(f"Failed to delete account {account_id} from billing service.")
raise
if not account:
logger.error(f"Account {account_id} not found.")
return
# send success email
send_deletion_success_task.delay(account.email)

@ -0,0 +1,70 @@
import logging
import time
import click
from celery import shared_task # type: ignore
from flask import render_template
from extensions.ext_mail import mail
@shared_task(queue="mail")
def send_deletion_success_task(to):
"""Send email to user regarding account deletion.
Args:
log (AccountDeletionLog): Account deletion log object
"""
if not mail.is_inited():
return
logging.info(click.style(f"Start send account deletion success email to {to}", fg="green"))
start_at = time.perf_counter()
try:
html_content = render_template(
"delete_account_success_template_en-US.html",
to=to,
email=to,
)
mail.send(to=to, subject="Your Dify.AI Account Has Been Successfully Deleted", html=html_content)
end_at = time.perf_counter()
logging.info(
click.style(
"Send account deletion success email to {}: latency: {}".format(to, end_at - start_at), fg="green"
)
)
except Exception:
logging.exception("Send account deletion success email to {} failed".format(to))
@shared_task(queue="mail")
def send_account_deletion_verification_code(to, code):
"""Send email to user regarding account deletion verification code.
Args:
to (str): Recipient email address
code (str): Verification code
"""
if not mail.is_inited():
return
logging.info(click.style(f"Start send account deletion verification code email to {to}", fg="green"))
start_at = time.perf_counter()
try:
html_content = render_template("delete_account_code_email_template_en-US.html", to=to, code=code)
mail.send(to=to, subject="Dify.AI Account Deletion and Verification", html=html_content)
end_at = time.perf_counter()
logging.info(
click.style(
"Send account deletion verification code email to {} succeeded: latency: {}".format(
to, end_at - start_at
),
fg="green",
)
)
except Exception:
logging.exception("Send account deletion verification code email to {} failed".format(to))

@ -1,3 +1,4 @@
import datetime
import logging import logging
import time import time
@ -46,6 +47,16 @@ def remove_document_from_index_task(document_id: str):
index_processor.clean(dataset, index_node_ids, with_keywords=True, delete_child_chunks=False) index_processor.clean(dataset, index_node_ids, with_keywords=True, delete_child_chunks=False)
except Exception: except Exception:
logging.exception(f"clean dataset {dataset.id} from index failed") logging.exception(f"clean dataset {dataset.id} from index failed")
# update segment to disable
db.session.query(DocumentSegment).filter(DocumentSegment.document_id == document.id).update(
{
DocumentSegment.enabled: False,
DocumentSegment.disabled_at: datetime.datetime.now(datetime.UTC).replace(tzinfo=None),
DocumentSegment.disabled_by: document.disabled_by,
DocumentSegment.updated_at: datetime.datetime.now(datetime.UTC).replace(tzinfo=None),
}
)
db.session.commit()
end_at = time.perf_counter() end_at = time.perf_counter()
logging.info( logging.info(

@ -45,14 +45,14 @@
.content ul li { .content ul li {
margin-bottom: 10px; margin-bottom: 10px;
} }
.cta-button { .cta-button, .cta-button:hover, .cta-button:active, .cta-button:visited, .cta-button:focus {
display: block; display: block;
margin: 20px auto; margin: 20px auto;
padding: 10px 20px; padding: 10px 20px;
background-color: #4e89f9; background-color: #4e89f9;
color: #ffffff; color: #ffffff !important;
text-align: center; text-align: center;
text-decoration: none; text-decoration: none !important;
border-radius: 5px; border-radius: 5px;
width: fit-content; width: fit-content;
} }
@ -69,7 +69,7 @@
<div class="email-container"> <div class="email-container">
<!-- Header --> <!-- Header -->
<div class="header"> <div class="header">
<img src="https://via.placeholder.com/150x40?text=Dify" alt="Dify Logo"> <img src="https://img.mailinblue.com/6365111/images/content_library/original/64cb67ca60532312c211dc72.png" alt="Dify Logo">
</div> </div>
<!-- Content --> <!-- Content -->
@ -78,11 +78,13 @@
<p>Dear {{userName}},</p> <p>Dear {{userName}},</p>
<p> <p>
We're sorry for the inconvenience. To ensure optimal performance, documents We're sorry for the inconvenience. To ensure optimal performance, documents
that havent been updated or accessed in the past 7 days have been disabled in that havent been updated or accessed in the past 30 days have been disabled in
your knowledge bases: your knowledge bases:
</p> </p>
<ul> <ul>
{{knowledge_details}} {% for item in knowledge_details %}
<li>{{ item }}</li>
{% endfor %}
</ul> </ul>
<p>You can re-enable them anytime.</p> <p>You can re-enable them anytime.</p>
<a href={{url}} class="cta-button">Re-enable in Dify</a> <a href={{url}} class="cta-button">Re-enable in Dify</a>

@ -0,0 +1,125 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: 'Arial', sans-serif;
line-height: 16pt;
color: #101828;
background-color: #e9ebf0;
margin: 0;
padding: 0;
}
.container {
width: 600px;
min-height: 605px;
margin: 40px auto;
padding: 36px 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
}
.header {
margin-bottom: 24px;
}
.header img {
max-width: 100px;
height: auto;
}
.title {
font-weight: 600;
font-size: 24px;
line-height: 28.8px;
}
.description {
font-size: 13px;
line-height: 16px;
color: #676f83;
margin-top: 12px;
}
.code-content {
padding: 16px 32px;
text-align: center;
border-radius: 16px;
background-color: #f2f4f7;
margin: 16px auto;
}
.code {
line-height: 36px;
font-weight: 700;
font-size: 30px;
}
.tips {
line-height: 16px;
color: #676f83;
font-size: 13px;
}
.typography {
letter-spacing: -0.07px;
font-weight: 400;
font-style: normal;
font-size: 14px;
line-height: 20px;
color: #354052;
margin-top: 12px;
margin-bottom: 12px;
}
.typography p{
margin: 0 auto;
}
.typography-title {
color: #101828;
font-size: 14px;
font-style: normal;
font-weight: 600;
line-height: 20px;
margin-top: 12px;
margin-bottom: 4px;
}
.tip-list{
margin: 0;
padding-left: 10px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<!-- Optional: Add a logo or a header image here -->
<img src="https://cloud.dify.ai/logo/logo-site.png" alt="Dify Logo" />
</div>
<p class="title">Dify.AI Account Deletion and Verification</p>
<p class="typography">We received a request to delete your Dify account. To ensure the security of your account and
confirm this action, please use the verification code below:</p>
<div class="code-content">
<span class="code">{{code}}</span>
</div>
<div class="typography">
<p style="margin-bottom:4px">To complete the account deletion process:</p>
<p>1. Return to the account deletion page on our website</p>
<p>2. Enter the verification code above</p>
<p>3. Click "Confirm Deletion"</p>
</div>
<p class="typography-title">Please note:</p>
<ul class="typography tip-list">
<li>This code is valid for 5 minutes</li>
<li>As the Owner of any Workspaces, your workspaces will be scheduled in a queue for permanent deletion.</li>
<li>All your user data will be queued for permanent deletion.</li>
</ul>
</div>
</body>
</html>

@ -0,0 +1,105 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: 'Arial', sans-serif;
line-height: 16pt;
color: #101828;
background-color: #e9ebf0;
margin: 0;
padding: 0;
}
.container {
width: 600px;
min-height: 380px;
margin: 40px auto;
padding: 36px 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
}
.header {
margin-bottom: 24px;
}
.header img {
max-width: 100px;
height: auto;
}
.title {
font-weight: 600;
font-size: 24px;
line-height: 28.8px;
margin-bottom: 12px;
}
.description {
color: #354052;
font-weight: 400;
line-height: 20px;
font-size: 14px;
}
.code-content {
padding: 16px 32px;
text-align: center;
border-radius: 16px;
background-color: #f2f4f7;
margin: 16px auto;
}
.code {
line-height: 36px;
font-weight: 700;
font-size: 30px;
}
.tips {
line-height: 16px;
color: #676f83;
font-size: 13px;
}
.email {
color: #354052;
font-weight: 600;
line-height: 20px;
font-size: 14px;
}
.typography{
font-weight: 400;
font-style: normal;
font-size: 14px;
line-height: 20px;
color: #354052;
margin-top: 4px;
margin-bottom: 0;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<!-- Optional: Add a logo or a header image here -->
<img src="https://cloud.dify.ai/logo/logo-site.png" alt="Dify Logo" />
</div>
<p class="title">Your Dify.AI Account Has Been Successfully Deleted</p>
<p class="typography">We're writing to confirm that your Dify.AI account has been successfully deleted as per your request. Your
account is no longer accessible, and you can't log in using your previous credentials. If you decide to use
Dify.AI services in the future, you'll need to create a new account after 30 days. We appreciate the time you
spent with Dify.AI and are sorry to see you go. If you have any questions or concerns about the deletion process,
please don't hesitate to reach out to our support team.</p>
<p class="typography">Thank you for being a part of the Dify.AI community.</p>
<p class="typography">Best regards,</p>
<p class="typography">Dify.AI Team</p>
</div>
</body>
</html>

@ -1,4 +1,3 @@
from collections.abc import Generator
from unittest.mock import MagicMock from unittest.mock import MagicMock
import google.generativeai.types.generation_types as generation_config_types # type: ignore import google.generativeai.types.generation_types as generation_config_types # type: ignore

@ -1,5 +1,3 @@
from unittest.mock import MagicMock
from core.rag.datasource.vdb.baidu.baidu_vector import BaiduConfig, BaiduVector from core.rag.datasource.vdb.baidu.baidu_vector import BaiduConfig, BaiduVector
from tests.integration_tests.vdb.__mock.baiduvectordb import setup_baiduvectordb_mock from tests.integration_tests.vdb.__mock.baiduvectordb import setup_baiduvectordb_mock
from tests.integration_tests.vdb.test_vector_store import AbstractVectorTest, get_example_text, setup_mock_redis from tests.integration_tests.vdb.test_vector_store import AbstractVectorTest, get_example_text, setup_mock_redis

@ -1,5 +1,3 @@
from unittest.mock import MagicMock, patch
import pytest import pytest
from core.rag.datasource.vdb.tidb_vector.tidb_vector import TiDBVector, TiDBVectorConfig from core.rag.datasource.vdb.tidb_vector.tidb_vector import TiDBVector, TiDBVectorConfig

@ -4,7 +4,7 @@ import pytest
from configs import dify_config from configs import dify_config
from core.app.app_config.entities import ModelConfigEntity from core.app.app_config.entities import ModelConfigEntity
from core.file import File, FileTransferMethod, FileType, FileUploadConfig, ImageConfig from core.file import File, FileTransferMethod, FileType
from core.memory.token_buffer_memory import TokenBufferMemory from core.memory.token_buffer_memory import TokenBufferMemory
from core.model_runtime.entities.message_entities import ( from core.model_runtime.entities.message_entities import (
AssistantPromptMessage, AssistantPromptMessage,

@ -1,6 +1,6 @@
import uuid import uuid
from collections.abc import Generator from collections.abc import Generator
from datetime import UTC, datetime, timezone from datetime import UTC, datetime
from core.workflow.entities.variable_pool import VariablePool from core.workflow.entities.variable_pool import VariablePool
from core.workflow.enums import SystemVariableKey from core.workflow.enums import SystemVariableKey

@ -21,8 +21,7 @@ from core.model_runtime.entities.message_entities import (
from core.model_runtime.entities.model_entities import AIModelEntity, FetchFrom, ModelFeature, ModelType from core.model_runtime.entities.model_entities import AIModelEntity, FetchFrom, ModelFeature, ModelType
from core.model_runtime.model_providers.model_provider_factory import ModelProviderFactory from core.model_runtime.model_providers.model_provider_factory import ModelProviderFactory
from core.prompt.entities.advanced_prompt_entities import MemoryConfig from core.prompt.entities.advanced_prompt_entities import MemoryConfig
from core.variables import ArrayAnySegment, ArrayFileSegment, NoneSegment, StringSegment from core.variables import ArrayAnySegment, ArrayFileSegment, NoneSegment
from core.workflow.entities.variable_entities import VariableSelector
from core.workflow.entities.variable_pool import VariablePool from core.workflow.entities.variable_pool import VariablePool
from core.workflow.graph_engine import Graph, GraphInitParams, GraphRuntimeState from core.workflow.graph_engine import Graph, GraphInitParams, GraphRuntimeState
from core.workflow.nodes.answer import AnswerStreamGenerateRoute from core.workflow.nodes.answer import AnswerStreamGenerateRoute

@ -1,7 +1,6 @@
from core.workflow.graph_engine.entities.event import ( from core.workflow.graph_engine.entities.event import (
GraphRunFailedEvent, GraphRunFailedEvent,
GraphRunPartialSucceededEvent, GraphRunPartialSucceededEvent,
GraphRunSucceededEvent,
NodeRunRetryEvent, NodeRunRetryEvent,
) )
from tests.unit_tests.core.workflow.nodes.test_continue_on_error import ContinueOnErrorTestHelper from tests.unit_tests.core.workflow.nodes.test_continue_on_error import ContinueOnErrorTestHelper

@ -1,5 +1,3 @@
import pytest
from core.variables import SegmentType from core.variables import SegmentType
from core.workflow.nodes.variable_assigner.v2.enums import Operation from core.workflow.nodes.variable_assigner.v2.enums import Operation
from core.workflow.nodes.variable_assigner.v2.helpers import is_input_value_valid from core.workflow.nodes.variable_assigner.v2.helpers import is_input_value_valid

@ -1,4 +1,4 @@
from unittest.mock import MagicMock, patch from unittest.mock import patch
import pytest import pytest
from oss2 import Auth # type: ignore from oss2 import Auth # type: ignore

@ -1,5 +1,3 @@
from textwrap import dedent
import pytest import pytest
from core.tools.utils.text_processing_utils import remove_leading_symbols from core.tools.utils.text_processing_utils import remove_leading_symbols

@ -315,7 +315,7 @@ AZURE_BLOB_ACCOUNT_URL=https://<your_account_name>.blob.core.windows.net
# Google Storage Configuration # Google Storage Configuration
# #
GOOGLE_STORAGE_BUCKET_NAME=your-bucket-name GOOGLE_STORAGE_BUCKET_NAME=your-bucket-name
GOOGLE_STORAGE_SERVICE_ACCOUNT_JSON_BASE64=your-google-service-account-json-base64-string GOOGLE_STORAGE_SERVICE_ACCOUNT_JSON_BASE64=
# The Alibaba Cloud OSS configurations, # The Alibaba Cloud OSS configurations,
# #

@ -90,7 +90,7 @@ x-shared-env: &shared-api-worker-env
AZURE_BLOB_CONTAINER_NAME: ${AZURE_BLOB_CONTAINER_NAME:-difyai-container} AZURE_BLOB_CONTAINER_NAME: ${AZURE_BLOB_CONTAINER_NAME:-difyai-container}
AZURE_BLOB_ACCOUNT_URL: ${AZURE_BLOB_ACCOUNT_URL:-https://<your_account_name>.blob.core.windows.net} AZURE_BLOB_ACCOUNT_URL: ${AZURE_BLOB_ACCOUNT_URL:-https://<your_account_name>.blob.core.windows.net}
GOOGLE_STORAGE_BUCKET_NAME: ${GOOGLE_STORAGE_BUCKET_NAME:-your-bucket-name} GOOGLE_STORAGE_BUCKET_NAME: ${GOOGLE_STORAGE_BUCKET_NAME:-your-bucket-name}
GOOGLE_STORAGE_SERVICE_ACCOUNT_JSON_BASE64: ${GOOGLE_STORAGE_SERVICE_ACCOUNT_JSON_BASE64:-your-google-service-account-json-base64-string} GOOGLE_STORAGE_SERVICE_ACCOUNT_JSON_BASE64: ${GOOGLE_STORAGE_SERVICE_ACCOUNT_JSON_BASE64:-}
ALIYUN_OSS_BUCKET_NAME: ${ALIYUN_OSS_BUCKET_NAME:-your-bucket-name} ALIYUN_OSS_BUCKET_NAME: ${ALIYUN_OSS_BUCKET_NAME:-your-bucket-name}
ALIYUN_OSS_ACCESS_KEY: ${ALIYUN_OSS_ACCESS_KEY:-your-access-key} ALIYUN_OSS_ACCESS_KEY: ${ALIYUN_OSS_ACCESS_KEY:-your-access-key}
ALIYUN_OSS_SECRET_KEY: ${ALIYUN_OSS_SECRET_KEY:-your-secret-key} ALIYUN_OSS_SECRET_KEY: ${ALIYUN_OSS_SECRET_KEY:-your-secret-key}
@ -374,7 +374,6 @@ x-shared-env: &shared-api-worker-env
SSRF_COREDUMP_DIR: ${SSRF_COREDUMP_DIR:-/var/spool/squid} SSRF_COREDUMP_DIR: ${SSRF_COREDUMP_DIR:-/var/spool/squid}
SSRF_REVERSE_PROXY_PORT: ${SSRF_REVERSE_PROXY_PORT:-8194} SSRF_REVERSE_PROXY_PORT: ${SSRF_REVERSE_PROXY_PORT:-8194}
SSRF_SANDBOX_HOST: ${SSRF_SANDBOX_HOST:-sandbox} SSRF_SANDBOX_HOST: ${SSRF_SANDBOX_HOST:-sandbox}
COMPOSE_PROFILES: ${COMPOSE_PROFILES:-${VECTOR_STORE:-weaviate}}
EXPOSE_NGINX_PORT: ${EXPOSE_NGINX_PORT:-80} EXPOSE_NGINX_PORT: ${EXPOSE_NGINX_PORT:-80}
EXPOSE_NGINX_SSL_PORT: ${EXPOSE_NGINX_SSL_PORT:-443} EXPOSE_NGINX_SSL_PORT: ${EXPOSE_NGINX_SSL_PORT:-443}
POSITION_TOOL_PINS: ${POSITION_TOOL_PINS:-} POSITION_TOOL_PINS: ${POSITION_TOOL_PINS:-}

@ -37,6 +37,8 @@ def generate_shared_env_block(env_vars, anchor_name="shared-api-worker-env"):
""" """
lines = [f"x-shared-env: &{anchor_name}"] lines = [f"x-shared-env: &{anchor_name}"]
for key, default in env_vars.items(): for key, default in env_vars.items():
if key == "COMPOSE_PROFILES":
continue
# If default value is empty, use ${KEY:-} # If default value is empty, use ${KEY:-}
if default == "": if default == "":
lines.append(f" {key}: ${{{key}:-}}") lines.append(f" {key}: ${{{key}:-}}")

@ -37,7 +37,7 @@ function useAppsQueryState() {
const syncSearchParams = useCallback((params: URLSearchParams) => { const syncSearchParams = useCallback((params: URLSearchParams) => {
const search = params.toString() const search = params.toString()
const query = search ? `?${search}` : '' const query = search ? `?${search}` : ''
router.push(`${pathname}${query}`) router.push(`${pathname}${query}`, { scroll: false })
}, [router, pathname]) }, [router, pathname])
// Update the URL search string whenever the query changes. // Update the URL search string whenever the query changes.

@ -8,27 +8,24 @@ import Header from '@/app/components/header'
import { EventEmitterContextProvider } from '@/context/event-emitter' import { EventEmitterContextProvider } from '@/context/event-emitter'
import { ProviderContextProvider } from '@/context/provider-context' import { ProviderContextProvider } from '@/context/provider-context'
import { ModalContextProvider } from '@/context/modal-context' import { ModalContextProvider } from '@/context/modal-context'
import { TanstackQueryIniter } from '@/context/query-client'
const Layout = ({ children }: { children: ReactNode }) => { const Layout = ({ children }: { children: ReactNode }) => {
return ( return (
<> <>
<GA gaType={GaType.admin} /> <GA gaType={GaType.admin} />
<SwrInitor> <SwrInitor>
<TanstackQueryIniter> <AppContextProvider>
<AppContextProvider> <EventEmitterContextProvider>
<EventEmitterContextProvider> <ProviderContextProvider>
<ProviderContextProvider> <ModalContextProvider>
<ModalContextProvider> <HeaderWrapper>
<HeaderWrapper> <Header />
<Header /> </HeaderWrapper>
</HeaderWrapper> {children}
{children} </ModalContextProvider>
</ModalContextProvider> </ProviderContextProvider>
</ProviderContextProvider> </EventEmitterContextProvider>
</EventEmitterContextProvider> </AppContextProvider>
</AppContextProvider>
</TanstackQueryIniter>
</SwrInitor> </SwrInitor>
</> </>
) )

@ -3,11 +3,11 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector' import { useContext } from 'use-context-selector'
import DeleteAccount from '../delete-account'
import s from './index.module.css' import s from './index.module.css'
import Collapse from '@/app/components/header/account-setting/collapse' import Collapse from '@/app/components/header/account-setting/collapse'
import type { IItem } from '@/app/components/header/account-setting/collapse' import type { IItem } from '@/app/components/header/account-setting/collapse'
import Modal from '@/app/components/base/modal' import Modal from '@/app/components/base/modal'
import Confirm from '@/app/components/base/confirm'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import { updateUserProfile } from '@/service/common' import { updateUserProfile } from '@/service/common'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
@ -296,37 +296,9 @@ export default function AccountPage() {
} }
{ {
showDeleteAccountModal && ( showDeleteAccountModal && (
<Confirm <DeleteAccount
isShow
onCancel={() => setShowDeleteAccountModal(false)} onCancel={() => setShowDeleteAccountModal(false)}
onConfirm={() => setShowDeleteAccountModal(false)} onConfirm={() => setShowDeleteAccountModal(false)}
showCancel={false}
type='warning'
title={t('common.account.delete')}
content={
<>
<div className='my-1 text-text-destructive body-md-medium'>
{t('common.account.deleteTip')}
</div>
<div className='mt-3 text-sm leading-5'>
<span>{t('common.account.deleteConfirmTip')}</span>
<a
className='text-text-accent cursor'
href={`mailto:support@dify.ai?subject=Delete Account Request&body=Delete Account: ${userProfile.email}`}
target='_blank'
rel='noreferrer noopener'
onClick={(e) => {
e.preventDefault()
window.location.href = e.currentTarget.href
}}
>
support@dify.ai
</a>
</div>
<div className='my-2 px-3 py-2 rounded-lg bg-components-input-bg-active border border-components-input-border-active system-sm-regular text-components-input-text-filled'>{`${t('common.account.delete')}: ${userProfile.email}`}</div>
</>
}
confirmText={t('common.operation.ok') as string}
/> />
) )
} }

@ -0,0 +1,48 @@
'use client'
import { useTranslation } from 'react-i18next'
import { useCallback, useState } from 'react'
import Link from 'next/link'
import { useSendDeleteAccountEmail } from '../state'
import { useAppContext } from '@/context/app-context'
import Input from '@/app/components/base/input'
import Button from '@/app/components/base/button'
type DeleteAccountProps = {
onCancel: () => void
onConfirm: () => void
}
export default function CheckEmail(props: DeleteAccountProps) {
const { t } = useTranslation()
const { userProfile } = useAppContext()
const [userInputEmail, setUserInputEmail] = useState('')
const { isPending: isSendingEmail, mutateAsync: getDeleteEmailVerifyCode } = useSendDeleteAccountEmail()
const handleConfirm = useCallback(async () => {
try {
const ret = await getDeleteEmailVerifyCode()
if (ret.result === 'success')
props.onConfirm()
}
catch (error) { console.error(error) }
}, [getDeleteEmailVerifyCode, props])
return <>
<div className='py-1 text-text-destructive body-md-medium'>
{t('common.account.deleteTip')}
</div>
<div className='pt-1 pb-2 text-text-secondary body-md-regular'>
{t('common.account.deletePrivacyLinkTip')}
<Link href='https://dify.ai/privacy' className='text-text-accent'>{t('common.account.deletePrivacyLink')}</Link>
</div>
<label className='mt-3 mb-1 h-6 flex items-center system-sm-semibold text-text-secondary'>{t('common.account.deleteLabel')}</label>
<Input placeholder={t('common.account.deletePlaceholder') as string} onChange={(e) => {
setUserInputEmail(e.target.value)
}} />
<div className='w-full flex flex-col mt-3 gap-2'>
<Button className='w-full' disabled={userInputEmail !== userProfile.email || isSendingEmail} loading={isSendingEmail} variant='primary' onClick={handleConfirm}>{t('common.account.sendVerificationButton')}</Button>
<Button className='w-full' onClick={props.onCancel}>{t('common.operation.cancel')}</Button>
</div>
</>
}

@ -0,0 +1,68 @@
'use client'
import { useTranslation } from 'react-i18next'
import { useCallback, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useDeleteAccountFeedback } from '../state'
import { useAppContext } from '@/context/app-context'
import Button from '@/app/components/base/button'
import CustomDialog from '@/app/components/base/dialog'
import Textarea from '@/app/components/base/textarea'
import Toast from '@/app/components/base/toast'
import { logout } from '@/service/common'
type DeleteAccountProps = {
onCancel: () => void
onConfirm: () => void
}
export default function FeedBack(props: DeleteAccountProps) {
const { t } = useTranslation()
const { userProfile } = useAppContext()
const router = useRouter()
const [userFeedback, setUserFeedback] = useState('')
const { isPending, mutateAsync: sendFeedback } = useDeleteAccountFeedback()
const handleSuccess = useCallback(async () => {
try {
await logout({
url: '/logout',
params: {},
})
localStorage.removeItem('refresh_token')
localStorage.removeItem('console_token')
router.push('/signin')
Toast.notify({ type: 'info', message: t('common.account.deleteSuccessTip') })
}
catch (error) { console.error(error) }
}, [router, t])
const handleSubmit = useCallback(async () => {
try {
await sendFeedback({ feedback: userFeedback, email: userProfile.email })
props.onConfirm()
await handleSuccess()
}
catch (error) { console.error(error) }
}, [handleSuccess, userFeedback, sendFeedback, userProfile, props])
const handleSkip = useCallback(() => {
props.onCancel()
handleSuccess()
}, [handleSuccess, props])
return <CustomDialog
show={true}
onClose={props.onCancel}
title={t('common.account.feedbackTitle')}
className="max-w-[480px]"
footer={false}
>
<label className='mt-3 mb-1 flex items-center system-sm-semibold text-text-secondary'>{t('common.account.feedbackLabel')}</label>
<Textarea rows={6} value={userFeedback} placeholder={t('common.account.feedbackPlaceholder') as string} onChange={(e) => {
setUserFeedback(e.target.value)
}} />
<div className='w-full flex flex-col mt-3 gap-2'>
<Button className='w-full' loading={isPending} variant='primary' onClick={handleSubmit}>{t('common.operation.submit')}</Button>
<Button className='w-full' onClick={handleSkip}>{t('common.operation.skip')}</Button>
</div>
</CustomDialog>
}

@ -0,0 +1,55 @@
'use client'
import { useTranslation } from 'react-i18next'
import { useCallback, useEffect, useState } from 'react'
import Link from 'next/link'
import { useAccountDeleteStore, useConfirmDeleteAccount, useSendDeleteAccountEmail } from '../state'
import Input from '@/app/components/base/input'
import Button from '@/app/components/base/button'
import Countdown from '@/app/components/signin/countdown'
const CODE_EXP = /[A-Za-z\d]{6}/gi
type DeleteAccountProps = {
onCancel: () => void
onConfirm: () => void
}
export default function VerifyEmail(props: DeleteAccountProps) {
const { t } = useTranslation()
const emailToken = useAccountDeleteStore(state => state.sendEmailToken)
const [verificationCode, setVerificationCode] = useState<string>()
const [shouldButtonDisabled, setShouldButtonDisabled] = useState(true)
const { mutate: sendEmail } = useSendDeleteAccountEmail()
const { isPending: isDeleting, mutateAsync: confirmDeleteAccount } = useConfirmDeleteAccount()
useEffect(() => {
setShouldButtonDisabled(!(verificationCode && CODE_EXP.test(verificationCode)) || isDeleting)
}, [verificationCode, isDeleting])
const handleConfirm = useCallback(async () => {
try {
const ret = await confirmDeleteAccount({ code: verificationCode!, token: emailToken })
if (ret.result === 'success')
props.onConfirm()
}
catch (error) { console.error(error) }
}, [emailToken, verificationCode, confirmDeleteAccount, props])
return <>
<div className='pt-1 text-text-destructive body-md-medium'>
{t('common.account.deleteTip')}
</div>
<div className='pt-1 pb-2 text-text-secondary body-md-regular'>
{t('common.account.deletePrivacyLinkTip')}
<Link href='https://dify.ai/privacy' className='text-text-accent'>{t('common.account.deletePrivacyLink')}</Link>
</div>
<label className='mt-3 mb-1 h-6 flex items-center system-sm-semibold text-text-secondary'>{t('common.account.verificationLabel')}</label>
<Input minLength={6} maxLength={6} placeholder={t('common.account.verificationPlaceholder') as string} onChange={(e) => {
setVerificationCode(e.target.value)
}} />
<div className='w-full flex flex-col mt-3 gap-2'>
<Button className='w-full' disabled={shouldButtonDisabled} loading={isDeleting} variant='warning' onClick={handleConfirm}>{t('common.account.permanentlyDeleteButton')}</Button>
<Button className='w-full' onClick={props.onCancel}>{t('common.operation.cancel')}</Button>
<Countdown onResend={sendEmail} />
</div>
</>
}

@ -0,0 +1,44 @@
'use client'
import { useTranslation } from 'react-i18next'
import { useCallback, useState } from 'react'
import CheckEmail from './components/check-email'
import VerifyEmail from './components/verify-email'
import FeedBack from './components/feed-back'
import CustomDialog from '@/app/components/base/dialog'
import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown'
type DeleteAccountProps = {
onCancel: () => void
onConfirm: () => void
}
export default function DeleteAccount(props: DeleteAccountProps) {
const { t } = useTranslation()
const [showVerifyEmail, setShowVerifyEmail] = useState(false)
const [showFeedbackDialog, setShowFeedbackDialog] = useState(false)
const handleEmailCheckSuccess = useCallback(async () => {
try {
setShowVerifyEmail(true)
localStorage.setItem(COUNT_DOWN_KEY, `${COUNT_DOWN_TIME_MS}`)
}
catch (error) { console.error(error) }
}, [])
if (showFeedbackDialog)
return <FeedBack onCancel={props.onCancel} onConfirm={props.onConfirm} />
return <CustomDialog
show={true}
onClose={props.onCancel}
title={t('common.account.delete')}
className="max-w-[480px]"
footer={false}
>
{!showVerifyEmail && <CheckEmail onCancel={props.onCancel} onConfirm={handleEmailCheckSuccess} />}
{showVerifyEmail && <VerifyEmail onCancel={props.onCancel} onConfirm={() => {
setShowFeedbackDialog(true)
}} />}
</CustomDialog>
}

@ -0,0 +1,39 @@
import { useMutation } from '@tanstack/react-query'
import { create } from 'zustand'
import { sendDeleteAccountCode, submitDeleteAccountFeedback, verifyDeleteAccountCode } from '@/service/common'
type State = {
sendEmailToken: string
setSendEmailToken: (token: string) => void
}
export const useAccountDeleteStore = create<State>(set => ({
sendEmailToken: '',
setSendEmailToken: (token: string) => set({ sendEmailToken: token }),
}))
export function useSendDeleteAccountEmail() {
const updateEmailToken = useAccountDeleteStore(state => state.setSendEmailToken)
return useMutation({
mutationKey: ['delete-account'],
mutationFn: sendDeleteAccountCode,
onSuccess: (ret) => {
if (ret.result === 'success')
updateEmailToken(ret.data)
},
})
}
export function useConfirmDeleteAccount() {
return useMutation({
mutationKey: ['confirm-delete-account'],
mutationFn: verifyDeleteAccountCode,
})
}
export function useDeleteAccountFeedback() {
return useMutation({
mutationKey: ['delete-account-feedback'],
mutationFn: submitDeleteAccountFeedback,
})
}

@ -47,7 +47,7 @@ const CustomDialog = ({
</Transition.Child> </Transition.Child>
<div className="fixed inset-0 overflow-y-auto"> <div className="fixed inset-0 overflow-y-auto">
<div className="flex items-center justify-center min-h-full p-4 text-center"> <div className="flex items-center justify-center min-h-full">
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
@ -57,20 +57,20 @@ const CustomDialog = ({
leaveFrom="opacity-100 scale-100" leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95" leaveTo="opacity-0 scale-95"
> >
<Dialog.Panel className={classNames('w-full max-w-[800px] p-0 overflow-hidden text-left text-gray-900 align-middle transition-all transform bg-white shadow-xl rounded-2xl', className)}> <Dialog.Panel className={classNames('w-full max-w-[800px] p-6 overflow-hidden transition-all transform bg-components-panel-bg border-[0.5px] border-components-panel-border shadow-xl rounded-2xl', className)}>
{Boolean(title) && ( {Boolean(title) && (
<Dialog.Title <Dialog.Title
as={titleAs || 'h3'} as={titleAs || 'h3'}
className={classNames('px-8 py-6 text-lg font-medium leading-6 text-gray-900', titleClassName)} className={classNames('pr-8 pb-3 title-2xl-semi-bold text-text-primary', titleClassName)}
> >
{title} {title}
</Dialog.Title> </Dialog.Title>
)} )}
<div className={classNames('px-8 text-lg font-medium leading-6', bodyClassName)}> <div className={classNames(bodyClassName)}>
{children} {children}
</div> </div>
{Boolean(footer) && ( {Boolean(footer) && (
<div className={classNames('flex items-center justify-end gap-2 px-8 py-6', footerClassName)}> <div className={classNames('flex items-center justify-end gap-2 px-6 pb-6 pt-3', footerClassName)}>
{footer} {footer}
</div> </div>
)} )}

@ -6,13 +6,30 @@ const MarkdownButton = ({ node }: any) => {
const { onSend } = useChatContext() const { onSend } = useChatContext()
const variant = node.properties.dataVariant const variant = node.properties.dataVariant
const message = node.properties.dataMessage const message = node.properties.dataMessage
const link = node.properties.dataLink
const size = node.properties.dataSize const size = node.properties.dataSize
function is_valid_url(url: string): boolean {
try {
const parsed_url = new URL(url)
return ['http:', 'https:'].includes(parsed_url.protocol)
}
catch {
return false
}
}
return <Button return <Button
variant={variant} variant={variant}
size={size} size={size}
className={cn('!h-8 !px-3 select-none')} className={cn('!h-8 !px-3 select-none')}
onClick={() => onSend?.(message)} onClick={() => {
if (is_valid_url(link)) {
window.open(link, '_blank')
return
}
onSend?.(message)
}}
> >
<span className='text-[13px]'>{node.children[0]?.value || ''}</span> <span className='text-[13px]'>{node.children[0]?.value || ''}</span>
</Button> </Button>

@ -11,59 +11,62 @@ interface SwitchProps {
className?: string className?: string
} }
const Switch = ({ onChange, size = 'md', defaultValue = false, disabled = false, className }: SwitchProps) => { const Switch = React.forwardRef(
const [enabled, setEnabled] = useState(defaultValue) ({ onChange, size = 'md', defaultValue = false, disabled = false, className }: SwitchProps,
useEffect(() => { propRef: React.Ref<HTMLButtonElement>) => {
setEnabled(defaultValue) const [enabled, setEnabled] = useState(defaultValue)
}, [defaultValue]) useEffect(() => {
const wrapStyle = { setEnabled(defaultValue)
lg: 'h-6 w-11', }, [defaultValue])
l: 'h-5 w-9', const wrapStyle = {
md: 'h-4 w-7', lg: 'h-6 w-11',
sm: 'h-3 w-5', l: 'h-5 w-9',
} md: 'h-4 w-7',
sm: 'h-3 w-5',
}
const circleStyle = { const circleStyle = {
lg: 'h-5 w-5', lg: 'h-5 w-5',
l: 'h-4 w-4', l: 'h-4 w-4',
md: 'h-3 w-3', md: 'h-3 w-3',
sm: 'h-2 w-2', sm: 'h-2 w-2',
} }
const translateLeft = { const translateLeft = {
lg: 'translate-x-5', lg: 'translate-x-5',
l: 'translate-x-4', l: 'translate-x-4',
md: 'translate-x-3', md: 'translate-x-3',
sm: 'translate-x-2', sm: 'translate-x-2',
} }
return ( return (
<OriginalSwitch <OriginalSwitch
checked={enabled} ref={propRef}
onChange={(checked: boolean) => { checked={enabled}
if (disabled) onChange={(checked: boolean) => {
return if (disabled)
setEnabled(checked) return
onChange?.(checked) setEnabled(checked)
}} onChange?.(checked)
className={classNames( }}
wrapStyle[size],
enabled ? 'bg-components-toggle-bg' : 'bg-components-toggle-bg-unchecked',
'relative inline-flex flex-shrink-0 cursor-pointer rounded-[5px] border-2 border-transparent transition-colors duration-200 ease-in-out',
disabled ? '!opacity-50 !cursor-not-allowed' : '',
className,
)}
>
<span
aria-hidden="true"
className={classNames( className={classNames(
circleStyle[size], wrapStyle[size],
enabled ? translateLeft[size] : 'translate-x-0', enabled ? 'bg-components-toggle-bg' : 'bg-components-toggle-bg-unchecked',
'pointer-events-none inline-block transform rounded-[3px] bg-components-toggle-knob shadow ring-0 transition duration-200 ease-in-out', 'relative inline-flex flex-shrink-0 cursor-pointer rounded-[5px] border-2 border-transparent transition-colors duration-200 ease-in-out',
disabled ? '!opacity-50 !cursor-not-allowed' : '',
className,
)} )}
/> >
</OriginalSwitch> <span
) aria-hidden="true"
} className={classNames(
circleStyle[size],
enabled ? translateLeft[size] : 'translate-x-0',
'pointer-events-none inline-block transform rounded-[3px] bg-components-toggle-knob shadow ring-0 transition duration-200 ease-in-out',
)}
/>
</OriginalSwitch>
)
})
Switch.displayName = 'Switch' Switch.displayName = 'Switch'

@ -15,13 +15,15 @@ type OptionCardHeaderProps = {
isActive?: boolean isActive?: boolean
activeClassName?: string activeClassName?: string
effectImg?: string effectImg?: string
disabled?: boolean
} }
export const OptionCardHeader: FC<OptionCardHeaderProps> = (props) => { export const OptionCardHeader: FC<OptionCardHeaderProps> = (props) => {
const { icon, title, description, isActive, activeClassName, effectImg } = props const { icon, title, description, isActive, activeClassName, effectImg, disabled } = props
return <div className={classNames( return <div className={classNames(
'flex h-full overflow-hidden rounded-t-xl relative', 'flex h-full overflow-hidden rounded-t-xl relative',
isActive && activeClassName, isActive && activeClassName,
!disabled && 'cursor-pointer',
)}> )}>
<div className='size-14 flex items-center justify-center relative overflow-hidden'> <div className='size-14 flex items-center justify-center relative overflow-hidden'>
{isActive && effectImg && <Image src={effectImg} className='absolute top-0 left-0 w-full h-full' alt='' width={56} height={56} />} {isActive && effectImg && <Image src={effectImg} className='absolute top-0 left-0 w-full h-full' alt='' width={56} height={56} />}
@ -63,7 +65,7 @@ export const OptionCard: FC<OptionCardProps> = forwardRef((props, ref) => {
(isActive && !noHighlight) (isActive && !noHighlight)
? 'border-[1.5px] border-components-option-card-option-selected-border' ? 'border-[1.5px] border-components-option-card-option-selected-border'
: 'border border-components-option-card-option-border', : 'border border-components-option-card-option-border',
disabled && 'opacity-50', disabled && 'opacity-50 cursor-not-allowed',
className, className,
)} )}
style={{ style={{
@ -83,6 +85,7 @@ export const OptionCard: FC<OptionCardProps> = forwardRef((props, ref) => {
isActive={isActive && !noHighlight} isActive={isActive && !noHighlight}
activeClassName={activeHeaderClassName} activeClassName={activeHeaderClassName}
effectImg={effectImg} effectImg={effectImg}
disabled={disabled}
/> />
{/** Body */} {/** Body */}
{isActive && (children || actions) && <div className='py-3 px-4 bg-components-panel-bg rounded-b-xl'> {isActive && (children || actions) && <div className='py-3 px-4 bg-components-panel-bg rounded-b-xl'>

@ -4,7 +4,7 @@ import React from 'react'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
type Props = { type Props = {
value: number value: number | null
besideChunkName?: boolean besideChunkName?: boolean
} }
@ -12,6 +12,9 @@ const Score: FC<Props> = ({
value, value,
besideChunkName, besideChunkName,
}) => { }) => {
if (!value)
return null
return ( return (
<div className={cn('relative items-center px-[5px] border border-components-progress-bar-border overflow-hidden', besideChunkName ? 'border-l-0 h-[20.5px]' : 'h-[20px] rounded-md')}> <div className={cn('relative items-center px-[5px] border border-components-progress-bar-border overflow-hidden', besideChunkName ? 'border-l-0 h-[20.5px]' : 'h-[20px] rounded-md')}>
<div className={cn('absolute top-0 left-0 h-full bg-util-colors-blue-brand-blue-brand-100 border-r-[1.5px] border-components-progress-brand-progress', value === 1 && 'border-r-0')} style={{ width: `${value * 100}%` }} /> <div className={cn('absolute top-0 left-0 h-full bg-util-colors-blue-brand-blue-brand-100 border-r-[1.5px] border-components-progress-brand-progress', value === 1 && 'border-r-0')} style={{ width: `${value * 100}%` }} />

@ -58,23 +58,21 @@ export default function AppSelector({ isMobile }: IAppSelector) {
{ {
({ open }) => ( ({ open }) => (
<> <>
<div> <Menu.Button
<Menu.Button className={`
className={`
inline-flex items-center inline-flex items-center
rounded-[20px] py-1 pr-2.5 pl-1 text-sm rounded-[20px] py-1 pr-2.5 pl-1 text-sm
text-gray-700 hover:bg-gray-200 text-gray-700 hover:bg-gray-200
mobile:px-1 mobile:px-1
${open && 'bg-gray-200'} ${open && 'bg-gray-200'}
`} `}
> >
<Avatar name={userProfile.name} className='sm:mr-2 mr-0' size={32} /> <Avatar name={userProfile.name} className='sm:mr-2 mr-0' size={32} />
{!isMobile && <> {!isMobile && <>
{userProfile.name} {userProfile.name}
<RiArrowDownSLine className="w-3 h-3 ml-1 text-gray-700" /> <RiArrowDownSLine className="w-3 h-3 ml-1 text-gray-700" />
</>} </>}
</Menu.Button> </Menu.Button>
</div>
<Transition <Transition
as={Fragment} as={Fragment}
enter="transition ease-out duration-100" enter="transition ease-out duration-100"
@ -88,10 +86,10 @@ export default function AppSelector({ isMobile }: IAppSelector) {
className=" className="
absolute right-0 mt-1.5 w-60 max-w-80 absolute right-0 mt-1.5 w-60 max-w-80
divide-y divide-divider-subtle origin-top-right rounded-lg bg-components-panel-bg-blur divide-y divide-divider-subtle origin-top-right rounded-lg bg-components-panel-bg-blur
shadow-lg shadow-lg focus:outline-none
" "
> >
<Menu.Item> <Menu.Item disabled>
<div className='flex flex-nowrap items-center px-4 py-[13px]'> <div className='flex flex-nowrap items-center px-4 py-[13px]'>
<Avatar name={userProfile.name} size={36} className='mr-3' /> <Avatar name={userProfile.name} size={36} className='mr-3' />
<div className='grow'> <div className='grow'>
@ -102,89 +100,107 @@ export default function AppSelector({ isMobile }: IAppSelector) {
</Menu.Item> </Menu.Item>
<div className="px-1 py-1"> <div className="px-1 py-1">
<Menu.Item> <Menu.Item>
<Link {({ active }) => <Link
className={classNames(itemClassName, 'group justify-between')} className={classNames(itemClassName, 'group justify-between',
active && 'bg-state-base-hover',
)}
href='/account' href='/account'
target='_self' rel='noopener noreferrer'> target='_self' rel='noopener noreferrer'>
<div>{t('common.account.account')}</div> <div>{t('common.account.account')}</div>
<ArrowUpRight className='hidden w-[14px] h-[14px] text-text-tertiary group-hover:flex' /> <ArrowUpRight className='hidden w-[14px] h-[14px] text-text-tertiary group-hover:flex' />
</Link> </Link>}
</Menu.Item> </Menu.Item>
<Menu.Item> <Menu.Item>
<div className={itemClassName} onClick={() => setShowAccountSettingModal({ payload: 'members' })}> {({ active }) => <div className={classNames(itemClassName,
active && 'bg-state-base-hover',
)} onClick={() => setShowAccountSettingModal({ payload: 'members' })}>
<div>{t('common.userProfile.settings')}</div> <div>{t('common.userProfile.settings')}</div>
</div> </div>}
</Menu.Item> </Menu.Item>
{canEmailSupport && <Menu.Item> {canEmailSupport && <Menu.Item>
<a {({ active }) => <a
className={classNames(itemClassName, 'group justify-between')} className={classNames(itemClassName, 'group justify-between',
active && 'bg-state-base-hover',
)}
href={mailToSupport(userProfile.email, plan.type, langeniusVersionInfo.current_version)} href={mailToSupport(userProfile.email, plan.type, langeniusVersionInfo.current_version)}
target='_blank' rel='noopener noreferrer'> target='_blank' rel='noopener noreferrer'>
<div>{t('common.userProfile.emailSupport')}</div> <div>{t('common.userProfile.emailSupport')}</div>
<ArrowUpRight className='hidden w-[14px] h-[14px] text-text-tertiary group-hover:flex' /> <ArrowUpRight className='hidden w-[14px] h-[14px] text-text-tertiary group-hover:flex' />
</a> </a>}
</Menu.Item>} </Menu.Item>}
<Menu.Item> <Menu.Item>
<Link {({ active }) => <Link
className={classNames(itemClassName, 'group justify-between')} className={classNames(itemClassName, 'group justify-between',
active && 'bg-state-base-hover',
)}
href='https://github.com/langgenius/dify/discussions/categories/feedbacks' href='https://github.com/langgenius/dify/discussions/categories/feedbacks'
target='_blank' rel='noopener noreferrer'> target='_blank' rel='noopener noreferrer'>
<div>{t('common.userProfile.communityFeedback')}</div> <div>{t('common.userProfile.communityFeedback')}</div>
<ArrowUpRight className='hidden w-[14px] h-[14px] text-text-tertiary group-hover:flex' /> <ArrowUpRight className='hidden w-[14px] h-[14px] text-text-tertiary group-hover:flex' />
</Link> </Link>}
</Menu.Item> </Menu.Item>
<Menu.Item> <Menu.Item>
<Link {({ active }) => <Link
className={classNames(itemClassName, 'group justify-between')} className={classNames(itemClassName, 'group justify-between',
active && 'bg-state-base-hover',
)}
href='https://discord.gg/5AEfbxcd9k' href='https://discord.gg/5AEfbxcd9k'
target='_blank' rel='noopener noreferrer'> target='_blank' rel='noopener noreferrer'>
<div>{t('common.userProfile.community')}</div> <div>{t('common.userProfile.community')}</div>
<ArrowUpRight className='hidden w-[14px] h-[14px] text-text-tertiary group-hover:flex' /> <ArrowUpRight className='hidden w-[14px] h-[14px] text-text-tertiary group-hover:flex' />
</Link> </Link>}
</Menu.Item> </Menu.Item>
<Menu.Item> <Menu.Item>
<Link {({ active }) => <Link
className={classNames(itemClassName, 'group justify-between')} className={classNames(itemClassName, 'group justify-between',
active && 'bg-state-base-hover',
)}
href={ href={
locale !== LanguagesSupported[1] ? 'https://docs.dify.ai/' : `https://docs.dify.ai/v/${locale.toLowerCase()}/` locale !== LanguagesSupported[1] ? 'https://docs.dify.ai/' : `https://docs.dify.ai/v/${locale.toLowerCase()}/`
} }
target='_blank' rel='noopener noreferrer'> target='_blank' rel='noopener noreferrer'>
<div>{t('common.userProfile.helpCenter')}</div> <div>{t('common.userProfile.helpCenter')}</div>
<ArrowUpRight className='hidden w-[14px] h-[14px] text-text-tertiary group-hover:flex' /> <ArrowUpRight className='hidden w-[14px] h-[14px] text-text-tertiary group-hover:flex' />
</Link> </Link>}
</Menu.Item> </Menu.Item>
<Menu.Item> <Menu.Item>
<Link {({ active }) => <Link
className={classNames(itemClassName, 'group justify-between')} className={classNames(itemClassName, 'group justify-between',
active && 'bg-state-base-hover',
)}
href='https://roadmap.dify.ai' href='https://roadmap.dify.ai'
target='_blank' rel='noopener noreferrer'> target='_blank' rel='noopener noreferrer'>
<div>{t('common.userProfile.roadmap')}</div> <div>{t('common.userProfile.roadmap')}</div>
<ArrowUpRight className='hidden w-[14px] h-[14px] text-text-tertiary group-hover:flex' /> <ArrowUpRight className='hidden w-[14px] h-[14px] text-text-tertiary group-hover:flex' />
</Link> </Link>}
</Menu.Item> </Menu.Item>
{ {
document?.body?.getAttribute('data-public-site-about') !== 'hide' && ( document?.body?.getAttribute('data-public-site-about') !== 'hide' && (
<Menu.Item> <Menu.Item>
<div className={classNames(itemClassName, 'justify-between')} onClick={() => setAboutVisible(true)}> {({ active }) => <div className={classNames(itemClassName, 'justify-between',
active && 'bg-state-base-hover',
)} onClick={() => setAboutVisible(true)}>
<div>{t('common.userProfile.about')}</div> <div>{t('common.userProfile.about')}</div>
<div className='flex items-center'> <div className='flex items-center'>
<div className='mr-2 system-xs-regular text-text-tertiary'>{langeniusVersionInfo.current_version}</div> <div className='mr-2 system-xs-regular text-text-tertiary'>{langeniusVersionInfo.current_version}</div>
<Indicator color={langeniusVersionInfo.current_version === langeniusVersionInfo.latest_version ? 'green' : 'orange'} /> <Indicator color={langeniusVersionInfo.current_version === langeniusVersionInfo.latest_version ? 'green' : 'orange'} />
</div> </div>
</div> </div>}
</Menu.Item> </Menu.Item>
) )
} }
</div> </div>
<Menu.Item> <Menu.Item>
<div className='p-1' onClick={() => handleLogout()}> {({ active }) => <div className='p-1' onClick={() => handleLogout()}>
<div <div
className='flex items-center justify-between h-9 px-3 rounded-lg cursor-pointer group hover:bg-state-base-hover' className={
classNames('flex items-center justify-between h-9 px-3 rounded-lg cursor-pointer group hover:bg-state-base-hover',
active && 'bg-state-base-hover')}
> >
<div className='system-md-regular text-text-secondary'>{t('common.userProfile.logout')}</div> <div className='system-md-regular text-text-secondary'>{t('common.userProfile.logout')}</div>
<RiLogoutBoxRLine className='hidden w-4 h-4 text-text-tertiary group-hover:flex' /> <RiLogoutBoxRLine className='hidden w-4 h-4 text-text-tertiary group-hover:flex' />
</div> </div>
</div> </div>}
</Menu.Item> </Menu.Item>
</Menu.Items> </Menu.Items>
</Transition> </Transition>

@ -1,9 +0,0 @@
.modal {
padding: 24px 32px !important;
width: 400px !important;
}
.bg {
background: linear-gradient(180deg, rgba(217, 45, 32, 0.05) 0%, rgba(217, 45, 32, 0.00) 24.02%), #F9FAFB;
}

@ -1,282 +0,0 @@
'use client'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext, useContextSelector } from 'use-context-selector'
import Collapse from '../collapse'
import type { IItem } from '../collapse'
import s from './index.module.css'
import classNames from '@/utils/classnames'
import Modal from '@/app/components/base/modal'
import Confirm from '@/app/components/base/confirm'
import Button from '@/app/components/base/button'
import { updateUserProfile } from '@/service/common'
import AppContext, { useAppContext } from '@/context/app-context'
import { ToastContext } from '@/app/components/base/toast'
import AppIcon from '@/app/components/base/app-icon'
import Avatar from '@/app/components/base/avatar'
import { IS_CE_EDITION } from '@/config'
const titleClassName = `
text-sm font-medium text-gray-900
`
const descriptionClassName = `
mt-1 text-xs font-normal text-gray-500
`
const inputClassName = `
mt-2 w-full px-3 py-2 bg-gray-100 rounded
text-sm font-normal text-gray-800
`
const validPassword = /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/
export default function AccountPage() {
const { t } = useTranslation()
const { mutateUserProfile, userProfile, apps } = useAppContext()
const { notify } = useContext(ToastContext)
const [editNameModalVisible, setEditNameModalVisible] = useState(false)
const [editName, setEditName] = useState('')
const [editing, setEditing] = useState(false)
const [editPasswordModalVisible, setEditPasswordModalVisible] = useState(false)
const [currentPassword, setCurrentPassword] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [showDeleteAccountModal, setShowDeleteAccountModal] = useState(false)
const systemFeatures = useContextSelector(AppContext, state => state.systemFeatures)
const handleEditName = () => {
setEditNameModalVisible(true)
setEditName(userProfile.name)
}
const handleSaveName = async () => {
try {
setEditing(true)
await updateUserProfile({ url: 'account/name', body: { name: editName } })
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
mutateUserProfile()
setEditNameModalVisible(false)
setEditing(false)
}
catch (e) {
notify({ type: 'error', message: (e as Error).message })
setEditNameModalVisible(false)
setEditing(false)
}
}
const showErrorMessage = (message: string) => {
notify({
type: 'error',
message,
})
}
const valid = () => {
if (!password.trim()) {
showErrorMessage(t('login.error.passwordEmpty'))
return false
}
if (!validPassword.test(password)) {
showErrorMessage(t('login.error.passwordInvalid'))
return false
}
if (password !== confirmPassword) {
showErrorMessage(t('common.account.notEqual'))
return false
}
return true
}
const resetPasswordForm = () => {
setCurrentPassword('')
setPassword('')
setConfirmPassword('')
}
const handleSavePassword = async () => {
if (!valid())
return
try {
setEditing(true)
await updateUserProfile({
url: 'account/password',
body: {
password: currentPassword,
new_password: password,
repeat_new_password: confirmPassword,
},
})
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
mutateUserProfile()
setEditPasswordModalVisible(false)
resetPasswordForm()
setEditing(false)
}
catch (e) {
notify({ type: 'error', message: (e as Error).message })
setEditPasswordModalVisible(false)
setEditing(false)
}
}
const renderAppItem = (item: IItem) => {
return (
<div className='flex px-3 py-1'>
<div className='mr-3'>
<AppIcon size='tiny' />
</div>
<div className='mt-[3px] text-xs font-medium text-gray-700 leading-[18px]'>{item.name}</div>
</div>
)
}
return (
<>
<div className='mb-8'>
<div className={titleClassName}>{t('common.account.avatar')}</div>
<Avatar name={userProfile.name} size={64} className='mt-2' />
</div>
<div className='mb-8'>
<div className={titleClassName}>{t('common.account.name')}</div>
<div className={classNames('flex items-center justify-between mt-2 w-full h-9 px-3 bg-gray-100 rounded text-sm font-normal text-gray-800 cursor-pointer group')}>
{userProfile.name}
<div className='items-center hidden h-6 px-2 text-xs font-normal bg-white border border-gray-200 rounded-md group-hover:flex' onClick={handleEditName}>{t('common.operation.edit')}</div>
</div>
</div>
<div className='mb-8'>
<div className={titleClassName}>{t('common.account.email')}</div>
<div className={classNames(inputClassName, 'cursor-pointer')}>{userProfile.email}</div>
</div>
{systemFeatures.enable_email_password_login && (
<div className='mb-8'>
<div className='mb-1 text-sm font-medium text-gray-900'>{t('common.account.password')}</div>
<div className='mb-2 text-xs text-gray-500'>{t('common.account.passwordTip')}</div>
<Button onClick={() => setEditPasswordModalVisible(true)}>{userProfile.is_password_set ? t('common.account.resetPassword') : t('common.account.setPassword')}</Button>
</div>
)}
<div className='mb-6 border-[0.5px] border-gray-100' />
<div className='mb-8'>
<div className={titleClassName}>{t('common.account.langGeniusAccount')}</div>
<div className={descriptionClassName}>{t('common.account.langGeniusAccountTip')}</div>
{!!apps.length && (
<Collapse
title={`${t('common.account.showAppLength', { length: apps.length })}`}
items={apps.map(app => ({ key: app.id, name: app.name }))}
renderItem={renderAppItem}
wrapperClassName='mt-2'
/>
)}
{!IS_CE_EDITION && <Button className='mt-2 text-[#D92D20]' onClick={() => setShowDeleteAccountModal(true)}>{t('common.account.delete')}</Button>}
</div>
{editNameModalVisible && (
<Modal
isShow
onClose={() => setEditNameModalVisible(false)}
className={s.modal}
>
<div className='mb-6 text-lg font-medium text-gray-900'>{t('common.account.editName')}</div>
<div className={titleClassName}>{t('common.account.name')}</div>
<input
className={inputClassName}
value={editName}
onChange={e => setEditName(e.target.value)}
/>
<div className='flex justify-end mt-10'>
<Button className='mr-2' onClick={() => setEditNameModalVisible(false)}>{t('common.operation.cancel')}</Button>
<Button
disabled={editing || !editName}
variant='primary'
onClick={handleSaveName}
>
{t('common.operation.save')}
</Button>
</div>
</Modal>
)}
{editPasswordModalVisible && (
<Modal
isShow
onClose={() => {
setEditPasswordModalVisible(false)
resetPasswordForm()
}}
className={s.modal}
>
<div className='mb-6 text-lg font-medium text-gray-900'>{userProfile.is_password_set ? t('common.account.resetPassword') : t('common.account.setPassword')}</div>
{userProfile.is_password_set && (
<>
<div className={titleClassName}>{t('common.account.currentPassword')}</div>
<input
type="password"
className={inputClassName}
value={currentPassword}
onChange={e => setCurrentPassword(e.target.value)}
/>
</>
)}
<div className='mt-8 text-sm font-medium text-gray-900'>
{userProfile.is_password_set ? t('common.account.newPassword') : t('common.account.password')}
</div>
<input
type="password"
className={inputClassName}
value={password}
onChange={e => setPassword(e.target.value)}
/>
<div className='mt-8 text-sm font-medium text-gray-900'>{t('common.account.confirmPassword')}</div>
<input
type="password"
className={inputClassName}
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
/>
<div className='flex justify-end mt-10'>
<Button className='mr-2' onClick={() => {
setEditPasswordModalVisible(false)
resetPasswordForm()
}}>{t('common.operation.cancel')}</Button>
<Button
disabled={editing}
variant='primary'
onClick={handleSavePassword}
>
{userProfile.is_password_set ? t('common.operation.reset') : t('common.operation.save')}
</Button>
</div>
</Modal>
)}
{showDeleteAccountModal && (
<Confirm
isShow
onCancel={() => setShowDeleteAccountModal(false)}
onConfirm={() => setShowDeleteAccountModal(false)}
showCancel={false}
type='warning'
title={t('common.account.delete')}
content={
<>
<div className='my-1 text-[#D92D20] text-sm leading-5'>
{t('common.account.deleteTip')}
</div>
<div className='mt-3 text-sm leading-5'>
<span>{t('common.account.deleteConfirmTip')}</span>
<a
className='text-primary-600 cursor'
href={`mailto:support@dify.ai?subject=Delete Account Request&body=Delete Account: ${userProfile.email}`}
target='_blank'
rel='noreferrer noopener'
onClick={(e) => {
e.preventDefault()
window.location.href = e.currentTarget.href
}}
>
support@dify.ai
</a>
</div>
<div className='my-2 px-3 py-2 rounded-lg bg-gray-100 text-sm font-medium leading-5 text-gray-800'>{`${t('common.account.delete')}: ${userProfile.email}`}</div>
</>
}
confirmText={t('common.operation.ok') as string}
/>
)}
</>
)
}

@ -22,14 +22,14 @@ const ModelIcon: FC<ModelIconProps> = ({
}) => { }) => {
const language = useLanguage() const language = useLanguage()
if (provider?.provider.includes('openai') && modelName?.includes('gpt-4o')) if (provider?.provider.includes('openai') && modelName?.includes('gpt-4o'))
return <div className='flex w-6 h-6 items-center justify-center'><OpenaiBlue className={cn('w-5 h-5', className)}/></div> return <div className='flex items-center justify-center'><OpenaiBlue className={cn('w-5 h-5', className)}/></div>
if (provider?.provider.includes('openai') && modelName?.startsWith('gpt-4')) if (provider?.provider.includes('openai') && modelName?.startsWith('gpt-4'))
return <div className='flex w-6 h-6 items-center justify-center'><OpenaiViolet className={cn('w-5 h-5', className)}/></div> return <div className='flex items-center justify-center'><OpenaiViolet className={cn('w-5 h-5', className)}/></div>
if (provider?.icon_small) { if (provider?.icon_small) {
return ( return (
<div className={`flex w-6 h-6 items-center justify-center ${isDeprecated ? 'opacity-50' : ''}`}> <div className={`flex items-center justify-center ${isDeprecated ? 'opacity-50' : ''}`}>
<img <img
alt='model-icon' alt='model-icon'
src={`${provider.icon_small[language] || provider.icon_small.en_US}`} src={`${provider.icon_small[language] || provider.icon_small.en_US}`}

@ -37,7 +37,7 @@ const ModelName: FC<ModelNameProps> = ({
if (!modelItem) if (!modelItem)
return null return null
return ( return (
<div className={cn('flex items-center overflow-hidden text-ellipsis truncate text-components-input-text-filled system-sm-regular', className)}> <div className={cn('flex gap-0.5 items-center overflow-hidden text-ellipsis truncate text-components-input-text-filled system-sm-regular', className)}>
<div <div
className='truncate' className='truncate'
title={modelItem.label[language] || modelItem.label.en_US} title={modelItem.label[language] || modelItem.label.en_US}

@ -22,11 +22,11 @@ const ModelTrigger: FC<ModelTriggerProps> = ({
return ( return (
<div <div
className={cn('group flex flex-grow items-center p-[3px] pl-1 gap-1 rounded-lg bg-components-input-bg-disabled cursor-pointer', className)} className={cn('group flex flex-grow items-center p-[3px] pl-1 h-6 gap-1 rounded-lg bg-components-input-bg-disabled cursor-pointer', className)}
> >
<div className='flex items-center py-[1px] gap-1 grow'> <div className='flex items-center py-[1px] gap-1 grow'>
<ModelIcon <ModelIcon
className="m-0.5" className="m-0.5 w-4 h-4"
provider={currentProvider} provider={currentProvider}
modelName={modelName} modelName={modelName}
/> />

@ -42,33 +42,35 @@ const ModelTrigger: FC<ModelTriggerProps> = ({
)} )}
> >
<ModelIcon <ModelIcon
className='shrink-0 mr-1.5' className='shrink-0 m-1'
provider={provider} provider={provider}
modelName={model.model} modelName={model.model}
/> />
<ModelName <div className='flex px-1 py-[3px] items-center gap-1 grow'>
className='grow' <ModelName
modelItem={model} className='grow'
showMode modelItem={model}
showFeatures showMode
/> showFeatures
{!readonly && ( />
<div className='shrink-0 flex items-center justify-center w-4 h-4'> {!readonly && (
{ <div className='shrink-0 flex items-center justify-center w-4 h-4'>
model.status !== ModelStatusEnum.active {
? ( model.status !== ModelStatusEnum.active
<Tooltip popupContent={MODEL_STATUS_TEXT[model.status][language]}> ? (
<AlertTriangle className='w-4 h-4 text-[#F79009]' /> <Tooltip popupContent={MODEL_STATUS_TEXT[model.status][language]}>
</Tooltip> <AlertTriangle className='w-4 h-4 text-text-warning-secondary' />
) </Tooltip>
: ( )
<RiArrowDownSLine : (
className='w-3.5 h-3.5 text-text-tertiary' <RiArrowDownSLine
/> className='w-3.5 h-3.5 text-text-tertiary'
) />
} )
</div> }
)} </div>
)}
</div>
</div> </div>
) )
} }

@ -69,13 +69,14 @@ const Blocks = ({
const listViewToolData = useMemo(() => { const listViewToolData = useMemo(() => {
const result: ToolWithProvider[] = [] const result: ToolWithProvider[] = []
Object.keys(withLetterAndGroupViewToolsData).forEach((letter) => { letters.forEach((letter) => {
Object.keys(withLetterAndGroupViewToolsData[letter]).forEach((groupName) => { Object.keys(withLetterAndGroupViewToolsData[letter]).forEach((groupName) => {
result.push(...withLetterAndGroupViewToolsData[letter][groupName]) result.push(...withLetterAndGroupViewToolsData[letter][groupName])
}) })
}) })
return result return result
}, [withLetterAndGroupViewToolsData]) }, [withLetterAndGroupViewToolsData, letters])
const toolRefs = useRef({}) const toolRefs = useRef({})

@ -20,6 +20,7 @@ import type { StartNodeType } from '../nodes/start/types'
import { import {
useChecklistBeforePublish, useChecklistBeforePublish,
useIsChatMode, useIsChatMode,
useNodesInteractions,
useNodesReadOnly, useNodesReadOnly,
useNodesSyncDraft, useNodesSyncDraft,
useWorkflowMode, useWorkflowMode,
@ -35,6 +36,7 @@ import RestoringTitle from './restoring-title'
import ViewHistory from './view-history' import ViewHistory from './view-history'
import ChatVariableButton from './chat-variable-button' import ChatVariableButton from './chat-variable-button'
import EnvButton from './env-button' import EnvButton from './env-button'
import VersionHistoryModal from './version-history-modal'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import { useStore as useAppStore } from '@/app/components/app/store' import { useStore as useAppStore } from '@/app/components/app/store'
import { publishWorkflow } from '@/service/workflow' import { publishWorkflow } from '@/service/workflow'
@ -49,11 +51,13 @@ const Header: FC = () => {
const appID = appDetail?.id const appID = appDetail?.id
const isChatMode = useIsChatMode() const isChatMode = useIsChatMode()
const { nodesReadOnly, getNodesReadOnly } = useNodesReadOnly() const { nodesReadOnly, getNodesReadOnly } = useNodesReadOnly()
const { handleNodeSelect } = useNodesInteractions()
const publishedAt = useStore(s => s.publishedAt) const publishedAt = useStore(s => s.publishedAt)
const draftUpdatedAt = useStore(s => s.draftUpdatedAt) const draftUpdatedAt = useStore(s => s.draftUpdatedAt)
const toolPublished = useStore(s => s.toolPublished) const toolPublished = useStore(s => s.toolPublished)
const nodes = useNodes<StartNodeType>() const nodes = useNodes<StartNodeType>()
const startNode = nodes.find(node => node.data.type === BlockEnum.Start) const startNode = nodes.find(node => node.data.type === BlockEnum.Start)
const selectedNode = nodes.find(node => node.data.selected)
const startVariables = startNode?.data.variables const startVariables = startNode?.data.variables
const fileSettings = useFeatures(s => s.features.file) const fileSettings = useFeatures(s => s.features.file)
const variables = useMemo(() => { const variables = useMemo(() => {
@ -76,7 +80,6 @@ const Header: FC = () => {
const { const {
handleLoadBackupDraft, handleLoadBackupDraft,
handleBackupDraft, handleBackupDraft,
handleRestoreFromPublishedWorkflow,
} = useWorkflowRun() } = useWorkflowRun()
const { handleCheckBeforePublish } = useChecklistBeforePublish() const { handleCheckBeforePublish } = useChecklistBeforePublish()
const { handleSyncWorkflowDraft } = useNodesSyncDraft() const { handleSyncWorkflowDraft } = useNodesSyncDraft()
@ -126,8 +129,10 @@ const Header: FC = () => {
const onStartRestoring = useCallback(() => { const onStartRestoring = useCallback(() => {
workflowStore.setState({ isRestoring: true }) workflowStore.setState({ isRestoring: true })
handleBackupDraft() handleBackupDraft()
handleRestoreFromPublishedWorkflow() // clear right panel
}, [handleBackupDraft, handleRestoreFromPublishedWorkflow, workflowStore]) if (selectedNode)
handleNodeSelect(selectedNode.id, true)
}, [handleBackupDraft, workflowStore, handleNodeSelect, selectedNode])
const onPublisherToggle = useCallback((state: boolean) => { const onPublisherToggle = useCallback((state: boolean) => {
if (state) if (state)
@ -209,23 +214,27 @@ const Header: FC = () => {
} }
{ {
restoring && ( restoring && (
<div className='flex items-center space-x-2'> <div className='flex flex-col mt-auto'>
<Button className='text-components-button-secondary-text' onClick={handleShowFeatures}> <div className='flex items-center justify-end my-4'>
<RiApps2AddLine className='w-4 h-4 mr-1 text-components-button-secondary-text' /> <Button className='text-components-button-secondary-text' onClick={handleShowFeatures}>
{t('workflow.common.features')} <RiApps2AddLine className='w-4 h-4 mr-1 text-components-button-secondary-text' />
</Button> {t('workflow.common.features')}
<Divider type='vertical' className='h-3.5 mx-auto' /> </Button>
<Button <div className='mx-2 w-[1px] h-3.5 bg-gray-200'></div>
onClick={handleCancelRestore} <Button
> className='mr-2'
{t('common.operation.cancel')} onClick={handleCancelRestore}
</Button> >
<Button {t('common.operation.cancel')}
onClick={handleRestore} </Button>
variant='primary' <Button
> onClick={handleRestore}
{t('workflow.common.restore')} variant='primary'
</Button> >
{t('workflow.common.restore')}
</Button>
</div>
<VersionHistoryModal />
</div> </div>
) )
} }

@ -0,0 +1,66 @@
import React from 'react'
import dayjs from 'dayjs'
import { useTranslation } from 'react-i18next'
import { WorkflowVersion } from '../types'
import cn from '@/utils/classnames'
import type { VersionHistory } from '@/types/workflow'
type VersionHistoryItemProps = {
item: VersionHistory
selectedVersion: string
onClick: (item: VersionHistory) => void
curIdx: number
page: number
}
const formatVersion = (version: string, curIdx: number, page: number): string => {
if (curIdx === 0 && page === 1)
return WorkflowVersion.Draft
if (curIdx === 1 && page === 1)
return WorkflowVersion.Latest
try {
const date = new Date(version)
if (isNaN(date.getTime()))
return version
// format as YYYY-MM-DD HH:mm:ss
return date.toISOString().slice(0, 19).replace('T', ' ')
}
catch {
return version
}
}
const VersionHistoryItem: React.FC<VersionHistoryItemProps> = ({ item, selectedVersion, onClick, curIdx, page }) => {
const { t } = useTranslation()
const formatTime = (time: number) => dayjs.unix(time).format('YYYY-MM-DD HH:mm:ss')
const formattedVersion = formatVersion(item.version, curIdx, page)
const renderVersionLabel = (version: string) => (
(version === WorkflowVersion.Draft || version === WorkflowVersion.Latest)
? (
<div className="shrink-0 px-1 border bg-white border-[rgba(0,0,0,0.08)] rounded-[5px] truncate">
{version}
</div>
)
: null
)
return (
<div
className={cn(
'flex items-center p-2 h-12 text-xs font-medium text-gray-700 justify-between',
formattedVersion === selectedVersion ? '' : 'hover:bg-gray-100',
formattedVersion === WorkflowVersion.Draft ? 'cursor-not-allowed' : 'cursor-pointer',
)}
onClick={() => item.version !== WorkflowVersion.Draft && onClick(item)}
>
<div className='flex flex-col gap-1 py-2'>
<span className="text-left">{formatTime(formattedVersion === WorkflowVersion.Draft ? item.updated_at : item.created_at)}</span>
<span className="text-left">{t('workflow.panel.createdBy')} {item.created_by.name}</span>
</div>
{renderVersionLabel(formattedVersion)}
</div>
)
}
export default React.memo(VersionHistoryItem)

@ -0,0 +1,89 @@
'use client'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import { useWorkflowRun } from '../hooks'
import VersionHistoryItem from './version-history-item'
import type { VersionHistory } from '@/types/workflow'
import { useStore as useAppStore } from '@/app/components/app/store'
import { fetchPublishedAllWorkflow } from '@/service/workflow'
import Loading from '@/app/components/base/loading'
import Button from '@/app/components/base/button'
const limit = 10
const VersionHistoryModal = () => {
const [selectedVersion, setSelectedVersion] = useState('draft')
const [page, setPage] = useState(1)
const { handleRestoreFromPublishedWorkflow } = useWorkflowRun()
const appDetail = useAppStore.getState().appDetail
const { t } = useTranslation()
const {
data: versionHistory,
isLoading,
} = useSWR(
`/apps/${appDetail?.id}/workflows?page=${page}&limit=${limit}`,
fetchPublishedAllWorkflow,
)
const handleVersionClick = (item: VersionHistory) => {
if (item.version !== selectedVersion) {
setSelectedVersion(item.version)
handleRestoreFromPublishedWorkflow(item)
}
}
const handleNextPage = () => {
if (versionHistory?.has_more)
setPage(page => page + 1)
}
return (
<div className='w-[336px] bg-white rounded-2xl border-[0.5px] border-gray-200 shadow-xl p-2'>
<div className="max-h-[400px] overflow-auto">
{(isLoading && page) === 1
? (
<div className='flex items-center justify-center h-10'>
<Loading/>
</div>
)
: (
<>
{versionHistory?.items?.map((item, idx) => (
<VersionHistoryItem
key={item.version}
item={item}
selectedVersion={selectedVersion}
onClick={handleVersionClick}
curIdx={idx}
page={page}
/>
))}
{isLoading && page > 1 && (
<div className='flex items-center justify-center h-10'>
<Loading/>
</div>
)}
{!isLoading && versionHistory?.has_more && (
<div className='flex items-center justify-center h-10 mt-2'>
<Button
className='text-sm'
onClick={handleNextPage}
>
{t('workflow.common.loadMore')}
</Button>
</div>
)}
{!isLoading && !versionHistory?.items?.length && (
<div className='flex items-center justify-center h-10 text-gray-500'>
{t('workflow.common.noHistory')}
</div>
)}
</>
)}
</div>
</div>
)
}
export default React.memo(VersionHistoryModal)

@ -14,12 +14,10 @@ import { useWorkflowRunEvent } from './use-workflow-run-event/use-workflow-run-e
import { useStore as useAppStore } from '@/app/components/app/store' import { useStore as useAppStore } from '@/app/components/app/store'
import type { IOtherOptions } from '@/service/base' import type { IOtherOptions } from '@/service/base'
import { ssePost } from '@/service/base' import { ssePost } from '@/service/base'
import { import { stopWorkflowRun } from '@/service/workflow'
fetchPublishedWorkflow,
stopWorkflowRun,
} from '@/service/workflow'
import { useFeaturesStore } from '@/app/components/base/features/hooks' import { useFeaturesStore } from '@/app/components/base/features/hooks'
import { AudioPlayerManager } from '@/app/components/base/audio-btn/audio.player.manager' import { AudioPlayerManager } from '@/app/components/base/audio-btn/audio.player.manager'
import type { VersionHistory } from '@/types/workflow'
export const useWorkflowRun = () => { export const useWorkflowRun = () => {
const store = useStoreApi() const store = useStoreApi()
@ -262,24 +260,18 @@ export const useWorkflowRun = () => {
stopWorkflowRun(`/apps/${appId}/workflow-runs/tasks/${taskId}/stop`) stopWorkflowRun(`/apps/${appId}/workflow-runs/tasks/${taskId}/stop`)
}, []) }, [])
const handleRestoreFromPublishedWorkflow = useCallback(async () => { const handleRestoreFromPublishedWorkflow = useCallback((publishedWorkflow: VersionHistory) => {
const appDetail = useAppStore.getState().appDetail const nodes = publishedWorkflow.graph.nodes.map(node => ({ ...node, selected: false, data: { ...node.data, selected: false } }))
const publishedWorkflow = await fetchPublishedWorkflow(`/apps/${appDetail?.id}/workflows/publish`) const edges = publishedWorkflow.graph.edges
const viewport = publishedWorkflow.graph.viewport!
if (publishedWorkflow) { handleUpdateWorkflowCanvas({
const nodes = publishedWorkflow.graph.nodes nodes,
const edges = publishedWorkflow.graph.edges edges,
const viewport = publishedWorkflow.graph.viewport! viewport,
})
handleUpdateWorkflowCanvas({ featuresStore?.setState({ features: publishedWorkflow.features })
nodes, workflowStore.getState().setPublishedAt(publishedWorkflow.created_at)
edges, workflowStore.getState().setEnvironmentVariables(publishedWorkflow.environment_variables || [])
viewport,
})
featuresStore?.setState({ features: publishedWorkflow.features })
workflowStore.getState().setPublishedAt(publishedWorkflow.created_at)
workflowStore.getState().setEnvironmentVariables(publishedWorkflow.environment_variables || [])
}
}, [featuresStore, handleUpdateWorkflowCanvas, workflowStore]) }, [featuresStore, handleUpdateWorkflowCanvas, workflowStore])
return { return {

@ -1,6 +1,7 @@
import { useStrategyProviderDetail } from '@/service/use-strategy' import { useStrategyProviderDetail } from '@/service/use-strategy'
import useNodeCrud from '../_base/hooks/use-node-crud' import useNodeCrud from '../_base/hooks/use-node-crud'
import useVarList from '../_base/hooks/use-var-list' import useVarList from '../_base/hooks/use-var-list'
import useOneStepRun from '../_base/hooks/use-one-step-run'
import type { AgentNodeType } from './types' import type { AgentNodeType } from './types'
import { import {
useNodesReadOnly, useNodesReadOnly,
@ -19,6 +20,27 @@ const useConfig = (id: string, payload: AgentNodeType) => {
const strategyProvider = useStrategyProviderDetail( const strategyProvider = useStrategyProviderDetail(
inputs.agent_strategy_provider_name || '', inputs.agent_strategy_provider_name || '',
) )
// single run
const agentInputKey = `${id}.input_selector`
const {
isShowSingleRun,
showSingleRun,
hideSingleRun,
toVarInputs,
runningStatus,
handleRun,
handleStop,
runInputData,
setRunInputData,
runResult,
} = useOneStepRun<AgentNodeType>({
id,
data: inputs,
defaultRunInputData: {
[agentInputKey]: [''],
},
})
const currentStrategy = strategyProvider.data?.declaration.strategies.find( const currentStrategy = strategyProvider.data?.declaration.strategies.find(
str => str.identity.name === inputs.agent_strategy_name, str => str.identity.name === inputs.agent_strategy_name,
) )
@ -59,6 +81,18 @@ const useConfig = (id: string, payload: AgentNodeType) => {
onFormChange, onFormChange,
currentStrategyStatus, currentStrategyStatus,
strategyProvider: strategyProvider.data, strategyProvider: strategyProvider.data,
isShowSingleRun,
showSingleRun,
hideSingleRun,
toVarInputs,
runningStatus,
handleRun,
handleStop,
runInputData,
setRunInputData,
runResult,
agentInputKey,
} }
} }

@ -1,5 +1,5 @@
import type { FC } from 'react' import type { FC } from 'react'
import React from 'react' import React, { useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Split from '../_base/components/split' import Split from '../_base/components/split'
import type { ToolNodeType } from './types' import type { ToolNodeType } from './types'
@ -15,6 +15,8 @@ import BeforeRunForm from '@/app/components/workflow/nodes/_base/components/befo
import OutputVars, { VarItem } from '@/app/components/workflow/nodes/_base/components/output-vars' import OutputVars, { VarItem } from '@/app/components/workflow/nodes/_base/components/output-vars'
import ResultPanel from '@/app/components/workflow/run/result-panel' import ResultPanel from '@/app/components/workflow/run/result-panel'
import { useToolIcon } from '@/app/components/workflow/hooks' import { useToolIcon } from '@/app/components/workflow/hooks'
import { useLogs } from '@/app/components/workflow/run/hooks'
import formatToTracingNodeList from '@/app/components/workflow/run/utils/format-log'
const i18nPrefix = 'workflow.nodes.tool' const i18nPrefix = 'workflow.nodes.tool'
@ -51,6 +53,12 @@ const Panel: FC<NodePanelProps<ToolNodeType>> = ({
outputSchema, outputSchema,
} = useConfig(id, data) } = useConfig(id, data)
const toolIcon = useToolIcon(data) const toolIcon = useToolIcon(data)
const logsParams = useLogs()
const nodeInfo = useMemo(() => {
if (!runResult)
return null
return formatToTracingNodeList([runResult], t)[0]
}, [runResult, t])
if (isLoading) { if (isLoading) {
return <div className='flex h-[200px] items-center justify-center'> return <div className='flex h-[200px] items-center justify-center'>
@ -161,7 +169,8 @@ const Panel: FC<NodePanelProps<ToolNodeType>> = ({
runningStatus={runningStatus} runningStatus={runningStatus}
onRun={handleRun} onRun={handleRun}
onStop={handleStop} onStop={handleStop}
result={<ResultPanel {...runResult} showSteps={false} />} {...logsParams}
result={<ResultPanel {...runResult} showSteps={false} {...logsParams} nodeInfo={nodeInfo} />}
/> />
)} )}
</div> </div>

@ -17,9 +17,9 @@ const Operator = ({ handleUndo, handleRedo }: OperatorProps) => {
width: 102, width: 102,
height: 72, height: 72,
}} }}
maskColor='var(--color-shadow-shadow-5)' maskColor='var(--color-workflow-minimap-bg)'
className='!absolute !left-4 !bottom-14 z-[9] !m-0 !w-[102px] !h-[72px] !border-[0.5px] !border-divider-subtle className='!absolute !left-4 !bottom-14 z-[9] !m-0 !w-[102px] !h-[72px] !border-[0.5px] !border-divider-subtle
!rounded-lg !shadow-md !shadow-shadow-shadow-5 !bg-workflow-minimap-bg' !rounded-lg !shadow-md !shadow-shadow-shadow-5 !bg-background-default-subtle'
/> />
<div className='flex items-center mt-1 gap-2 absolute left-4 bottom-4 z-[9]'> <div className='flex items-center mt-1 gap-2 absolute left-4 bottom-4 z-[9]'>
<ZoomInOut /> <ZoomInOut />

@ -4,7 +4,7 @@ import Button from '@/app/components/base/button'
import type { AgentLogItemWithChildren } from '@/types/workflow' import type { AgentLogItemWithChildren } from '@/types/workflow'
type AgentLogNavProps = { type AgentLogNavProps = {
agentOrToolLogItemStack: { id: string; label: string }[] agentOrToolLogItemStack: AgentLogItemWithChildren[]
onShowAgentOrToolLog: (detail?: AgentLogItemWithChildren) => void onShowAgentOrToolLog: (detail?: AgentLogItemWithChildren) => void
} }
const AgentLogNav = ({ const AgentLogNav = ({

@ -1,9 +1,10 @@
import { RiAlertFill } from '@remixicon/react'
import AgentLogItem from './agent-log-item' import AgentLogItem from './agent-log-item'
import AgentLogNav from './agent-log-nav' import AgentLogNav from './agent-log-nav'
import type { AgentLogItemWithChildren } from '@/types/workflow' import type { AgentLogItemWithChildren } from '@/types/workflow'
type AgentResultPanelProps = { type AgentResultPanelProps = {
agentOrToolLogItemStack: { id: string; label: string }[] agentOrToolLogItemStack: AgentLogItemWithChildren[]
agentOrToolLogListMap: Record<string, AgentLogItemWithChildren[]> agentOrToolLogListMap: Record<string, AgentLogItemWithChildren[]>
onShowAgentOrToolLog: (detail?: AgentLogItemWithChildren) => void onShowAgentOrToolLog: (detail?: AgentLogItemWithChildren) => void
} }
@ -34,6 +35,22 @@ const AgentResultPanel = ({
} }
</div> </div>
} }
{
top.hasCircle && (
<div className='flex items-center rounded-xl px-3 pr-2 border border-components-panel-border bg-components-panel-bg-blur shadow-md'>
<div
className='absolute inset-0 opacity-[0.4] rounded-xl'
style={{
background: 'linear-gradient(92deg, rgba(247, 144, 9, 0.25) 0%, rgba(255, 255, 255, 0.00) 100%)',
}}
></div>
<RiAlertFill className='mr-1.5 w-4 h-4 text-text-warning-secondary' />
<div className='system-xs-medium text-text-primary'>
There is circular invocation of tools/nodes in the current workflow.
</div>
</div>
)
}
</div> </div>
) )
} }

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

Loading…
Cancel
Save