Merge branch 'main' into fix/chore-fix

This commit is contained in:
Yeuoly 2024-12-31 16:47:56 +08:00
commit 107e44c8fb
93 changed files with 1717 additions and 911 deletions

View File

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

View File

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

View File

@ -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,22 @@ 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()
import psycogreen.gevent # type: ignore
psycogreen.gevent.patch_psycopg()
from app_factory import create_app
app = create_app()
celery = app.extensions["celery"]

View File

@ -823,6 +823,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,
@ -852,6 +859,7 @@ class FeatureConfig(
WorkflowNodeExecutionConfig,
WorkspaceConfig,
LoginConfig,
AccountConfig,
# hosted services config
HostedServiceConfig,
CeleryBeatConfig,

View File

@ -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
@ -474,6 +474,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")
@ -488,6 +513,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>"

View File

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

View File

@ -8,13 +8,8 @@ from sqlalchemy.orm import Session
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
@ -22,6 +17,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
@ -133,6 +129,8 @@ class ForgotPasswordResetApi(Resource):
)
except WorkSpaceNotAllowedCreateError:
pass
except AccountRegisterError as are:
raise AccountInFreezeError()
return {"result": "success"}

View File

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

View File

@ -18,7 +18,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
@ -101,6 +101,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:

View File

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

View File

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

View File

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

View File

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

View File

@ -330,13 +330,13 @@ class BaseAgentRunner(AppRunner):
if not updated_agent_thought:
raise ValueError("agent thought not found")
if thought is not None:
updated_agent_thought.thought = thought
if thought:
agent_thought.thought = thought
if tool_name is not None:
updated_agent_thought.tool = tool_name
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)
@ -345,7 +345,7 @@ class BaseAgentRunner(AppRunner):
updated_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)
@ -354,8 +354,8 @@ class BaseAgentRunner(AppRunner):
updated_agent_thought.observation = observation
if answer is not None:
updated_agent_thought.answer = answer
if answer:
agent_thought.answer = answer
if messages_ids is not None and len(messages_ids) > 0:
updated_agent_thought.message_files = json.dumps(messages_ids)

View File

@ -276,7 +276,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
@ -392,7 +392,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
@ -825,7 +825,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)

View File

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

View File

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

View File

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

View File

@ -2,12 +2,14 @@ import json
import logging
from collections.abc import Generator
from typing import Any, Optional, Union
from typing import cast
from core.file import FILE_MODEL_IDENTITY, File, FileTransferMethod
from core.tools.__base.tool import Tool
from core.tools.__base.tool_runtime import ToolRuntime
from core.tools.entities.tool_entities import ToolEntity, ToolInvokeMessage, ToolParameter, ToolProviderType
from 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
@ -222,10 +224,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(ToolRuntime, 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(ToolRuntime, self.runtime).tenant_id),
)
files.append(file)
result[key] = value

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

56
api/poetry.lock generated
View File

@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand.
# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand.
[[package]]
name = "aiofiles"
@ -955,10 +955,6 @@ files = [
{file = "Brotli-1.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a37b8f0391212d29b3a91a799c8e4a2855e0576911cdfb2515487e30e322253d"},
{file = "Brotli-1.1.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:e84799f09591700a4154154cab9787452925578841a94321d5ee8fb9a9a328f0"},
{file = "Brotli-1.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f66b5337fa213f1da0d9000bc8dc0cb5b896b726eefd9c6046f699b169c41b9e"},
{file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5dab0844f2cf82be357a0eb11a9087f70c5430b2c241493fc122bb6f2bb0917c"},
{file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e4fe605b917c70283db7dfe5ada75e04561479075761a0b3866c081d035b01c1"},
{file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:1e9a65b5736232e7a7f91ff3d02277f11d339bf34099a56cdab6a8b3410a02b2"},
{file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:58d4b711689366d4a03ac7957ab8c28890415e267f9b6589969e74b6e42225ec"},
{file = "Brotli-1.1.0-cp310-cp310-win32.whl", hash = "sha256:be36e3d172dc816333f33520154d708a2657ea63762ec16b62ece02ab5e4daf2"},
{file = "Brotli-1.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:0c6244521dda65ea562d5a69b9a26120769b7a9fb3db2fe9545935ed6735b128"},
{file = "Brotli-1.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a3daabb76a78f829cafc365531c972016e4aa8d5b4bf60660ad8ecee19df7ccc"},
@ -971,14 +967,8 @@ files = [
{file = "Brotli-1.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:19c116e796420b0cee3da1ccec3b764ed2952ccfcc298b55a10e5610ad7885f9"},
{file = "Brotli-1.1.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:510b5b1bfbe20e1a7b3baf5fed9e9451873559a976c1a78eebaa3b86c57b4265"},
{file = "Brotli-1.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a1fd8a29719ccce974d523580987b7f8229aeace506952fa9ce1d53a033873c8"},
{file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c247dd99d39e0338a604f8c2b3bc7061d5c2e9e2ac7ba9cc1be5a69cb6cd832f"},
{file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1b2c248cd517c222d89e74669a4adfa5577e06ab68771a529060cf5a156e9757"},
{file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:2a24c50840d89ded6c9a8fdc7b6ed3692ed4e86f1c4a4a938e1e92def92933e0"},
{file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f31859074d57b4639318523d6ffdca586ace54271a73ad23ad021acd807eb14b"},
{file = "Brotli-1.1.0-cp311-cp311-win32.whl", hash = "sha256:39da8adedf6942d76dc3e46653e52df937a3c4d6d18fdc94a7c29d263b1f5b50"},
{file = "Brotli-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:aac0411d20e345dc0920bdec5548e438e999ff68d77564d5e9463a7ca9d3e7b1"},
{file = "Brotli-1.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:32d95b80260d79926f5fab3c41701dbb818fde1c9da590e77e571eefd14abe28"},
{file = "Brotli-1.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b760c65308ff1e462f65d69c12e4ae085cff3b332d894637f6273a12a482d09f"},
{file = "Brotli-1.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:316cc9b17edf613ac76b1f1f305d2a748f1b976b033b049a6ecdfd5612c70409"},
{file = "Brotli-1.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:caf9ee9a5775f3111642d33b86237b05808dafcd6268faa492250e9b78046eb2"},
{file = "Brotli-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:70051525001750221daa10907c77830bc889cb6d865cc0b813d9db7fefc21451"},
@ -989,24 +979,8 @@ files = [
{file = "Brotli-1.1.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:4093c631e96fdd49e0377a9c167bfd75b6d0bad2ace734c6eb20b348bc3ea180"},
{file = "Brotli-1.1.0-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:7e4c4629ddad63006efa0ef968c8e4751c5868ff0b1c5c40f76524e894c50248"},
{file = "Brotli-1.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:861bf317735688269936f755fa136a99d1ed526883859f86e41a5d43c61d8966"},
{file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:87a3044c3a35055527ac75e419dfa9f4f3667a1e887ee80360589eb8c90aabb9"},
{file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c5529b34c1c9d937168297f2c1fde7ebe9ebdd5e121297ff9c043bdb2ae3d6fb"},
{file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ca63e1890ede90b2e4454f9a65135a4d387a4585ff8282bb72964fab893f2111"},
{file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e79e6520141d792237c70bcd7a3b122d00f2613769ae0cb61c52e89fd3443839"},
{file = "Brotli-1.1.0-cp312-cp312-win32.whl", hash = "sha256:5f4d5ea15c9382135076d2fb28dde923352fe02951e66935a9efaac8f10e81b0"},
{file = "Brotli-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:906bc3a79de8c4ae5b86d3d75a8b77e44404b0f4261714306e3ad248d8ab0951"},
{file = "Brotli-1.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8bf32b98b75c13ec7cf774164172683d6e7891088f6316e54425fde1efc276d5"},
{file = "Brotli-1.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7bc37c4d6b87fb1017ea28c9508b36bbcb0c3d18b4260fcdf08b200c74a6aee8"},
{file = "Brotli-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c0ef38c7a7014ffac184db9e04debe495d317cc9c6fb10071f7fefd93100a4f"},
{file = "Brotli-1.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91d7cc2a76b5567591d12c01f019dd7afce6ba8cba6571187e21e2fc418ae648"},
{file = "Brotli-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a93dde851926f4f2678e704fadeb39e16c35d8baebd5252c9fd94ce8ce68c4a0"},
{file = "Brotli-1.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f0db75f47be8b8abc8d9e31bc7aad0547ca26f24a54e6fd10231d623f183d089"},
{file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6967ced6730aed543b8673008b5a391c3b1076d834ca438bbd70635c73775368"},
{file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7eedaa5d036d9336c95915035fb57422054014ebdeb6f3b42eac809928e40d0c"},
{file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d487f5432bf35b60ed625d7e1b448e2dc855422e87469e3f450aa5552b0eb284"},
{file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:832436e59afb93e1836081a20f324cb185836c617659b07b129141a8426973c7"},
{file = "Brotli-1.1.0-cp313-cp313-win32.whl", hash = "sha256:43395e90523f9c23a3d5bdf004733246fba087f2948f87ab28015f12359ca6a0"},
{file = "Brotli-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:9011560a466d2eb3f5a6e4929cf4a09be405c64154e12df0dd72713f6500e32b"},
{file = "Brotli-1.1.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:a090ca607cbb6a34b0391776f0cb48062081f5f60ddcce5d11838e67a01928d1"},
{file = "Brotli-1.1.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2de9d02f5bda03d27ede52e8cfe7b865b066fa49258cbab568720aa5be80a47d"},
{file = "Brotli-1.1.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2333e30a5e00fe0fe55903c8832e08ee9c3b1382aacf4db26664a16528d51b4b"},
@ -1016,10 +990,6 @@ files = [
{file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:fd5f17ff8f14003595ab414e45fce13d073e0762394f957182e69035c9f3d7c2"},
{file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:069a121ac97412d1fe506da790b3e69f52254b9df4eb665cd42460c837193354"},
{file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:e93dfc1a1165e385cc8239fab7c036fb2cd8093728cbd85097b284d7b99249a2"},
{file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_2_aarch64.whl", hash = "sha256:aea440a510e14e818e67bfc4027880e2fb500c2ccb20ab21c7a7c8b5b4703d75"},
{file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_2_i686.whl", hash = "sha256:6974f52a02321b36847cd19d1b8e381bf39939c21efd6ee2fc13a28b0d99348c"},
{file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_2_ppc64le.whl", hash = "sha256:a7e53012d2853a07a4a79c00643832161a910674a893d296c9f1259859a289d2"},
{file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:d7702622a8b40c49bffb46e1e3ba2e81268d5c04a34f460978c6b5517a34dd52"},
{file = "Brotli-1.1.0-cp36-cp36m-win32.whl", hash = "sha256:a599669fd7c47233438a56936988a2478685e74854088ef5293802123b5b2460"},
{file = "Brotli-1.1.0-cp36-cp36m-win_amd64.whl", hash = "sha256:d143fd47fad1db3d7c27a1b1d66162e855b5d50a89666af46e1679c496e8e579"},
{file = "Brotli-1.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:11d00ed0a83fa22d29bc6b64ef636c4552ebafcef57154b4ddd132f5638fbd1c"},
@ -1031,10 +1001,6 @@ files = [
{file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:919e32f147ae93a09fe064d77d5ebf4e35502a8df75c29fb05788528e330fe74"},
{file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:23032ae55523cc7bccb4f6a0bf368cd25ad9bcdcc1990b64a647e7bbcce9cb5b"},
{file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:224e57f6eac61cc449f498cc5f0e1725ba2071a3d4f48d5d9dffba42db196438"},
{file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:cb1dac1770878ade83f2ccdf7d25e494f05c9165f5246b46a621cc849341dc01"},
{file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:3ee8a80d67a4334482d9712b8e83ca6b1d9bc7e351931252ebef5d8f7335a547"},
{file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:5e55da2c8724191e5b557f8e18943b1b4839b8efc3ef60d65985bcf6f587dd38"},
{file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:d342778ef319e1026af243ed0a07c97acf3bad33b9f29e7ae6a1f68fd083e90c"},
{file = "Brotli-1.1.0-cp37-cp37m-win32.whl", hash = "sha256:587ca6d3cef6e4e868102672d3bd9dc9698c309ba56d41c2b9c85bbb903cdb95"},
{file = "Brotli-1.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:2954c1c23f81c2eaf0b0717d9380bd348578a94161a65b3a2afc62c86467dd68"},
{file = "Brotli-1.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:efa8b278894b14d6da122a72fefcebc28445f2d3f880ac59d46c90f4c13be9a3"},
@ -1047,10 +1013,6 @@ files = [
{file = "Brotli-1.1.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1ab4fbee0b2d9098c74f3057b2bc055a8bd92ccf02f65944a241b4349229185a"},
{file = "Brotli-1.1.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:141bd4d93984070e097521ed07e2575b46f817d08f9fa42b16b9b5f27b5ac088"},
{file = "Brotli-1.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fce1473f3ccc4187f75b4690cfc922628aed4d3dd013d047f95a9b3919a86596"},
{file = "Brotli-1.1.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d2b35ca2c7f81d173d2fadc2f4f31e88cc5f7a39ae5b6db5513cf3383b0e0ec7"},
{file = "Brotli-1.1.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:af6fa6817889314555aede9a919612b23739395ce767fe7fcbea9a80bf140fe5"},
{file = "Brotli-1.1.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:2feb1d960f760a575dbc5ab3b1c00504b24caaf6986e2dc2b01c09c87866a943"},
{file = "Brotli-1.1.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:4410f84b33374409552ac9b6903507cdb31cd30d2501fc5ca13d18f73548444a"},
{file = "Brotli-1.1.0-cp38-cp38-win32.whl", hash = "sha256:db85ecf4e609a48f4b29055f1e144231b90edc90af7481aa731ba2d059226b1b"},
{file = "Brotli-1.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:3d7954194c36e304e1523f55d7042c59dc53ec20dd4e9ea9d151f1b62b4415c0"},
{file = "Brotli-1.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5fb2ce4b8045c78ebbc7b8f3c15062e435d47e7393cc57c25115cfd49883747a"},
@ -1063,10 +1025,6 @@ files = [
{file = "Brotli-1.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:949f3b7c29912693cee0afcf09acd6ebc04c57af949d9bf77d6101ebb61e388c"},
{file = "Brotli-1.1.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:89f4988c7203739d48c6f806f1e87a1d96e0806d44f0fba61dba81392c9e474d"},
{file = "Brotli-1.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:de6551e370ef19f8de1807d0a9aa2cdfdce2e85ce88b122fe9f6b2b076837e59"},
{file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0737ddb3068957cf1b054899b0883830bb1fec522ec76b1098f9b6e0f02d9419"},
{file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:4f3607b129417e111e30637af1b56f24f7a49e64763253bbc275c75fa887d4b2"},
{file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:6c6e0c425f22c1c719c42670d561ad682f7bfeeef918edea971a79ac5252437f"},
{file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:494994f807ba0b92092a163a0a283961369a65f6cbe01e8891132b7a320e61eb"},
{file = "Brotli-1.1.0-cp39-cp39-win32.whl", hash = "sha256:f0d8a7a6b5983c2496e364b969f0e526647a06b075d034f3297dc66f3b360c64"},
{file = "Brotli-1.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:cdad5b9014d83ca68c25d2e9444e28e967ef16e80f6b436918c700c117a85467"},
{file = "Brotli-1.1.0.tar.gz", hash = "sha256:81de08ac11bcb85841e440c13611c00b67d3bf82698314928d0b676362546724"},
@ -7000,6 +6958,16 @@ files = [
dev = ["black", "check-manifest", "coverage", "packaging", "pylint", "pyperf", "pypinfo", "pytest-cov", "requests", "rstcheck", "ruff", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "virtualenv", "wheel"]
test = ["pytest", "pytest-xdist", "setuptools"]
[[package]]
name = "psycogreen"
version = "1.0.2"
description = "psycopg2 integration with coroutine libraries"
optional = false
python-versions = "*"
files = [
{file = "psycogreen-1.0.2.tar.gz", hash = "sha256:c429845a8a49cf2f76b71265008760bcd7c7c77d80b806db4dc81116dbcd130d"},
]
[[package]]
name = "psycopg2-binary"
version = "2.9.10"
@ -11173,4 +11141,4 @@ cffi = ["cffi (>=1.11)"]
[metadata]
lock-version = "2.0"
python-versions = ">=3.11,<3.13"
content-hash = "f4accd01805cbf080c4c5295f97a06c8e4faec7365d2c43d0435e56b46461732"
content-hash = "8b2b1bbc4d9c1d47f126775ea587ee116956df29f500534cb87f512402856e05"

View File

@ -61,6 +61,7 @@ openai = "~1.52.0"
openpyxl = "~3.1.5"
pandas = { version = "~2.2.2", extras = ["performance", "excel"] }
pandas-stubs = "~2.2.3.241009"
psycogreen = "~1.0.2"
psycopg2-binary = "~2.9.6"
pycryptodome = "3.19.1"
pydantic = "~2.9.2"

View File

@ -33,6 +33,7 @@ from models.account import (
TenantStatus,
)
from models.model import DifySetup
from services.billing_service import BillingService
from services.errors.account import (
AccountAlreadyInTenantError,
AccountLoginError,
@ -51,6 +52,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
@ -71,6 +74,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
@ -202,6 +208,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
@ -241,6 +256,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"""
@ -380,6 +431,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):
@ -409,6 +461,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
@ -825,6 +885,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")

View File

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

View File

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

View File

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

View File

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

View File

@ -5,6 +5,8 @@ from datetime import UTC, datetime
from typing import Any, Optional
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
@ -77,6 +79,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,
*,

View File

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

View 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)

View 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))

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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}:-}}")

View File

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

View File

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

View 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>
</>
}

View 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>
}

View 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>
</>
}

View 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>
}

View 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,
})
}

View File

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

View File

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

View File

@ -11,59 +11,62 @@ type 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'

View File

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

View File

@ -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}%` }} />

View File

@ -59,23 +59,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"
@ -89,10 +87,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'>
@ -107,89 +105,107 @@ export default function AppSelector({ isMobile }: IAppSelector) {
</div>
<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>

View File

@ -9,6 +9,7 @@ import { useWorkspacesContext } from '@/context/workspace-context'
import { ChevronRight } from '@/app/components/base/icons/src/vender/line/arrows'
import { Check } from '@/app/components/base/icons/src/vender/line/general'
import { ToastContext } from '@/app/components/base/toast'
import classNames from '@/utils/classnames'
const itemClassName = `
flex items-center px-3 py-2 h-10 cursor-pointer
@ -50,7 +51,7 @@ const WorkplaceSelector = () => {
<Menu.Button className={cn(
`
${itemClassName} w-full
group hover:bg-gray-50 cursor-pointer ${open && 'bg-gray-50'} rounded-lg
group hover:bg-state-base-hover cursor-pointer ${open && 'bg-state-base-hover'} rounded-lg
`,
)}>
<div className={itemIconClassName}>{currentWorkspace?.name[0].toLocaleUpperCase()}</div>
@ -70,7 +71,7 @@ const WorkplaceSelector = () => {
className={cn(
`
absolute top-[1px] min-w-[200px] max-h-[70vh] overflow-y-scroll z-10 bg-white border-[0.5px] border-gray-200
divide-y divide-gray-100 origin-top-right rounded-xl
divide-y divide-gray-100 origin-top-right rounded-xl focus:outline-none
`,
s.popup,
)}
@ -78,11 +79,16 @@ const WorkplaceSelector = () => {
<div className="px-1 py-1">
{
workspaces.map(workspace => (
<div className={itemClassName} key={workspace.id} onClick={() => handleSwitchWorkspace(workspace.id)}>
<div className={itemIconClassName}>{workspace.name[0].toLocaleUpperCase()}</div>
<div className={itemNameClassName}>{workspace.name}</div>
{workspace.current && <Check className={itemCheckClassName} />}
</div>
<Menu.Item key={workspace.id}>
{({ active }) => <div className={classNames(itemClassName,
active && 'bg-state-base-hover',
)} key={workspace.id} onClick={() => handleSwitchWorkspace(workspace.id)}>
<div className={itemIconClassName}>{workspace.name[0].toLocaleUpperCase()}</div>
<div className={itemNameClassName}>{workspace.name}</div>
{workspace.current && <Check className={itemCheckClassName} />}
</div>}
</Menu.Item>
))
}
</div>

View File

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

View File

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

View File

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

View 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)

View 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)

View File

@ -202,7 +202,7 @@ const ViewHistory = ({
{`Test ${isChatMode ? 'Chat' : 'Run'}#${item.sequence_number}`}
</div>
<div className='flex items-center text-xs text-gray-500 leading-[18px]'>
{item.created_by_account.name} · {formatTimeFromNow((item.finished_at || item.created_at) * 1000)}
{item.created_by_account?.name} · {formatTimeFromNow((item.finished_at || item.created_at) * 1000)}
</div>
</div>
</div>

View File

@ -18,17 +18,14 @@ import { useWorkflowUpdate } from './use-workflow-interactions'
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 {
getFilesInLogs,
} from '@/app/components/base/file-uploader/utils'
import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
import type { NodeTracing } from '@/types/workflow'
import type { NodeTracing, VersionHistory } from '@/types/workflow'
export const useWorkflowRun = () => {
const store = useStoreApi()
@ -754,24 +751,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 {

View File

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

View File

@ -21,7 +21,7 @@ import type {
WorkflowRunningData,
} from './types'
import { WorkflowContext } from './context'
import type { NodeTracing } from '@/types/workflow'
import type { NodeTracing, VersionHistory } from '@/types/workflow'
// #TODO chatVar#
// const MOCK_DATA = [
@ -171,6 +171,8 @@ type Shape = {
setIterTimes: (iterTimes: number) => void
iterParallelLogMap: Map<string, Map<string, NodeTracing[]>>
setIterParallelLogMap: (iterParallelLogMap: Map<string, Map<string, NodeTracing[]>>) => void
versionHistory: VersionHistory[]
setVersionHistory: (versionHistory: VersionHistory[]) => void
}
export const createWorkflowStore = () => {
@ -291,6 +293,8 @@ export const createWorkflowStore = () => {
iterParallelLogMap: new Map<string, Map<string, NodeTracing[]>>(),
setIterParallelLogMap: iterParallelLogMap => set(() => ({ iterParallelLogMap })),
versionHistory: [],
setVersionHistory: versionHistory => set(() => ({ versionHistory })),
}))
}

View File

@ -289,6 +289,11 @@ export enum WorkflowRunningStatus {
Stopped = 'stopped',
}
export enum WorkflowVersion {
Draft = 'draft',
Latest = 'latest',
}
export enum NodeRunningStatus {
NotStart = 'not-start',
Waiting = 'waiting',

View File

@ -3,6 +3,7 @@ import I18nServer from './components/i18n-server'
import BrowserInitor from './components/browser-initor'
import SentryInitor from './components/sentry-initor'
import { getLocaleOnServer } from '@/i18n/server'
import { TanstackQueryIniter } from '@/context/query-client'
import './styles/globals.css'
import './styles/markdown.scss'
@ -46,7 +47,9 @@ const LocaleLayout = ({
>
<BrowserInitor>
<SentryInitor>
<I18nServer>{children}</I18nServer>
<TanstackQueryIniter>
<I18nServer>{children}</I18nServer>
</TanstackQueryIniter>
</SentryInitor>
</BrowserInitor>
</body>

View File

@ -47,6 +47,8 @@ const translation = {
view: 'View',
viewMore: 'VIEW MORE',
regenerate: 'Regenerate',
submit: 'Submit',
skip: 'Skip',
},
errorMsg: {
fieldRequired: '{{field}} is required',
@ -181,8 +183,19 @@ const translation = {
editName: 'Edit Name',
showAppLength: 'Show {{length}} apps',
delete: 'Delete Account',
deleteTip: 'Deleting your account will permanently erase all your data and it cannot be recovered.',
deleteConfirmTip: 'To confirm, please send the following from your registered email to ',
deleteTip: 'Please note, once confirmed, as the Owner of any Workspaces, your workspaces will be scheduled in a queue for permanent deletion, and all your user data will be queued for permanent deletion.',
deletePrivacyLinkTip: 'For more information about how we handle your data, please see our ',
deletePrivacyLink: 'Privacy Policy.',
deleteSuccessTip: 'Your account needs time to finish deleting. We\'ll email you when it\'s all done.',
deleteLabel: 'To confirm, please type in your email below',
deletePlaceholder: 'Please enter your email',
sendVerificationButton: 'Send Verification Code',
verificationLabel: 'Verification Code',
verificationPlaceholder: 'Paste the 6-digit code',
permanentlyDeleteButton: 'Permanently Delete Account',
feedbackTitle: 'Feedback',
feedbackLabel: 'Tell us why you deleted your account?',
feedbackPlaceholder: 'Optional',
},
members: {
team: 'Team',

View File

@ -104,6 +104,8 @@ const translation = {
branch: 'BRANCH',
onFailure: 'On Failure',
addFailureBranch: 'Add Fail Branch',
loadMore: 'Load More Workflows',
noHistory: 'No History',
},
env: {
envPanelTitle: 'Environment Variables',

View File

@ -11,7 +11,7 @@ const translation = {
advancedWarning: {
title: 'エキスパートモードに切り替えました。PROMPTを変更すると、基本モードに戻ることはできません。',
description: 'エキスパートモードでは、PROMPT全体を編集できます。',
learnMore: '詳細を見る',
learnMore: '詳細はこちら',
ok: 'OK',
},
operation: {
@ -150,7 +150,7 @@ const translation = {
title: '会話履歴',
description: '会話の役割に接頭辞名を設定します',
tip: '会話履歴は有効になっていません。上記のプロンプトに <histories> を追加してください。',
learnMore: '詳細を見る',
learnMore: '詳細はこちら',
editModal: {
title: '会話役割名の編集',
userPrefix: 'ユーザー接頭辞',

View File

@ -33,7 +33,7 @@ const translation = {
reload: '再読み込み',
ok: 'OK',
log: 'ログ',
learnMore: '詳細を見る',
learnMore: '詳細はこちら',
params: 'パラメータ',
duplicate: '重複',
rename: '名前の変更',
@ -42,11 +42,11 @@ const translation = {
openInNewTab: '新しいタブで開く',
zoomOut: 'ズームアウト',
copyImage: '画像をコピー',
viewMore: 'もっと見る',
view: '眺める',
close: '閉める',
saveAndRegenerate: '子チャンクの保存と再生成',
regenerate: '再生',
saveAndRegenerate: '保存して子チャンクを再生成',
close: '閉じる',
view: '表示',
viewMore: 'さらに表示',
regenerate: '再生',
},
errorMsg: {
fieldRequired: '{{field}}は必要です',
@ -393,7 +393,7 @@ const translation = {
configure: '設定',
notion: {
title: 'ノーション',
description: '知識データソースとしてノーションを使用します。',
description: 'ナレッジデータソースとしてノーションを使用します。',
connectedWorkspace: '接続済みワークスペース',
addWorkspace: 'ワークスペースの追加',
connected: '接続済み',
@ -480,10 +480,10 @@ const translation = {
documents: 'ドキュメント',
hitTesting: '検索テスト',
settings: '設定',
emptyTip: '関連付けられた知識がありません。アプリケーションやプラグインに移動して関連付けを完了してください。',
emptyTip: 'このナレッジはどのアプリケーションにも統合されていません。ドキュメントを参照してガイダンスを確認してください。',
viewDoc: 'ドキュメントを表示',
relatedApp: '関連アプリ',
noRelatedApp: 'リンクされたアプリはありません',
noRelatedApp: '関連付けられたアプリはありません',
},
voiceInput: {
speaking: '今話しています...',
@ -508,7 +508,7 @@ const translation = {
conversationNameCanNotEmpty: '会話名は必須です',
citation: {
title: '引用',
linkToDataset: '知識へのリンク',
linkToDataset: 'ナレッジへのリンク',
characters: '文字数:',
hitCount: '検索回数:',
vectorHash: 'ベクトルハッシュ:',

View File

@ -4,9 +4,9 @@ const translation = {
creation: 'ナレッジの作成',
update: 'データの追加',
},
one: 'データソースの選択',
two: 'テキストの前処理とクリーニング',
three: '実行して完了',
one: 'データソース',
two: 'テキスト進行中',
three: '実行と完成',
},
error: {
unavailable: 'このナレッジは利用できません',
@ -16,6 +16,11 @@ const translation = {
apiKeyPlaceholder: 'firecrawl.devからのAPIキー',
getApiKeyLinkText: 'firecrawl.devからAPIキーを取得する',
},
jinaReader: {
getApiKeyLinkText: '無料のAPIキーを jina.ai で取得',
apiKeyPlaceholder: 'jina.ai からの API キー',
configJinaReader: 'Jina Readerの設定',
},
stepOne: {
filePreview: 'ファイルプレビュー',
pagePreview: 'ページプレビュー',
@ -42,6 +47,7 @@ const translation = {
notionSyncTitle: 'Notionが接続されていません',
notionSyncTip: 'Notionと同期するには、まずNotionへの接続が必要です。',
connect: '接続する',
cancel: 'キャンセル',
button: '次へ',
emptyDatasetCreation: '空のナレッジを作成します',
modal: {
@ -87,7 +93,6 @@ const translation = {
jinaReaderNotConfiguredDescription: '無料のAPIキーを入力してJina Readerを設定します。',
useSitemapTooltip: 'サイトマップに沿ってサイトをクロールします。そうでない場合、Jina Readerはページの関連性に基づいて繰り返しクロールし、ページ数は少なくなりますが、高品質のページが得られます。',
},
cancel: 'キャンセル',
},
stepTwo: {
segmentation: 'チャンク設定',
@ -95,6 +100,16 @@ const translation = {
autoDescription: 'チャンクと前処理ルールを自動的に設定します。初めてのユーザーはこれを選択することをおすすめします。',
custom: 'カスタム',
customDescription: 'チャンクのルール、チャンクの長さ、前処理ルールなどをカスタマイズします。',
general: '一般',
generalTip: '標準的なテキスト分割モードです。検索とコンテキスト抽出に同じチャンクを使用します。',
parentChild: '親子',
parentChildTip: '親子モードでは、子チャンクを検索に、親チャンクをコンテキスト抽出に使用します。',
parentChunkForContext: 'コンテキスト用親チャンク',
childChunkForRetrieval: '検索用子チャンク',
paragraph: '段落',
paragraphTip: '区切り文字と最大チャンク長に基づいてテキストを段落に分割し、分割されたテキストを検索用の親チャンクとして使用します。',
fullDoc: '全文',
fullDocTip: 'ドキュメント全体を親チャンクとして使用し、直接検索します。パフォーマンス上の理由から、10000トークンを超えるテキストは自動的に切り捨てられます。',
separator: 'セグメント識別子',
separatorPlaceholder: '例えば改行(\\\\nや特殊なセパレータ「***」)',
maxLength: '最大チャンク長',
@ -105,19 +120,22 @@ const translation = {
removeExtraSpaces: '連続するスペース、改行、タブを置換する',
removeUrlEmails: 'すべてのURLとメールアドレスを削除する',
removeStopwords: '「a」「an」「the」などのストップワードを削除する',
preview: '確認&プレビュー',
preview: 'プレビュー',
previewChunk: 'チュンクをプレビュー',
reset: 'リセット',
indexMode: 'インデックスモード',
indexMode: 'インデックス方法',
qualified: '高品質',
recommend: 'おすすめ',
qualifiedTip: 'ユーザーのクエリに対してより高い精度を提供するために、デフォルトのシステム埋め込みインターフェースを呼び出して処理します。',
highQualityTip: '高品質モードで埋め込みを終了したら、経済的モードに戻すことはできません。',
recommend: '推奨',
qualifiedTip: '埋め込みモデルを呼び出してドキュメントを処理し、より正確な検索を行うと、LLMが高品質の回答を生成するのに役立ちます。',
warning: 'モデルプロバイダのAPIキーを設定してください。',
click: '設定に移動',
economical: '経済的',
economicalTip: 'オフラインのベクトルエンジン、キーワードインデックスなどを使用して、トークンを消費せずに精度を低下させます。',
economicalTip: '検索時にチャンクあたり10個のキーワードを使用することで、精度は低下しますが、トークン消費を抑えられます。',
QATitle: '質問と回答形式でセグメント化',
QATip: 'このオプションを有効にすると、追加のトークンが消費されます',
QALanguage: '使用言語',
useQALanguage: 'Q&A形式で分割',
estimateCost: '見積もり',
estimateSegment: '推定チャンク数',
segmentCount: 'チャンク',
@ -149,32 +167,19 @@ const translation = {
datasetSettingLink: 'ナレッジ設定',
separatorTip: '区切り文字は、テキストを区切るために使用される文字です。\\n\\n と \\n は、段落と行を区切るために一般的に使用される区切り記号です。カンマ (\\n\\n,\\n) と組み合わせると、最大チャンク長を超えると、段落は行で区切られます。自分で定義した特別な区切り文字を使用することもできます(例:***)。',
maxLengthCheck: 'チャンクの最大長は {{limit}} 未満にする必要があります',
useQALanguage: 'Q&A 形式を使用したチャンク',
previewChunkTip: '左側の「Preview Chunk」ボタンをクリックして、プレビューをロードします',
qaSwitchHighQualityTipTitle: 'Q&A 形式には高品質のインデックス作成方法が必要',
qaSwitchHighQualityTipContent: '現在、Q&A 形式のチャンク化をサポートしているのは、高品質のインデックス メソッドのみです。高品質モードに切り替えますか?',
childChunkForRetrieval: '取得用の子チャンク',
fullDoc: 'フルドキュメント',
parentChildDelimiterTip: '区切り文字は、テキストを区切るために使用される文字です。\\n\\n は、元のドキュメントを大きな親チャンクに分割する場合に推奨されます。また、自分で定義した特別な区切り文字を使用することもできます。',
general: '全般',
switch: 'スイッチ',
parentChild: '親子',
parentChildChunkDelimiterTip: '区切り文字は、テキストを区切るために使用される文字です。\\n は、親チャンクを小さな子チャンクに分割する場合に推奨されます。また、自分で定義した特別な区切り文字を使用することもできます。',
generalTip: '一般的なテキストチャンクモードでは、取得されたチャンクとリコールされたチャンクは同じです。',
previewChunk: 'プレビューチャンク',
parentChunkForContext: 'コンテキストの親チャンク',
notAvailableForQA: 'Q&Aインデックスでは使用できません',
paragraph: '段落',
notAvailableForParentChild: '親子インデックスでは使用できません',
fullDocTip: 'ドキュメント全体が親チャンクとして使用され、直接取得されます。パフォーマンス上の理由から、10000トークンを超えるテキストは自動的に切り捨てられることに注意してください。',
previewChunkCount: '{{カウント}}推定チャンク',
paragraphTip: 'このモードでは、区切り記号とチャンクの最大長に基づいてテキストを段落に分割し、分割されたテキストを取得用の親チャンクとして使用します。',
highQualityTip: '高品質モードでの埋め込みが完了すると、経済モードに戻すことはできません。',
parentChildTip: '親子モードを使用する場合、子チャンクは取得に使用され、親チャンクはコンテキストとしての再呼び出しに使用されます。',
previewChunkTip: 'プレビューを読み込むには、左側の \'チュンクをプレビュー\' ボタンをクリックしてください',
previewChunkCount: '推定チャンク数: {{count}}',
switch: '切り替え',
qaSwitchHighQualityTipTitle: 'Q&A形式には高品質なインデックスが必要です',
qaSwitchHighQualityTipContent: '現在、高品質なインデックス作成のみがQ&A形式の分割をサポートしています。高品質モードに切り替えますか',
notAvailableForParentChild: '親子インデックスでは利用できません',
notAvailableForQA: 'Q&Aインデックスでは利用できません',
parentChildDelimiterTip: '区切り文字とは、テキストを分割するために使用される文字です。\\n\\n は、元のドキュメントを大きな親チャンクに分割する際におすすめです。独自の区切り文字も使用できます。',
parentChildChunkDelimiterTip: '区切り文字とは、テキストを分割するために使用される文字です。\\n は、親チャンクを小さな子チャンクに分割する際におすすめです。独自の区切り文字も使用できます。',
},
stepThree: {
creationTitle: '🎉 ナレッジが作成されました',
creationContent: 'ナレッジの名前は自動的に設定されましたが、いつでも変更できます',
creationContent: 'ナレッジの名前は自動的に設定されましたが、いつでも変更できます。',
label: 'ナレッジ名',
additionTitle: '🎉 ドキュメントがアップロードされました',
additionP1: 'ドキュメントはナレッジにアップロードされました',
@ -189,15 +194,10 @@ const translation = {
modelButtonConfirm: '確認',
modelButtonCancel: 'キャンセル',
},
jinaReader: {
getApiKeyLinkText: '無料のAPIキーを jina.ai で取得',
apiKeyPlaceholder: 'jina.ai からの API キー',
configJinaReader: 'Jina Readerの設定',
},
otherDataSource: {
description: '現在、Difyのナレッジベースには限られたデータソースしかありません。データソースをDifyナレッジベースに投稿することは、すべてのユーザーにとってプラットフォームの柔軟性とパワーを向上させるのに役立つ素晴らしい方法です。私たちのコントリビューションガイドは、簡単に始めることができます。詳細については、以下のリンクをクリックしてください。',
title: '他のデータソースに接続しますか?',
learnMore: '詳細情報',
title: '他のデータソースと接続しますか?',
description: '現在、Difyのナレッジベースには利用できるデータソースが限られています。Difyのナレッジベースにデータソースを提供いただくことは、プラットフォームの柔軟性と能力を向上させる上で非常に有益です。貢献ガイドをご用意していますので、ぜひご協力ください。詳細については、以下のリンクをクリックしてください。',
learnMore: '詳細はこちら',
},
}

View File

@ -2,25 +2,26 @@ const translation = {
list: {
title: 'ドキュメント',
desc: 'ナレッジのすべてのファイルがここに表示され、ナレッジ全体がDifyの引用やチャットプラグインを介してリンクされるか、インデックス化されることができます。',
learnMore: '詳細はこちら',
addFile: 'ファイルを追加',
addPages: 'ページを追加',
addUrl: 'URLを追加',
table: {
header: {
fileName: 'ファイル名',
chunkingMode: 'チャンキングモード',
words: '単語数',
hitCount: '検索回数',
uploadTime: 'アップロード時間',
status: 'ステータス',
action: 'アクション',
chunkingMode: 'チャンクモード',
},
rename: '名前を変更',
name: '名前',
},
action: {
uploadFile: '新しいファイルをアップロード',
settings: 'セグメント設定',
settings: 'チャンク設定',
addButton: 'チャンクを追加',
add: 'チャンクを追加',
batchAdd: '一括追加',
@ -78,7 +79,6 @@ const translation = {
error: 'インポートエラー',
ok: 'OK',
},
learnMore: '詳細情報',
},
metadata: {
title: 'メタデータ',
@ -318,29 +318,46 @@ const translation = {
completed: '埋め込みが完了しました',
error: '埋め込みエラー',
docName: 'ドキュメントの前処理',
mode: 'セグメンテーションルール',
segmentLength: 'チャンクの長さ',
textCleaning: 'テキストの前処理',
mode: 'チャンキングモード',
segmentLength: '最大なチャンクの長さ',
textCleaning: 'テキストの前処理ルール',
segments: '段落',
highQuality: '高品質モード',
economy: '経済モード',
estimate: '推定消費量',
stop: '処理を停止',
resume: '処理を再開',
pause: '処理を一時停止',
resume: '再開',
automatic: '自動',
custom: 'カスタム',
hierarchical: '親子チャンキング',
previewTip: '埋め込みが完了した後、段落のプレビューが利用可能になります',
parentMaxTokens: '親',
hierarchical: '親子',
pause: '休止',
childMaxTokens: '子供',
childMaxTokens: '子',
},
segment: {
paragraphs: '段落',
chunks_one: 'チャンク',
chunks_other: 'チャンク',
parentChunks_one: '親チャンク',
parentChunks_other: '親チャンク',
childChunks_one: '子チャンク',
childChunks_other: '子チャンク',
searchResults_zero: '検索結果',
searchResults_one: '検索結果',
searchResults_other: '検索結果',
empty: 'チャンクが見つかりません',
clearFilter: 'フィルターをクリア',
chunk: 'チャンク',
parentChunk: '親チャンク',
newChunk: '新しいチャンク',
childChunk: '子チャンク',
newChildChunk: '新しい子チャンク',
keywords: 'キーワード',
addKeyWord: 'キーワードを追加',
keywordError: 'キーワードの最大長は20です',
characters: '文字',
characters_one: '文字',
characters_other: '文字',
hitCount: '検索回数',
vectorHash: 'ベクトルハッシュ: ',
questionPlaceholder: 'ここに質問を追加',
@ -349,46 +366,28 @@ const translation = {
answerEmpty: '回答は空にできません',
contentPlaceholder: 'ここに内容を追加',
contentEmpty: '内容は空にできません',
newTextSegment: '新しいテキストセグメント',
newQaSegment: '新しいQ&Aセグメント',
delete: 'このチャンクを削除しますか?',
searchResults_other: '業績',
edited: '編集',
parentChunk: '親チャンク',
regeneratingTitle: '子チャンクの再生成',
collapseChunks: 'チャンクの折りたたみ',
characters_other: '文字',
childChunk: '子チャンク',
regenerationSuccessMessage: 'このウィンドウは閉じることができます。',
editChildChunk: '子チャンクの編集',
clearFilter: 'フィルターをクリア',
chunkDetail: 'チャンクの詳細',
regenerationSuccessTitle: '再生完了',
parentChunks_one: '親チャンク',
newChunk: '新しいチャンク',
childChunks_other: '子チャンク',
searchResults_zero: '結果',
addChildChunk: '子チャンクを追加',
searchResults_one: '結果',
regeneratingMessage: 'これには少し時間がかかる場合がありますので、お待ちください...',
empty: 'チャンクが見つかりません',
editedAt: 'で編集',
addAnother: '別のものを追加',
chunkAdded: '1チャンク追加',
childChunks_one: '子チャンク',
regenerationConfirmMessage: '子チャンクを再生成すると、編集されたチャンクや新しく追加されたチャンクなど、現在の子チャンクが上書きされます。再生は元に戻せません。',
newChildChunk: '新しい子チャンク',
childChunkAdded: '子チャンクが1つ追加',
regenerationConfirmTitle: '子チャンクを再生成しますか?',
expandChunks: 'チャンクの展開',
chunks_one: 'チャンク',
editChunk: 'チャンクの編集',
editParentChunk: '親チャンクの編集',
parentChunks_other: '親チャンク',
characters_one: '文字',
chunks_other: 'チャンク',
newTextSegment: '新しいテキストチャンク',
newQaSegment: '新しいQ&Aチャンク',
addChunk: 'チャンクを追加',
chunk: 'チャンク',
addChildChunk: '子チャンクを追加',
addAnother: '続けて追加',
delete: 'このチャンクを削除しますか?',
chunkAdded: 'チャンクを追加しました',
childChunkAdded: '子チャンクを追加しました',
editChunk: 'チャンクを編集',
editParentChunk: '親チャンクを編集',
editChildChunk: '子チャンクを編集',
chunkDetail: 'チャンクの詳細',
regenerationConfirmTitle: '子チャンクを再生成しますか?',
regenerationConfirmMessage: '再生成された子チャンクは、編集済みまたは新規追加の子チャンクを含め、現在の子チャンクを上書きします。この操作は取り消せません。',
regeneratingTitle: '子チャンクを生成中',
regeneratingMessage: '子チャンクの生成には時間がかかります、しばらくお待ちください。',
regenerationSuccessTitle: '子チャンクの再生成が完了しました',
regenerationSuccessMessage: 'ウィンドウを閉じても大丈夫です',
edited: '編集済み',
editedAt: '編集日時',
expandChunks: 'チャンクを展開',
collapseChunks: 'チャンクを折りたたむ',
},
}

View File

@ -2,7 +2,7 @@ const translation = {
title: '検索テスト',
desc: '与えられたクエリテキストに基づいたナレッジのヒット効果をテストします。',
dateTimeFormat: 'MM/DD/YYYY hh:mm A',
recents: '最近の結果',
records: '記録',
table: {
header: {
source: 'ソース',
@ -18,18 +18,17 @@ const translation = {
testing: 'テスト中',
},
hit: {
title: '検索結果パラグラフ',
title: '取得したチャンク{{num}}個',
emptyTip: '検索テストの結果がここに表示されます。',
},
noRecentTip: '最近のクエリ結果はありません。',
viewChart: 'ベクトルチャートを表示',
settingTitle: '取得設定',
viewDetail: '詳細を表示',
records: '誌',
hitChunks: '{{num}}子チャンクをヒット',
open: '開ける',
keyword: 'キーワード',
chunkDetail: 'チャンクの詳細',
hitChunks: '{{num}}個の子チャンクをヒット',
open: '開く',
keyword: 'キーワード',
}
export default translation

View File

@ -7,7 +7,8 @@ const translation = {
nameError: '名前は空にできません',
desc: 'ナレッジの説明',
descInfo: 'ナレッジの内容を概説するための明確なテキストの説明を書いてください。この説明は、複数のナレッジから推論を選択する際の基準として使用されます。',
descPlaceholder: 'このナレッジに含まれる内容を説明してください。詳細な説明は、AIがナレッジの内容にタイムリーにアクセスできるようにします。空の場合、Difyはデフォルトのヒット戦略を使用します。',
descPlaceholder: 'このデータセットの内容を記述してください。詳細に記述することで、AIがデータセットの内容に迅速にアクセスできるようになります。空欄の場合、LangGeniusはデフォルトの検索方法を使用します。',
helpText: '適切なデータセットの説明を作成する方法を学びましょう。',
descWrite: '良いナレッジの説明の書き方を学ぶ。',
permissions: '権限',
permissionsOnlyMe: '自分のみ',
@ -16,15 +17,16 @@ const translation = {
me: '(あなた様)',
indexMethod: 'インデックス方法',
indexMethodHighQuality: '高品質',
indexMethodHighQualityTip: 'ユーザーがクエリを実行する際により高い精度を提供するために、Embeddingモデルを呼び出して処理を行う。',
indexMethodHighQualityTip: 'より正確な検索のため、埋め込みモデルを呼び出してドキュメントを処理することで、LLMは高品質な回答を生成できます。',
upgradeHighQualityTip: '高品質モードにアップグレードすると、経済的モードには戻せません。',
indexMethodEconomy: '経済的',
indexMethodEconomyTip: 'オフラインのベクトルエンジン、キーワードインデックスなどを使用して精度を低下させることなく、トークンを消費せずに処理します。',
indexMethodEconomyTip: 'チャンクあたり10個のキーワードを検索に使用します。トークンは消費しませんが、検索精度は低下します。',
embeddingModel: '埋め込みモデル',
embeddingModelTip: '埋め込みモデルを変更するには、',
embeddingModelTipLink: '設定',
retrievalSetting: {
title: '検索設定',
learnMore: '詳細を見る',
learnMore: '詳細はこちら',
description: ' 検索方法についての詳細',
longDescription: ' 検索方法についての詳細については、いつでもナレッジの設定で変更できます。',
},
@ -32,9 +34,7 @@ const translation = {
externalKnowledgeID: '外部ナレッジID',
retrievalSettings: '取得設定',
externalKnowledgeAPI: '外部ナレッジAPI',
upgradeHighQualityTip: 'ハイクオリティモードにアップグレードすると、エコノミーモードに戻すことはできません',
indexMethodChangeToEconomyDisabledTip: '本社からECOへのダウングレードは対象外です',
helpText: '適切なデータセットの説明を書く方法を学びます。',
indexMethodChangeToEconomyDisabledTip: 'HQからECOへのダウングレードはできません。',
},
}

View File

@ -1,9 +1,74 @@
const translation = {
knowledge: 'ナレッジ',
chunkingMode: {
general: '一般',
parentChild: '親子',
},
parentMode: {
paragraph: '段落',
fullDoc: '全体',
},
externalTag: '外部',
externalAPI: '外部API',
externalAPIPanelTitle: '外部ナレッジ連携API',
externalKnowledgeId: '外部ナレッジID',
externalKnowledgeName: '外部ナレッジ名',
externalKnowledgeDescription: 'ナレッジの説明',
externalKnowledgeIdPlaceholder: 'ナレッジIDを入力',
externalKnowledgeNamePlaceholder: 'ナレッジベース名を入力',
externalKnowledgeDescriptionPlaceholder: 'このナレッジベースの説明(任意)',
learnHowToWriteGoodKnowledgeDescription: '効果的なナレッジの説明の書き方',
externalAPIPanelDescription: '外部ナレッジ連携APIは、Dify外のナレッジベースと連携し、そこからナレッジを取得するために使用します。',
externalAPIPanelDocumentation: '外部ナレッジ連携APIの作成方法',
localDocs: 'ローカルドキュメント',
documentCount: ' ドキュメント',
wordCount: ' k 単語',
appCount: ' リンクされたアプリ',
createDataset: 'ナレッジを作成',
createNewExternalAPI: '新しい外部ナレッジ連携APIを作成',
noExternalKnowledge: '外部ナレッジ連携APIがありません。ここをクリックして作成してください',
createExternalAPI: '外部ナレッジ連携APIを追加',
editExternalAPIFormTitle: '外部ナレッジ連携APIを編集',
editExternalAPITooltipTitle: '連携中のナレッジ',
editExternalAPIConfirmWarningContent: {
front: 'この外部ナレッジ連携APIは',
end: '件の外部ナレッジと連携しており、この変更はすべてに適用されます。変更を保存しますか?',
},
editExternalAPIFormWarning: {
front: 'この外部APIは',
end: '件の外部ナレッジと連携しています',
},
deleteExternalAPIConfirmWarningContent: {
title: {
front: '削除',
end: 'しますか?',
},
content: {
front: 'この外部ナレッジ連携APIは',
end: '件の外部ナレッジと連携しています。このAPIを削除すると、すべて無効になります。このAPIを削除しますか',
},
noConnectionContent: 'このAPIを削除しますか',
},
selectExternalKnowledgeAPI: {
placeholder: '外部ナレッジ連携APIを選択',
},
connectDataset: '外部ナレッジベースと連携',
connectDatasetIntro: {
title: '外部ナレッジベースとの連携方法',
content: {
front: '外部ナレッジベースと連携するには、まず外部APIを作成する必要があります。以下の手順を参照し、',
link: '外部APIの作成方法',
end: 'をご確認ください。次に、対応するナレッジIDを左側のフォームに入力してください。すべての情報が正しければ、連携ボタンをクリックすると、自動的にナレッジベースの検索テストに移動します。',
},
learnMore: '詳細はこちら',
},
connectHelper: {
helper1: 'APIとナレッジベースIDを使って外部ナレッジベースと連携します。現在、',
helper2: '検索機能のみがサポートされています。',
helper3: 'この機能を使用する前に、',
helper4: 'ヘルプドキュメント',
helper5: 'をよくお読みください。',
},
createDatasetIntro: '独自のテキストデータをインポートするか、LLMコンテキストの強化のためにWebhookを介してリアルタイムでデータを書き込むことができます。',
deleteDatasetConfirmTitle: 'このナレッジを削除しますか?',
deleteDatasetConfirmContent:
@ -21,7 +86,23 @@ const translation = {
unavailable: '利用不可',
unavailableTip: '埋め込みモデルが利用できません。デフォルトの埋め込みモデルを設定する必要があります',
datasets: 'ナレッジ',
datasetsApi: 'API',
datasetsApi: 'API ACCESS',
externalKnowledgeForm: {
connect: '連携',
cancel: 'キャンセル',
},
externalAPIForm: {
name: '名前',
endpoint: 'APIエンドポイント',
apiKey: 'APIキー',
save: '保存',
cancel: 'キャンセル',
edit: '編集',
encrypted: {
front: 'APIトークンは',
end: '技術で暗号化され、安全に保存されます。',
},
},
retrieval: {
semantic_search: {
title: 'ベクトル検索',
@ -34,7 +115,7 @@ const translation = {
hybrid_search: {
title: 'ハイブリッド検索',
description: '全文検索とベクトル検索を同時に実行し、ユーザーのクエリに最適なマッチを選択するためにRerank付けを行います。RerankモデルAPIの設定が必要です。',
recommend: 'おすすめ',
recommend: '推奨',
},
invertedIndex: {
title: '転置インデックス',
@ -43,8 +124,10 @@ const translation = {
change: '変更',
changeRetrievalMethod: '検索方法の変更',
},
docsFailedNotice: 'ドキュメントのインデックスに失敗しました',
docsFailedNotice: 'ドキュメントのインデックス作成に失敗しました',
retry: '再試行',
documentsDisabled: '{{num}}件のドキュメントが無効 - 30日以上非アクティブ',
enable: '有効化',
indexingTechnique: {
high_quality: '高品質',
economy: '経済',
@ -55,8 +138,11 @@ const translation = {
hybrid_search: 'ハイブリッド検索',
invertedIndex: '転置',
},
mixtureHighQualityAndEconomicTip: '高品質なナレッジベースと経済的なナレッジベースを混在させるには、Rerankモデルを構成する必要がある。',
inconsistentEmbeddingModelTip: '選択されたナレッジベースが一貫性のない埋め込みモデルで構成されている場合、Rerankモデルの構成が必要です。',
defaultRetrievalTip: 'デフォルトでは、マルチパス検索が使用されます。複数のナレッジベースから情報を取得した後、再ランキングを行います。',
mixtureHighQualityAndEconomicTip: '高品質なナレッジベースとコスト重視のナレッジベースを混在させるには、Rerankモデルが必要です。',
inconsistentEmbeddingModelTip: '選択されたナレッジベースの埋め込みモデルに一貫性がない場合、Rerankモデルが必要です。',
mixtureInternalAndExternalTip: '内部ナレッジと外部ナレッジを混在させるには、Rerankモデルが必要です。',
allExternalTip: '外部ナレッジのみを使用する場合、Rerankモデルを有効にするかを選択できます。有効にしない場合、検索結果はスコアに基づいてソートされます。異なるナレッジベースで検索戦略が一貫していないと、結果が不正確になる可能性があります。',
retrievalSettings: '検索設定',
rerankSettings: 'Rerank設定',
weightedScore: {
@ -69,103 +155,17 @@ const translation = {
keyword: 'キーワード',
},
nTo1RetrievalLegacy: '製品計画によると、N-to-1 Retrievalは9月に正式に廃止される予定です。それまでは通常通り使用できます。',
nTo1RetrievalLegacyLink: '詳細を見る',
nTo1RetrievalLegacyLink: '詳細はこちら',
nTo1RetrievalLegacyLinkText: ' N-to-1 retrievalは9月に正式に廃止されます。',
defaultRetrievalTip: 'デフォルトでは、マルチパス取得が使用されます。ナレッジは複数のナレッジ ベースから取得され、再ランク付けされます。',
editExternalAPIConfirmWarningContent: {
front: 'この外部ナレッジAPIは、',
end: '外部の知識、そしてこの変更はそれらすべてに適用されます。この変更を保存してもよろしいですか?',
},
editExternalAPIFormWarning: {
end: '外部の知識',
front: 'この外部APIはにリンクされています',
},
deleteExternalAPIConfirmWarningContent: {
title: {
end: '?',
front: '削除',
},
content: {
front: 'この外部ナレッジAPIは、',
end: '外部の知識。このAPIを削除すると、それらすべてが無効になります。この API を削除してもよろしいですか ?',
},
noConnectionContent: 'この API を削除してもよろしいですか ?',
},
selectExternalKnowledgeAPI: {
placeholder: '外部ナレッジ API を選択する',
},
connectDatasetIntro: {
content: {
link: '外部 API の作成方法を学ぶ',
front: '外部ナレッジ ベースに接続するには、まず外部 API を作成する必要があります。よくお読みになり、以下を参照してください。',
end: '.次に、対応するナレッジIDを見つけて、左側のフォームに入力します。すべての情報が正しい場合は、接続ボタンをクリックした後、ナレッジベースの検索テストに自動的にジャンプします。',
},
title: '外部ナレッジベースに接続する方法',
learnMore: '詳細を見る',
},
connectHelper: {
helper2: '取得機能のみがサポートされています',
helper3: '.次のことを強くお勧めします。',
helper4: 'ヘルプドキュメントを読む',
helper5: 'この機能を使用する前に慎重に。',
helper1: 'APIとナレッジベースIDを介して外部ナレッジベースに接続します。',
},
externalKnowledgeForm: {
cancel: 'キャンセル',
connect: '繋ぐ',
},
externalAPIForm: {
encrypted: {
front: 'APIトークンは暗号化され、',
end: 'テクノロジー。',
},
apiKey: 'APIキー',
name: '名前',
edit: '編集',
save: 'セーブ',
cancel: 'キャンセル',
endpoint: 'API エンドポイント',
},
externalTag: '外',
editExternalAPITooltipTitle: 'リンクされた知識',
externalKnowledgeName: '外部ナレッジ名',
externalAPI: '外部 API',
externalAPIPanelDocumentation: 'External Knowledge API の作成方法を学ぶ',
editExternalAPIFormTitle: '外部ナレッジ API の編集',
externalAPIPanelTitle: '外部ナレッジAPI',
externalKnowledgeId: '外部ナレッジID',
connectDataset: '外部ナレッジベースへの接続',
externalKnowledgeIdPlaceholder: 'ナレッジIDを入力してください',
createNewExternalAPI: '新しい外部ナレッジ API を作成する',
noExternalKnowledge: 'External Knowledge APIはまだありませんので、こちらをクリックして作成してください',
mixtureInternalAndExternalTip: '再ランク付けモデルは、内部知識と外部知識の混合に必要です。',
learnHowToWriteGoodKnowledgeDescription: '良い知識の説明を書く方法を学ぶ',
externalKnowledgeNamePlaceholder: 'ナレッジベースの名前を入力してください',
externalKnowledgeDescription: 'ナレッジの説明',
createExternalAPI: '外部ナレッジ API を追加する',
externalKnowledgeDescriptionPlaceholder: 'このナレッジベースの内容を説明する(オプション)',
allExternalTip: '外部ナレッジのみを使用する場合、ユーザーは Rerank モデルを有効にするかどうかを選択できます。有効にしない場合、取得されたチャンクはスコアに基づいて並べ替えられます。異なるナレッジベースの検索戦略に一貫性がない場合、不正確になります。',
externalAPIPanelDescription: '外部ナレッジAPIは、Difyの外部のナレッジベースに接続し、そのナレッジベースからナレッジを取得するために使用されます。',
chunkingMode: {
general: '全般',
parentChild: '親子',
},
parentMode: {
fullDoc: 'フルドキュメント',
paragraph: '段落',
},
batchAction: {
delete: '削除',
selected: '入選',
archive: 'アーカイブ',
enable: 'エネーブル',
selected: '選択済み',
enable: '有効にする',
disable: '無効にする',
archive: 'アーカイブ',
delete: '削除',
cancel: 'キャンセル',
},
documentsDisabled: '{{num}}ドキュメントが無効 - 30日以上非アクティブ',
localDocs: 'ローカルドキュメント',
enable: 'エネーブル',
preprocessDocument: '{{数値}}ドキュメントの前処理',
preprocessDocument: '{{num}}件のドキュメントを前処理',
}
export default translation

View File

@ -18,7 +18,7 @@ const translation = {
apps: {
title: 'Difyによるアプリの探索',
description: 'これらのテンプレートアプリを即座に使用するか、テンプレートに基づいて独自のアプリをカスタマイズしてください。',
allCategories: 'おすすめ',
allCategories: '推奨',
},
appCard: {
addToWorkspace: 'ワークスペースに追加',

View File

@ -22,7 +22,7 @@ const translation = {
featuresDescription: 'Webアプリのユーザーエクスペリエンスを強化する',
ImageUploadLegacyTip: '開始フォームでファイルタイプ変数を作成できるようになりました。まもなく、画像アップロード機能のサポートは終了いたします。',
fileUploadTip: '画像アップロード機能がファイルのアップロード機能にアップグレードされました。',
featuresDocLink: '詳細を見る',
featuresDocLink: '詳細はこちら',
debugAndPreview: 'プレビュー',
restart: '再起動',
currentDraft: '現在の下書き',
@ -60,7 +60,7 @@ const translation = {
viewOnly: '表示のみ',
showRunHistory: '実行履歴を表示',
enableJinja: 'Jinjaテンプレートのサポートを有効にする',
learnMore: '詳細を見る',
learnMore: '詳細はこちら',
copy: 'コピー',
duplicate: '複製',
addBlock: 'ブロックを追加',
@ -323,18 +323,18 @@ const translation = {
tip: 'ノードが例外を検出したときにトリガーされる例外処理戦略。',
},
retry: {
retry: 'リトライ',
retry: '再試行',
retryOnFailure: '失敗時の再試行',
maxRetries: '最大再試行回数',
retryInterval: '再試行間隔',
retrying: '再試行。。。',
retryFailed: '再試行に失敗しました',
times: '',
ms: 'さん',
times: '',
ms: 'ms',
retryTimes: '失敗時に{{times}}回再試行',
retrySuccessful: '再試行に成功しました',
retries: '{{num}} 回の再試行',
retryFailedTimes: '{{times}}回のリトライが失敗しました',
retryFailedTimes: '{{times}}回の再試行が失敗しました',
},
},
start: {
@ -669,7 +669,7 @@ const translation = {
text: '抽出されたテキスト',
},
inputVar: '入力変数',
learnMore: '詳細を見る',
learnMore: '詳細はこちら',
supportFileTypes: 'サポートするファイルタイプ: {{types}}。',
},
listFilter: {

View File

@ -47,6 +47,8 @@ const translation = {
view: '查看',
viewMore: '查看更多',
regenerate: '重新生成',
submit: '提交',
skip: '跳过',
},
errorMsg: {
fieldRequired: '{{field}} 为必填项',
@ -181,8 +183,19 @@ const translation = {
editName: '编辑名字',
showAppLength: '显示 {{length}} 个应用',
delete: '删除账户',
deleteTip: '删除账户后,所有数据将被永久删除且不可恢复。',
deleteConfirmTip: '请将以下内容通过您的账户邮箱发送到 ',
deleteTip: '请注意,一旦确认,作为任何空间的所有者,您的空间将被安排进入永久删除队列,您的所有用户数据也将被排入永久删除队列。',
deletePrivacyLinkTip: '有关我们如何处理您的数据的更多信息,请参阅我们的',
deletePrivacyLink: '隐私政策',
deleteSuccessTip: '删除账户需要一些时间。完成后,我们会通过邮件通知您。',
deleteLabel: '请输入您的邮箱以确认',
deletePlaceholder: '输入您的邮箱...',
sendVerificationButton: '发送验证码',
verificationLabel: '验证码',
verificationPlaceholder: '输入 6 位数字验证码',
permanentlyDeleteButton: '永久删除',
feedbackTitle: '反馈',
feedbackLabel: '请告诉我们您为什么删除账户?',
feedbackPlaceholder: '选填',
},
members: {
team: '团队',

View File

@ -104,6 +104,8 @@ const translation = {
onFailure: '异常时',
addFailureBranch: '添加异常分支',
openInExplore: '在“探索”中打开',
loadMore: '加载更多',
noHistory: '没有历史版本',
},
env: {
envPanelTitle: '环境变量',

View File

@ -339,3 +339,12 @@ export const sendResetPasswordCode = (email: string, language = 'en-US') =>
export const verifyResetPasswordCode = (body: { email: string;code: string;token: string }) =>
post<CommonResponse & { is_valid: boolean }>('/forgot-password/validity', { body })
export const sendDeleteAccountCode = () =>
get<CommonResponse & { data: string }>('/account/delete/verify')
export const verifyDeleteAccountCode = (body: { code: string;token: string }) =>
post<CommonResponse & { is_valid: boolean }>('/account/delete', { body })
export const submitDeleteAccountFeedback = (body: { feedback: string;email: string }) =>
post<CommonResponse>('/account/delete/feedback', { body })

View File

@ -4,6 +4,7 @@ import type { CommonResponse } from '@/models/common'
import type {
ChatRunHistoryResponse,
ConversationVariableResponse,
FetchWorkflowDraftPageResponse,
FetchWorkflowDraftResponse,
NodesDefaultConfigsResponse,
WorkflowRunHistoryResponse,
@ -14,7 +15,10 @@ export const fetchWorkflowDraft = (url: string) => {
return get(url, {}, { silent: true }) as Promise<FetchWorkflowDraftResponse>
}
export const syncWorkflowDraft = ({ url, params }: { url: string; params: Pick<FetchWorkflowDraftResponse, 'graph' | 'features' | 'environment_variables' | 'conversation_variables'> }) => {
export const syncWorkflowDraft = ({ url, params }: {
url: string
params: Pick<FetchWorkflowDraftResponse, 'graph' | 'features' | 'environment_variables' | 'conversation_variables'>
}) => {
return post<CommonResponse & { updated_at: number; hash: string }>(url, { body: params }, { silent: true })
}
@ -46,6 +50,10 @@ export const fetchPublishedWorkflow: Fetcher<FetchWorkflowDraftResponse, string>
return get<FetchWorkflowDraftResponse>(url)
}
export const fetchPublishedAllWorkflow: Fetcher<FetchWorkflowDraftPageResponse, string> = (url) => {
return get<FetchWorkflowDraftPageResponse>(url)
}
export const stopWorkflowRun = (url: string) => {
return post<CommonResponse>(url)
}
@ -61,6 +69,9 @@ export const updateWorkflowDraftFromDSL = (appId: string, data: string) => {
return post<FetchWorkflowDraftResponse>(`apps/${appId}/workflows/draft/import`, { body: { data } })
}
export const fetchCurrentValueOfConversationVariable: Fetcher<ConversationVariableResponse, { url: string; params: { conversation_id: string } }> = ({ url, params }) => {
export const fetchCurrentValueOfConversationVariable: Fetcher<ConversationVariableResponse, {
url: string
params: { conversation_id: string }
}> = ({ url, params }) => {
return get<ConversationVariableResponse>(url, { params })
}

View File

@ -1,11 +1,5 @@
import type { Viewport } from 'reactflow'
import type {
BlockEnum,
ConversationVariable,
Edge,
EnvironmentVariable,
Node,
} from '@/app/components/workflow/types'
import type { BlockEnum, ConversationVariable, Edge, EnvironmentVariable, Node } from '@/app/components/workflow/types'
import type { TransferMethod } from '@/types/app'
import type { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
@ -79,6 +73,15 @@ export type FetchWorkflowDraftResponse = {
tool_published: boolean
environment_variables?: EnvironmentVariable[]
conversation_variables?: ConversationVariable[]
version: string
}
export type VersionHistory = FetchWorkflowDraftResponse
export type FetchWorkflowDraftPageResponse = {
items: VersionHistory[]
has_more: boolean
page: number
}
export type NodeTracingListResponse = {

View File

@ -1,11 +1,16 @@
import { MAX_VAR_KEY_LENGTH, VAR_ITEM_TEMPLATE, VAR_ITEM_TEMPLATE_IN_WORKFLOW, getMaxVarNameLength } from '@/config'
import { CONTEXT_PLACEHOLDER_TEXT, HISTORY_PLACEHOLDER_TEXT, PRE_PROMPT_PLACEHOLDER_TEXT, QUERY_PLACEHOLDER_TEXT } from '@/app/components/base/prompt-editor/constants'
import {
CONTEXT_PLACEHOLDER_TEXT,
HISTORY_PLACEHOLDER_TEXT,
PRE_PROMPT_PLACEHOLDER_TEXT,
QUERY_PLACEHOLDER_TEXT,
} from '@/app/components/base/prompt-editor/constants'
import { InputVarType } from '@/app/components/workflow/types'
const otherAllowedRegex = /^[a-zA-Z0-9_]+$/
export const getNewVar = (key: string, type: string) => {
const { max_length, ...rest } = VAR_ITEM_TEMPLATE
const { ...rest } = VAR_ITEM_TEMPLATE
if (type !== 'string') {
return {
...rest,