merge main

This commit is contained in:
zxhlyh 2024-12-31 14:12:35 +08:00
commit 7011a5029e
214 changed files with 3627 additions and 831 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,18 @@ if is_db_command():
app = create_migrations_app()
else:
from app_factory import create_app
from libs import threadings_utils
if os.environ.get("FLASK_DEBUG", "False") != "True":
from gevent import monkey # type: ignore
threadings_utils.apply_gevent_threading_patch()
# gevent
monkey.patch_all()
from grpc.experimental import gevent as grpc_gevent # type: ignore
# grpc gevent
grpc_gevent.init_gevent()
from app_factory import create_app
app = create_app()
celery = app.extensions["celery"]

View File

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

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
@ -440,6 +440,31 @@ class WorkflowConfigApi(Resource):
}
class PublishedAllWorkflowApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
@marshal_with(workflow_pagination_fields)
def get(self, app_model: App):
"""
Get published workflows
"""
if not current_user.is_editor:
raise Forbidden()
parser = reqparse.RequestParser()
parser.add_argument("page", type=inputs.int_range(1, 99999), required=False, default=1, location="args")
parser.add_argument("limit", type=inputs.int_range(1, 100), required=False, default=20, location="args")
args = parser.parse_args()
page = args.get("page")
limit = args.get("limit")
workflow_service = WorkflowService()
workflows, has_more = workflow_service.get_all_published_workflow(app_model=app_model, page=page, limit=limit)
return {"items": workflows, "page": page, "limit": limit, "has_more": has_more}
api.add_resource(DraftWorkflowApi, "/apps/<uuid:app_id>/workflows/draft")
api.add_resource(WorkflowConfigApi, "/apps/<uuid:app_id>/workflows/draft/config")
api.add_resource(AdvancedChatDraftWorkflowRunApi, "/apps/<uuid:app_id>/advanced-chat/workflows/draft/run")
@ -454,6 +479,7 @@ api.add_resource(
WorkflowDraftRunIterationNodeApi, "/apps/<uuid:app_id>/workflows/draft/iteration/nodes/<string:node_id>/run"
)
api.add_resource(PublishedWorkflowApi, "/apps/<uuid:app_id>/workflows/publish")
api.add_resource(PublishedAllWorkflowApi, "/apps/<uuid:app_id>/workflows")
api.add_resource(DefaultBlockConfigsApi, "/apps/<uuid:app_id>/workflows/default-workflow-block-configs")
api.add_resource(
DefaultBlockConfigApi, "/apps/<uuid:app_id>/workflows/default-workflow-block-configs/<string:block_type>"

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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.

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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

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

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,4 +1,3 @@
from collections.abc import Generator
from unittest.mock import MagicMock
import google.generativeai.types.generation_types as generation_config_types # type: ignore

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

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

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

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

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

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

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

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

@ -290,6 +290,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'
@ -48,7 +49,9 @@ const LocaleLayout = ({
>
<BrowserInitor>
<SentryInitor>
<I18nServer>{children}</I18nServer>
<TanstackQueryIniter>
<I18nServer>{children}</I18nServer>
</TanstackQueryIniter>
</SentryInitor>
</BrowserInitor>
</body>

View File

@ -42,6 +42,11 @@ const translation = {
zoomIn: 'Vergrößern',
openInNewTab: 'In neuem Tab öffnen',
copyImage: 'Bild kopieren',
close: 'Schließen',
viewMore: 'MEHR SEHEN',
regenerate: 'Erneuern',
saveAndRegenerate: 'Speichern und Regenerieren von untergeordneten Chunks',
view: 'Ansehen',
},
placeholder: {
input: 'Bitte eingeben',
@ -474,6 +479,7 @@ const translation = {
emptyTip: 'Das Wissen wurde nicht zugeordnet, bitte gehen Sie zur Anwendung oder zum Plug-in, um die Zuordnung abzuschließen.',
viewDoc: 'Dokumentation anzeigen',
relatedApp: 'verbundene Apps',
noRelatedApp: 'Keine verknüpften Apps',
},
voiceInput: {
speaking: 'Sprechen Sie jetzt...',

View File

@ -82,6 +82,7 @@ const translation = {
useSitemapTooltip: 'Folgen Sie der Sitemap, um die Website zu crawlen. Ist dies nicht der Fall, crawlt Jina Reader iterativ basierend auf der Seitenrelevanz, sodass weniger, aber qualitativ hochwertigere Seiten angezeigt werden.',
jinaReaderDoc: 'Erfahre mehr über Jina Reader',
},
cancel: 'Abbrechen',
},
stepTwo: {
segmentation: 'Chunk-Einstellungen',
@ -143,6 +144,28 @@ const translation = {
webpageUnit: 'Seiten',
separatorTip: 'Ein Trennzeichen ist das Zeichen, das zum Trennen von Text verwendet wird. \\n\\n und \\n sind häufig verwendete Trennzeichen zum Trennen von Absätzen und Zeilen. In Kombination mit Kommas (\\n\\n,\\n) werden Absätze nach Zeilen segmentiert, wenn die maximale Blocklänge überschritten wird. Sie können auch spezielle, von Ihnen selbst definierte Trennzeichen verwenden (z. B. ***).',
maxLengthCheck: 'Die maximale Stücklänge sollte weniger als {{limit}} betragen',
switch: 'Schalter',
previewChunk: 'Vorschau Chunk',
highQualityTip: 'Sobald die Einbettung im Modus "Hohe Qualität" abgeschlossen ist, ist es nicht mehr möglich, in den Modus "Wirtschaftlich" zurückzukehren.',
parentChildTip: 'Wenn Sie den Parent-Child-Modus verwenden, wird der Child-Chunk für den Abruf und der Parent-Chunk für den Abruf als Kontext verwendet.',
fullDoc: 'Vollständiges Dokument',
parentChildDelimiterTip: 'Ein Trennzeichen ist das Zeichen, das zum Trennen von Text verwendet wird. \\n\\n wird empfohlen, um das Originaldokument in große übergeordnete Blöcke aufzuteilen. Sie können auch spezielle Trennzeichen verwenden, die Sie selbst definiert haben.',
qaSwitchHighQualityTipContent: 'Derzeit unterstützt nur eine hochwertige Indexmethode das Q&A-Format-Chunking. Möchten Sie in den High-Quality-Modus wechseln?',
childChunkForRetrieval: 'Child-Chunk zum Abrufen',
previewChunkCount: '{{Anzahl}} Geschätzte Chunks',
previewChunkTip: 'Klicken Sie auf die Schaltfläche "Preview Chunk" auf der linken Seite, um die Vorschau zu laden',
qaSwitchHighQualityTipTitle: 'Das Q&A-Format erfordert eine qualitativ hochwertige Indizierungsmethode',
general: 'Allgemein',
generalTip: 'Allgemeiner Text-Chunking-Modus, die abgerufenen und zurückgerufenen Chunks sind gleich.',
notAvailableForQA: 'Nicht verfügbar für Q&A Index',
notAvailableForParentChild: 'Nicht verfügbar für den Parent-Child-Index',
parentChild: 'Eltern-Kind',
parentChunkForContext: 'Parent-chunk für Context',
parentChildChunkDelimiterTip: 'Ein Trennzeichen ist das Zeichen, das zum Trennen von Text verwendet wird. \\n wird empfohlen, um übergeordnete Blöcke in kleine untergeordnete Blöcke aufzuteilen. Sie können auch spezielle Trennzeichen verwenden, die Sie selbst definiert haben.',
useQALanguage: 'Chunk im Q&A-Format in',
paragraph: 'Absatz',
fullDocTip: 'Das gesamte Dokument wird als übergeordneter Block verwendet und direkt abgerufen. Bitte beachten Sie, dass aus Leistungsgründen Texte, die 10000 Token überschreiten, automatisch abgeschnitten werden.',
paragraphTip: 'In diesem Modus wird der Text basierend auf Trennzeichen und der maximalen Blocklänge in Absätze aufgeteilt, wobei der geteilte Text als übergeordneter Block für den Abruf verwendet wird.',
},
stepThree: {
creationTitle: '🎉 Wissen erstellt',
@ -171,6 +194,11 @@ const translation = {
apiKeyPlaceholder: 'API-Schlüssel von jina.ai',
getApiKeyLinkText: 'Holen Sie sich Ihren kostenlosen API-Schlüssel bei jina.ai',
},
otherDataSource: {
learnMore: 'Weitere Informationen',
title: 'Verbinden Sie sich mit anderen Datenquellen?',
description: 'Derzeit verfügt die Wissensdatenbank von Dify nur über begrenzte Datenquellen. Das Beitragen einer Datenquelle zur Dify-Wissensdatenbank ist eine fantastische Möglichkeit, die Flexibilität und Leistungsfähigkeit der Plattform für alle Benutzer zu verbessern. Unser Beitragsleitfaden erleichtert Ihnen den Einstieg. Bitte klicken Sie auf den untenstehenden Link, um mehr zu erfahren.',
},
}
export default translation

View File

@ -12,6 +12,7 @@ const translation = {
uploadTime: 'HOCHLADEZEIT',
status: 'STATUS',
action: 'AKTION',
chunkingMode: 'CHUNKING-MODUS',
},
name: 'Name',
rename: 'Umbenennen',
@ -77,6 +78,7 @@ const translation = {
ok: 'OK',
},
addUrl: 'URL hinzufügen',
learnMore: 'Weitere Informationen',
},
metadata: {
title: 'Metadaten',
@ -328,6 +330,10 @@ const translation = {
automatic: 'Automatisch',
custom: 'Benutzerdefiniert',
previewTip: 'Absatzvorschau ist nach Abschluss der Einbettung verfügbar',
parentMaxTokens: 'Elternteil',
childMaxTokens: 'Kind',
hierarchical: 'Eltern-Kind',
pause: 'Pause',
},
segment: {
paragraphs: 'Absätze',
@ -346,6 +352,43 @@ const translation = {
newTextSegment: 'Neues Textsegment',
newQaSegment: 'Neues Q&A-Segment',
delete: 'Diesen Chunk löschen?',
parentChunks_one: 'ÜBERGEORDNETER CHUNK',
searchResults_other: 'BEFUND',
clearFilter: 'Filter löschen',
chunk: 'Stück',
childChunk: 'Untergeordneter Brocken',
newChildChunk: 'Neuer untergeordneter Block',
chunkDetail: 'Chunk-Detail',
regeneratingMessage: 'Das kann einen Moment dauern, bitte warten...',
searchResults_zero: 'ERGEBNIS',
parentChunks_other: 'ÜBERGEORDNETE BLÖCKE',
editParentChunk: 'Übergeordneter Block bearbeiten',
childChunks_other: 'UNTERGEORDNETE BLÖCKE',
editChunk: 'Chunk bearbeiten',
regenerationSuccessTitle: 'Regeneration abgeschlossen',
parentChunk: 'Übergeordneter Chunk',
childChunkAdded: '1 untergeordneter Block hinzugefügt',
edited: 'BEARBEITETE',
collapseChunks: 'Blöcke reduzieren',
empty: 'Kein Chunk gefunden',
regenerationSuccessMessage: 'Sie können dieses Fenster schließen.',
chunks_other: 'STÜCKE',
regenerationConfirmMessage: 'Beim Regenerieren von untergeordneten Blöcken werden die aktuellen untergeordneten Blöcke überschrieben, einschließlich bearbeiteter und neu hinzugefügter Blöcke. Die Regeneration kann nicht rückgängig gemacht werden.',
childChunks_one: 'UNTERGEORDNETER CHUNK',
characters_other: 'Zeichen',
newChunk: 'Neuer Brocken',
editChildChunk: 'Untergeordneten Block bearbeiten',
chunkAdded: '1 Stück hinzugefügt',
expandChunks: 'Blöcke erweitern',
editedAt: 'Bearbeitet am',
addChunk: 'Block hinzufügen',
addAnother: 'Fügen Sie eine weitere hinzu',
regeneratingTitle: 'Regenerieren von untergeordneten Blöcken',
chunks_one: 'STÜCK',
characters_one: 'Zeichen',
addChildChunk: 'Untergeordneten Block hinzufügen',
regenerationConfirmTitle: 'Möchten Sie untergeordnete Chunks regenerieren?',
searchResults_one: 'ERGEBNIS',
},
}

View File

@ -25,6 +25,11 @@ const translation = {
viewChart: 'VEKTORDIAGRAMM ansehen',
viewDetail: 'Im Detail sehen',
settingTitle: 'Einstellung für den Abruf',
records: 'Aufzeichnungen',
open: 'Offen',
hitChunks: 'Klicken Sie auf {{num}} untergeordnete Chunks',
keyword: 'Schlüsselwörter',
chunkDetail: 'Chunk-Detail',
}
export default translation

View File

@ -32,6 +32,9 @@ const translation = {
externalKnowledgeID: 'ID für externes Wissen',
externalKnowledgeAPI: 'API für externes Wissen',
retrievalSettings: 'Einstellungen für den Abruf',
upgradeHighQualityTip: 'Nach dem Upgrade auf den Modus "Hohe Qualität" ist das Zurücksetzen auf den Modus "Wirtschaftlich" nicht mehr möglich',
helpText: 'Erfahren Sie, wie Sie eine gute Datensatzbeschreibung schreiben.',
indexMethodChangeToEconomyDisabledTip: 'Nicht verfügbar für ein Downgrade von HQ auf ECO',
},
}

View File

@ -146,6 +146,26 @@ const translation = {
mixtureInternalAndExternalTip: 'Das Rerank-Modell ist für die Mischung von internem und externem Wissen erforderlich.',
externalKnowledgeId: 'ID für externes Wissen',
editExternalAPIFormTitle: 'Bearbeiten der API für externes Wissen',
chunkingMode: {
parentChild: 'Eltern-Kind',
general: 'Allgemein',
},
parentMode: {
paragraph: 'Absatz',
fullDoc: 'Vollständiges Dokument',
},
batchAction: {
selected: 'Ausgewählt',
cancel: 'Abbrechen',
archive: 'Archiv',
disable: 'Abschalten',
delete: 'Löschen',
enable: 'Ermöglichen',
},
enable: 'Ermöglichen',
localDocs: 'Lokale Dokumente',
preprocessDocument: '{{num}} Vorverarbeiten von Dokumenten',
documentsDisabled: '{{num}} Dokumente deaktiviert - seit über 30 Tagen inaktiv',
}
export default translation

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