mirror of
https://github.com/langgenius/dify.git
synced 2026-04-04 22:20:25 +08:00
Merge branch 'feat/plugins' of https://github.com/langgenius/dify into feat/plugins
This commit is contained in:
commit
870779534f
@ -85,11 +85,11 @@ ignore = [
|
||||
]
|
||||
"tests/*" = [
|
||||
"F811", # redefined-while-unused
|
||||
"F401", # unused-import
|
||||
]
|
||||
|
||||
[lint.pyflakes]
|
||||
extend-generics = [
|
||||
allowed-unused-imports = [
|
||||
"_pytest.monkeypatch",
|
||||
"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 \
|
||||
&& apt-get update \
|
||||
# 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
|
||||
&& apt-get install -y fonts-noto-cjk \
|
||||
&& apt-get autoremove -y \
|
||||
|
||||
22
api/app.py
22
api/app.py
@ -1,12 +1,8 @@
|
||||
from libs import version_utils
|
||||
|
||||
# preparation before creating app
|
||||
version_utils.check_supported_python_version()
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def is_db_command():
|
||||
import sys
|
||||
|
||||
if len(sys.argv) > 1 and sys.argv[0].endswith("flask") and sys.argv[1] == "db":
|
||||
return True
|
||||
return False
|
||||
@ -18,10 +14,18 @@ if is_db_command():
|
||||
|
||||
app = create_migrations_app()
|
||||
else:
|
||||
from app_factory import create_app
|
||||
from libs import threadings_utils
|
||||
if os.environ.get("FLASK_DEBUG", "False") != "True":
|
||||
from gevent import monkey # type: ignore
|
||||
|
||||
threadings_utils.apply_gevent_threading_patch()
|
||||
# gevent
|
||||
monkey.patch_all()
|
||||
|
||||
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()
|
||||
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(
|
||||
# place the configs in alphabet order
|
||||
AppExecutionConfig,
|
||||
@ -792,6 +799,7 @@ class FeatureConfig(
|
||||
WorkflowNodeExecutionConfig,
|
||||
WorkspaceConfig,
|
||||
LoginConfig,
|
||||
AccountConfig,
|
||||
# hosted services config
|
||||
HostedServiceConfig,
|
||||
CeleryBeatConfig,
|
||||
|
||||
@ -2,7 +2,7 @@ import json
|
||||
import logging
|
||||
|
||||
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
|
||||
|
||||
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.entities.app_invoke_entities import InvokeFrom
|
||||
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 libs import helper
|
||||
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(WorkflowConfigApi, "/apps/<uuid:app_id>/workflows/draft/config")
|
||||
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"
|
||||
)
|
||||
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(
|
||||
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"
|
||||
description = "Too many login emails have been sent. Please try again in 5 minutes."
|
||||
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 controllers.console import api
|
||||
from controllers.console.auth.error import (
|
||||
EmailCodeError,
|
||||
InvalidEmailError,
|
||||
InvalidTokenError,
|
||||
PasswordMismatchError,
|
||||
)
|
||||
from controllers.console.error import AccountNotFound, EmailSendIpLimitError
|
||||
from controllers.console.auth.error import EmailCodeError, InvalidEmailError, InvalidTokenError, PasswordMismatchError
|
||||
from controllers.console.error import AccountInFreezeError, AccountNotFound, EmailSendIpLimitError
|
||||
from controllers.console.wraps import setup_required
|
||||
from events.tenant_event import tenant_was_created
|
||||
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 models.account import Account
|
||||
from services.account_service import AccountService, TenantService
|
||||
from services.errors.account import AccountRegisterError
|
||||
from services.errors.workspace import WorkSpaceNotAllowedCreateError
|
||||
from services.feature_service import FeatureService
|
||||
|
||||
@ -129,6 +125,8 @@ class ForgotPasswordResetApi(Resource):
|
||||
)
|
||||
except WorkSpaceNotAllowedCreateError:
|
||||
pass
|
||||
except AccountRegisterError as are:
|
||||
raise AccountInFreezeError()
|
||||
|
||||
return {"result": "success"}
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ from flask import request
|
||||
from flask_restful import Resource, reqparse # type: ignore
|
||||
|
||||
import services
|
||||
from configs import dify_config
|
||||
from constants.languages import languages
|
||||
from controllers.console import api
|
||||
from controllers.console.auth.error import (
|
||||
@ -16,6 +17,7 @@ from controllers.console.auth.error import (
|
||||
)
|
||||
from controllers.console.error import (
|
||||
AccountBannedError,
|
||||
AccountInFreezeError,
|
||||
AccountNotFound,
|
||||
EmailSendIpLimitError,
|
||||
NotAllowedCreateWorkspace,
|
||||
@ -26,6 +28,8 @@ from libs.helper import email, extract_remote_ip
|
||||
from libs.password import valid_password
|
||||
from models.account import Account
|
||||
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.feature_service import FeatureService
|
||||
|
||||
@ -44,6 +48,9 @@ class LoginApi(Resource):
|
||||
parser.add_argument("language", type=str, required=False, default="en-US", location="json")
|
||||
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"])
|
||||
if is_login_error_rate_limit:
|
||||
raise EmailPasswordLoginLimitError()
|
||||
@ -113,8 +120,10 @@ class ResetPasswordSendEmailApi(Resource):
|
||||
language = "zh-Hans"
|
||||
else:
|
||||
language = "en-US"
|
||||
|
||||
account = AccountService.get_user_through_email(args["email"])
|
||||
try:
|
||||
account = AccountService.get_user_through_email(args["email"])
|
||||
except AccountRegisterError as are:
|
||||
raise AccountInFreezeError()
|
||||
if account is None:
|
||||
if FeatureService.get_system_features().is_allow_register:
|
||||
token = AccountService.send_reset_password_email(email=args["email"], language=language)
|
||||
@ -142,8 +151,11 @@ class EmailCodeLoginSendEmailApi(Resource):
|
||||
language = "zh-Hans"
|
||||
else:
|
||||
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 FeatureService.get_system_features().is_allow_register:
|
||||
token = AccountService.send_email_code_login_email(email=args["email"], language=language)
|
||||
@ -177,7 +189,10 @@ class EmailCodeLoginApi(Resource):
|
||||
raise EmailCodeError()
|
||||
|
||||
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:
|
||||
tenant = TenantService.get_join_tenants(account)
|
||||
if not tenant:
|
||||
@ -196,6 +211,8 @@ class EmailCodeLoginApi(Resource):
|
||||
)
|
||||
except WorkSpaceNotAllowedCreateError:
|
||||
return NotAllowedCreateWorkspace()
|
||||
except AccountRegisterError as are:
|
||||
raise AccountInFreezeError()
|
||||
token_pair = AccountService.login(account, ip_address=extract_remote_ip(request))
|
||||
AccountService.reset_login_error_rate_limit(args["email"])
|
||||
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.account import AccountStatus
|
||||
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.feature_service import FeatureService
|
||||
|
||||
@ -99,6 +99,8 @@ class OAuthCallback(Resource):
|
||||
f"{dify_config.CONSOLE_WEB_URL}/signin"
|
||||
"?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
|
||||
if account.status == AccountStatus.BANNED.value:
|
||||
|
||||
@ -92,3 +92,12 @@ class UnauthorizedAndForceLogout(BaseHTTPException):
|
||||
error_code = "unauthorized_and_force_logout"
|
||||
description = "Unauthorized and force logout."
|
||||
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.add_argument("rating", type=str, choices=["like", "dislike", None], location="json")
|
||||
parser.add_argument("content", type=str, location="json")
|
||||
args = parser.parse_args()
|
||||
|
||||
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:
|
||||
raise NotFound("Message Not Exists.")
|
||||
|
||||
|
||||
@ -11,6 +11,7 @@ from controllers.console import api
|
||||
from controllers.console.workspace.error import (
|
||||
AccountAlreadyInitedError,
|
||||
CurrentPasswordIncorrectError,
|
||||
InvalidAccountDeletionCodeError,
|
||||
InvalidInvitationCodeError,
|
||||
RepeatPasswordNotMatchError,
|
||||
)
|
||||
@ -21,6 +22,7 @@ from libs.helper import TimestampField, timezone
|
||||
from libs.login import login_required
|
||||
from models import AccountIntegrate, InvitationCode
|
||||
from services.account_service import AccountService
|
||||
from services.billing_service import BillingService
|
||||
from services.errors.account import CurrentPasswordIncorrectError as ServiceCurrentPasswordIncorrectError
|
||||
|
||||
|
||||
@ -242,6 +244,54 @@ class AccountIntegrateApi(Resource):
|
||||
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
|
||||
api.add_resource(AccountInitApi, "/account/init")
|
||||
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(AccountPasswordApi, "/account/password")
|
||||
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(AccountEmailVerifyApi, '/account/email-verify')
|
||||
|
||||
@ -35,3 +35,9 @@ class AccountNotInitializedError(BaseHTTPException):
|
||||
error_code = "account_not_initialized"
|
||||
description = "The account has not been initialized yet. Please proceed with the initialization process first."
|
||||
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
|
||||
|
||||
member = db.session.get(Account, str(member_id))
|
||||
if member:
|
||||
if not member:
|
||||
abort(404)
|
||||
|
||||
try:
|
||||
|
||||
@ -108,7 +108,13 @@ class MessageFeedbackApi(Resource):
|
||||
args = parser.parse_args()
|
||||
|
||||
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:
|
||||
raise NotFound("Message Not Exists.")
|
||||
|
||||
|
||||
@ -8,12 +8,16 @@ from werkzeug.exceptions import NotFound
|
||||
import services.dataset_service
|
||||
from controllers.common.errors import FilenameNotExistsError
|
||||
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 (
|
||||
ArchivedDocumentImmutableError,
|
||||
DocumentIndexingError,
|
||||
NoFileUploadedError,
|
||||
TooManyFilesError,
|
||||
)
|
||||
from controllers.service_api.wraps import DatasetApiResource, cloud_edition_billing_resource_check
|
||||
from core.errors.error import ProviderTokenNotInitError
|
||||
@ -238,13 +242,18 @@ class DocumentUpdateByFileApi(DatasetApiResource):
|
||||
if not file.filename:
|
||||
raise FilenameNotExistsError
|
||||
|
||||
upload_file = FileService.upload_file(
|
||||
filename=file.filename,
|
||||
content=file.read(),
|
||||
mimetype=file.mimetype,
|
||||
user=current_user,
|
||||
source="datasets",
|
||||
)
|
||||
try:
|
||||
upload_file = FileService.upload_file(
|
||||
filename=file.filename,
|
||||
content=file.read(),
|
||||
mimetype=file.mimetype,
|
||||
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]}}}
|
||||
args["data_source"] = data_source
|
||||
# validate args
|
||||
|
||||
@ -339,13 +339,13 @@ class BaseAgentRunner(AppRunner):
|
||||
raise ValueError(f"Agent thought {agent_thought.id} not found")
|
||||
agent_thought = queried_thought
|
||||
|
||||
if thought is not None:
|
||||
if thought:
|
||||
agent_thought.thought = thought
|
||||
|
||||
if tool_name is not None:
|
||||
if tool_name:
|
||||
agent_thought.tool = tool_name
|
||||
|
||||
if tool_input is not None:
|
||||
if tool_input:
|
||||
if isinstance(tool_input, dict):
|
||||
try:
|
||||
tool_input = json.dumps(tool_input, ensure_ascii=False)
|
||||
@ -354,7 +354,7 @@ class BaseAgentRunner(AppRunner):
|
||||
|
||||
agent_thought.tool_input = tool_input
|
||||
|
||||
if observation is not None:
|
||||
if observation:
|
||||
if isinstance(observation, dict):
|
||||
try:
|
||||
observation = json.dumps(observation, ensure_ascii=False)
|
||||
@ -363,7 +363,7 @@ class BaseAgentRunner(AppRunner):
|
||||
|
||||
agent_thought.observation = observation
|
||||
|
||||
if answer is not None:
|
||||
if answer:
|
||||
agent_thought.answer = answer
|
||||
|
||||
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
|
||||
) -> 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.app_id = workflow_run.app_id
|
||||
workflow_node_execution.workflow_id = workflow_run.workflow_id
|
||||
@ -391,7 +391,7 @@ class WorkflowCycleManage:
|
||||
execution_metadata = json.dumps(merged_metadata)
|
||||
|
||||
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.app_id = workflow_run.app_id
|
||||
workflow_node_execution.workflow_id = workflow_run.workflow_id
|
||||
@ -824,7 +824,7 @@ class WorkflowCycleManage:
|
||||
return workflow_run
|
||||
|
||||
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)
|
||||
if not workflow_node_execution:
|
||||
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",
|
||||
"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",
|
||||
"ernie-lite-pro-128k": "https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop/chat/ernie-lite-pro-128k",
|
||||
}
|
||||
|
||||
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
|
||||
from typing import Optional
|
||||
from typing import Optional, cast
|
||||
|
||||
|
||||
class JiebaKeywordTableHandler:
|
||||
@ -8,18 +8,20 @@ class JiebaKeywordTableHandler:
|
||||
|
||||
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]:
|
||||
"""Extract keywords with JIEBA tfidf."""
|
||||
import jieba # type: ignore
|
||||
import jieba.analyse # type: ignore
|
||||
|
||||
keywords = jieba.analyse.extract_tags(
|
||||
sentence=text,
|
||||
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]:
|
||||
"""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)
|
||||
while True:
|
||||
query_dict: dict[str, Any] = {} if not start_cursor else {"start_cursor": start_cursor}
|
||||
res = requests.request(
|
||||
"GET",
|
||||
block_url,
|
||||
headers={
|
||||
"Authorization": "Bearer " + self._notion_access_token,
|
||||
"Content-Type": "application/json",
|
||||
"Notion-Version": "2022-06-28",
|
||||
},
|
||||
params=query_dict,
|
||||
)
|
||||
data = res.json()
|
||||
try:
|
||||
res = requests.request(
|
||||
"GET",
|
||||
block_url,
|
||||
headers={
|
||||
"Authorization": "Bearer " + self._notion_access_token,
|
||||
"Content-Type": "application/json",
|
||||
"Notion-Version": "2022-06-28",
|
||||
},
|
||||
params=query_dict,
|
||||
)
|
||||
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"]:
|
||||
result_type = result["type"]
|
||||
result_obj = result[result_type]
|
||||
|
||||
@ -31,3 +31,7 @@ class ToolApiSchemaError(ValueError):
|
||||
|
||||
class ToolEngineInvokeError(Exception):
|
||||
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}}
|
||||
|
||||
# 如果有元数据过滤条件,则添加到检索配置中
|
||||
# Add metadata filter to retrieval configuration if present
|
||||
if metadata_filter:
|
||||
retrieval_configuration["vectorSearchConfiguration"]["filter"] = metadata_filter
|
||||
|
||||
@ -77,7 +77,7 @@ class BedrockRetrieveTool(BuiltinTool):
|
||||
if not 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 = json.loads(metadata_filter_str) if metadata_filter_str else None
|
||||
|
||||
@ -86,7 +86,7 @@ class BedrockRetrieveTool(BuiltinTool):
|
||||
query_input=query,
|
||||
knowledge_base_id=self.knowledge_base_id,
|
||||
num_results=self.topk,
|
||||
metadata_filter=metadata_filter, # 将元数据过滤条件传递给检索方法
|
||||
metadata_filter=metadata_filter,
|
||||
)
|
||||
|
||||
line = 5
|
||||
@ -109,7 +109,7 @@ class BedrockRetrieveTool(BuiltinTool):
|
||||
if not parameters.get("query"):
|
||||
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")
|
||||
if metadata_filter_str and not isinstance(json.loads(metadata_filter_str), dict):
|
||||
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
|
||||
form: form
|
||||
|
||||
- name: metadata_filter
|
||||
type: string
|
||||
required: false
|
||||
- name: metadata_filter # Additional parameter for metadata filtering
|
||||
type: string # String type, expects JSON-formatted filter conditions
|
||||
required: false # Optional field - can be omitted
|
||||
label:
|
||||
en_US: Metadata Filter
|
||||
zh_Hans: 元数据过滤器
|
||||
|
||||
@ -6,8 +6,8 @@ import boto3
|
||||
from core.tools.entities.tool_entities import ToolInvokeMessage
|
||||
from core.tools.tool.builtin_tool import BuiltinTool
|
||||
|
||||
# 定义标签映射
|
||||
LABEL_MAPPING = {"LABEL_0": "SAFE", "LABEL_1": "NO_SAFE"}
|
||||
# Define label mappings
|
||||
LABEL_MAPPING = {0: "SAFE", 1: "NO_SAFE"}
|
||||
|
||||
|
||||
class ContentModerationTool(BuiltinTool):
|
||||
@ -28,12 +28,12 @@ class ContentModerationTool(BuiltinTool):
|
||||
# Handle nested JSON if present
|
||||
if isinstance(json_obj, dict) and "body" in json_obj:
|
||||
body_content = json.loads(json_obj["body"])
|
||||
raw_label = body_content.get("label")
|
||||
prediction_result = body_content.get("prediction")
|
||||
else:
|
||||
raw_label = json_obj.get("label")
|
||||
prediction_result = json_obj.get("prediction")
|
||||
|
||||
# 映射标签并返回
|
||||
result = LABEL_MAPPING.get(raw_label, "NO_SAFE") # 如果映射中没有找到,默认返回NO_SAFE
|
||||
# Map labels and return
|
||||
result = LABEL_MAPPING.get(prediction_result, "NO_SAFE") # If not found in mapping, default to NO_SAFE
|
||||
return result
|
||||
|
||||
def _invoke(
|
||||
|
||||
@ -10,8 +10,7 @@ from core.tools.tool.builtin_tool import BuiltinTool
|
||||
|
||||
class SageMakerReRankTool(BuiltinTool):
|
||||
sagemaker_client: Any = None
|
||||
sagemaker_endpoint: str | None = None
|
||||
topk: int | None = None
|
||||
sagemaker_endpoint: str = None
|
||||
|
||||
def _sagemaker_rerank(self, query_input: str, docs: list[str], rerank_endpoint: str):
|
||||
inputs = [query_input] * len(docs)
|
||||
@ -47,8 +46,7 @@ class SageMakerReRankTool(BuiltinTool):
|
||||
self.sagemaker_endpoint = tool_parameters.get("sagemaker_endpoint")
|
||||
|
||||
line = 2
|
||||
if not self.topk:
|
||||
self.topk = tool_parameters.get("topk", 5)
|
||||
topk = tool_parameters.get("topk", 5)
|
||||
|
||||
line = 3
|
||||
query = tool_parameters.get("query", "")
|
||||
@ -75,7 +73,7 @@ class SageMakerReRankTool(BuiltinTool):
|
||||
sorted_candidate_docs = sorted(candidate_docs, key=operator.itemgetter("score"), reverse=True)
|
||||
|
||||
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:
|
||||
return self.create_text_message(f"Exception {str(e)}, line : {line}")
|
||||
|
||||
@ -125,7 +125,7 @@ class ComfyUiClient:
|
||||
for output in history["outputs"].values():
|
||||
for img in output.get("images", []):
|
||||
image_data = self.get_image(img["filename"], img["subfolder"], img["type"])
|
||||
images.append(image_data)
|
||||
images.append((image_data, img["filename"]))
|
||||
return images
|
||||
finally:
|
||||
ws.close()
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import json
|
||||
import mimetypes
|
||||
from typing import Any
|
||||
|
||||
from core.file import FileType
|
||||
@ -75,10 +76,12 @@ class ComfyUIWorkflowTool(BuiltinTool):
|
||||
|
||||
images = comfyui.generate_image_by_prompt(prompt)
|
||||
result = []
|
||||
for img in images:
|
||||
for image_data, filename in images:
|
||||
result.append(
|
||||
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
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
import json
|
||||
import logging
|
||||
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.tools.entities.tool_entities import ToolInvokeMessage, ToolParameter, ToolProviderType
|
||||
from core.tools.tool.tool import Tool
|
||||
from extensions.ext_database import db
|
||||
from factories.file_factory import build_from_mapping
|
||||
from models.account import Account
|
||||
from models.model import App, EndUser
|
||||
from models.workflow import Workflow
|
||||
@ -194,10 +195,18 @@ class WorkflowTool(Tool):
|
||||
if isinstance(value, list):
|
||||
for item in value:
|
||||
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)
|
||||
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)
|
||||
|
||||
result[key] = value
|
||||
|
||||
@ -613,10 +613,10 @@ class Graph(BaseModel):
|
||||
for (node_id, node_id2), branch_node_ids in duplicate_end_node_ids.items():
|
||||
# check which node is after
|
||||
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]
|
||||
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]
|
||||
|
||||
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()
|
||||
|
||||
removed_dataset_ids: set[int] = set()
|
||||
removed_dataset_ids: set[str] = set()
|
||||
if not app_dataset_joins:
|
||||
added_dataset_ids = dataset_ids
|
||||
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)
|
||||
|
||||
added_dataset_ids = dataset_ids - old_dataset_ids
|
||||
@ -39,8 +39,8 @@ def handle(sender, **kwargs):
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def get_dataset_ids_from_model_config(app_model_config: AppModelConfig) -> set[int]:
|
||||
dataset_ids: set[int] = set()
|
||||
def get_dataset_ids_from_model_config(app_model_config: AppModelConfig) -> set[str]:
|
||||
dataset_ids: set[str] = set()
|
||||
if not app_model_config:
|
||||
return dataset_ids
|
||||
|
||||
|
||||
@ -17,11 +17,11 @@ def handle(sender, **kwargs):
|
||||
dataset_ids = get_dataset_ids_from_workflow(published_workflow)
|
||||
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:
|
||||
added_dataset_ids = dataset_ids
|
||||
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)
|
||||
|
||||
added_dataset_ids = dataset_ids - old_dataset_ids
|
||||
@ -41,8 +41,8 @@ def handle(sender, **kwargs):
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def get_dataset_ids_from_workflow(published_workflow: Workflow) -> set[int]:
|
||||
dataset_ids: set[int] = set()
|
||||
def get_dataset_ids_from_workflow(published_workflow: Workflow) -> set[str]:
|
||||
dataset_ids: set[str] = set()
|
||||
graph = published_workflow.graph_dict
|
||||
if not graph:
|
||||
return dataset_ids
|
||||
@ -60,7 +60,7 @@ def get_dataset_ids_from_workflow(published_workflow: Workflow) -> set[int]:
|
||||
for node in knowledge_retrieval_nodes:
|
||||
try:
|
||||
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:
|
||||
continue
|
||||
|
||||
|
||||
@ -69,6 +69,7 @@ def init_app(app: DifyApp) -> Celery:
|
||||
"schedule.create_tidb_serverless_task",
|
||||
"schedule.update_tidb_serverless_status_task",
|
||||
"schedule.clean_messages",
|
||||
"schedule.mail_clean_document_notify_task",
|
||||
]
|
||||
day = dify_config.CELERY_BEAT_SCHEDULER_TIME
|
||||
beat_schedule = {
|
||||
@ -92,6 +93,11 @@ def init_app(app: DifyApp) -> Celery:
|
||||
"task": "schedule.clean_messages.clean_messages",
|
||||
"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)
|
||||
|
||||
|
||||
@ -45,6 +45,7 @@ workflow_fields = {
|
||||
"graph": fields.Raw(attribute="graph_dict"),
|
||||
"features": fields.Raw(attribute="features_dict"),
|
||||
"hash": fields.String(attribute="unique_hash"),
|
||||
"version": fields.String(attribute="version"),
|
||||
"created_by": fields.Nested(simple_account_fields, attribute="created_by_account"),
|
||||
"created_at": TimestampField,
|
||||
"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_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.
|
||||
|
||||
|
||||
@ -414,6 +414,18 @@ class WorkflowRun(db.Model): # type: ignore[name-defined]
|
||||
finished_at = db.Column(db.DateTime)
|
||||
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
|
||||
def graph_dict(self):
|
||||
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(
|
||||
days=dify_config.PLAN_SANDBOX_CLEAN_MESSAGE_DAY_SETTING
|
||||
)
|
||||
page = 1
|
||||
while True:
|
||||
try:
|
||||
# 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.commit()
|
||||
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
|
||||
|
||||
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 models.account import Account, Tenant, TenantAccountJoin
|
||||
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():
|
||||
"""
|
||||
Async Send document clean notify mail
|
||||
@ -29,35 +33,58 @@ def send_document_clean_notify_task():
|
||||
# group by tenant_id
|
||||
dataset_auto_disable_logs_map: dict[str, list[DatasetAutoDisableLog]] = defaultdict(list)
|
||||
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)
|
||||
|
||||
url = f"{dify_config.CONSOLE_WEB_URL}/datasets"
|
||||
for tenant_id, tenant_dataset_auto_disable_logs in dataset_auto_disable_logs_map.items():
|
||||
knowledge_details = []
|
||||
tenant = Tenant.query.filter(Tenant.id == tenant_id).first()
|
||||
if not tenant:
|
||||
continue
|
||||
current_owner_join = TenantAccountJoin.query.filter_by(tenant_id=tenant.id, role="owner").first()
|
||||
if not current_owner_join:
|
||||
continue
|
||||
account = Account.query.filter(Account.id == current_owner_join.account_id).first()
|
||||
if not account:
|
||||
continue
|
||||
features = FeatureService.get_features(tenant_id)
|
||||
plan = features.billing.subscription.plan
|
||||
if plan != "sandbox":
|
||||
knowledge_details = []
|
||||
# check tenant
|
||||
tenant = Tenant.query.filter(Tenant.id == tenant_id).first()
|
||||
if not tenant:
|
||||
continue
|
||||
# check current owner
|
||||
current_owner_join = TenantAccountJoin.query.filter_by(tenant_id=tenant.id, role="owner").first()
|
||||
if not current_owner_join:
|
||||
continue
|
||||
account = Account.query.filter(Account.id == current_owner_join.account_id).first()
|
||||
if not account:
|
||||
continue
|
||||
|
||||
dataset_auto_dataset_map = {} # type: ignore
|
||||
dataset_auto_dataset_map = {} # type: ignore
|
||||
for dataset_auto_disable_log in tenant_dataset_auto_disable_logs:
|
||||
if dataset_auto_disable_log.dataset_id not in dataset_auto_dataset_map:
|
||||
dataset_auto_dataset_map[dataset_auto_disable_log.dataset_id] = []
|
||||
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_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(f"<li>Knowledge base {dataset.name}: {document_count} documents</li>")
|
||||
|
||||
dataset_auto_disable_log.notified = True
|
||||
db.session.commit()
|
||||
end_at = time.perf_counter()
|
||||
logging.info(
|
||||
click.style("Send document clean notify mail succeeded: latency: {}".format(end_at - start_at), fg="green")
|
||||
)
|
||||
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,
|
||||
)
|
||||
from models.model import DifySetup
|
||||
from services.billing_service import BillingService
|
||||
from services.errors.account import (
|
||||
AccountAlreadyInTenantError,
|
||||
AccountLoginError,
|
||||
@ -50,6 +51,8 @@ from services.errors.account import (
|
||||
)
|
||||
from services.errors.workspace import WorkSpaceNotAllowedCreateError
|
||||
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_invite_member_task import send_invite_member_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(
|
||||
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
|
||||
|
||||
@staticmethod
|
||||
@ -201,6 +207,15 @@ class AccountService:
|
||||
from controllers.console.error import 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.email = email
|
||||
account.name = name
|
||||
@ -240,6 +255,42 @@ class AccountService:
|
||||
|
||||
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
|
||||
def link_account_integrate(provider: str, open_id: str, account: Account) -> None:
|
||||
"""Link account integrate"""
|
||||
@ -379,6 +430,7 @@ class AccountService:
|
||||
def send_email_code_login_email(
|
||||
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:
|
||||
raise ValueError("Email must be provided.")
|
||||
if cls.email_code_login_rate_limiter.is_rate_limited(email):
|
||||
@ -408,6 +460,14 @@ class AccountService:
|
||||
|
||||
@classmethod
|
||||
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()
|
||||
if not account:
|
||||
return None
|
||||
@ -824,6 +884,10 @@ class RegisterService:
|
||||
db.session.commit()
|
||||
except WorkSpaceNotAllowedCreateError:
|
||||
db.session.rollback()
|
||||
except AccountRegisterError as are:
|
||||
db.session.rollback()
|
||||
logging.exception("Register failed")
|
||||
raise are
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logging.exception("Register failed")
|
||||
|
||||
@ -176,6 +176,9 @@ class AppDslService:
|
||||
data["kind"] = "app"
|
||||
|
||||
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)
|
||||
|
||||
# Extract app data
|
||||
|
||||
@ -139,7 +139,7 @@ class AudioService:
|
||||
return Response(stream_with_context(response), content_type="audio/mpeg")
|
||||
return response
|
||||
else:
|
||||
if not text:
|
||||
if text is None:
|
||||
raise ValueError("Text is required")
|
||||
response = invoke_tts(text, app_model, voice)
|
||||
if isinstance(response, Generator):
|
||||
|
||||
@ -70,3 +70,24 @@ class BillingService:
|
||||
|
||||
if not TenantAccountRole.is_privileged_role(join.role):
|
||||
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:
|
||||
return [], 0
|
||||
else:
|
||||
# show all datasets that the user has permission to access
|
||||
if permitted_dataset_ids:
|
||||
query = query.filter(
|
||||
db.or_(
|
||||
Dataset.permission == DatasetPermissionEnum.ALL_TEAM,
|
||||
db.and_(Dataset.permission == DatasetPermissionEnum.ONLY_ME, Dataset.created_by == user.id),
|
||||
db.and_(
|
||||
Dataset.permission == DatasetPermissionEnum.PARTIAL_TEAM,
|
||||
Dataset.id.in_(permitted_dataset_ids),
|
||||
),
|
||||
if user.current_role not in (TenantAccountRole.OWNER, TenantAccountRole.ADMIN):
|
||||
# show all datasets that the user has permission to access
|
||||
if permitted_dataset_ids:
|
||||
query = query.filter(
|
||||
db.or_(
|
||||
Dataset.permission == DatasetPermissionEnum.ALL_TEAM,
|
||||
db.and_(
|
||||
Dataset.permission == DatasetPermissionEnum.ONLY_ME, Dataset.created_by == user.id
|
||||
),
|
||||
db.and_(
|
||||
Dataset.permission == DatasetPermissionEnum.PARTIAL_TEAM,
|
||||
Dataset.id.in_(permitted_dataset_ids),
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
else:
|
||||
query = query.filter(
|
||||
db.or_(
|
||||
Dataset.permission == DatasetPermissionEnum.ALL_TEAM,
|
||||
db.and_(Dataset.permission == DatasetPermissionEnum.ONLY_ME, Dataset.created_by == user.id),
|
||||
else:
|
||||
query = query.filter(
|
||||
db.or_(
|
||||
Dataset.permission == DatasetPermissionEnum.ALL_TEAM,
|
||||
db.and_(
|
||||
Dataset.permission == DatasetPermissionEnum.ONLY_ME, Dataset.created_by == user.id
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
else:
|
||||
# if no user, only show datasets that are shared with all team members
|
||||
query = query.filter(Dataset.permission == DatasetPermissionEnum.ALL_TEAM)
|
||||
@ -377,14 +382,19 @@ class DatasetService:
|
||||
if dataset.tenant_id != user.current_tenant_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.")
|
||||
if dataset.permission == DatasetPermissionEnum.ONLY_ME 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.")
|
||||
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:
|
||||
if user.current_role not in (TenantAccountRole.OWNER, TenantAccountRole.ADMIN):
|
||||
if dataset.permission == DatasetPermissionEnum.ONLY_ME 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.")
|
||||
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
|
||||
def check_dataset_operator_permission(user: Optional[Account] = None, dataset: Optional[Dataset] = None):
|
||||
@ -394,15 +404,16 @@ class DatasetService:
|
||||
if not user:
|
||||
raise ValueError("User not found")
|
||||
|
||||
if dataset.permission == DatasetPermissionEnum.ONLY_ME:
|
||||
if dataset.created_by != user.id:
|
||||
raise NoPermissionError("You do not have permission to access this dataset.")
|
||||
if user.current_role not in (TenantAccountRole.OWNER, TenantAccountRole.ADMIN):
|
||||
if dataset.permission == DatasetPermissionEnum.ONLY_ME:
|
||||
if dataset.created_by != user.id:
|
||||
raise NoPermissionError("You do not have permission to access this dataset.")
|
||||
|
||||
elif dataset.permission == DatasetPermissionEnum.PARTIAL_TEAM:
|
||||
if not any(
|
||||
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.")
|
||||
elif dataset.permission == DatasetPermissionEnum.PARTIAL_TEAM:
|
||||
if not any(
|
||||
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.")
|
||||
|
||||
@staticmethod
|
||||
def get_dataset_queries(dataset_id: str, page: int, per_page: int):
|
||||
@ -441,7 +452,7 @@ class DatasetService:
|
||||
|
||||
|
||||
class DocumentService:
|
||||
DEFAULT_RULES = {
|
||||
DEFAULT_RULES: dict[str, Any] = {
|
||||
"mode": "custom",
|
||||
"rules": {
|
||||
"pre_processing_rules": [
|
||||
@ -455,7 +466,7 @@ class DocumentService:
|
||||
},
|
||||
}
|
||||
|
||||
DOCUMENT_METADATA_SCHEMA = {
|
||||
DOCUMENT_METADATA_SCHEMA: dict[str, Any] = {
|
||||
"book": {
|
||||
"title": str,
|
||||
"language": str,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class BaseServiceError(Exception):
|
||||
class BaseServiceError(ValueError):
|
||||
def __init__(self, description: Optional[str] = None):
|
||||
self.description = description
|
||||
|
||||
@ -152,6 +152,7 @@ class MessageService:
|
||||
@classmethod
|
||||
def create_feedback(
|
||||
cls,
|
||||
*,
|
||||
app_model: App,
|
||||
message_id: str,
|
||||
user: Optional[Union[Account, EndUser]],
|
||||
|
||||
@ -425,7 +425,7 @@ class ApiToolManageService:
|
||||
"tenant_id": tenant_id,
|
||||
}
|
||||
)
|
||||
result = tool.validate_credentials(credentials, parameters)
|
||||
result = runtime_tool.validate_credentials(credentials, parameters)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
@ -5,6 +5,8 @@ from datetime import UTC, datetime
|
||||
from typing import Any, Optional, cast
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import desc
|
||||
|
||||
from core.app.apps.advanced_chat.app_config_manager import AdvancedChatAppConfigManager
|
||||
from core.app.apps.workflow.app_config_manager import WorkflowAppConfigManager
|
||||
from core.model_runtime.utils.encoders import jsonable_encoder
|
||||
@ -76,6 +78,28 @@ class WorkflowService:
|
||||
|
||||
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(
|
||||
self,
|
||||
*,
|
||||
|
||||
@ -38,7 +38,11 @@ def add_document_to_index_task(dataset_document_id: str):
|
||||
try:
|
||||
segments = (
|
||||
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())
|
||||
.all()
|
||||
)
|
||||
@ -85,6 +89,16 @@ def add_document_to_index_task(dataset_document_id: str):
|
||||
db.session.query(DatasetAutoDisableLog).filter(
|
||||
DatasetAutoDisableLog.document_id == dataset_document.id
|
||||
).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()
|
||||
|
||||
end_at = time.perf_counter()
|
||||
|
||||
26
api/tasks/delete_account_task.py
Normal file
26
api/tasks/delete_account_task.py
Normal file
@ -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)
|
||||
70
api/tasks/mail_account_deletion_task.py
Normal file
70
api/tasks/mail_account_deletion_task.py
Normal file
@ -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 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)
|
||||
except Exception:
|
||||
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()
|
||||
logging.info(
|
||||
|
||||
@ -45,14 +45,14 @@
|
||||
.content ul li {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.cta-button {
|
||||
.cta-button, .cta-button:hover, .cta-button:active, .cta-button:visited, .cta-button:focus {
|
||||
display: block;
|
||||
margin: 20px auto;
|
||||
padding: 10px 20px;
|
||||
background-color: #4e89f9;
|
||||
color: #ffffff;
|
||||
color: #ffffff !important;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
text-decoration: none !important;
|
||||
border-radius: 5px;
|
||||
width: fit-content;
|
||||
}
|
||||
@ -69,7 +69,7 @@
|
||||
<div class="email-container">
|
||||
<!-- 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>
|
||||
|
||||
<!-- Content -->
|
||||
@ -78,11 +78,13 @@
|
||||
<p>Dear {{userName}},</p>
|
||||
<p>
|
||||
We're sorry for the inconvenience. To ensure optimal performance, documents
|
||||
that haven’t been updated or accessed in the past 7 days have been disabled in
|
||||
that haven’t been updated or accessed in the past 30 days have been disabled in
|
||||
your knowledge bases:
|
||||
</p>
|
||||
<ul>
|
||||
{{knowledge_details}}
|
||||
{% for item in knowledge_details %}
|
||||
<li>{{ item }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<p>You can re-enable them anytime.</p>
|
||||
<a href={{url}} class="cta-button">Re-enable in Dify</a>
|
||||
|
||||
125
api/templates/delete_account_code_email_template_en-US.html
Normal file
125
api/templates/delete_account_code_email_template_en-US.html
Normal file
@ -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>
|
||||
105
api/templates/delete_account_success_template_en-US.html
Normal file
105
api/templates/delete_account_success_template_en-US.html
Normal file
@ -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
|
||||
|
||||
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 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
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from core.rag.datasource.vdb.tidb_vector.tidb_vector import TiDBVector, TiDBVectorConfig
|
||||
|
||||
@ -4,7 +4,7 @@ import pytest
|
||||
|
||||
from configs import dify_config
|
||||
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.model_runtime.entities.message_entities import (
|
||||
AssistantPromptMessage,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import uuid
|
||||
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.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.model_providers.model_provider_factory import ModelProviderFactory
|
||||
from core.prompt.entities.advanced_prompt_entities import MemoryConfig
|
||||
from core.variables import ArrayAnySegment, ArrayFileSegment, NoneSegment, StringSegment
|
||||
from core.workflow.entities.variable_entities import VariableSelector
|
||||
from core.variables import ArrayAnySegment, ArrayFileSegment, NoneSegment
|
||||
from core.workflow.entities.variable_pool import VariablePool
|
||||
from core.workflow.graph_engine import Graph, GraphInitParams, GraphRuntimeState
|
||||
from core.workflow.nodes.answer import AnswerStreamGenerateRoute
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
from core.workflow.graph_engine.entities.event import (
|
||||
GraphRunFailedEvent,
|
||||
GraphRunPartialSucceededEvent,
|
||||
GraphRunSucceededEvent,
|
||||
NodeRunRetryEvent,
|
||||
)
|
||||
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.workflow.nodes.variable_assigner.v2.enums import Operation
|
||||
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
|
||||
from oss2 import Auth # type: ignore
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
from textwrap import dedent
|
||||
|
||||
import pytest
|
||||
|
||||
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_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,
|
||||
#
|
||||
|
||||
@ -90,7 +90,7 @@ x-shared-env: &shared-api-worker-env
|
||||
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}
|
||||
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_ACCESS_KEY: ${ALIYUN_OSS_ACCESS_KEY:-your-access-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_REVERSE_PROXY_PORT: ${SSRF_REVERSE_PROXY_PORT:-8194}
|
||||
SSRF_SANDBOX_HOST: ${SSRF_SANDBOX_HOST:-sandbox}
|
||||
COMPOSE_PROFILES: ${COMPOSE_PROFILES:-${VECTOR_STORE:-weaviate}}
|
||||
EXPOSE_NGINX_PORT: ${EXPOSE_NGINX_PORT:-80}
|
||||
EXPOSE_NGINX_SSL_PORT: ${EXPOSE_NGINX_SSL_PORT:-443}
|
||||
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}"]
|
||||
for key, default in env_vars.items():
|
||||
if key == "COMPOSE_PROFILES":
|
||||
continue
|
||||
# If default value is empty, use ${KEY:-}
|
||||
if default == "":
|
||||
lines.append(f" {key}: ${{{key}:-}}")
|
||||
|
||||
@ -37,7 +37,7 @@ function useAppsQueryState() {
|
||||
const syncSearchParams = useCallback((params: URLSearchParams) => {
|
||||
const search = params.toString()
|
||||
const query = search ? `?${search}` : ''
|
||||
router.push(`${pathname}${query}`)
|
||||
router.push(`${pathname}${query}`, { scroll: false })
|
||||
}, [router, pathname])
|
||||
|
||||
// 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 { ProviderContextProvider } from '@/context/provider-context'
|
||||
import { ModalContextProvider } from '@/context/modal-context'
|
||||
import { TanstackQueryIniter } from '@/context/query-client'
|
||||
|
||||
const Layout = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<>
|
||||
<GA gaType={GaType.admin} />
|
||||
<SwrInitor>
|
||||
<TanstackQueryIniter>
|
||||
<AppContextProvider>
|
||||
<EventEmitterContextProvider>
|
||||
<ProviderContextProvider>
|
||||
<ModalContextProvider>
|
||||
<HeaderWrapper>
|
||||
<Header />
|
||||
</HeaderWrapper>
|
||||
{children}
|
||||
</ModalContextProvider>
|
||||
</ProviderContextProvider>
|
||||
</EventEmitterContextProvider>
|
||||
</AppContextProvider>
|
||||
</TanstackQueryIniter>
|
||||
<AppContextProvider>
|
||||
<EventEmitterContextProvider>
|
||||
<ProviderContextProvider>
|
||||
<ModalContextProvider>
|
||||
<HeaderWrapper>
|
||||
<Header />
|
||||
</HeaderWrapper>
|
||||
{children}
|
||||
</ModalContextProvider>
|
||||
</ProviderContextProvider>
|
||||
</EventEmitterContextProvider>
|
||||
</AppContextProvider>
|
||||
</SwrInitor>
|
||||
</>
|
||||
)
|
||||
|
||||
@ -3,11 +3,11 @@ import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useContext } from 'use-context-selector'
|
||||
import DeleteAccount from '../delete-account'
|
||||
import s from './index.module.css'
|
||||
import Collapse 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 Confirm from '@/app/components/base/confirm'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { updateUserProfile } from '@/service/common'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
@ -296,37 +296,9 @@ export default function AccountPage() {
|
||||
}
|
||||
{
|
||||
showDeleteAccountModal && (
|
||||
<Confirm
|
||||
isShow
|
||||
<DeleteAccount
|
||||
onCancel={() => 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
48
web/app/account/delete-account/components/check-email.tsx
Normal file
48
web/app/account/delete-account/components/check-email.tsx
Normal file
@ -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>
|
||||
</>
|
||||
}
|
||||
68
web/app/account/delete-account/components/feed-back.tsx
Normal file
68
web/app/account/delete-account/components/feed-back.tsx
Normal file
@ -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>
|
||||
}
|
||||
55
web/app/account/delete-account/components/verify-email.tsx
Normal file
55
web/app/account/delete-account/components/verify-email.tsx
Normal file
@ -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>
|
||||
</>
|
||||
}
|
||||
44
web/app/account/delete-account/index.tsx
Normal file
44
web/app/account/delete-account/index.tsx
Normal file
@ -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>
|
||||
}
|
||||
39
web/app/account/delete-account/state.tsx
Normal file
39
web/app/account/delete-account/state.tsx
Normal file
@ -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>
|
||||
|
||||
<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
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
@ -57,20 +57,20 @@ const CustomDialog = ({
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
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) && (
|
||||
<Dialog.Title
|
||||
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}
|
||||
</Dialog.Title>
|
||||
)}
|
||||
<div className={classNames('px-8 text-lg font-medium leading-6', bodyClassName)}>
|
||||
<div className={classNames(bodyClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -6,13 +6,30 @@ const MarkdownButton = ({ node }: any) => {
|
||||
const { onSend } = useChatContext()
|
||||
const variant = node.properties.dataVariant
|
||||
const message = node.properties.dataMessage
|
||||
const link = node.properties.dataLink
|
||||
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
|
||||
variant={variant}
|
||||
size={size}
|
||||
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>
|
||||
</Button>
|
||||
|
||||
@ -11,59 +11,62 @@ interface SwitchProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
const Switch = ({ onChange, size = 'md', defaultValue = false, disabled = false, className }: SwitchProps) => {
|
||||
const [enabled, setEnabled] = useState(defaultValue)
|
||||
useEffect(() => {
|
||||
setEnabled(defaultValue)
|
||||
}, [defaultValue])
|
||||
const wrapStyle = {
|
||||
lg: 'h-6 w-11',
|
||||
l: 'h-5 w-9',
|
||||
md: 'h-4 w-7',
|
||||
sm: 'h-3 w-5',
|
||||
}
|
||||
const Switch = React.forwardRef(
|
||||
({ onChange, size = 'md', defaultValue = false, disabled = false, className }: SwitchProps,
|
||||
propRef: React.Ref<HTMLButtonElement>) => {
|
||||
const [enabled, setEnabled] = useState(defaultValue)
|
||||
useEffect(() => {
|
||||
setEnabled(defaultValue)
|
||||
}, [defaultValue])
|
||||
const wrapStyle = {
|
||||
lg: 'h-6 w-11',
|
||||
l: 'h-5 w-9',
|
||||
md: 'h-4 w-7',
|
||||
sm: 'h-3 w-5',
|
||||
}
|
||||
|
||||
const circleStyle = {
|
||||
lg: 'h-5 w-5',
|
||||
l: 'h-4 w-4',
|
||||
md: 'h-3 w-3',
|
||||
sm: 'h-2 w-2',
|
||||
}
|
||||
const circleStyle = {
|
||||
lg: 'h-5 w-5',
|
||||
l: 'h-4 w-4',
|
||||
md: 'h-3 w-3',
|
||||
sm: 'h-2 w-2',
|
||||
}
|
||||
|
||||
const translateLeft = {
|
||||
lg: 'translate-x-5',
|
||||
l: 'translate-x-4',
|
||||
md: 'translate-x-3',
|
||||
sm: 'translate-x-2',
|
||||
}
|
||||
return (
|
||||
<OriginalSwitch
|
||||
checked={enabled}
|
||||
onChange={(checked: boolean) => {
|
||||
if (disabled)
|
||||
return
|
||||
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"
|
||||
const translateLeft = {
|
||||
lg: 'translate-x-5',
|
||||
l: 'translate-x-4',
|
||||
md: 'translate-x-3',
|
||||
sm: 'translate-x-2',
|
||||
}
|
||||
return (
|
||||
<OriginalSwitch
|
||||
ref={propRef}
|
||||
checked={enabled}
|
||||
onChange={(checked: boolean) => {
|
||||
if (disabled)
|
||||
return
|
||||
setEnabled(checked)
|
||||
onChange?.(checked)
|
||||
}}
|
||||
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',
|
||||
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,
|
||||
)}
|
||||
/>
|
||||
</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'
|
||||
|
||||
|
||||
@ -15,13 +15,15 @@ type OptionCardHeaderProps = {
|
||||
isActive?: boolean
|
||||
activeClassName?: string
|
||||
effectImg?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
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(
|
||||
'flex h-full overflow-hidden rounded-t-xl relative',
|
||||
isActive && activeClassName,
|
||||
!disabled && 'cursor-pointer',
|
||||
)}>
|
||||
<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} />}
|
||||
@ -63,7 +65,7 @@ export const OptionCard: FC<OptionCardProps> = forwardRef((props, ref) => {
|
||||
(isActive && !noHighlight)
|
||||
? 'border-[1.5px] border-components-option-card-option-selected-border'
|
||||
: 'border border-components-option-card-option-border',
|
||||
disabled && 'opacity-50',
|
||||
disabled && 'opacity-50 cursor-not-allowed',
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
@ -83,6 +85,7 @@ export const OptionCard: FC<OptionCardProps> = forwardRef((props, ref) => {
|
||||
isActive={isActive && !noHighlight}
|
||||
activeClassName={activeHeaderClassName}
|
||||
effectImg={effectImg}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{/** Body */}
|
||||
{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'
|
||||
|
||||
type Props = {
|
||||
value: number
|
||||
value: number | null
|
||||
besideChunkName?: boolean
|
||||
}
|
||||
|
||||
@ -12,6 +12,9 @@ const Score: FC<Props> = ({
|
||||
value,
|
||||
besideChunkName,
|
||||
}) => {
|
||||
if (!value)
|
||||
return null
|
||||
|
||||
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('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 }) => (
|
||||
<>
|
||||
<div>
|
||||
<Menu.Button
|
||||
className={`
|
||||
<Menu.Button
|
||||
className={`
|
||||
inline-flex items-center
|
||||
rounded-[20px] py-1 pr-2.5 pl-1 text-sm
|
||||
text-gray-700 hover:bg-gray-200
|
||||
mobile:px-1
|
||||
${open && 'bg-gray-200'}
|
||||
`}
|
||||
>
|
||||
<Avatar name={userProfile.name} className='sm:mr-2 mr-0' size={32} />
|
||||
{!isMobile && <>
|
||||
{userProfile.name}
|
||||
<RiArrowDownSLine className="w-3 h-3 ml-1 text-gray-700" />
|
||||
</>}
|
||||
</Menu.Button>
|
||||
</div>
|
||||
>
|
||||
<Avatar name={userProfile.name} className='sm:mr-2 mr-0' size={32} />
|
||||
{!isMobile && <>
|
||||
{userProfile.name}
|
||||
<RiArrowDownSLine className="w-3 h-3 ml-1 text-gray-700" />
|
||||
</>}
|
||||
</Menu.Button>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
@ -88,10 +86,10 @@ export default function AppSelector({ isMobile }: IAppSelector) {
|
||||
className="
|
||||
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
|
||||
shadow-lg
|
||||
shadow-lg focus:outline-none
|
||||
"
|
||||
>
|
||||
<Menu.Item>
|
||||
<Menu.Item disabled>
|
||||
<div className='flex flex-nowrap items-center px-4 py-[13px]'>
|
||||
<Avatar name={userProfile.name} size={36} className='mr-3' />
|
||||
<div className='grow'>
|
||||
@ -102,89 +100,107 @@ export default function AppSelector({ isMobile }: IAppSelector) {
|
||||
</Menu.Item>
|
||||
<div className="px-1 py-1">
|
||||
<Menu.Item>
|
||||
<Link
|
||||
className={classNames(itemClassName, 'group justify-between')}
|
||||
{({ active }) => <Link
|
||||
className={classNames(itemClassName, 'group justify-between',
|
||||
active && 'bg-state-base-hover',
|
||||
)}
|
||||
href='/account'
|
||||
target='_self' rel='noopener noreferrer'>
|
||||
<div>{t('common.account.account')}</div>
|
||||
<ArrowUpRight className='hidden w-[14px] h-[14px] text-text-tertiary group-hover:flex' />
|
||||
</Link>
|
||||
</Link>}
|
||||
</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>
|
||||
</div>}
|
||||
</Menu.Item>
|
||||
{canEmailSupport && <Menu.Item>
|
||||
<a
|
||||
className={classNames(itemClassName, 'group justify-between')}
|
||||
{({ active }) => <a
|
||||
className={classNames(itemClassName, 'group justify-between',
|
||||
active && 'bg-state-base-hover',
|
||||
)}
|
||||
href={mailToSupport(userProfile.email, plan.type, langeniusVersionInfo.current_version)}
|
||||
target='_blank' rel='noopener noreferrer'>
|
||||
<div>{t('common.userProfile.emailSupport')}</div>
|
||||
<ArrowUpRight className='hidden w-[14px] h-[14px] text-text-tertiary group-hover:flex' />
|
||||
</a>
|
||||
</a>}
|
||||
</Menu.Item>}
|
||||
<Menu.Item>
|
||||
<Link
|
||||
className={classNames(itemClassName, 'group justify-between')}
|
||||
{({ active }) => <Link
|
||||
className={classNames(itemClassName, 'group justify-between',
|
||||
active && 'bg-state-base-hover',
|
||||
)}
|
||||
href='https://github.com/langgenius/dify/discussions/categories/feedbacks'
|
||||
target='_blank' rel='noopener noreferrer'>
|
||||
<div>{t('common.userProfile.communityFeedback')}</div>
|
||||
<ArrowUpRight className='hidden w-[14px] h-[14px] text-text-tertiary group-hover:flex' />
|
||||
</Link>
|
||||
</Link>}
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
<Link
|
||||
className={classNames(itemClassName, 'group justify-between')}
|
||||
{({ active }) => <Link
|
||||
className={classNames(itemClassName, 'group justify-between',
|
||||
active && 'bg-state-base-hover',
|
||||
)}
|
||||
href='https://discord.gg/5AEfbxcd9k'
|
||||
target='_blank' rel='noopener noreferrer'>
|
||||
<div>{t('common.userProfile.community')}</div>
|
||||
<ArrowUpRight className='hidden w-[14px] h-[14px] text-text-tertiary group-hover:flex' />
|
||||
</Link>
|
||||
</Link>}
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
<Link
|
||||
className={classNames(itemClassName, 'group justify-between')}
|
||||
{({ active }) => <Link
|
||||
className={classNames(itemClassName, 'group justify-between',
|
||||
active && 'bg-state-base-hover',
|
||||
)}
|
||||
href={
|
||||
locale !== LanguagesSupported[1] ? 'https://docs.dify.ai/' : `https://docs.dify.ai/v/${locale.toLowerCase()}/`
|
||||
}
|
||||
target='_blank' rel='noopener noreferrer'>
|
||||
<div>{t('common.userProfile.helpCenter')}</div>
|
||||
<ArrowUpRight className='hidden w-[14px] h-[14px] text-text-tertiary group-hover:flex' />
|
||||
</Link>
|
||||
</Link>}
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
<Link
|
||||
className={classNames(itemClassName, 'group justify-between')}
|
||||
{({ active }) => <Link
|
||||
className={classNames(itemClassName, 'group justify-between',
|
||||
active && 'bg-state-base-hover',
|
||||
)}
|
||||
href='https://roadmap.dify.ai'
|
||||
target='_blank' rel='noopener noreferrer'>
|
||||
<div>{t('common.userProfile.roadmap')}</div>
|
||||
<ArrowUpRight className='hidden w-[14px] h-[14px] text-text-tertiary group-hover:flex' />
|
||||
</Link>
|
||||
</Link>}
|
||||
</Menu.Item>
|
||||
{
|
||||
document?.body?.getAttribute('data-public-site-about') !== 'hide' && (
|
||||
<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 className='flex items-center'>
|
||||
<div className='mr-2 system-xs-regular text-text-tertiary'>{langeniusVersionInfo.current_version}</div>
|
||||
<Indicator color={langeniusVersionInfo.current_version === langeniusVersionInfo.latest_version ? 'green' : 'orange'} />
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
</Menu.Item>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<Menu.Item>
|
||||
<div className='p-1' onClick={() => handleLogout()}>
|
||||
{({ active }) => <div className='p-1' onClick={() => handleLogout()}>
|
||||
<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>
|
||||
<RiLogoutBoxRLine className='hidden w-4 h-4 text-text-tertiary group-hover:flex' />
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
</Menu.Item>
|
||||
</Menu.Items>
|
||||
</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()
|
||||
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'))
|
||||
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) {
|
||||
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
|
||||
alt='model-icon'
|
||||
src={`${provider.icon_small[language] || provider.icon_small.en_US}`}
|
||||
|
||||
@ -37,7 +37,7 @@ const ModelName: FC<ModelNameProps> = ({
|
||||
if (!modelItem)
|
||||
return null
|
||||
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
|
||||
className='truncate'
|
||||
title={modelItem.label[language] || modelItem.label.en_US}
|
||||
|
||||
@ -22,11 +22,11 @@ const ModelTrigger: FC<ModelTriggerProps> = ({
|
||||
|
||||
return (
|
||||
<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'>
|
||||
<ModelIcon
|
||||
className="m-0.5"
|
||||
className="m-0.5 w-4 h-4"
|
||||
provider={currentProvider}
|
||||
modelName={modelName}
|
||||
/>
|
||||
|
||||
@ -42,33 +42,35 @@ const ModelTrigger: FC<ModelTriggerProps> = ({
|
||||
)}
|
||||
>
|
||||
<ModelIcon
|
||||
className='shrink-0 mr-1.5'
|
||||
className='shrink-0 m-1'
|
||||
provider={provider}
|
||||
modelName={model.model}
|
||||
/>
|
||||
<ModelName
|
||||
className='grow'
|
||||
modelItem={model}
|
||||
showMode
|
||||
showFeatures
|
||||
/>
|
||||
{!readonly && (
|
||||
<div className='shrink-0 flex items-center justify-center w-4 h-4'>
|
||||
{
|
||||
model.status !== ModelStatusEnum.active
|
||||
? (
|
||||
<Tooltip popupContent={MODEL_STATUS_TEXT[model.status][language]}>
|
||||
<AlertTriangle className='w-4 h-4 text-[#F79009]' />
|
||||
</Tooltip>
|
||||
)
|
||||
: (
|
||||
<RiArrowDownSLine
|
||||
className='w-3.5 h-3.5 text-text-tertiary'
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
<div className='flex px-1 py-[3px] items-center gap-1 grow'>
|
||||
<ModelName
|
||||
className='grow'
|
||||
modelItem={model}
|
||||
showMode
|
||||
showFeatures
|
||||
/>
|
||||
{!readonly && (
|
||||
<div className='shrink-0 flex items-center justify-center w-4 h-4'>
|
||||
{
|
||||
model.status !== ModelStatusEnum.active
|
||||
? (
|
||||
<Tooltip popupContent={MODEL_STATUS_TEXT[model.status][language]}>
|
||||
<AlertTriangle className='w-4 h-4 text-text-warning-secondary' />
|
||||
</Tooltip>
|
||||
)
|
||||
: (
|
||||
<RiArrowDownSLine
|
||||
className='w-3.5 h-3.5 text-text-tertiary'
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -69,13 +69,14 @@ const Blocks = ({
|
||||
|
||||
const listViewToolData = useMemo(() => {
|
||||
const result: ToolWithProvider[] = []
|
||||
Object.keys(withLetterAndGroupViewToolsData).forEach((letter) => {
|
||||
letters.forEach((letter) => {
|
||||
Object.keys(withLetterAndGroupViewToolsData[letter]).forEach((groupName) => {
|
||||
result.push(...withLetterAndGroupViewToolsData[letter][groupName])
|
||||
})
|
||||
})
|
||||
|
||||
return result
|
||||
}, [withLetterAndGroupViewToolsData])
|
||||
}, [withLetterAndGroupViewToolsData, letters])
|
||||
|
||||
const toolRefs = useRef({})
|
||||
|
||||
|
||||
@ -20,6 +20,7 @@ import type { StartNodeType } from '../nodes/start/types'
|
||||
import {
|
||||
useChecklistBeforePublish,
|
||||
useIsChatMode,
|
||||
useNodesInteractions,
|
||||
useNodesReadOnly,
|
||||
useNodesSyncDraft,
|
||||
useWorkflowMode,
|
||||
@ -35,6 +36,7 @@ import RestoringTitle from './restoring-title'
|
||||
import ViewHistory from './view-history'
|
||||
import ChatVariableButton from './chat-variable-button'
|
||||
import EnvButton from './env-button'
|
||||
import VersionHistoryModal from './version-history-modal'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { publishWorkflow } from '@/service/workflow'
|
||||
@ -49,11 +51,13 @@ const Header: FC = () => {
|
||||
const appID = appDetail?.id
|
||||
const isChatMode = useIsChatMode()
|
||||
const { nodesReadOnly, getNodesReadOnly } = useNodesReadOnly()
|
||||
const { handleNodeSelect } = useNodesInteractions()
|
||||
const publishedAt = useStore(s => s.publishedAt)
|
||||
const draftUpdatedAt = useStore(s => s.draftUpdatedAt)
|
||||
const toolPublished = useStore(s => s.toolPublished)
|
||||
const nodes = useNodes<StartNodeType>()
|
||||
const startNode = nodes.find(node => node.data.type === BlockEnum.Start)
|
||||
const selectedNode = nodes.find(node => node.data.selected)
|
||||
const startVariables = startNode?.data.variables
|
||||
const fileSettings = useFeatures(s => s.features.file)
|
||||
const variables = useMemo(() => {
|
||||
@ -76,7 +80,6 @@ const Header: FC = () => {
|
||||
const {
|
||||
handleLoadBackupDraft,
|
||||
handleBackupDraft,
|
||||
handleRestoreFromPublishedWorkflow,
|
||||
} = useWorkflowRun()
|
||||
const { handleCheckBeforePublish } = useChecklistBeforePublish()
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
@ -126,8 +129,10 @@ const Header: FC = () => {
|
||||
const onStartRestoring = useCallback(() => {
|
||||
workflowStore.setState({ isRestoring: true })
|
||||
handleBackupDraft()
|
||||
handleRestoreFromPublishedWorkflow()
|
||||
}, [handleBackupDraft, handleRestoreFromPublishedWorkflow, workflowStore])
|
||||
// clear right panel
|
||||
if (selectedNode)
|
||||
handleNodeSelect(selectedNode.id, true)
|
||||
}, [handleBackupDraft, workflowStore, handleNodeSelect, selectedNode])
|
||||
|
||||
const onPublisherToggle = useCallback((state: boolean) => {
|
||||
if (state)
|
||||
@ -209,23 +214,27 @@ const Header: FC = () => {
|
||||
}
|
||||
{
|
||||
restoring && (
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Button className='text-components-button-secondary-text' onClick={handleShowFeatures}>
|
||||
<RiApps2AddLine className='w-4 h-4 mr-1 text-components-button-secondary-text' />
|
||||
{t('workflow.common.features')}
|
||||
</Button>
|
||||
<Divider type='vertical' className='h-3.5 mx-auto' />
|
||||
<Button
|
||||
onClick={handleCancelRestore}
|
||||
>
|
||||
{t('common.operation.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleRestore}
|
||||
variant='primary'
|
||||
>
|
||||
{t('workflow.common.restore')}
|
||||
</Button>
|
||||
<div className='flex flex-col mt-auto'>
|
||||
<div className='flex items-center justify-end my-4'>
|
||||
<Button className='text-components-button-secondary-text' onClick={handleShowFeatures}>
|
||||
<RiApps2AddLine className='w-4 h-4 mr-1 text-components-button-secondary-text' />
|
||||
{t('workflow.common.features')}
|
||||
</Button>
|
||||
<div className='mx-2 w-[1px] h-3.5 bg-gray-200'></div>
|
||||
<Button
|
||||
className='mr-2'
|
||||
onClick={handleCancelRestore}
|
||||
>
|
||||
{t('common.operation.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleRestore}
|
||||
variant='primary'
|
||||
>
|
||||
{t('workflow.common.restore')}
|
||||
</Button>
|
||||
</div>
|
||||
<VersionHistoryModal />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
66
web/app/components/workflow/header/version-history-item.tsx
Normal file
66
web/app/components/workflow/header/version-history-item.tsx
Normal file
@ -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)
|
||||
89
web/app/components/workflow/header/version-history-modal.tsx
Normal file
89
web/app/components/workflow/header/version-history-modal.tsx
Normal file
@ -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 type { IOtherOptions } from '@/service/base'
|
||||
import { ssePost } from '@/service/base'
|
||||
import {
|
||||
fetchPublishedWorkflow,
|
||||
stopWorkflowRun,
|
||||
} from '@/service/workflow'
|
||||
import { stopWorkflowRun } from '@/service/workflow'
|
||||
import { useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||
import { AudioPlayerManager } from '@/app/components/base/audio-btn/audio.player.manager'
|
||||
import type { VersionHistory } from '@/types/workflow'
|
||||
|
||||
export const useWorkflowRun = () => {
|
||||
const store = useStoreApi()
|
||||
@ -262,24 +260,18 @@ export const useWorkflowRun = () => {
|
||||
stopWorkflowRun(`/apps/${appId}/workflow-runs/tasks/${taskId}/stop`)
|
||||
}, [])
|
||||
|
||||
const handleRestoreFromPublishedWorkflow = useCallback(async () => {
|
||||
const appDetail = useAppStore.getState().appDetail
|
||||
const publishedWorkflow = await fetchPublishedWorkflow(`/apps/${appDetail?.id}/workflows/publish`)
|
||||
|
||||
if (publishedWorkflow) {
|
||||
const nodes = publishedWorkflow.graph.nodes
|
||||
const edges = publishedWorkflow.graph.edges
|
||||
const viewport = publishedWorkflow.graph.viewport!
|
||||
|
||||
handleUpdateWorkflowCanvas({
|
||||
nodes,
|
||||
edges,
|
||||
viewport,
|
||||
})
|
||||
featuresStore?.setState({ features: publishedWorkflow.features })
|
||||
workflowStore.getState().setPublishedAt(publishedWorkflow.created_at)
|
||||
workflowStore.getState().setEnvironmentVariables(publishedWorkflow.environment_variables || [])
|
||||
}
|
||||
const handleRestoreFromPublishedWorkflow = useCallback((publishedWorkflow: VersionHistory) => {
|
||||
const nodes = publishedWorkflow.graph.nodes.map(node => ({ ...node, selected: false, data: { ...node.data, selected: false } }))
|
||||
const edges = publishedWorkflow.graph.edges
|
||||
const viewport = publishedWorkflow.graph.viewport!
|
||||
handleUpdateWorkflowCanvas({
|
||||
nodes,
|
||||
edges,
|
||||
viewport,
|
||||
})
|
||||
featuresStore?.setState({ features: publishedWorkflow.features })
|
||||
workflowStore.getState().setPublishedAt(publishedWorkflow.created_at)
|
||||
workflowStore.getState().setEnvironmentVariables(publishedWorkflow.environment_variables || [])
|
||||
}, [featuresStore, handleUpdateWorkflowCanvas, workflowStore])
|
||||
|
||||
return {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useStrategyProviderDetail } from '@/service/use-strategy'
|
||||
import useNodeCrud from '../_base/hooks/use-node-crud'
|
||||
import useVarList from '../_base/hooks/use-var-list'
|
||||
import useOneStepRun from '../_base/hooks/use-one-step-run'
|
||||
import type { AgentNodeType } from './types'
|
||||
import {
|
||||
useNodesReadOnly,
|
||||
@ -19,6 +20,27 @@ const useConfig = (id: string, payload: AgentNodeType) => {
|
||||
const strategyProvider = useStrategyProviderDetail(
|
||||
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(
|
||||
str => str.identity.name === inputs.agent_strategy_name,
|
||||
)
|
||||
@ -59,6 +81,18 @@ const useConfig = (id: string, payload: AgentNodeType) => {
|
||||
onFormChange,
|
||||
currentStrategyStatus,
|
||||
strategyProvider: strategyProvider.data,
|
||||
|
||||
isShowSingleRun,
|
||||
showSingleRun,
|
||||
hideSingleRun,
|
||||
toVarInputs,
|
||||
runningStatus,
|
||||
handleRun,
|
||||
handleStop,
|
||||
runInputData,
|
||||
setRunInputData,
|
||||
runResult,
|
||||
agentInputKey,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import React, { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Split from '../_base/components/split'
|
||||
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 ResultPanel from '@/app/components/workflow/run/result-panel'
|
||||
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'
|
||||
|
||||
@ -51,6 +53,12 @@ const Panel: FC<NodePanelProps<ToolNodeType>> = ({
|
||||
outputSchema,
|
||||
} = useConfig(id, data)
|
||||
const toolIcon = useToolIcon(data)
|
||||
const logsParams = useLogs()
|
||||
const nodeInfo = useMemo(() => {
|
||||
if (!runResult)
|
||||
return null
|
||||
return formatToTracingNodeList([runResult], t)[0]
|
||||
}, [runResult, t])
|
||||
|
||||
if (isLoading) {
|
||||
return <div className='flex h-[200px] items-center justify-center'>
|
||||
@ -161,7 +169,8 @@ const Panel: FC<NodePanelProps<ToolNodeType>> = ({
|
||||
runningStatus={runningStatus}
|
||||
onRun={handleRun}
|
||||
onStop={handleStop}
|
||||
result={<ResultPanel {...runResult} showSteps={false} />}
|
||||
{...logsParams}
|
||||
result={<ResultPanel {...runResult} showSteps={false} {...logsParams} nodeInfo={nodeInfo} />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -17,9 +17,9 @@ const Operator = ({ handleUndo, handleRedo }: OperatorProps) => {
|
||||
width: 102,
|
||||
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
|
||||
!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]'>
|
||||
<ZoomInOut />
|
||||
|
||||
@ -4,7 +4,7 @@ import Button from '@/app/components/base/button'
|
||||
import type { AgentLogItemWithChildren } from '@/types/workflow'
|
||||
|
||||
type AgentLogNavProps = {
|
||||
agentOrToolLogItemStack: { id: string; label: string }[]
|
||||
agentOrToolLogItemStack: AgentLogItemWithChildren[]
|
||||
onShowAgentOrToolLog: (detail?: AgentLogItemWithChildren) => void
|
||||
}
|
||||
const AgentLogNav = ({
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { RiAlertFill } from '@remixicon/react'
|
||||
import AgentLogItem from './agent-log-item'
|
||||
import AgentLogNav from './agent-log-nav'
|
||||
import type { AgentLogItemWithChildren } from '@/types/workflow'
|
||||
|
||||
type AgentResultPanelProps = {
|
||||
agentOrToolLogItemStack: { id: string; label: string }[]
|
||||
agentOrToolLogItemStack: AgentLogItemWithChildren[]
|
||||
agentOrToolLogListMap: Record<string, AgentLogItemWithChildren[]>
|
||||
onShowAgentOrToolLog: (detail?: AgentLogItemWithChildren) => void
|
||||
}
|
||||
@ -34,6 +35,22 @@ const AgentResultPanel = ({
|
||||
}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user