mirror of https://github.com/langgenius/dify.git
Merge branch 'main' into fix/chore-fix
This commit is contained in:
commit
107e44c8fb
|
|
@ -85,11 +85,11 @@ ignore = [
|
|||
]
|
||||
"tests/*" = [
|
||||
"F811", # redefined-while-unused
|
||||
"F401", # unused-import
|
||||
]
|
||||
|
||||
[lint.pyflakes]
|
||||
extend-generics = [
|
||||
allowed-unused-imports = [
|
||||
"_pytest.monkeypatch",
|
||||
"tests.integration_tests",
|
||||
"tests.unit_tests",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ RUN apt-get update \
|
|||
&& echo "deb http://deb.debian.org/debian testing main" > /etc/apt/sources.list \
|
||||
&& apt-get update \
|
||||
# For Security
|
||||
&& apt-get install -y --no-install-recommends expat=2.6.4-1 libldap-2.5-0=2.5.18+dfsg-3+b1 perl=5.40.0-8 libsqlite3-0=3.46.1-1 zlib1g=1:1.3.dfsg+really1.3.1-1+b1 \
|
||||
&& apt-get install -y --no-install-recommends expat=2.6.4-1 libldap-2.5-0=2.5.19+dfsg-1 perl=5.40.0-8 libsqlite3-0=3.46.1-1 zlib1g=1:1.3.dfsg+really1.3.1-1+b1 \
|
||||
# install a chinese font to support the use of tools like matplotlib
|
||||
&& apt-get install -y fonts-noto-cjk \
|
||||
&& apt-get autoremove -y \
|
||||
|
|
|
|||
26
api/app.py
26
api/app.py
|
|
@ -1,12 +1,8 @@
|
|||
from libs import version_utils
|
||||
|
||||
# preparation before creating app
|
||||
version_utils.check_supported_python_version()
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def is_db_command():
|
||||
import sys
|
||||
|
||||
if len(sys.argv) > 1 and sys.argv[0].endswith("flask") and sys.argv[1] == "db":
|
||||
return True
|
||||
return False
|
||||
|
|
@ -18,10 +14,22 @@ if is_db_command():
|
|||
|
||||
app = create_migrations_app()
|
||||
else:
|
||||
from app_factory import create_app
|
||||
from libs import threadings_utils
|
||||
if os.environ.get("FLASK_DEBUG", "False") != "True":
|
||||
from gevent import monkey # type: ignore
|
||||
|
||||
threadings_utils.apply_gevent_threading_patch()
|
||||
# gevent
|
||||
monkey.patch_all()
|
||||
|
||||
from grpc.experimental import gevent as grpc_gevent # type: ignore
|
||||
|
||||
# grpc gevent
|
||||
grpc_gevent.init_gevent()
|
||||
|
||||
import psycogreen.gevent # type: ignore
|
||||
|
||||
psycogreen.gevent.patch_psycopg()
|
||||
|
||||
from app_factory import create_app
|
||||
|
||||
app = create_app()
|
||||
celery = app.extensions["celery"]
|
||||
|
|
|
|||
|
|
@ -823,6 +823,13 @@ class LoginConfig(BaseSettings):
|
|||
)
|
||||
|
||||
|
||||
class AccountConfig(BaseSettings):
|
||||
ACCOUNT_DELETION_TOKEN_EXPIRY_MINUTES: PositiveInt = Field(
|
||||
description="Duration in minutes for which a account deletion token remains valid",
|
||||
default=5,
|
||||
)
|
||||
|
||||
|
||||
class FeatureConfig(
|
||||
# place the configs in alphabet order
|
||||
AppExecutionConfig,
|
||||
|
|
@ -852,6 +859,7 @@ class FeatureConfig(
|
|||
WorkflowNodeExecutionConfig,
|
||||
WorkspaceConfig,
|
||||
LoginConfig,
|
||||
AccountConfig,
|
||||
# hosted services config
|
||||
HostedServiceConfig,
|
||||
CeleryBeatConfig,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import json
|
|||
import logging
|
||||
|
||||
from flask import abort, request
|
||||
from flask_restful import Resource, marshal_with, reqparse # type: ignore
|
||||
from flask_restful import Resource, inputs, marshal_with, reqparse # type: ignore
|
||||
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound
|
||||
|
||||
import services
|
||||
|
|
@ -14,7 +14,7 @@ from controllers.console.wraps import account_initialization_required, setup_req
|
|||
from core.app.apps.base_app_queue_manager import AppQueueManager
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from factories import variable_factory
|
||||
from fields.workflow_fields import workflow_fields
|
||||
from fields.workflow_fields import workflow_fields, workflow_pagination_fields
|
||||
from fields.workflow_run_fields import workflow_run_node_execution_fields
|
||||
from libs import helper
|
||||
from libs.helper import TimestampField, uuid_value
|
||||
|
|
@ -474,6 +474,31 @@ class WorkflowConfigApi(Resource):
|
|||
}
|
||||
|
||||
|
||||
class PublishedAllWorkflowApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
||||
@marshal_with(workflow_pagination_fields)
|
||||
def get(self, app_model: App):
|
||||
"""
|
||||
Get published workflows
|
||||
"""
|
||||
if not current_user.is_editor:
|
||||
raise Forbidden()
|
||||
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument("page", type=inputs.int_range(1, 99999), required=False, default=1, location="args")
|
||||
parser.add_argument("limit", type=inputs.int_range(1, 100), required=False, default=20, location="args")
|
||||
args = parser.parse_args()
|
||||
page = args.get("page")
|
||||
limit = args.get("limit")
|
||||
workflow_service = WorkflowService()
|
||||
workflows, has_more = workflow_service.get_all_published_workflow(app_model=app_model, page=page, limit=limit)
|
||||
|
||||
return {"items": workflows, "page": page, "limit": limit, "has_more": has_more}
|
||||
|
||||
|
||||
api.add_resource(DraftWorkflowApi, "/apps/<uuid:app_id>/workflows/draft")
|
||||
api.add_resource(WorkflowConfigApi, "/apps/<uuid:app_id>/workflows/draft/config")
|
||||
api.add_resource(AdvancedChatDraftWorkflowRunApi, "/apps/<uuid:app_id>/advanced-chat/workflows/draft/run")
|
||||
|
|
@ -488,6 +513,7 @@ api.add_resource(
|
|||
WorkflowDraftRunIterationNodeApi, "/apps/<uuid:app_id>/workflows/draft/iteration/nodes/<string:node_id>/run"
|
||||
)
|
||||
api.add_resource(PublishedWorkflowApi, "/apps/<uuid:app_id>/workflows/publish")
|
||||
api.add_resource(PublishedAllWorkflowApi, "/apps/<uuid:app_id>/workflows")
|
||||
api.add_resource(DefaultBlockConfigsApi, "/apps/<uuid:app_id>/workflows/default-workflow-block-configs")
|
||||
api.add_resource(
|
||||
DefaultBlockConfigApi, "/apps/<uuid:app_id>/workflows/default-workflow-block-configs/<string:block_type>"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -8,13 +8,8 @@ from sqlalchemy.orm import Session
|
|||
|
||||
from constants.languages import languages
|
||||
from controllers.console import api
|
||||
from controllers.console.auth.error import (
|
||||
EmailCodeError,
|
||||
InvalidEmailError,
|
||||
InvalidTokenError,
|
||||
PasswordMismatchError,
|
||||
)
|
||||
from controllers.console.error import AccountNotFound, EmailSendIpLimitError
|
||||
from controllers.console.auth.error import EmailCodeError, InvalidEmailError, InvalidTokenError, PasswordMismatchError
|
||||
from controllers.console.error import AccountInFreezeError, AccountNotFound, EmailSendIpLimitError
|
||||
from controllers.console.wraps import setup_required
|
||||
from events.tenant_event import tenant_was_created
|
||||
from extensions.ext_database import db
|
||||
|
|
@ -22,6 +17,7 @@ from libs.helper import email, extract_remote_ip
|
|||
from libs.password import hash_password, valid_password
|
||||
from models.account import Account
|
||||
from services.account_service import AccountService, TenantService
|
||||
from services.errors.account import AccountRegisterError
|
||||
from services.errors.workspace import WorkSpaceNotAllowedCreateError
|
||||
from services.feature_service import FeatureService
|
||||
|
||||
|
|
@ -133,6 +129,8 @@ class ForgotPasswordResetApi(Resource):
|
|||
)
|
||||
except WorkSpaceNotAllowedCreateError:
|
||||
pass
|
||||
except AccountRegisterError as are:
|
||||
raise AccountInFreezeError()
|
||||
|
||||
return {"result": "success"}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ from libs.oauth import GitHubOAuth, GoogleOAuth, OAuthUserInfo
|
|||
from models import Account
|
||||
from models.account import AccountStatus
|
||||
from services.account_service import AccountService, RegisterService, TenantService
|
||||
from services.errors.account import AccountNotFoundError
|
||||
from services.errors.account import AccountNotFoundError, AccountRegisterError
|
||||
from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkSpaceNotFoundError
|
||||
from services.feature_service import FeatureService
|
||||
|
||||
|
|
@ -101,6 +101,8 @@ class OAuthCallback(Resource):
|
|||
f"{dify_config.CONSOLE_WEB_URL}/signin"
|
||||
"?message=Workspace not found, please contact system admin to invite you to join in a workspace."
|
||||
)
|
||||
except AccountRegisterError as e:
|
||||
return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message={e.description}")
|
||||
|
||||
# Check account status
|
||||
if account.status == AccountStatus.BANNED.value:
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from controllers.console import api
|
|||
from controllers.console.workspace.error import (
|
||||
AccountAlreadyInitedError,
|
||||
CurrentPasswordIncorrectError,
|
||||
InvalidAccountDeletionCodeError,
|
||||
InvalidInvitationCodeError,
|
||||
RepeatPasswordNotMatchError,
|
||||
)
|
||||
|
|
@ -21,6 +22,7 @@ from libs.helper import TimestampField, timezone
|
|||
from libs.login import login_required
|
||||
from models import AccountIntegrate, InvitationCode
|
||||
from services.account_service import AccountService
|
||||
from services.billing_service import BillingService
|
||||
from services.errors.account import CurrentPasswordIncorrectError as ServiceCurrentPasswordIncorrectError
|
||||
|
||||
|
||||
|
|
@ -242,6 +244,54 @@ class AccountIntegrateApi(Resource):
|
|||
return {"data": integrate_data}
|
||||
|
||||
|
||||
class AccountDeleteVerifyApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self):
|
||||
account = current_user
|
||||
|
||||
token, code = AccountService.generate_account_deletion_verification_code(account)
|
||||
AccountService.send_account_deletion_verification_email(account, code)
|
||||
|
||||
return {"result": "success", "data": token}
|
||||
|
||||
|
||||
class AccountDeleteApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def post(self):
|
||||
account = current_user
|
||||
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument("token", type=str, required=True, location="json")
|
||||
parser.add_argument("code", type=str, required=True, location="json")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not AccountService.verify_account_deletion_code(args["token"], args["code"]):
|
||||
raise InvalidAccountDeletionCodeError()
|
||||
|
||||
AccountService.delete_account(account)
|
||||
|
||||
return {"result": "success"}
|
||||
|
||||
|
||||
class AccountDeleteUpdateFeedbackApi(Resource):
|
||||
@setup_required
|
||||
def post(self):
|
||||
account = current_user
|
||||
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument("email", type=str, required=True, location="json")
|
||||
parser.add_argument("feedback", type=str, required=True, location="json")
|
||||
args = parser.parse_args()
|
||||
|
||||
BillingService.update_account_deletion_feedback(args["email"], args["feedback"])
|
||||
|
||||
return {"result": "success"}
|
||||
|
||||
|
||||
# Register API resources
|
||||
api.add_resource(AccountInitApi, "/account/init")
|
||||
api.add_resource(AccountProfileApi, "/account/profile")
|
||||
|
|
@ -252,5 +302,8 @@ api.add_resource(AccountInterfaceThemeApi, "/account/interface-theme")
|
|||
api.add_resource(AccountTimezoneApi, "/account/timezone")
|
||||
api.add_resource(AccountPasswordApi, "/account/password")
|
||||
api.add_resource(AccountIntegrateApi, "/account/integrates")
|
||||
api.add_resource(AccountDeleteVerifyApi, "/account/delete/verify")
|
||||
api.add_resource(AccountDeleteApi, "/account/delete")
|
||||
api.add_resource(AccountDeleteUpdateFeedbackApi, "/account/delete/feedback")
|
||||
# api.add_resource(AccountEmailApi, '/account/email')
|
||||
# api.add_resource(AccountEmailVerifyApi, '/account/email-verify')
|
||||
|
|
|
|||
|
|
@ -35,3 +35,9 @@ class AccountNotInitializedError(BaseHTTPException):
|
|||
error_code = "account_not_initialized"
|
||||
description = "The account has not been initialized yet. Please proceed with the initialization process first."
|
||||
code = 400
|
||||
|
||||
|
||||
class InvalidAccountDeletionCodeError(BaseHTTPException):
|
||||
error_code = "invalid_account_deletion_code"
|
||||
description = "Invalid account deletion code."
|
||||
code = 400
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ class MemberUpdateRoleApi(Resource):
|
|||
return {"code": "invalid-role", "message": "Invalid role"}, 400
|
||||
|
||||
member = db.session.get(Account, str(member_id))
|
||||
if member:
|
||||
if not member:
|
||||
abort(404)
|
||||
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -330,13 +330,13 @@ class BaseAgentRunner(AppRunner):
|
|||
if not updated_agent_thought:
|
||||
raise ValueError("agent thought not found")
|
||||
|
||||
if thought is not None:
|
||||
updated_agent_thought.thought = thought
|
||||
if thought:
|
||||
agent_thought.thought = thought
|
||||
|
||||
if tool_name is not None:
|
||||
updated_agent_thought.tool = tool_name
|
||||
if tool_name:
|
||||
agent_thought.tool = tool_name
|
||||
|
||||
if tool_input is not None:
|
||||
if tool_input:
|
||||
if isinstance(tool_input, dict):
|
||||
try:
|
||||
tool_input = json.dumps(tool_input, ensure_ascii=False)
|
||||
|
|
@ -345,7 +345,7 @@ class BaseAgentRunner(AppRunner):
|
|||
|
||||
updated_agent_thought.tool_input = tool_input
|
||||
|
||||
if observation is not None:
|
||||
if observation:
|
||||
if isinstance(observation, dict):
|
||||
try:
|
||||
observation = json.dumps(observation, ensure_ascii=False)
|
||||
|
|
@ -354,8 +354,8 @@ class BaseAgentRunner(AppRunner):
|
|||
|
||||
updated_agent_thought.observation = observation
|
||||
|
||||
if answer is not None:
|
||||
updated_agent_thought.answer = answer
|
||||
if answer:
|
||||
agent_thought.answer = answer
|
||||
|
||||
if messages_ids is not None and len(messages_ids) > 0:
|
||||
updated_agent_thought.message_files = json.dumps(messages_ids)
|
||||
|
|
|
|||
|
|
@ -276,7 +276,7 @@ class WorkflowCycleManage:
|
|||
self, *, session: Session, workflow_run: WorkflowRun, event: QueueNodeStartedEvent
|
||||
) -> WorkflowNodeExecution:
|
||||
workflow_node_execution = WorkflowNodeExecution()
|
||||
workflow_node_execution.id = event.node_execution_id
|
||||
workflow_node_execution.id = str(uuid4())
|
||||
workflow_node_execution.tenant_id = workflow_run.tenant_id
|
||||
workflow_node_execution.app_id = workflow_run.app_id
|
||||
workflow_node_execution.workflow_id = workflow_run.workflow_id
|
||||
|
|
@ -392,7 +392,7 @@ class WorkflowCycleManage:
|
|||
execution_metadata = json.dumps(merged_metadata)
|
||||
|
||||
workflow_node_execution = WorkflowNodeExecution()
|
||||
workflow_node_execution.id = event.node_execution_id
|
||||
workflow_node_execution.id = str(uuid4())
|
||||
workflow_node_execution.tenant_id = workflow_run.tenant_id
|
||||
workflow_node_execution.app_id = workflow_run.app_id
|
||||
workflow_node_execution.workflow_id = workflow_run.workflow_id
|
||||
|
|
@ -825,7 +825,7 @@ class WorkflowCycleManage:
|
|||
return workflow_run
|
||||
|
||||
def _get_workflow_node_execution(self, session: Session, node_execution_id: str) -> WorkflowNodeExecution:
|
||||
stmt = select(WorkflowNodeExecution).where(WorkflowNodeExecution.id == node_execution_id)
|
||||
stmt = select(WorkflowNodeExecution).where(WorkflowNodeExecution.node_execution_id == node_execution_id)
|
||||
workflow_node_execution = session.scalar(stmt)
|
||||
if not workflow_node_execution:
|
||||
raise WorkflowNodeExecutionNotFoundError(node_execution_id)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -138,17 +138,24 @@ class NotionExtractor(BaseExtractor):
|
|||
block_url = BLOCK_CHILD_URL_TMPL.format(block_id=page_id)
|
||||
while True:
|
||||
query_dict: dict[str, Any] = {} if not start_cursor else {"start_cursor": start_cursor}
|
||||
res = requests.request(
|
||||
"GET",
|
||||
block_url,
|
||||
headers={
|
||||
"Authorization": "Bearer " + self._notion_access_token,
|
||||
"Content-Type": "application/json",
|
||||
"Notion-Version": "2022-06-28",
|
||||
},
|
||||
params=query_dict,
|
||||
)
|
||||
data = res.json()
|
||||
try:
|
||||
res = requests.request(
|
||||
"GET",
|
||||
block_url,
|
||||
headers={
|
||||
"Authorization": "Bearer " + self._notion_access_token,
|
||||
"Content-Type": "application/json",
|
||||
"Notion-Version": "2022-06-28",
|
||||
},
|
||||
params=query_dict,
|
||||
)
|
||||
if res.status_code != 200:
|
||||
raise ValueError(f"Error fetching Notion block data: {res.text}")
|
||||
data = res.json()
|
||||
except requests.RequestException as e:
|
||||
raise ValueError("Error fetching Notion block data") from e
|
||||
if "results" not in data or not isinstance(data["results"], list):
|
||||
raise ValueError("Error fetching Notion block data")
|
||||
for result in data["results"]:
|
||||
result_type = result["type"]
|
||||
result_obj = result[result_type]
|
||||
|
|
|
|||
|
|
@ -31,3 +31,7 @@ class ToolApiSchemaError(ValueError):
|
|||
|
||||
class ToolEngineInvokeError(Exception):
|
||||
meta: ToolInvokeMeta
|
||||
|
||||
def __init__(self, meta, **kwargs):
|
||||
self.meta = meta
|
||||
super().__init__(**kwargs)
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@ import json
|
|||
import logging
|
||||
from collections.abc import Generator
|
||||
from typing import Any, Optional, Union
|
||||
from typing import cast
|
||||
|
||||
from core.file import FILE_MODEL_IDENTITY, File, FileTransferMethod
|
||||
from core.tools.__base.tool import Tool
|
||||
from core.tools.__base.tool_runtime import ToolRuntime
|
||||
from core.tools.entities.tool_entities import ToolEntity, ToolInvokeMessage, ToolParameter, ToolProviderType
|
||||
from extensions.ext_database import db
|
||||
from factories.file_factory import build_from_mapping
|
||||
from models.account import Account
|
||||
from models.model import App, EndUser
|
||||
from models.workflow import Workflow
|
||||
|
|
@ -222,10 +224,18 @@ class WorkflowTool(Tool):
|
|||
if isinstance(value, list):
|
||||
for item in value:
|
||||
if isinstance(item, dict) and item.get("dify_model_identity") == FILE_MODEL_IDENTITY:
|
||||
file = File.model_validate(item)
|
||||
item["tool_file_id"] = item.get("related_id")
|
||||
file = build_from_mapping(
|
||||
mapping=item,
|
||||
tenant_id=str(cast(ToolRuntime, self.runtime).tenant_id),
|
||||
)
|
||||
files.append(file)
|
||||
elif isinstance(value, dict) and value.get("dify_model_identity") == FILE_MODEL_IDENTITY:
|
||||
file = File.model_validate(value)
|
||||
value["tool_file_id"] = value.get("related_id")
|
||||
file = build_from_mapping(
|
||||
mapping=value,
|
||||
tenant_id=str(cast(ToolRuntime, self.runtime).tenant_id),
|
||||
)
|
||||
files.append(file)
|
||||
|
||||
result[key] = value
|
||||
|
|
|
|||
|
|
@ -15,11 +15,11 @@ def handle(sender, **kwargs):
|
|||
|
||||
app_dataset_joins = db.session.query(AppDatasetJoin).filter(AppDatasetJoin.app_id == app.id).all()
|
||||
|
||||
removed_dataset_ids: set[int] = set()
|
||||
removed_dataset_ids: set[str] = set()
|
||||
if not app_dataset_joins:
|
||||
added_dataset_ids = dataset_ids
|
||||
else:
|
||||
old_dataset_ids: set[int] = set()
|
||||
old_dataset_ids: set[str] = set()
|
||||
old_dataset_ids.update(app_dataset_join.dataset_id for app_dataset_join in app_dataset_joins)
|
||||
|
||||
added_dataset_ids = dataset_ids - old_dataset_ids
|
||||
|
|
@ -39,8 +39,8 @@ def handle(sender, **kwargs):
|
|||
db.session.commit()
|
||||
|
||||
|
||||
def get_dataset_ids_from_model_config(app_model_config: AppModelConfig) -> set[int]:
|
||||
dataset_ids: set[int] = set()
|
||||
def get_dataset_ids_from_model_config(app_model_config: AppModelConfig) -> set[str]:
|
||||
dataset_ids: set[str] = set()
|
||||
if not app_model_config:
|
||||
return dataset_ids
|
||||
|
||||
|
|
|
|||
|
|
@ -17,11 +17,11 @@ def handle(sender, **kwargs):
|
|||
dataset_ids = get_dataset_ids_from_workflow(published_workflow)
|
||||
app_dataset_joins = db.session.query(AppDatasetJoin).filter(AppDatasetJoin.app_id == app.id).all()
|
||||
|
||||
removed_dataset_ids: set[int] = set()
|
||||
removed_dataset_ids: set[str] = set()
|
||||
if not app_dataset_joins:
|
||||
added_dataset_ids = dataset_ids
|
||||
else:
|
||||
old_dataset_ids: set[int] = set()
|
||||
old_dataset_ids: set[str] = set()
|
||||
old_dataset_ids.update(app_dataset_join.dataset_id for app_dataset_join in app_dataset_joins)
|
||||
|
||||
added_dataset_ids = dataset_ids - old_dataset_ids
|
||||
|
|
@ -41,8 +41,8 @@ def handle(sender, **kwargs):
|
|||
db.session.commit()
|
||||
|
||||
|
||||
def get_dataset_ids_from_workflow(published_workflow: Workflow) -> set[int]:
|
||||
dataset_ids: set[int] = set()
|
||||
def get_dataset_ids_from_workflow(published_workflow: Workflow) -> set[str]:
|
||||
dataset_ids: set[str] = set()
|
||||
graph = published_workflow.graph_dict
|
||||
if not graph:
|
||||
return dataset_ids
|
||||
|
|
@ -60,7 +60,7 @@ def get_dataset_ids_from_workflow(published_workflow: Workflow) -> set[int]:
|
|||
for node in knowledge_retrieval_nodes:
|
||||
try:
|
||||
node_data = KnowledgeRetrievalNodeData(**node.get("data", {}))
|
||||
dataset_ids.update(int(dataset_id) for dataset_id in node_data.dataset_ids)
|
||||
dataset_ids.update(dataset_id for dataset_id in node_data.dataset_ids)
|
||||
except Exception as e:
|
||||
continue
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ workflow_fields = {
|
|||
"graph": fields.Raw(attribute="graph_dict"),
|
||||
"features": fields.Raw(attribute="features_dict"),
|
||||
"hash": fields.String(attribute="unique_hash"),
|
||||
"version": fields.String(attribute="version"),
|
||||
"created_by": fields.Nested(simple_account_fields, attribute="created_by_account"),
|
||||
"created_at": TimestampField,
|
||||
"updated_by": fields.Nested(simple_account_fields, attribute="updated_by_account", allow_null=True),
|
||||
|
|
@ -61,3 +62,10 @@ workflow_partial_fields = {
|
|||
"updated_by": fields.String,
|
||||
"updated_at": TimestampField,
|
||||
}
|
||||
|
||||
workflow_pagination_fields = {
|
||||
"items": fields.List(fields.Nested(workflow_fields), attribute="items"),
|
||||
"page": fields.Integer,
|
||||
"limit": fields.Integer(attribute="limit"),
|
||||
"has_more": fields.Boolean(attribute="has_more"),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +0,0 @@
|
|||
from configs import dify_config
|
||||
|
||||
|
||||
def apply_gevent_threading_patch():
|
||||
"""
|
||||
Run threading patch by gevent
|
||||
to make standard library threading compatible.
|
||||
Patching should be done as early as possible in the lifecycle of the program.
|
||||
:return:
|
||||
"""
|
||||
if not dify_config.DEBUG:
|
||||
from gevent import monkey # type: ignore
|
||||
from grpc.experimental import gevent as grpc_gevent # type: ignore
|
||||
|
||||
# gevent
|
||||
monkey.patch_all()
|
||||
|
||||
# grpc gevent
|
||||
grpc_gevent.init_gevent()
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import sys
|
||||
|
||||
|
||||
def check_supported_python_version():
|
||||
python_version = sys.version_info
|
||||
if not ((3, 11) <= python_version < (3, 13)):
|
||||
print(
|
||||
"Aborted to launch the service "
|
||||
f" with unsupported Python version {python_version.major}.{python_version.minor}."
|
||||
" Please ensure Python 3.11 or 3.12."
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
|
@ -1,2 +1 @@
|
|||
Single-database configuration for Flask.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "aiofiles"
|
||||
|
|
@ -955,10 +955,6 @@ files = [
|
|||
{file = "Brotli-1.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a37b8f0391212d29b3a91a799c8e4a2855e0576911cdfb2515487e30e322253d"},
|
||||
{file = "Brotli-1.1.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:e84799f09591700a4154154cab9787452925578841a94321d5ee8fb9a9a328f0"},
|
||||
{file = "Brotli-1.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f66b5337fa213f1da0d9000bc8dc0cb5b896b726eefd9c6046f699b169c41b9e"},
|
||||
{file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5dab0844f2cf82be357a0eb11a9087f70c5430b2c241493fc122bb6f2bb0917c"},
|
||||
{file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e4fe605b917c70283db7dfe5ada75e04561479075761a0b3866c081d035b01c1"},
|
||||
{file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:1e9a65b5736232e7a7f91ff3d02277f11d339bf34099a56cdab6a8b3410a02b2"},
|
||||
{file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:58d4b711689366d4a03ac7957ab8c28890415e267f9b6589969e74b6e42225ec"},
|
||||
{file = "Brotli-1.1.0-cp310-cp310-win32.whl", hash = "sha256:be36e3d172dc816333f33520154d708a2657ea63762ec16b62ece02ab5e4daf2"},
|
||||
{file = "Brotli-1.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:0c6244521dda65ea562d5a69b9a26120769b7a9fb3db2fe9545935ed6735b128"},
|
||||
{file = "Brotli-1.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a3daabb76a78f829cafc365531c972016e4aa8d5b4bf60660ad8ecee19df7ccc"},
|
||||
|
|
@ -971,14 +967,8 @@ files = [
|
|||
{file = "Brotli-1.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:19c116e796420b0cee3da1ccec3b764ed2952ccfcc298b55a10e5610ad7885f9"},
|
||||
{file = "Brotli-1.1.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:510b5b1bfbe20e1a7b3baf5fed9e9451873559a976c1a78eebaa3b86c57b4265"},
|
||||
{file = "Brotli-1.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a1fd8a29719ccce974d523580987b7f8229aeace506952fa9ce1d53a033873c8"},
|
||||
{file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c247dd99d39e0338a604f8c2b3bc7061d5c2e9e2ac7ba9cc1be5a69cb6cd832f"},
|
||||
{file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1b2c248cd517c222d89e74669a4adfa5577e06ab68771a529060cf5a156e9757"},
|
||||
{file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:2a24c50840d89ded6c9a8fdc7b6ed3692ed4e86f1c4a4a938e1e92def92933e0"},
|
||||
{file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f31859074d57b4639318523d6ffdca586ace54271a73ad23ad021acd807eb14b"},
|
||||
{file = "Brotli-1.1.0-cp311-cp311-win32.whl", hash = "sha256:39da8adedf6942d76dc3e46653e52df937a3c4d6d18fdc94a7c29d263b1f5b50"},
|
||||
{file = "Brotli-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:aac0411d20e345dc0920bdec5548e438e999ff68d77564d5e9463a7ca9d3e7b1"},
|
||||
{file = "Brotli-1.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:32d95b80260d79926f5fab3c41701dbb818fde1c9da590e77e571eefd14abe28"},
|
||||
{file = "Brotli-1.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b760c65308ff1e462f65d69c12e4ae085cff3b332d894637f6273a12a482d09f"},
|
||||
{file = "Brotli-1.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:316cc9b17edf613ac76b1f1f305d2a748f1b976b033b049a6ecdfd5612c70409"},
|
||||
{file = "Brotli-1.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:caf9ee9a5775f3111642d33b86237b05808dafcd6268faa492250e9b78046eb2"},
|
||||
{file = "Brotli-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:70051525001750221daa10907c77830bc889cb6d865cc0b813d9db7fefc21451"},
|
||||
|
|
@ -989,24 +979,8 @@ files = [
|
|||
{file = "Brotli-1.1.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:4093c631e96fdd49e0377a9c167bfd75b6d0bad2ace734c6eb20b348bc3ea180"},
|
||||
{file = "Brotli-1.1.0-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:7e4c4629ddad63006efa0ef968c8e4751c5868ff0b1c5c40f76524e894c50248"},
|
||||
{file = "Brotli-1.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:861bf317735688269936f755fa136a99d1ed526883859f86e41a5d43c61d8966"},
|
||||
{file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:87a3044c3a35055527ac75e419dfa9f4f3667a1e887ee80360589eb8c90aabb9"},
|
||||
{file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c5529b34c1c9d937168297f2c1fde7ebe9ebdd5e121297ff9c043bdb2ae3d6fb"},
|
||||
{file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ca63e1890ede90b2e4454f9a65135a4d387a4585ff8282bb72964fab893f2111"},
|
||||
{file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e79e6520141d792237c70bcd7a3b122d00f2613769ae0cb61c52e89fd3443839"},
|
||||
{file = "Brotli-1.1.0-cp312-cp312-win32.whl", hash = "sha256:5f4d5ea15c9382135076d2fb28dde923352fe02951e66935a9efaac8f10e81b0"},
|
||||
{file = "Brotli-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:906bc3a79de8c4ae5b86d3d75a8b77e44404b0f4261714306e3ad248d8ab0951"},
|
||||
{file = "Brotli-1.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8bf32b98b75c13ec7cf774164172683d6e7891088f6316e54425fde1efc276d5"},
|
||||
{file = "Brotli-1.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7bc37c4d6b87fb1017ea28c9508b36bbcb0c3d18b4260fcdf08b200c74a6aee8"},
|
||||
{file = "Brotli-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c0ef38c7a7014ffac184db9e04debe495d317cc9c6fb10071f7fefd93100a4f"},
|
||||
{file = "Brotli-1.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91d7cc2a76b5567591d12c01f019dd7afce6ba8cba6571187e21e2fc418ae648"},
|
||||
{file = "Brotli-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a93dde851926f4f2678e704fadeb39e16c35d8baebd5252c9fd94ce8ce68c4a0"},
|
||||
{file = "Brotli-1.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f0db75f47be8b8abc8d9e31bc7aad0547ca26f24a54e6fd10231d623f183d089"},
|
||||
{file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6967ced6730aed543b8673008b5a391c3b1076d834ca438bbd70635c73775368"},
|
||||
{file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7eedaa5d036d9336c95915035fb57422054014ebdeb6f3b42eac809928e40d0c"},
|
||||
{file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d487f5432bf35b60ed625d7e1b448e2dc855422e87469e3f450aa5552b0eb284"},
|
||||
{file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:832436e59afb93e1836081a20f324cb185836c617659b07b129141a8426973c7"},
|
||||
{file = "Brotli-1.1.0-cp313-cp313-win32.whl", hash = "sha256:43395e90523f9c23a3d5bdf004733246fba087f2948f87ab28015f12359ca6a0"},
|
||||
{file = "Brotli-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:9011560a466d2eb3f5a6e4929cf4a09be405c64154e12df0dd72713f6500e32b"},
|
||||
{file = "Brotli-1.1.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:a090ca607cbb6a34b0391776f0cb48062081f5f60ddcce5d11838e67a01928d1"},
|
||||
{file = "Brotli-1.1.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2de9d02f5bda03d27ede52e8cfe7b865b066fa49258cbab568720aa5be80a47d"},
|
||||
{file = "Brotli-1.1.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2333e30a5e00fe0fe55903c8832e08ee9c3b1382aacf4db26664a16528d51b4b"},
|
||||
|
|
@ -1016,10 +990,6 @@ files = [
|
|||
{file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:fd5f17ff8f14003595ab414e45fce13d073e0762394f957182e69035c9f3d7c2"},
|
||||
{file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:069a121ac97412d1fe506da790b3e69f52254b9df4eb665cd42460c837193354"},
|
||||
{file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:e93dfc1a1165e385cc8239fab7c036fb2cd8093728cbd85097b284d7b99249a2"},
|
||||
{file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_2_aarch64.whl", hash = "sha256:aea440a510e14e818e67bfc4027880e2fb500c2ccb20ab21c7a7c8b5b4703d75"},
|
||||
{file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_2_i686.whl", hash = "sha256:6974f52a02321b36847cd19d1b8e381bf39939c21efd6ee2fc13a28b0d99348c"},
|
||||
{file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_2_ppc64le.whl", hash = "sha256:a7e53012d2853a07a4a79c00643832161a910674a893d296c9f1259859a289d2"},
|
||||
{file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:d7702622a8b40c49bffb46e1e3ba2e81268d5c04a34f460978c6b5517a34dd52"},
|
||||
{file = "Brotli-1.1.0-cp36-cp36m-win32.whl", hash = "sha256:a599669fd7c47233438a56936988a2478685e74854088ef5293802123b5b2460"},
|
||||
{file = "Brotli-1.1.0-cp36-cp36m-win_amd64.whl", hash = "sha256:d143fd47fad1db3d7c27a1b1d66162e855b5d50a89666af46e1679c496e8e579"},
|
||||
{file = "Brotli-1.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:11d00ed0a83fa22d29bc6b64ef636c4552ebafcef57154b4ddd132f5638fbd1c"},
|
||||
|
|
@ -1031,10 +1001,6 @@ files = [
|
|||
{file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:919e32f147ae93a09fe064d77d5ebf4e35502a8df75c29fb05788528e330fe74"},
|
||||
{file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:23032ae55523cc7bccb4f6a0bf368cd25ad9bcdcc1990b64a647e7bbcce9cb5b"},
|
||||
{file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:224e57f6eac61cc449f498cc5f0e1725ba2071a3d4f48d5d9dffba42db196438"},
|
||||
{file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:cb1dac1770878ade83f2ccdf7d25e494f05c9165f5246b46a621cc849341dc01"},
|
||||
{file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:3ee8a80d67a4334482d9712b8e83ca6b1d9bc7e351931252ebef5d8f7335a547"},
|
||||
{file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:5e55da2c8724191e5b557f8e18943b1b4839b8efc3ef60d65985bcf6f587dd38"},
|
||||
{file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:d342778ef319e1026af243ed0a07c97acf3bad33b9f29e7ae6a1f68fd083e90c"},
|
||||
{file = "Brotli-1.1.0-cp37-cp37m-win32.whl", hash = "sha256:587ca6d3cef6e4e868102672d3bd9dc9698c309ba56d41c2b9c85bbb903cdb95"},
|
||||
{file = "Brotli-1.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:2954c1c23f81c2eaf0b0717d9380bd348578a94161a65b3a2afc62c86467dd68"},
|
||||
{file = "Brotli-1.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:efa8b278894b14d6da122a72fefcebc28445f2d3f880ac59d46c90f4c13be9a3"},
|
||||
|
|
@ -1047,10 +1013,6 @@ files = [
|
|||
{file = "Brotli-1.1.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1ab4fbee0b2d9098c74f3057b2bc055a8bd92ccf02f65944a241b4349229185a"},
|
||||
{file = "Brotli-1.1.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:141bd4d93984070e097521ed07e2575b46f817d08f9fa42b16b9b5f27b5ac088"},
|
||||
{file = "Brotli-1.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fce1473f3ccc4187f75b4690cfc922628aed4d3dd013d047f95a9b3919a86596"},
|
||||
{file = "Brotli-1.1.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d2b35ca2c7f81d173d2fadc2f4f31e88cc5f7a39ae5b6db5513cf3383b0e0ec7"},
|
||||
{file = "Brotli-1.1.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:af6fa6817889314555aede9a919612b23739395ce767fe7fcbea9a80bf140fe5"},
|
||||
{file = "Brotli-1.1.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:2feb1d960f760a575dbc5ab3b1c00504b24caaf6986e2dc2b01c09c87866a943"},
|
||||
{file = "Brotli-1.1.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:4410f84b33374409552ac9b6903507cdb31cd30d2501fc5ca13d18f73548444a"},
|
||||
{file = "Brotli-1.1.0-cp38-cp38-win32.whl", hash = "sha256:db85ecf4e609a48f4b29055f1e144231b90edc90af7481aa731ba2d059226b1b"},
|
||||
{file = "Brotli-1.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:3d7954194c36e304e1523f55d7042c59dc53ec20dd4e9ea9d151f1b62b4415c0"},
|
||||
{file = "Brotli-1.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5fb2ce4b8045c78ebbc7b8f3c15062e435d47e7393cc57c25115cfd49883747a"},
|
||||
|
|
@ -1063,10 +1025,6 @@ files = [
|
|||
{file = "Brotli-1.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:949f3b7c29912693cee0afcf09acd6ebc04c57af949d9bf77d6101ebb61e388c"},
|
||||
{file = "Brotli-1.1.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:89f4988c7203739d48c6f806f1e87a1d96e0806d44f0fba61dba81392c9e474d"},
|
||||
{file = "Brotli-1.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:de6551e370ef19f8de1807d0a9aa2cdfdce2e85ce88b122fe9f6b2b076837e59"},
|
||||
{file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0737ddb3068957cf1b054899b0883830bb1fec522ec76b1098f9b6e0f02d9419"},
|
||||
{file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:4f3607b129417e111e30637af1b56f24f7a49e64763253bbc275c75fa887d4b2"},
|
||||
{file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:6c6e0c425f22c1c719c42670d561ad682f7bfeeef918edea971a79ac5252437f"},
|
||||
{file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:494994f807ba0b92092a163a0a283961369a65f6cbe01e8891132b7a320e61eb"},
|
||||
{file = "Brotli-1.1.0-cp39-cp39-win32.whl", hash = "sha256:f0d8a7a6b5983c2496e364b969f0e526647a06b075d034f3297dc66f3b360c64"},
|
||||
{file = "Brotli-1.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:cdad5b9014d83ca68c25d2e9444e28e967ef16e80f6b436918c700c117a85467"},
|
||||
{file = "Brotli-1.1.0.tar.gz", hash = "sha256:81de08ac11bcb85841e440c13611c00b67d3bf82698314928d0b676362546724"},
|
||||
|
|
@ -7000,6 +6958,16 @@ files = [
|
|||
dev = ["black", "check-manifest", "coverage", "packaging", "pylint", "pyperf", "pypinfo", "pytest-cov", "requests", "rstcheck", "ruff", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "virtualenv", "wheel"]
|
||||
test = ["pytest", "pytest-xdist", "setuptools"]
|
||||
|
||||
[[package]]
|
||||
name = "psycogreen"
|
||||
version = "1.0.2"
|
||||
description = "psycopg2 integration with coroutine libraries"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
files = [
|
||||
{file = "psycogreen-1.0.2.tar.gz", hash = "sha256:c429845a8a49cf2f76b71265008760bcd7c7c77d80b806db4dc81116dbcd130d"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psycopg2-binary"
|
||||
version = "2.9.10"
|
||||
|
|
@ -11173,4 +11141,4 @@ cffi = ["cffi (>=1.11)"]
|
|||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = ">=3.11,<3.13"
|
||||
content-hash = "f4accd01805cbf080c4c5295f97a06c8e4faec7365d2c43d0435e56b46461732"
|
||||
content-hash = "8b2b1bbc4d9c1d47f126775ea587ee116956df29f500534cb87f512402856e05"
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ openai = "~1.52.0"
|
|||
openpyxl = "~3.1.5"
|
||||
pandas = { version = "~2.2.2", extras = ["performance", "excel"] }
|
||||
pandas-stubs = "~2.2.3.241009"
|
||||
psycogreen = "~1.0.2"
|
||||
psycopg2-binary = "~2.9.6"
|
||||
pycryptodome = "3.19.1"
|
||||
pydantic = "~2.9.2"
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ from models.account import (
|
|||
TenantStatus,
|
||||
)
|
||||
from models.model import DifySetup
|
||||
from services.billing_service import BillingService
|
||||
from services.errors.account import (
|
||||
AccountAlreadyInTenantError,
|
||||
AccountLoginError,
|
||||
|
|
@ -51,6 +52,8 @@ from services.errors.account import (
|
|||
)
|
||||
from services.errors.workspace import WorkSpaceNotAllowedCreateError
|
||||
from services.feature_service import FeatureService
|
||||
from tasks.delete_account_task import delete_account_task
|
||||
from tasks.mail_account_deletion_task import send_account_deletion_verification_code
|
||||
from tasks.mail_email_code_login import send_email_code_login_mail_task
|
||||
from tasks.mail_invite_member_task import send_invite_member_mail_task
|
||||
from tasks.mail_reset_password_task import send_reset_password_mail_task
|
||||
|
|
@ -71,6 +74,9 @@ class AccountService:
|
|||
email_code_login_rate_limiter = RateLimiter(
|
||||
prefix="email_code_login_rate_limit", max_attempts=1, time_window=60 * 1
|
||||
)
|
||||
email_code_account_deletion_rate_limiter = RateLimiter(
|
||||
prefix="email_code_account_deletion_rate_limit", max_attempts=1, time_window=60 * 1
|
||||
)
|
||||
LOGIN_MAX_ERROR_LIMITS = 5
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -202,6 +208,15 @@ class AccountService:
|
|||
from controllers.console.error import AccountNotFound
|
||||
|
||||
raise AccountNotFound()
|
||||
|
||||
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(email):
|
||||
raise AccountRegisterError(
|
||||
description=(
|
||||
"This email account has been deleted within the past "
|
||||
"30 days and is temporarily unavailable for new account registration"
|
||||
)
|
||||
)
|
||||
|
||||
account = Account()
|
||||
account.email = email
|
||||
account.name = name
|
||||
|
|
@ -241,6 +256,42 @@ class AccountService:
|
|||
|
||||
return account
|
||||
|
||||
@staticmethod
|
||||
def generate_account_deletion_verification_code(account: Account) -> tuple[str, str]:
|
||||
code = "".join([str(random.randint(0, 9)) for _ in range(6)])
|
||||
token = TokenManager.generate_token(
|
||||
account=account, token_type="account_deletion", additional_data={"code": code}
|
||||
)
|
||||
return token, code
|
||||
|
||||
@classmethod
|
||||
def send_account_deletion_verification_email(cls, account: Account, code: str):
|
||||
email = account.email
|
||||
if cls.email_code_account_deletion_rate_limiter.is_rate_limited(email):
|
||||
from controllers.console.auth.error import EmailCodeAccountDeletionRateLimitExceededError
|
||||
|
||||
raise EmailCodeAccountDeletionRateLimitExceededError()
|
||||
|
||||
send_account_deletion_verification_code.delay(to=email, code=code)
|
||||
|
||||
cls.email_code_account_deletion_rate_limiter.increment_rate_limit(email)
|
||||
|
||||
@staticmethod
|
||||
def verify_account_deletion_code(token: str, code: str) -> bool:
|
||||
token_data = TokenManager.get_token_data(token, "account_deletion")
|
||||
if token_data is None:
|
||||
return False
|
||||
|
||||
if token_data["code"] != code:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def delete_account(account: Account) -> None:
|
||||
"""Delete account. This method only adds a task to the queue for deletion."""
|
||||
delete_account_task.delay(account.id)
|
||||
|
||||
@staticmethod
|
||||
def link_account_integrate(provider: str, open_id: str, account: Account) -> None:
|
||||
"""Link account integrate"""
|
||||
|
|
@ -380,6 +431,7 @@ class AccountService:
|
|||
def send_email_code_login_email(
|
||||
cls, account: Optional[Account] = None, email: Optional[str] = None, language: Optional[str] = "en-US"
|
||||
):
|
||||
email = account.email if account else email
|
||||
if email is None:
|
||||
raise ValueError("Email must be provided.")
|
||||
if cls.email_code_login_rate_limiter.is_rate_limited(email):
|
||||
|
|
@ -409,6 +461,14 @@ class AccountService:
|
|||
|
||||
@classmethod
|
||||
def get_user_through_email(cls, email: str):
|
||||
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(email):
|
||||
raise AccountRegisterError(
|
||||
description=(
|
||||
"This email account has been deleted within the past "
|
||||
"30 days and is temporarily unavailable for new account registration"
|
||||
)
|
||||
)
|
||||
|
||||
account = db.session.query(Account).filter(Account.email == email).first()
|
||||
if not account:
|
||||
return None
|
||||
|
|
@ -825,6 +885,10 @@ class RegisterService:
|
|||
db.session.commit()
|
||||
except WorkSpaceNotAllowedCreateError:
|
||||
db.session.rollback()
|
||||
except AccountRegisterError as are:
|
||||
db.session.rollback()
|
||||
logging.exception("Register failed")
|
||||
raise are
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logging.exception("Register failed")
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ class AudioService:
|
|||
return Response(stream_with_context(response), content_type="audio/mpeg")
|
||||
return response
|
||||
else:
|
||||
if not text:
|
||||
if text is None:
|
||||
raise ValueError("Text is required")
|
||||
response = invoke_tts(text, app_model, voice)
|
||||
if isinstance(response, Generator):
|
||||
|
|
|
|||
|
|
@ -70,3 +70,24 @@ class BillingService:
|
|||
|
||||
if not TenantAccountRole.is_privileged_role(join.role):
|
||||
raise ValueError("Only team owner or team admin can perform this action")
|
||||
|
||||
@classmethod
|
||||
def delete_account(cls, account_id: str):
|
||||
"""Delete account."""
|
||||
params = {"account_id": account_id}
|
||||
return cls._send_request("DELETE", "/account/", params=params)
|
||||
|
||||
@classmethod
|
||||
def is_email_in_freeze(cls, email: str) -> bool:
|
||||
params = {"email": email}
|
||||
try:
|
||||
response = cls._send_request("GET", "/account/in-freeze", params=params)
|
||||
return bool(response.get("data", False))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def update_account_deletion_feedback(cls, email: str, feedback: str):
|
||||
"""Update account deletion feedback."""
|
||||
json = {"email": email, "feedback": feedback}
|
||||
return cls._send_request("POST", "/account/delete-feedback", json=json)
|
||||
|
|
|
|||
|
|
@ -86,25 +86,30 @@ class DatasetService:
|
|||
else:
|
||||
return [], 0
|
||||
else:
|
||||
# show all datasets that the user has permission to access
|
||||
if permitted_dataset_ids:
|
||||
query = query.filter(
|
||||
db.or_(
|
||||
Dataset.permission == DatasetPermissionEnum.ALL_TEAM,
|
||||
db.and_(Dataset.permission == DatasetPermissionEnum.ONLY_ME, Dataset.created_by == user.id),
|
||||
db.and_(
|
||||
Dataset.permission == DatasetPermissionEnum.PARTIAL_TEAM,
|
||||
Dataset.id.in_(permitted_dataset_ids),
|
||||
),
|
||||
if user.current_role not in (TenantAccountRole.OWNER, TenantAccountRole.ADMIN):
|
||||
# show all datasets that the user has permission to access
|
||||
if permitted_dataset_ids:
|
||||
query = query.filter(
|
||||
db.or_(
|
||||
Dataset.permission == DatasetPermissionEnum.ALL_TEAM,
|
||||
db.and_(
|
||||
Dataset.permission == DatasetPermissionEnum.ONLY_ME, Dataset.created_by == user.id
|
||||
),
|
||||
db.and_(
|
||||
Dataset.permission == DatasetPermissionEnum.PARTIAL_TEAM,
|
||||
Dataset.id.in_(permitted_dataset_ids),
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
else:
|
||||
query = query.filter(
|
||||
db.or_(
|
||||
Dataset.permission == DatasetPermissionEnum.ALL_TEAM,
|
||||
db.and_(Dataset.permission == DatasetPermissionEnum.ONLY_ME, Dataset.created_by == user.id),
|
||||
else:
|
||||
query = query.filter(
|
||||
db.or_(
|
||||
Dataset.permission == DatasetPermissionEnum.ALL_TEAM,
|
||||
db.and_(
|
||||
Dataset.permission == DatasetPermissionEnum.ONLY_ME, Dataset.created_by == user.id
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
else:
|
||||
# if no user, only show datasets that are shared with all team members
|
||||
query = query.filter(Dataset.permission == DatasetPermissionEnum.ALL_TEAM)
|
||||
|
|
@ -377,14 +382,19 @@ class DatasetService:
|
|||
if dataset.tenant_id != user.current_tenant_id:
|
||||
logging.debug(f"User {user.id} does not have permission to access dataset {dataset.id}")
|
||||
raise NoPermissionError("You do not have permission to access this dataset.")
|
||||
if dataset.permission == DatasetPermissionEnum.ONLY_ME and dataset.created_by != user.id:
|
||||
logging.debug(f"User {user.id} does not have permission to access dataset {dataset.id}")
|
||||
raise NoPermissionError("You do not have permission to access this dataset.")
|
||||
if dataset.permission == "partial_members":
|
||||
user_permission = DatasetPermission.query.filter_by(dataset_id=dataset.id, account_id=user.id).first()
|
||||
if not user_permission and dataset.tenant_id != user.current_tenant_id and dataset.created_by != user.id:
|
||||
if user.current_role not in (TenantAccountRole.OWNER, TenantAccountRole.ADMIN):
|
||||
if dataset.permission == DatasetPermissionEnum.ONLY_ME and dataset.created_by != user.id:
|
||||
logging.debug(f"User {user.id} does not have permission to access dataset {dataset.id}")
|
||||
raise NoPermissionError("You do not have permission to access this dataset.")
|
||||
if dataset.permission == "partial_members":
|
||||
user_permission = DatasetPermission.query.filter_by(dataset_id=dataset.id, account_id=user.id).first()
|
||||
if (
|
||||
not user_permission
|
||||
and dataset.tenant_id != user.current_tenant_id
|
||||
and dataset.created_by != user.id
|
||||
):
|
||||
logging.debug(f"User {user.id} does not have permission to access dataset {dataset.id}")
|
||||
raise NoPermissionError("You do not have permission to access this dataset.")
|
||||
|
||||
@staticmethod
|
||||
def check_dataset_operator_permission(user: Optional[Account] = None, dataset: Optional[Dataset] = None):
|
||||
|
|
@ -394,15 +404,16 @@ class DatasetService:
|
|||
if not user:
|
||||
raise ValueError("User not found")
|
||||
|
||||
if dataset.permission == DatasetPermissionEnum.ONLY_ME:
|
||||
if dataset.created_by != user.id:
|
||||
raise NoPermissionError("You do not have permission to access this dataset.")
|
||||
if user.current_role not in (TenantAccountRole.OWNER, TenantAccountRole.ADMIN):
|
||||
if dataset.permission == DatasetPermissionEnum.ONLY_ME:
|
||||
if dataset.created_by != user.id:
|
||||
raise NoPermissionError("You do not have permission to access this dataset.")
|
||||
|
||||
elif dataset.permission == DatasetPermissionEnum.PARTIAL_TEAM:
|
||||
if not any(
|
||||
dp.dataset_id == dataset.id for dp in DatasetPermission.query.filter_by(account_id=user.id).all()
|
||||
):
|
||||
raise NoPermissionError("You do not have permission to access this dataset.")
|
||||
elif dataset.permission == DatasetPermissionEnum.PARTIAL_TEAM:
|
||||
if not any(
|
||||
dp.dataset_id == dataset.id for dp in DatasetPermission.query.filter_by(account_id=user.id).all()
|
||||
):
|
||||
raise NoPermissionError("You do not have permission to access this dataset.")
|
||||
|
||||
@staticmethod
|
||||
def get_dataset_queries(dataset_id: str, page: int, per_page: int):
|
||||
|
|
@ -441,7 +452,7 @@ class DatasetService:
|
|||
|
||||
|
||||
class DocumentService:
|
||||
DEFAULT_RULES = {
|
||||
DEFAULT_RULES: dict[str, Any] = {
|
||||
"mode": "custom",
|
||||
"rules": {
|
||||
"pre_processing_rules": [
|
||||
|
|
@ -455,7 +466,7 @@ class DocumentService:
|
|||
},
|
||||
}
|
||||
|
||||
DOCUMENT_METADATA_SCHEMA = {
|
||||
DOCUMENT_METADATA_SCHEMA: dict[str, Any] = {
|
||||
"book": {
|
||||
"title": str,
|
||||
"language": str,
|
||||
|
|
|
|||
|
|
@ -439,7 +439,7 @@ class ApiToolManageService:
|
|||
tenant_id=tenant_id,
|
||||
)
|
||||
)
|
||||
result = tool.validate_credentials(credentials, parameters)
|
||||
result = runtime_tool.validate_credentials(credentials, parameters)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ from datetime import UTC, datetime
|
|||
from typing import Any, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import desc
|
||||
|
||||
from core.app.apps.advanced_chat.app_config_manager import AdvancedChatAppConfigManager
|
||||
from core.app.apps.workflow.app_config_manager import WorkflowAppConfigManager
|
||||
from core.model_runtime.utils.encoders import jsonable_encoder
|
||||
|
|
@ -77,6 +79,28 @@ class WorkflowService:
|
|||
|
||||
return workflow
|
||||
|
||||
def get_all_published_workflow(self, app_model: App, page: int, limit: int) -> tuple[list[Workflow], bool]:
|
||||
"""
|
||||
Get published workflow with pagination
|
||||
"""
|
||||
if not app_model.workflow_id:
|
||||
return [], False
|
||||
|
||||
workflows = (
|
||||
db.session.query(Workflow)
|
||||
.filter(Workflow.app_id == app_model.id)
|
||||
.order_by(desc(Workflow.version))
|
||||
.offset((page - 1) * limit)
|
||||
.limit(limit + 1)
|
||||
.all()
|
||||
)
|
||||
|
||||
has_more = len(workflows) > limit
|
||||
if has_more:
|
||||
workflows = workflows[:-1]
|
||||
|
||||
return workflows, has_more
|
||||
|
||||
def sync_draft_workflow(
|
||||
self,
|
||||
*,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
import logging
|
||||
|
||||
from celery import shared_task # type: ignore
|
||||
|
||||
from extensions.ext_database import db
|
||||
from models.account import Account
|
||||
from services.billing_service import BillingService
|
||||
from tasks.mail_account_deletion_task import send_deletion_success_task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task(queue="dataset")
|
||||
def delete_account_task(account_id):
|
||||
account = db.session.query(Account).filter(Account.id == account_id).first()
|
||||
try:
|
||||
BillingService.delete_account(account_id)
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to delete account {account_id} from billing service.")
|
||||
raise
|
||||
|
||||
if not account:
|
||||
logger.error(f"Account {account_id} not found.")
|
||||
return
|
||||
# send success email
|
||||
send_deletion_success_task.delay(account.email)
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import logging
|
||||
import time
|
||||
|
||||
import click
|
||||
from celery import shared_task # type: ignore
|
||||
from flask import render_template
|
||||
|
||||
from extensions.ext_mail import mail
|
||||
|
||||
|
||||
@shared_task(queue="mail")
|
||||
def send_deletion_success_task(to):
|
||||
"""Send email to user regarding account deletion.
|
||||
|
||||
Args:
|
||||
log (AccountDeletionLog): Account deletion log object
|
||||
"""
|
||||
if not mail.is_inited():
|
||||
return
|
||||
|
||||
logging.info(click.style(f"Start send account deletion success email to {to}", fg="green"))
|
||||
start_at = time.perf_counter()
|
||||
|
||||
try:
|
||||
html_content = render_template(
|
||||
"delete_account_success_template_en-US.html",
|
||||
to=to,
|
||||
email=to,
|
||||
)
|
||||
mail.send(to=to, subject="Your Dify.AI Account Has Been Successfully Deleted", html=html_content)
|
||||
|
||||
end_at = time.perf_counter()
|
||||
logging.info(
|
||||
click.style(
|
||||
"Send account deletion success email to {}: latency: {}".format(to, end_at - start_at), fg="green"
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("Send account deletion success email to {} failed".format(to))
|
||||
|
||||
|
||||
@shared_task(queue="mail")
|
||||
def send_account_deletion_verification_code(to, code):
|
||||
"""Send email to user regarding account deletion verification code.
|
||||
|
||||
Args:
|
||||
to (str): Recipient email address
|
||||
code (str): Verification code
|
||||
"""
|
||||
if not mail.is_inited():
|
||||
return
|
||||
|
||||
logging.info(click.style(f"Start send account deletion verification code email to {to}", fg="green"))
|
||||
start_at = time.perf_counter()
|
||||
|
||||
try:
|
||||
html_content = render_template("delete_account_code_email_template_en-US.html", to=to, code=code)
|
||||
mail.send(to=to, subject="Dify.AI Account Deletion and Verification", html=html_content)
|
||||
|
||||
end_at = time.perf_counter()
|
||||
logging.info(
|
||||
click.style(
|
||||
"Send account deletion verification code email to {} succeeded: latency: {}".format(
|
||||
to, end_at - start_at
|
||||
),
|
||||
fg="green",
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("Send account deletion verification code email to {} failed".format(to))
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import datetime
|
||||
import logging
|
||||
import 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(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,125 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Arial', sans-serif;
|
||||
line-height: 16pt;
|
||||
color: #101828;
|
||||
background-color: #e9ebf0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 600px;
|
||||
min-height: 605px;
|
||||
margin: 40px auto;
|
||||
padding: 36px 48px;
|
||||
background-color: #fcfcfd;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #ffffff;
|
||||
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header img {
|
||||
max-width: 100px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 24px;
|
||||
line-height: 28.8px;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 13px;
|
||||
line-height: 16px;
|
||||
color: #676f83;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.code-content {
|
||||
padding: 16px 32px;
|
||||
text-align: center;
|
||||
border-radius: 16px;
|
||||
background-color: #f2f4f7;
|
||||
margin: 16px auto;
|
||||
}
|
||||
|
||||
.code {
|
||||
line-height: 36px;
|
||||
font-weight: 700;
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.tips {
|
||||
line-height: 16px;
|
||||
color: #676f83;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.typography {
|
||||
letter-spacing: -0.07px;
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
color: #354052;
|
||||
margin-top: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.typography p{
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.typography-title {
|
||||
color: #101828;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
margin-top: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.tip-list{
|
||||
margin: 0;
|
||||
padding-left: 10px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<!-- Optional: Add a logo or a header image here -->
|
||||
<img src="https://cloud.dify.ai/logo/logo-site.png" alt="Dify Logo" />
|
||||
</div>
|
||||
<p class="title">Dify.AI Account Deletion and Verification</p>
|
||||
<p class="typography">We received a request to delete your Dify account. To ensure the security of your account and
|
||||
confirm this action, please use the verification code below:</p>
|
||||
<div class="code-content">
|
||||
<span class="code">{{code}}</span>
|
||||
</div>
|
||||
<div class="typography">
|
||||
<p style="margin-bottom:4px">To complete the account deletion process:</p>
|
||||
<p>1. Return to the account deletion page on our website</p>
|
||||
<p>2. Enter the verification code above</p>
|
||||
<p>3. Click "Confirm Deletion"</p>
|
||||
</div>
|
||||
<p class="typography-title">Please note:</p>
|
||||
<ul class="typography tip-list">
|
||||
<li>This code is valid for 5 minutes</li>
|
||||
<li>As the Owner of any Workspaces, your workspaces will be scheduled in a queue for permanent deletion.</li>
|
||||
<li>All your user data will be queued for permanent deletion.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Arial', sans-serif;
|
||||
line-height: 16pt;
|
||||
color: #101828;
|
||||
background-color: #e9ebf0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 600px;
|
||||
min-height: 380px;
|
||||
margin: 40px auto;
|
||||
padding: 36px 48px;
|
||||
background-color: #fcfcfd;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #ffffff;
|
||||
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header img {
|
||||
max-width: 100px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 24px;
|
||||
line-height: 28.8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: #354052;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.code-content {
|
||||
padding: 16px 32px;
|
||||
text-align: center;
|
||||
border-radius: 16px;
|
||||
background-color: #f2f4f7;
|
||||
margin: 16px auto;
|
||||
}
|
||||
|
||||
.code {
|
||||
line-height: 36px;
|
||||
font-weight: 700;
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.tips {
|
||||
line-height: 16px;
|
||||
color: #676f83;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.email {
|
||||
color: #354052;
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.typography{
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
color: #354052;
|
||||
margin-top: 4px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<!-- Optional: Add a logo or a header image here -->
|
||||
<img src="https://cloud.dify.ai/logo/logo-site.png" alt="Dify Logo" />
|
||||
</div>
|
||||
<p class="title">Your Dify.AI Account Has Been Successfully Deleted</p>
|
||||
<p class="typography">We're writing to confirm that your Dify.AI account has been successfully deleted as per your request. Your
|
||||
account is no longer accessible, and you can't log in using your previous credentials. If you decide to use
|
||||
Dify.AI services in the future, you'll need to create a new account after 30 days. We appreciate the time you
|
||||
spent with Dify.AI and are sorry to see you go. If you have any questions or concerns about the deletion process,
|
||||
please don't hesitate to reach out to our support team.</p>
|
||||
<p class="typography">Thank you for being a part of the Dify.AI community.</p>
|
||||
<p class="typography">Best regards,</p>
|
||||
<p class="typography">Dify.AI Team</p>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
from unittest.mock import MagicMock
|
||||
|
||||
from core.rag.datasource.vdb.baidu.baidu_vector import BaiduConfig, BaiduVector
|
||||
from tests.integration_tests.vdb.__mock.baiduvectordb import setup_baiduvectordb_mock
|
||||
from tests.integration_tests.vdb.test_vector_store import AbstractVectorTest, get_example_text, setup_mock_redis
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from core.rag.datasource.vdb.tidb_vector.tidb_vector import TiDBVector, TiDBVectorConfig
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import pytest
|
|||
|
||||
from configs import dify_config
|
||||
from core.app.app_config.entities import ModelConfigEntity
|
||||
from core.file import File, FileTransferMethod, FileType, FileUploadConfig, ImageConfig
|
||||
from core.file import File, FileTransferMethod, FileType
|
||||
from core.memory.token_buffer_memory import TokenBufferMemory
|
||||
from core.model_runtime.entities.message_entities import (
|
||||
AssistantPromptMessage,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import uuid
|
||||
from collections.abc import Generator
|
||||
from datetime import UTC, datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from core.workflow.entities.variable_pool import VariablePool
|
||||
from core.workflow.enums import SystemVariableKey
|
||||
|
|
|
|||
|
|
@ -21,8 +21,7 @@ from core.model_runtime.entities.message_entities import (
|
|||
from core.model_runtime.entities.model_entities import AIModelEntity, FetchFrom, ModelFeature, ModelType
|
||||
from core.model_runtime.model_providers.model_provider_factory import ModelProviderFactory
|
||||
from core.prompt.entities.advanced_prompt_entities import MemoryConfig
|
||||
from core.variables import ArrayAnySegment, ArrayFileSegment, NoneSegment, StringSegment
|
||||
from core.workflow.entities.variable_entities import VariableSelector
|
||||
from core.variables import ArrayAnySegment, ArrayFileSegment, NoneSegment
|
||||
from core.workflow.entities.variable_pool import VariablePool
|
||||
from core.workflow.graph_engine import Graph, GraphInitParams, GraphRuntimeState
|
||||
from core.workflow.nodes.answer import AnswerStreamGenerateRoute
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
from core.workflow.graph_engine.entities.event import (
|
||||
GraphRunFailedEvent,
|
||||
GraphRunPartialSucceededEvent,
|
||||
GraphRunSucceededEvent,
|
||||
NodeRunRetryEvent,
|
||||
)
|
||||
from tests.unit_tests.core.workflow.nodes.test_continue_on_error import ContinueOnErrorTestHelper
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import pytest
|
||||
|
||||
from core.variables import SegmentType
|
||||
from core.workflow.nodes.variable_assigner.v2.enums import Operation
|
||||
from core.workflow.nodes.variable_assigner.v2.helpers import is_input_value_valid
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from oss2 import Auth # type: ignore
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
from textwrap import dedent
|
||||
|
||||
import pytest
|
||||
|
||||
from core.tools.utils.text_processing_utils import remove_leading_symbols
|
||||
|
|
|
|||
|
|
@ -315,7 +315,7 @@ AZURE_BLOB_ACCOUNT_URL=https://<your_account_name>.blob.core.windows.net
|
|||
# Google Storage Configuration
|
||||
#
|
||||
GOOGLE_STORAGE_BUCKET_NAME=your-bucket-name
|
||||
GOOGLE_STORAGE_SERVICE_ACCOUNT_JSON_BASE64=your-google-service-account-json-base64-string
|
||||
GOOGLE_STORAGE_SERVICE_ACCOUNT_JSON_BASE64=
|
||||
|
||||
# The Alibaba Cloud OSS configurations,
|
||||
#
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ x-shared-env: &shared-api-worker-env
|
|||
AZURE_BLOB_CONTAINER_NAME: ${AZURE_BLOB_CONTAINER_NAME:-difyai-container}
|
||||
AZURE_BLOB_ACCOUNT_URL: ${AZURE_BLOB_ACCOUNT_URL:-https://<your_account_name>.blob.core.windows.net}
|
||||
GOOGLE_STORAGE_BUCKET_NAME: ${GOOGLE_STORAGE_BUCKET_NAME:-your-bucket-name}
|
||||
GOOGLE_STORAGE_SERVICE_ACCOUNT_JSON_BASE64: ${GOOGLE_STORAGE_SERVICE_ACCOUNT_JSON_BASE64:-your-google-service-account-json-base64-string}
|
||||
GOOGLE_STORAGE_SERVICE_ACCOUNT_JSON_BASE64: ${GOOGLE_STORAGE_SERVICE_ACCOUNT_JSON_BASE64:-}
|
||||
ALIYUN_OSS_BUCKET_NAME: ${ALIYUN_OSS_BUCKET_NAME:-your-bucket-name}
|
||||
ALIYUN_OSS_ACCESS_KEY: ${ALIYUN_OSS_ACCESS_KEY:-your-access-key}
|
||||
ALIYUN_OSS_SECRET_KEY: ${ALIYUN_OSS_SECRET_KEY:-your-secret-key}
|
||||
|
|
@ -374,7 +374,6 @@ x-shared-env: &shared-api-worker-env
|
|||
SSRF_COREDUMP_DIR: ${SSRF_COREDUMP_DIR:-/var/spool/squid}
|
||||
SSRF_REVERSE_PROXY_PORT: ${SSRF_REVERSE_PROXY_PORT:-8194}
|
||||
SSRF_SANDBOX_HOST: ${SSRF_SANDBOX_HOST:-sandbox}
|
||||
COMPOSE_PROFILES: ${COMPOSE_PROFILES:-${VECTOR_STORE:-weaviate}}
|
||||
EXPOSE_NGINX_PORT: ${EXPOSE_NGINX_PORT:-80}
|
||||
EXPOSE_NGINX_SSL_PORT: ${EXPOSE_NGINX_SSL_PORT:-443}
|
||||
POSITION_TOOL_PINS: ${POSITION_TOOL_PINS:-}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ def generate_shared_env_block(env_vars, anchor_name="shared-api-worker-env"):
|
|||
"""
|
||||
lines = [f"x-shared-env: &{anchor_name}"]
|
||||
for key, default in env_vars.items():
|
||||
if key == "COMPOSE_PROFILES":
|
||||
continue
|
||||
# If default value is empty, use ${KEY:-}
|
||||
if default == "":
|
||||
lines.append(f" {key}: ${{{key}:-}}")
|
||||
|
|
|
|||
|
|
@ -8,27 +8,24 @@ import Header from '@/app/components/header'
|
|||
import { EventEmitterContextProvider } from '@/context/event-emitter'
|
||||
import { ProviderContextProvider } from '@/context/provider-context'
|
||||
import { ModalContextProvider } from '@/context/modal-context'
|
||||
import { TanstackQueryIniter } from '@/context/query-client'
|
||||
|
||||
const Layout = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<>
|
||||
<GA gaType={GaType.admin} />
|
||||
<SwrInitor>
|
||||
<TanstackQueryIniter>
|
||||
<AppContextProvider>
|
||||
<EventEmitterContextProvider>
|
||||
<ProviderContextProvider>
|
||||
<ModalContextProvider>
|
||||
<HeaderWrapper>
|
||||
<Header />
|
||||
</HeaderWrapper>
|
||||
{children}
|
||||
</ModalContextProvider>
|
||||
</ProviderContextProvider>
|
||||
</EventEmitterContextProvider>
|
||||
</AppContextProvider>
|
||||
</TanstackQueryIniter>
|
||||
<AppContextProvider>
|
||||
<EventEmitterContextProvider>
|
||||
<ProviderContextProvider>
|
||||
<ModalContextProvider>
|
||||
<HeaderWrapper>
|
||||
<Header />
|
||||
</HeaderWrapper>
|
||||
{children}
|
||||
</ModalContextProvider>
|
||||
</ProviderContextProvider>
|
||||
</EventEmitterContextProvider>
|
||||
</AppContextProvider>
|
||||
</SwrInitor>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@ import { useState } from 'react'
|
|||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useContext } from 'use-context-selector'
|
||||
import DeleteAccount from '../delete-account'
|
||||
import s from './index.module.css'
|
||||
import Collapse from '@/app/components/header/account-setting/collapse'
|
||||
import type { IItem } from '@/app/components/header/account-setting/collapse'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { updateUserProfile } from '@/service/common'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
|
|
@ -296,37 +296,9 @@ export default function AccountPage() {
|
|||
}
|
||||
{
|
||||
showDeleteAccountModal && (
|
||||
<Confirm
|
||||
isShow
|
||||
<DeleteAccount
|
||||
onCancel={() => setShowDeleteAccountModal(false)}
|
||||
onConfirm={() => setShowDeleteAccountModal(false)}
|
||||
showCancel={false}
|
||||
type='warning'
|
||||
title={t('common.account.delete')}
|
||||
content={
|
||||
<>
|
||||
<div className='my-1 text-text-destructive body-md-medium'>
|
||||
{t('common.account.deleteTip')}
|
||||
</div>
|
||||
<div className='mt-3 text-sm leading-5'>
|
||||
<span>{t('common.account.deleteConfirmTip')}</span>
|
||||
<a
|
||||
className='text-text-accent cursor'
|
||||
href={`mailto:support@dify.ai?subject=Delete Account Request&body=Delete Account: ${userProfile.email}`}
|
||||
target='_blank'
|
||||
rel='noreferrer noopener'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
window.location.href = e.currentTarget.href
|
||||
}}
|
||||
>
|
||||
support@dify.ai
|
||||
</a>
|
||||
</div>
|
||||
<div className='my-2 px-3 py-2 rounded-lg bg-components-input-bg-active border border-components-input-border-active system-sm-regular text-components-input-text-filled'>{`${t('common.account.delete')}: ${userProfile.email}`}</div>
|
||||
</>
|
||||
}
|
||||
confirmText={t('common.operation.ok') as string}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
'use client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useCallback, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useSendDeleteAccountEmail } from '../state'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Button from '@/app/components/base/button'
|
||||
|
||||
type DeleteAccountProps = {
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
export default function CheckEmail(props: DeleteAccountProps) {
|
||||
const { t } = useTranslation()
|
||||
const { userProfile } = useAppContext()
|
||||
const [userInputEmail, setUserInputEmail] = useState('')
|
||||
|
||||
const { isPending: isSendingEmail, mutateAsync: getDeleteEmailVerifyCode } = useSendDeleteAccountEmail()
|
||||
|
||||
const handleConfirm = useCallback(async () => {
|
||||
try {
|
||||
const ret = await getDeleteEmailVerifyCode()
|
||||
if (ret.result === 'success')
|
||||
props.onConfirm()
|
||||
}
|
||||
catch (error) { console.error(error) }
|
||||
}, [getDeleteEmailVerifyCode, props])
|
||||
|
||||
return <>
|
||||
<div className='py-1 text-text-destructive body-md-medium'>
|
||||
{t('common.account.deleteTip')}
|
||||
</div>
|
||||
<div className='pt-1 pb-2 text-text-secondary body-md-regular'>
|
||||
{t('common.account.deletePrivacyLinkTip')}
|
||||
<Link href='https://dify.ai/privacy' className='text-text-accent'>{t('common.account.deletePrivacyLink')}</Link>
|
||||
</div>
|
||||
<label className='mt-3 mb-1 h-6 flex items-center system-sm-semibold text-text-secondary'>{t('common.account.deleteLabel')}</label>
|
||||
<Input placeholder={t('common.account.deletePlaceholder') as string} onChange={(e) => {
|
||||
setUserInputEmail(e.target.value)
|
||||
}} />
|
||||
<div className='w-full flex flex-col mt-3 gap-2'>
|
||||
<Button className='w-full' disabled={userInputEmail !== userProfile.email || isSendingEmail} loading={isSendingEmail} variant='primary' onClick={handleConfirm}>{t('common.account.sendVerificationButton')}</Button>
|
||||
<Button className='w-full' onClick={props.onCancel}>{t('common.operation.cancel')}</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
'use client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useDeleteAccountFeedback } from '../state'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import Button from '@/app/components/base/button'
|
||||
import CustomDialog from '@/app/components/base/dialog'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { logout } from '@/service/common'
|
||||
|
||||
type DeleteAccountProps = {
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
export default function FeedBack(props: DeleteAccountProps) {
|
||||
const { t } = useTranslation()
|
||||
const { userProfile } = useAppContext()
|
||||
const router = useRouter()
|
||||
const [userFeedback, setUserFeedback] = useState('')
|
||||
const { isPending, mutateAsync: sendFeedback } = useDeleteAccountFeedback()
|
||||
|
||||
const handleSuccess = useCallback(async () => {
|
||||
try {
|
||||
await logout({
|
||||
url: '/logout',
|
||||
params: {},
|
||||
})
|
||||
localStorage.removeItem('refresh_token')
|
||||
localStorage.removeItem('console_token')
|
||||
router.push('/signin')
|
||||
Toast.notify({ type: 'info', message: t('common.account.deleteSuccessTip') })
|
||||
}
|
||||
catch (error) { console.error(error) }
|
||||
}, [router, t])
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
try {
|
||||
await sendFeedback({ feedback: userFeedback, email: userProfile.email })
|
||||
props.onConfirm()
|
||||
await handleSuccess()
|
||||
}
|
||||
catch (error) { console.error(error) }
|
||||
}, [handleSuccess, userFeedback, sendFeedback, userProfile, props])
|
||||
|
||||
const handleSkip = useCallback(() => {
|
||||
props.onCancel()
|
||||
handleSuccess()
|
||||
}, [handleSuccess, props])
|
||||
return <CustomDialog
|
||||
show={true}
|
||||
onClose={props.onCancel}
|
||||
title={t('common.account.feedbackTitle')}
|
||||
className="max-w-[480px]"
|
||||
footer={false}
|
||||
>
|
||||
<label className='mt-3 mb-1 flex items-center system-sm-semibold text-text-secondary'>{t('common.account.feedbackLabel')}</label>
|
||||
<Textarea rows={6} value={userFeedback} placeholder={t('common.account.feedbackPlaceholder') as string} onChange={(e) => {
|
||||
setUserFeedback(e.target.value)
|
||||
}} />
|
||||
<div className='w-full flex flex-col mt-3 gap-2'>
|
||||
<Button className='w-full' loading={isPending} variant='primary' onClick={handleSubmit}>{t('common.operation.submit')}</Button>
|
||||
<Button className='w-full' onClick={handleSkip}>{t('common.operation.skip')}</Button>
|
||||
</div>
|
||||
</CustomDialog>
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
'use client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useAccountDeleteStore, useConfirmDeleteAccount, useSendDeleteAccountEmail } from '../state'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Countdown from '@/app/components/signin/countdown'
|
||||
|
||||
const CODE_EXP = /[A-Za-z\d]{6}/gi
|
||||
|
||||
type DeleteAccountProps = {
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
export default function VerifyEmail(props: DeleteAccountProps) {
|
||||
const { t } = useTranslation()
|
||||
const emailToken = useAccountDeleteStore(state => state.sendEmailToken)
|
||||
const [verificationCode, setVerificationCode] = useState<string>()
|
||||
const [shouldButtonDisabled, setShouldButtonDisabled] = useState(true)
|
||||
const { mutate: sendEmail } = useSendDeleteAccountEmail()
|
||||
const { isPending: isDeleting, mutateAsync: confirmDeleteAccount } = useConfirmDeleteAccount()
|
||||
|
||||
useEffect(() => {
|
||||
setShouldButtonDisabled(!(verificationCode && CODE_EXP.test(verificationCode)) || isDeleting)
|
||||
}, [verificationCode, isDeleting])
|
||||
|
||||
const handleConfirm = useCallback(async () => {
|
||||
try {
|
||||
const ret = await confirmDeleteAccount({ code: verificationCode!, token: emailToken })
|
||||
if (ret.result === 'success')
|
||||
props.onConfirm()
|
||||
}
|
||||
catch (error) { console.error(error) }
|
||||
}, [emailToken, verificationCode, confirmDeleteAccount, props])
|
||||
return <>
|
||||
<div className='pt-1 text-text-destructive body-md-medium'>
|
||||
{t('common.account.deleteTip')}
|
||||
</div>
|
||||
<div className='pt-1 pb-2 text-text-secondary body-md-regular'>
|
||||
{t('common.account.deletePrivacyLinkTip')}
|
||||
<Link href='https://dify.ai/privacy' className='text-text-accent'>{t('common.account.deletePrivacyLink')}</Link>
|
||||
</div>
|
||||
<label className='mt-3 mb-1 h-6 flex items-center system-sm-semibold text-text-secondary'>{t('common.account.verificationLabel')}</label>
|
||||
<Input minLength={6} maxLength={6} placeholder={t('common.account.verificationPlaceholder') as string} onChange={(e) => {
|
||||
setVerificationCode(e.target.value)
|
||||
}} />
|
||||
<div className='w-full flex flex-col mt-3 gap-2'>
|
||||
<Button className='w-full' disabled={shouldButtonDisabled} loading={isDeleting} variant='warning' onClick={handleConfirm}>{t('common.account.permanentlyDeleteButton')}</Button>
|
||||
<Button className='w-full' onClick={props.onCancel}>{t('common.operation.cancel')}</Button>
|
||||
<Countdown onResend={sendEmail} />
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
'use client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useCallback, useState } from 'react'
|
||||
import CheckEmail from './components/check-email'
|
||||
import VerifyEmail from './components/verify-email'
|
||||
import FeedBack from './components/feed-back'
|
||||
import CustomDialog from '@/app/components/base/dialog'
|
||||
import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown'
|
||||
|
||||
type DeleteAccountProps = {
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
export default function DeleteAccount(props: DeleteAccountProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [showVerifyEmail, setShowVerifyEmail] = useState(false)
|
||||
const [showFeedbackDialog, setShowFeedbackDialog] = useState(false)
|
||||
|
||||
const handleEmailCheckSuccess = useCallback(async () => {
|
||||
try {
|
||||
setShowVerifyEmail(true)
|
||||
localStorage.setItem(COUNT_DOWN_KEY, `${COUNT_DOWN_TIME_MS}`)
|
||||
}
|
||||
catch (error) { console.error(error) }
|
||||
}, [])
|
||||
|
||||
if (showFeedbackDialog)
|
||||
return <FeedBack onCancel={props.onCancel} onConfirm={props.onConfirm} />
|
||||
|
||||
return <CustomDialog
|
||||
show={true}
|
||||
onClose={props.onCancel}
|
||||
title={t('common.account.delete')}
|
||||
className="max-w-[480px]"
|
||||
footer={false}
|
||||
>
|
||||
{!showVerifyEmail && <CheckEmail onCancel={props.onCancel} onConfirm={handleEmailCheckSuccess} />}
|
||||
{showVerifyEmail && <VerifyEmail onCancel={props.onCancel} onConfirm={() => {
|
||||
setShowFeedbackDialog(true)
|
||||
}} />}
|
||||
</CustomDialog>
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import { useMutation } from '@tanstack/react-query'
|
||||
import { create } from 'zustand'
|
||||
import { sendDeleteAccountCode, submitDeleteAccountFeedback, verifyDeleteAccountCode } from '@/service/common'
|
||||
|
||||
type State = {
|
||||
sendEmailToken: string
|
||||
setSendEmailToken: (token: string) => void
|
||||
}
|
||||
|
||||
export const useAccountDeleteStore = create<State>(set => ({
|
||||
sendEmailToken: '',
|
||||
setSendEmailToken: (token: string) => set({ sendEmailToken: token }),
|
||||
}))
|
||||
|
||||
export function useSendDeleteAccountEmail() {
|
||||
const updateEmailToken = useAccountDeleteStore(state => state.setSendEmailToken)
|
||||
return useMutation({
|
||||
mutationKey: ['delete-account'],
|
||||
mutationFn: sendDeleteAccountCode,
|
||||
onSuccess: (ret) => {
|
||||
if (ret.result === 'success')
|
||||
updateEmailToken(ret.data)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useConfirmDeleteAccount() {
|
||||
return useMutation({
|
||||
mutationKey: ['confirm-delete-account'],
|
||||
mutationFn: verifyDeleteAccountCode,
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteAccountFeedback() {
|
||||
return useMutation({
|
||||
mutationKey: ['delete-account-feedback'],
|
||||
mutationFn: submitDeleteAccountFeedback,
|
||||
})
|
||||
}
|
||||
|
|
@ -47,7 +47,7 @@ const CustomDialog = ({
|
|||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex items-center justify-center min-h-full p-4 text-center">
|
||||
<div className="flex items-center justify-center min-h-full">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
|
|
@ -57,20 +57,20 @@ const CustomDialog = ({
|
|||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className={classNames('w-full max-w-[800px] p-0 overflow-hidden text-left text-gray-900 align-middle transition-all transform bg-white shadow-xl rounded-2xl', className)}>
|
||||
<Dialog.Panel className={classNames('w-full max-w-[800px] p-6 overflow-hidden transition-all transform bg-components-panel-bg border-[0.5px] border-components-panel-border shadow-xl rounded-2xl', className)}>
|
||||
{Boolean(title) && (
|
||||
<Dialog.Title
|
||||
as={titleAs || 'h3'}
|
||||
className={classNames('px-8 py-6 text-lg font-medium leading-6 text-gray-900', titleClassName)}
|
||||
className={classNames('pr-8 pb-3 title-2xl-semi-bold text-text-primary', titleClassName)}
|
||||
>
|
||||
{title}
|
||||
</Dialog.Title>
|
||||
)}
|
||||
<div className={classNames('px-8 text-lg font-medium leading-6', bodyClassName)}>
|
||||
<div className={classNames(bodyClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
{Boolean(footer) && (
|
||||
<div className={classNames('flex items-center justify-end gap-2 px-8 py-6', footerClassName)}>
|
||||
<div className={classNames('flex items-center justify-end gap-2 px-6 pb-6 pt-3', footerClassName)}>
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -6,13 +6,30 @@ const MarkdownButton = ({ node }: any) => {
|
|||
const { onSend } = useChatContext()
|
||||
const variant = node.properties.dataVariant
|
||||
const message = node.properties.dataMessage
|
||||
const link = node.properties.dataLink
|
||||
const size = node.properties.dataSize
|
||||
|
||||
function is_valid_url(url: string): boolean {
|
||||
try {
|
||||
const parsed_url = new URL(url)
|
||||
return ['http:', 'https:'].includes(parsed_url.protocol)
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return <Button
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn('!h-8 !px-3 select-none')}
|
||||
onClick={() => onSend?.(message)}
|
||||
onClick={() => {
|
||||
if (is_valid_url(link)) {
|
||||
window.open(link, '_blank')
|
||||
return
|
||||
}
|
||||
onSend?.(message)
|
||||
}}
|
||||
>
|
||||
<span className='text-[13px]'>{node.children[0]?.value || ''}</span>
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -11,59 +11,62 @@ type SwitchProps = {
|
|||
className?: string
|
||||
}
|
||||
|
||||
const Switch = ({ onChange, size = 'md', defaultValue = false, disabled = false, className }: SwitchProps) => {
|
||||
const [enabled, setEnabled] = useState(defaultValue)
|
||||
useEffect(() => {
|
||||
setEnabled(defaultValue)
|
||||
}, [defaultValue])
|
||||
const wrapStyle = {
|
||||
lg: 'h-6 w-11',
|
||||
l: 'h-5 w-9',
|
||||
md: 'h-4 w-7',
|
||||
sm: 'h-3 w-5',
|
||||
}
|
||||
const Switch = React.forwardRef(
|
||||
({ onChange, size = 'md', defaultValue = false, disabled = false, className }: SwitchProps,
|
||||
propRef: React.Ref<HTMLButtonElement>) => {
|
||||
const [enabled, setEnabled] = useState(defaultValue)
|
||||
useEffect(() => {
|
||||
setEnabled(defaultValue)
|
||||
}, [defaultValue])
|
||||
const wrapStyle = {
|
||||
lg: 'h-6 w-11',
|
||||
l: 'h-5 w-9',
|
||||
md: 'h-4 w-7',
|
||||
sm: 'h-3 w-5',
|
||||
}
|
||||
|
||||
const circleStyle = {
|
||||
lg: 'h-5 w-5',
|
||||
l: 'h-4 w-4',
|
||||
md: 'h-3 w-3',
|
||||
sm: 'h-2 w-2',
|
||||
}
|
||||
const circleStyle = {
|
||||
lg: 'h-5 w-5',
|
||||
l: 'h-4 w-4',
|
||||
md: 'h-3 w-3',
|
||||
sm: 'h-2 w-2',
|
||||
}
|
||||
|
||||
const translateLeft = {
|
||||
lg: 'translate-x-5',
|
||||
l: 'translate-x-4',
|
||||
md: 'translate-x-3',
|
||||
sm: 'translate-x-2',
|
||||
}
|
||||
return (
|
||||
<OriginalSwitch
|
||||
checked={enabled}
|
||||
onChange={(checked: boolean) => {
|
||||
if (disabled)
|
||||
return
|
||||
setEnabled(checked)
|
||||
onChange?.(checked)
|
||||
}}
|
||||
className={classNames(
|
||||
wrapStyle[size],
|
||||
enabled ? 'bg-components-toggle-bg' : 'bg-components-toggle-bg-unchecked',
|
||||
'relative inline-flex flex-shrink-0 cursor-pointer rounded-[5px] border-2 border-transparent transition-colors duration-200 ease-in-out',
|
||||
disabled ? '!opacity-50 !cursor-not-allowed' : '',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
const translateLeft = {
|
||||
lg: 'translate-x-5',
|
||||
l: 'translate-x-4',
|
||||
md: 'translate-x-3',
|
||||
sm: 'translate-x-2',
|
||||
}
|
||||
return (
|
||||
<OriginalSwitch
|
||||
ref={propRef}
|
||||
checked={enabled}
|
||||
onChange={(checked: boolean) => {
|
||||
if (disabled)
|
||||
return
|
||||
setEnabled(checked)
|
||||
onChange?.(checked)
|
||||
}}
|
||||
className={classNames(
|
||||
circleStyle[size],
|
||||
enabled ? translateLeft[size] : 'translate-x-0',
|
||||
'pointer-events-none inline-block transform rounded-[3px] bg-components-toggle-knob shadow ring-0 transition duration-200 ease-in-out',
|
||||
wrapStyle[size],
|
||||
enabled ? 'bg-components-toggle-bg' : 'bg-components-toggle-bg-unchecked',
|
||||
'relative inline-flex flex-shrink-0 cursor-pointer rounded-[5px] border-2 border-transparent transition-colors duration-200 ease-in-out',
|
||||
disabled ? '!opacity-50 !cursor-not-allowed' : '',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
</OriginalSwitch>
|
||||
)
|
||||
}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={classNames(
|
||||
circleStyle[size],
|
||||
enabled ? translateLeft[size] : 'translate-x-0',
|
||||
'pointer-events-none inline-block transform rounded-[3px] bg-components-toggle-knob shadow ring-0 transition duration-200 ease-in-out',
|
||||
)}
|
||||
/>
|
||||
</OriginalSwitch>
|
||||
)
|
||||
})
|
||||
|
||||
Switch.displayName = 'Switch'
|
||||
|
||||
|
|
|
|||
|
|
@ -15,13 +15,15 @@ type OptionCardHeaderProps = {
|
|||
isActive?: boolean
|
||||
activeClassName?: string
|
||||
effectImg?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export const OptionCardHeader: FC<OptionCardHeaderProps> = (props) => {
|
||||
const { icon, title, description, isActive, activeClassName, effectImg } = props
|
||||
const { icon, title, description, isActive, activeClassName, effectImg, disabled } = props
|
||||
return <div className={classNames(
|
||||
'flex h-full overflow-hidden rounded-t-xl relative',
|
||||
isActive && activeClassName,
|
||||
!disabled && 'cursor-pointer',
|
||||
)}>
|
||||
<div className='size-14 flex items-center justify-center relative overflow-hidden'>
|
||||
{isActive && effectImg && <Image src={effectImg} className='absolute top-0 left-0 w-full h-full' alt='' width={56} height={56} />}
|
||||
|
|
@ -63,7 +65,7 @@ export const OptionCard: FC<OptionCardProps> = forwardRef((props, ref) => {
|
|||
(isActive && !noHighlight)
|
||||
? 'border-[1.5px] border-components-option-card-option-selected-border'
|
||||
: 'border border-components-option-card-option-border',
|
||||
disabled && 'opacity-50',
|
||||
disabled && 'opacity-50 cursor-not-allowed',
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
|
|
@ -83,6 +85,7 @@ export const OptionCard: FC<OptionCardProps> = forwardRef((props, ref) => {
|
|||
isActive={isActive && !noHighlight}
|
||||
activeClassName={activeHeaderClassName}
|
||||
effectImg={effectImg}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{/** Body */}
|
||||
{isActive && (children || actions) && <div className='py-3 px-4 bg-components-panel-bg rounded-b-xl'>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import React from 'react'
|
|||
import cn from '@/utils/classnames'
|
||||
|
||||
type Props = {
|
||||
value: number
|
||||
value: number | null
|
||||
besideChunkName?: boolean
|
||||
}
|
||||
|
||||
|
|
@ -12,6 +12,9 @@ const Score: FC<Props> = ({
|
|||
value,
|
||||
besideChunkName,
|
||||
}) => {
|
||||
if (!value)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className={cn('relative items-center px-[5px] border border-components-progress-bar-border overflow-hidden', besideChunkName ? 'border-l-0 h-[20.5px]' : 'h-[20px] rounded-md')}>
|
||||
<div className={cn('absolute top-0 left-0 h-full bg-util-colors-blue-brand-blue-brand-100 border-r-[1.5px] border-components-progress-brand-progress', value === 1 && 'border-r-0')} style={{ width: `${value * 100}%` }} />
|
||||
|
|
|
|||
|
|
@ -59,23 +59,21 @@ export default function AppSelector({ isMobile }: IAppSelector) {
|
|||
{
|
||||
({ open }) => (
|
||||
<>
|
||||
<div>
|
||||
<Menu.Button
|
||||
className={`
|
||||
<Menu.Button
|
||||
className={`
|
||||
inline-flex items-center
|
||||
rounded-[20px] py-1 pr-2.5 pl-1 text-sm
|
||||
text-gray-700 hover:bg-gray-200
|
||||
mobile:px-1
|
||||
${open && 'bg-gray-200'}
|
||||
`}
|
||||
>
|
||||
<Avatar name={userProfile.name} className='sm:mr-2 mr-0' size={32} />
|
||||
{!isMobile && <>
|
||||
{userProfile.name}
|
||||
<RiArrowDownSLine className="w-3 h-3 ml-1 text-gray-700" />
|
||||
</>}
|
||||
</Menu.Button>
|
||||
</div>
|
||||
>
|
||||
<Avatar name={userProfile.name} className='sm:mr-2 mr-0' size={32} />
|
||||
{!isMobile && <>
|
||||
{userProfile.name}
|
||||
<RiArrowDownSLine className="w-3 h-3 ml-1 text-gray-700" />
|
||||
</>}
|
||||
</Menu.Button>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
|
|
@ -89,10 +87,10 @@ export default function AppSelector({ isMobile }: IAppSelector) {
|
|||
className="
|
||||
absolute right-0 mt-1.5 w-60 max-w-80
|
||||
divide-y divide-divider-subtle origin-top-right rounded-lg bg-components-panel-bg-blur
|
||||
shadow-lg
|
||||
shadow-lg focus:outline-none
|
||||
"
|
||||
>
|
||||
<Menu.Item>
|
||||
<Menu.Item disabled>
|
||||
<div className='flex flex-nowrap items-center px-4 py-[13px]'>
|
||||
<Avatar name={userProfile.name} size={36} className='mr-3' />
|
||||
<div className='grow'>
|
||||
|
|
@ -107,89 +105,107 @@ export default function AppSelector({ isMobile }: IAppSelector) {
|
|||
</div>
|
||||
<div className="px-1 py-1">
|
||||
<Menu.Item>
|
||||
<Link
|
||||
className={classNames(itemClassName, 'group justify-between')}
|
||||
{({ active }) => <Link
|
||||
className={classNames(itemClassName, 'group justify-between',
|
||||
active && 'bg-state-base-hover',
|
||||
)}
|
||||
href='/account'
|
||||
target='_self' rel='noopener noreferrer'>
|
||||
<div>{t('common.account.account')}</div>
|
||||
<ArrowUpRight className='hidden w-[14px] h-[14px] text-text-tertiary group-hover:flex' />
|
||||
</Link>
|
||||
</Link>}
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
<div className={itemClassName} onClick={() => setShowAccountSettingModal({ payload: 'members' })}>
|
||||
{({ active }) => <div className={classNames(itemClassName,
|
||||
active && 'bg-state-base-hover',
|
||||
)} onClick={() => setShowAccountSettingModal({ payload: 'members' })}>
|
||||
<div>{t('common.userProfile.settings')}</div>
|
||||
</div>
|
||||
</div>}
|
||||
</Menu.Item>
|
||||
{canEmailSupport && <Menu.Item>
|
||||
<a
|
||||
className={classNames(itemClassName, 'group justify-between')}
|
||||
{({ active }) => <a
|
||||
className={classNames(itemClassName, 'group justify-between',
|
||||
active && 'bg-state-base-hover',
|
||||
)}
|
||||
href={mailToSupport(userProfile.email, plan.type, langeniusVersionInfo.current_version)}
|
||||
target='_blank' rel='noopener noreferrer'>
|
||||
<div>{t('common.userProfile.emailSupport')}</div>
|
||||
<ArrowUpRight className='hidden w-[14px] h-[14px] text-text-tertiary group-hover:flex' />
|
||||
</a>
|
||||
</a>}
|
||||
</Menu.Item>}
|
||||
<Menu.Item>
|
||||
<Link
|
||||
className={classNames(itemClassName, 'group justify-between')}
|
||||
{({ active }) => <Link
|
||||
className={classNames(itemClassName, 'group justify-between',
|
||||
active && 'bg-state-base-hover',
|
||||
)}
|
||||
href='https://github.com/langgenius/dify/discussions/categories/feedbacks'
|
||||
target='_blank' rel='noopener noreferrer'>
|
||||
<div>{t('common.userProfile.communityFeedback')}</div>
|
||||
<ArrowUpRight className='hidden w-[14px] h-[14px] text-text-tertiary group-hover:flex' />
|
||||
</Link>
|
||||
</Link>}
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
<Link
|
||||
className={classNames(itemClassName, 'group justify-between')}
|
||||
{({ active }) => <Link
|
||||
className={classNames(itemClassName, 'group justify-between',
|
||||
active && 'bg-state-base-hover',
|
||||
)}
|
||||
href='https://discord.gg/5AEfbxcd9k'
|
||||
target='_blank' rel='noopener noreferrer'>
|
||||
<div>{t('common.userProfile.community')}</div>
|
||||
<ArrowUpRight className='hidden w-[14px] h-[14px] text-text-tertiary group-hover:flex' />
|
||||
</Link>
|
||||
</Link>}
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
<Link
|
||||
className={classNames(itemClassName, 'group justify-between')}
|
||||
{({ active }) => <Link
|
||||
className={classNames(itemClassName, 'group justify-between',
|
||||
active && 'bg-state-base-hover',
|
||||
)}
|
||||
href={
|
||||
locale !== LanguagesSupported[1] ? 'https://docs.dify.ai/' : `https://docs.dify.ai/v/${locale.toLowerCase()}/`
|
||||
}
|
||||
target='_blank' rel='noopener noreferrer'>
|
||||
<div>{t('common.userProfile.helpCenter')}</div>
|
||||
<ArrowUpRight className='hidden w-[14px] h-[14px] text-text-tertiary group-hover:flex' />
|
||||
</Link>
|
||||
</Link>}
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
<Link
|
||||
className={classNames(itemClassName, 'group justify-between')}
|
||||
{({ active }) => <Link
|
||||
className={classNames(itemClassName, 'group justify-between',
|
||||
active && 'bg-state-base-hover',
|
||||
)}
|
||||
href='https://roadmap.dify.ai'
|
||||
target='_blank' rel='noopener noreferrer'>
|
||||
<div>{t('common.userProfile.roadmap')}</div>
|
||||
<ArrowUpRight className='hidden w-[14px] h-[14px] text-text-tertiary group-hover:flex' />
|
||||
</Link>
|
||||
</Link>}
|
||||
</Menu.Item>
|
||||
{
|
||||
document?.body?.getAttribute('data-public-site-about') !== 'hide' && (
|
||||
<Menu.Item>
|
||||
<div className={classNames(itemClassName, 'justify-between')} onClick={() => setAboutVisible(true)}>
|
||||
{({ active }) => <div className={classNames(itemClassName, 'justify-between',
|
||||
active && 'bg-state-base-hover',
|
||||
)} onClick={() => setAboutVisible(true)}>
|
||||
<div>{t('common.userProfile.about')}</div>
|
||||
<div className='flex items-center'>
|
||||
<div className='mr-2 system-xs-regular text-text-tertiary'>{langeniusVersionInfo.current_version}</div>
|
||||
<Indicator color={langeniusVersionInfo.current_version === langeniusVersionInfo.latest_version ? 'green' : 'orange'} />
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
</Menu.Item>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<Menu.Item>
|
||||
<div className='p-1' onClick={() => handleLogout()}>
|
||||
{({ active }) => <div className='p-1' onClick={() => handleLogout()}>
|
||||
<div
|
||||
className='flex items-center justify-between h-9 px-3 rounded-lg cursor-pointer group hover:bg-state-base-hover'
|
||||
className={
|
||||
classNames('flex items-center justify-between h-9 px-3 rounded-lg cursor-pointer group hover:bg-state-base-hover',
|
||||
active && 'bg-state-base-hover')}
|
||||
>
|
||||
<div className='system-md-regular text-text-secondary'>{t('common.userProfile.logout')}</div>
|
||||
<RiLogoutBoxRLine className='hidden w-4 h-4 text-text-tertiary group-hover:flex' />
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
</Menu.Item>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { useWorkspacesContext } from '@/context/workspace-context'
|
|||
import { ChevronRight } from '@/app/components/base/icons/src/vender/line/arrows'
|
||||
import { Check } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import classNames from '@/utils/classnames'
|
||||
|
||||
const itemClassName = `
|
||||
flex items-center px-3 py-2 h-10 cursor-pointer
|
||||
|
|
@ -50,7 +51,7 @@ const WorkplaceSelector = () => {
|
|||
<Menu.Button className={cn(
|
||||
`
|
||||
${itemClassName} w-full
|
||||
group hover:bg-gray-50 cursor-pointer ${open && 'bg-gray-50'} rounded-lg
|
||||
group hover:bg-state-base-hover cursor-pointer ${open && 'bg-state-base-hover'} rounded-lg
|
||||
`,
|
||||
)}>
|
||||
<div className={itemIconClassName}>{currentWorkspace?.name[0].toLocaleUpperCase()}</div>
|
||||
|
|
@ -70,7 +71,7 @@ const WorkplaceSelector = () => {
|
|||
className={cn(
|
||||
`
|
||||
absolute top-[1px] min-w-[200px] max-h-[70vh] overflow-y-scroll z-10 bg-white border-[0.5px] border-gray-200
|
||||
divide-y divide-gray-100 origin-top-right rounded-xl
|
||||
divide-y divide-gray-100 origin-top-right rounded-xl focus:outline-none
|
||||
`,
|
||||
s.popup,
|
||||
)}
|
||||
|
|
@ -78,11 +79,16 @@ const WorkplaceSelector = () => {
|
|||
<div className="px-1 py-1">
|
||||
{
|
||||
workspaces.map(workspace => (
|
||||
<div className={itemClassName} key={workspace.id} onClick={() => handleSwitchWorkspace(workspace.id)}>
|
||||
<div className={itemIconClassName}>{workspace.name[0].toLocaleUpperCase()}</div>
|
||||
<div className={itemNameClassName}>{workspace.name}</div>
|
||||
{workspace.current && <Check className={itemCheckClassName} />}
|
||||
</div>
|
||||
<Menu.Item key={workspace.id}>
|
||||
{({ active }) => <div className={classNames(itemClassName,
|
||||
active && 'bg-state-base-hover',
|
||||
)} key={workspace.id} onClick={() => handleSwitchWorkspace(workspace.id)}>
|
||||
<div className={itemIconClassName}>{workspace.name[0].toLocaleUpperCase()}</div>
|
||||
<div className={itemNameClassName}>{workspace.name}</div>
|
||||
{workspace.current && <Check className={itemCheckClassName} />}
|
||||
</div>}
|
||||
|
||||
</Menu.Item>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
.modal {
|
||||
padding: 24px 32px !important;
|
||||
width: 400px !important;
|
||||
}
|
||||
|
||||
.bg {
|
||||
background: linear-gradient(180deg, rgba(217, 45, 32, 0.05) 0%, rgba(217, 45, 32, 0.00) 24.02%), #F9FAFB;
|
||||
}
|
||||
|
||||
|
|
@ -1,282 +0,0 @@
|
|||
'use client'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useContext, useContextSelector } from 'use-context-selector'
|
||||
import Collapse from '../collapse'
|
||||
import type { IItem } from '../collapse'
|
||||
import s from './index.module.css'
|
||||
import classNames from '@/utils/classnames'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { updateUserProfile } from '@/service/common'
|
||||
import AppContext, { useAppContext } from '@/context/app-context'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import Avatar from '@/app/components/base/avatar'
|
||||
import { IS_CE_EDITION } from '@/config'
|
||||
|
||||
const titleClassName = `
|
||||
text-sm font-medium text-gray-900
|
||||
`
|
||||
const descriptionClassName = `
|
||||
mt-1 text-xs font-normal text-gray-500
|
||||
`
|
||||
const inputClassName = `
|
||||
mt-2 w-full px-3 py-2 bg-gray-100 rounded
|
||||
text-sm font-normal text-gray-800
|
||||
`
|
||||
|
||||
const validPassword = /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/
|
||||
|
||||
export default function AccountPage() {
|
||||
const { t } = useTranslation()
|
||||
const { mutateUserProfile, userProfile, apps } = useAppContext()
|
||||
const { notify } = useContext(ToastContext)
|
||||
const [editNameModalVisible, setEditNameModalVisible] = useState(false)
|
||||
const [editName, setEditName] = useState('')
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editPasswordModalVisible, setEditPasswordModalVisible] = useState(false)
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [showDeleteAccountModal, setShowDeleteAccountModal] = useState(false)
|
||||
const systemFeatures = useContextSelector(AppContext, state => state.systemFeatures)
|
||||
|
||||
const handleEditName = () => {
|
||||
setEditNameModalVisible(true)
|
||||
setEditName(userProfile.name)
|
||||
}
|
||||
const handleSaveName = async () => {
|
||||
try {
|
||||
setEditing(true)
|
||||
await updateUserProfile({ url: 'account/name', body: { name: editName } })
|
||||
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
|
||||
mutateUserProfile()
|
||||
setEditNameModalVisible(false)
|
||||
setEditing(false)
|
||||
}
|
||||
catch (e) {
|
||||
notify({ type: 'error', message: (e as Error).message })
|
||||
setEditNameModalVisible(false)
|
||||
setEditing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const showErrorMessage = (message: string) => {
|
||||
notify({
|
||||
type: 'error',
|
||||
message,
|
||||
})
|
||||
}
|
||||
const valid = () => {
|
||||
if (!password.trim()) {
|
||||
showErrorMessage(t('login.error.passwordEmpty'))
|
||||
return false
|
||||
}
|
||||
if (!validPassword.test(password)) {
|
||||
showErrorMessage(t('login.error.passwordInvalid'))
|
||||
return false
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
showErrorMessage(t('common.account.notEqual'))
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
const resetPasswordForm = () => {
|
||||
setCurrentPassword('')
|
||||
setPassword('')
|
||||
setConfirmPassword('')
|
||||
}
|
||||
const handleSavePassword = async () => {
|
||||
if (!valid())
|
||||
return
|
||||
try {
|
||||
setEditing(true)
|
||||
await updateUserProfile({
|
||||
url: 'account/password',
|
||||
body: {
|
||||
password: currentPassword,
|
||||
new_password: password,
|
||||
repeat_new_password: confirmPassword,
|
||||
},
|
||||
})
|
||||
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
|
||||
mutateUserProfile()
|
||||
setEditPasswordModalVisible(false)
|
||||
resetPasswordForm()
|
||||
setEditing(false)
|
||||
}
|
||||
catch (e) {
|
||||
notify({ type: 'error', message: (e as Error).message })
|
||||
setEditPasswordModalVisible(false)
|
||||
setEditing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const renderAppItem = (item: IItem) => {
|
||||
return (
|
||||
<div className='flex px-3 py-1'>
|
||||
<div className='mr-3'>
|
||||
<AppIcon size='tiny' />
|
||||
</div>
|
||||
<div className='mt-[3px] text-xs font-medium text-gray-700 leading-[18px]'>{item.name}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='mb-8'>
|
||||
<div className={titleClassName}>{t('common.account.avatar')}</div>
|
||||
<Avatar name={userProfile.name} size={64} className='mt-2' />
|
||||
</div>
|
||||
<div className='mb-8'>
|
||||
<div className={titleClassName}>{t('common.account.name')}</div>
|
||||
<div className={classNames('flex items-center justify-between mt-2 w-full h-9 px-3 bg-gray-100 rounded text-sm font-normal text-gray-800 cursor-pointer group')}>
|
||||
{userProfile.name}
|
||||
<div className='items-center hidden h-6 px-2 text-xs font-normal bg-white border border-gray-200 rounded-md group-hover:flex' onClick={handleEditName}>{t('common.operation.edit')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='mb-8'>
|
||||
<div className={titleClassName}>{t('common.account.email')}</div>
|
||||
<div className={classNames(inputClassName, 'cursor-pointer')}>{userProfile.email}</div>
|
||||
</div>
|
||||
{systemFeatures.enable_email_password_login && (
|
||||
<div className='mb-8'>
|
||||
<div className='mb-1 text-sm font-medium text-gray-900'>{t('common.account.password')}</div>
|
||||
<div className='mb-2 text-xs text-gray-500'>{t('common.account.passwordTip')}</div>
|
||||
<Button onClick={() => setEditPasswordModalVisible(true)}>{userProfile.is_password_set ? t('common.account.resetPassword') : t('common.account.setPassword')}</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className='mb-6 border-[0.5px] border-gray-100' />
|
||||
<div className='mb-8'>
|
||||
<div className={titleClassName}>{t('common.account.langGeniusAccount')}</div>
|
||||
<div className={descriptionClassName}>{t('common.account.langGeniusAccountTip')}</div>
|
||||
{!!apps.length && (
|
||||
<Collapse
|
||||
title={`${t('common.account.showAppLength', { length: apps.length })}`}
|
||||
items={apps.map(app => ({ key: app.id, name: app.name }))}
|
||||
renderItem={renderAppItem}
|
||||
wrapperClassName='mt-2'
|
||||
/>
|
||||
)}
|
||||
{!IS_CE_EDITION && <Button className='mt-2 text-[#D92D20]' onClick={() => setShowDeleteAccountModal(true)}>{t('common.account.delete')}</Button>}
|
||||
</div>
|
||||
{editNameModalVisible && (
|
||||
<Modal
|
||||
isShow
|
||||
onClose={() => setEditNameModalVisible(false)}
|
||||
className={s.modal}
|
||||
>
|
||||
<div className='mb-6 text-lg font-medium text-gray-900'>{t('common.account.editName')}</div>
|
||||
<div className={titleClassName}>{t('common.account.name')}</div>
|
||||
<input
|
||||
className={inputClassName}
|
||||
value={editName}
|
||||
onChange={e => setEditName(e.target.value)}
|
||||
/>
|
||||
<div className='flex justify-end mt-10'>
|
||||
<Button className='mr-2' onClick={() => setEditNameModalVisible(false)}>{t('common.operation.cancel')}</Button>
|
||||
<Button
|
||||
disabled={editing || !editName}
|
||||
variant='primary'
|
||||
onClick={handleSaveName}
|
||||
>
|
||||
{t('common.operation.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
{editPasswordModalVisible && (
|
||||
<Modal
|
||||
isShow
|
||||
onClose={() => {
|
||||
setEditPasswordModalVisible(false)
|
||||
resetPasswordForm()
|
||||
}}
|
||||
className={s.modal}
|
||||
>
|
||||
<div className='mb-6 text-lg font-medium text-gray-900'>{userProfile.is_password_set ? t('common.account.resetPassword') : t('common.account.setPassword')}</div>
|
||||
{userProfile.is_password_set && (
|
||||
<>
|
||||
<div className={titleClassName}>{t('common.account.currentPassword')}</div>
|
||||
<input
|
||||
type="password"
|
||||
className={inputClassName}
|
||||
value={currentPassword}
|
||||
onChange={e => setCurrentPassword(e.target.value)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className='mt-8 text-sm font-medium text-gray-900'>
|
||||
{userProfile.is_password_set ? t('common.account.newPassword') : t('common.account.password')}
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
className={inputClassName}
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
/>
|
||||
<div className='mt-8 text-sm font-medium text-gray-900'>{t('common.account.confirmPassword')}</div>
|
||||
<input
|
||||
type="password"
|
||||
className={inputClassName}
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
<div className='flex justify-end mt-10'>
|
||||
<Button className='mr-2' onClick={() => {
|
||||
setEditPasswordModalVisible(false)
|
||||
resetPasswordForm()
|
||||
}}>{t('common.operation.cancel')}</Button>
|
||||
<Button
|
||||
disabled={editing}
|
||||
variant='primary'
|
||||
onClick={handleSavePassword}
|
||||
>
|
||||
{userProfile.is_password_set ? t('common.operation.reset') : t('common.operation.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
{showDeleteAccountModal && (
|
||||
<Confirm
|
||||
isShow
|
||||
onCancel={() => setShowDeleteAccountModal(false)}
|
||||
onConfirm={() => setShowDeleteAccountModal(false)}
|
||||
showCancel={false}
|
||||
type='warning'
|
||||
title={t('common.account.delete')}
|
||||
content={
|
||||
<>
|
||||
<div className='my-1 text-[#D92D20] text-sm leading-5'>
|
||||
{t('common.account.deleteTip')}
|
||||
</div>
|
||||
<div className='mt-3 text-sm leading-5'>
|
||||
<span>{t('common.account.deleteConfirmTip')}</span>
|
||||
<a
|
||||
className='text-primary-600 cursor'
|
||||
href={`mailto:support@dify.ai?subject=Delete Account Request&body=Delete Account: ${userProfile.email}`}
|
||||
target='_blank'
|
||||
rel='noreferrer noopener'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
window.location.href = e.currentTarget.href
|
||||
}}
|
||||
>
|
||||
support@dify.ai
|
||||
</a>
|
||||
</div>
|
||||
<div className='my-2 px-3 py-2 rounded-lg bg-gray-100 text-sm font-medium leading-5 text-gray-800'>{`${t('common.account.delete')}: ${userProfile.email}`}</div>
|
||||
</>
|
||||
}
|
||||
confirmText={t('common.operation.ok') as string}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
import React from 'react'
|
||||
import dayjs from 'dayjs'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { WorkflowVersion } from '../types'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { VersionHistory } from '@/types/workflow'
|
||||
|
||||
type VersionHistoryItemProps = {
|
||||
item: VersionHistory
|
||||
selectedVersion: string
|
||||
onClick: (item: VersionHistory) => void
|
||||
curIdx: number
|
||||
page: number
|
||||
}
|
||||
|
||||
const formatVersion = (version: string, curIdx: number, page: number): string => {
|
||||
if (curIdx === 0 && page === 1)
|
||||
return WorkflowVersion.Draft
|
||||
if (curIdx === 1 && page === 1)
|
||||
return WorkflowVersion.Latest
|
||||
try {
|
||||
const date = new Date(version)
|
||||
if (isNaN(date.getTime()))
|
||||
return version
|
||||
|
||||
// format as YYYY-MM-DD HH:mm:ss
|
||||
return date.toISOString().slice(0, 19).replace('T', ' ')
|
||||
}
|
||||
catch {
|
||||
return version
|
||||
}
|
||||
}
|
||||
|
||||
const VersionHistoryItem: React.FC<VersionHistoryItemProps> = ({ item, selectedVersion, onClick, curIdx, page }) => {
|
||||
const { t } = useTranslation()
|
||||
const formatTime = (time: number) => dayjs.unix(time).format('YYYY-MM-DD HH:mm:ss')
|
||||
const formattedVersion = formatVersion(item.version, curIdx, page)
|
||||
const renderVersionLabel = (version: string) => (
|
||||
(version === WorkflowVersion.Draft || version === WorkflowVersion.Latest)
|
||||
? (
|
||||
<div className="shrink-0 px-1 border bg-white border-[rgba(0,0,0,0.08)] rounded-[5px] truncate">
|
||||
{version}
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center p-2 h-12 text-xs font-medium text-gray-700 justify-between',
|
||||
formattedVersion === selectedVersion ? '' : 'hover:bg-gray-100',
|
||||
formattedVersion === WorkflowVersion.Draft ? 'cursor-not-allowed' : 'cursor-pointer',
|
||||
)}
|
||||
onClick={() => item.version !== WorkflowVersion.Draft && onClick(item)}
|
||||
>
|
||||
<div className='flex flex-col gap-1 py-2'>
|
||||
<span className="text-left">{formatTime(formattedVersion === WorkflowVersion.Draft ? item.updated_at : item.created_at)}</span>
|
||||
<span className="text-left">{t('workflow.panel.createdBy')} {item.created_by.name}</span>
|
||||
</div>
|
||||
{renderVersionLabel(formattedVersion)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(VersionHistoryItem)
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
'use client'
|
||||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useSWR from 'swr'
|
||||
import { useWorkflowRun } from '../hooks'
|
||||
import VersionHistoryItem from './version-history-item'
|
||||
import type { VersionHistory } from '@/types/workflow'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { fetchPublishedAllWorkflow } from '@/service/workflow'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import Button from '@/app/components/base/button'
|
||||
|
||||
const limit = 10
|
||||
|
||||
const VersionHistoryModal = () => {
|
||||
const [selectedVersion, setSelectedVersion] = useState('draft')
|
||||
const [page, setPage] = useState(1)
|
||||
const { handleRestoreFromPublishedWorkflow } = useWorkflowRun()
|
||||
const appDetail = useAppStore.getState().appDetail
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
data: versionHistory,
|
||||
isLoading,
|
||||
} = useSWR(
|
||||
`/apps/${appDetail?.id}/workflows?page=${page}&limit=${limit}`,
|
||||
fetchPublishedAllWorkflow,
|
||||
)
|
||||
|
||||
const handleVersionClick = (item: VersionHistory) => {
|
||||
if (item.version !== selectedVersion) {
|
||||
setSelectedVersion(item.version)
|
||||
handleRestoreFromPublishedWorkflow(item)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNextPage = () => {
|
||||
if (versionHistory?.has_more)
|
||||
setPage(page => page + 1)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='w-[336px] bg-white rounded-2xl border-[0.5px] border-gray-200 shadow-xl p-2'>
|
||||
<div className="max-h-[400px] overflow-auto">
|
||||
{(isLoading && page) === 1
|
||||
? (
|
||||
<div className='flex items-center justify-center h-10'>
|
||||
<Loading/>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
{versionHistory?.items?.map((item, idx) => (
|
||||
<VersionHistoryItem
|
||||
key={item.version}
|
||||
item={item}
|
||||
selectedVersion={selectedVersion}
|
||||
onClick={handleVersionClick}
|
||||
curIdx={idx}
|
||||
page={page}
|
||||
/>
|
||||
))}
|
||||
{isLoading && page > 1 && (
|
||||
<div className='flex items-center justify-center h-10'>
|
||||
<Loading/>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && versionHistory?.has_more && (
|
||||
<div className='flex items-center justify-center h-10 mt-2'>
|
||||
<Button
|
||||
className='text-sm'
|
||||
onClick={handleNextPage}
|
||||
>
|
||||
{t('workflow.common.loadMore')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && !versionHistory?.items?.length && (
|
||||
<div className='flex items-center justify-center h-10 text-gray-500'>
|
||||
{t('workflow.common.noHistory')}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(VersionHistoryModal)
|
||||
|
|
@ -202,7 +202,7 @@ const ViewHistory = ({
|
|||
{`Test ${isChatMode ? 'Chat' : 'Run'}#${item.sequence_number}`}
|
||||
</div>
|
||||
<div className='flex items-center text-xs text-gray-500 leading-[18px]'>
|
||||
{item.created_by_account.name} · {formatTimeFromNow((item.finished_at || item.created_at) * 1000)}
|
||||
{item.created_by_account?.name} · {formatTimeFromNow((item.finished_at || item.created_at) * 1000)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -18,17 +18,14 @@ import { useWorkflowUpdate } from './use-workflow-interactions'
|
|||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import type { IOtherOptions } from '@/service/base'
|
||||
import { ssePost } from '@/service/base'
|
||||
import {
|
||||
fetchPublishedWorkflow,
|
||||
stopWorkflowRun,
|
||||
} from '@/service/workflow'
|
||||
import { stopWorkflowRun } from '@/service/workflow'
|
||||
import { useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||
import { AudioPlayerManager } from '@/app/components/base/audio-btn/audio.player.manager'
|
||||
import {
|
||||
getFilesInLogs,
|
||||
} from '@/app/components/base/file-uploader/utils'
|
||||
import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
|
||||
import type { NodeTracing } from '@/types/workflow'
|
||||
import type { NodeTracing, VersionHistory } from '@/types/workflow'
|
||||
|
||||
export const useWorkflowRun = () => {
|
||||
const store = useStoreApi()
|
||||
|
|
@ -754,24 +751,18 @@ export const useWorkflowRun = () => {
|
|||
stopWorkflowRun(`/apps/${appId}/workflow-runs/tasks/${taskId}/stop`)
|
||||
}, [])
|
||||
|
||||
const handleRestoreFromPublishedWorkflow = useCallback(async () => {
|
||||
const appDetail = useAppStore.getState().appDetail
|
||||
const publishedWorkflow = await fetchPublishedWorkflow(`/apps/${appDetail?.id}/workflows/publish`)
|
||||
|
||||
if (publishedWorkflow) {
|
||||
const nodes = publishedWorkflow.graph.nodes
|
||||
const edges = publishedWorkflow.graph.edges
|
||||
const viewport = publishedWorkflow.graph.viewport!
|
||||
|
||||
handleUpdateWorkflowCanvas({
|
||||
nodes,
|
||||
edges,
|
||||
viewport,
|
||||
})
|
||||
featuresStore?.setState({ features: publishedWorkflow.features })
|
||||
workflowStore.getState().setPublishedAt(publishedWorkflow.created_at)
|
||||
workflowStore.getState().setEnvironmentVariables(publishedWorkflow.environment_variables || [])
|
||||
}
|
||||
const handleRestoreFromPublishedWorkflow = useCallback((publishedWorkflow: VersionHistory) => {
|
||||
const nodes = publishedWorkflow.graph.nodes.map(node => ({ ...node, selected: false, data: { ...node.data, selected: false } }))
|
||||
const edges = publishedWorkflow.graph.edges
|
||||
const viewport = publishedWorkflow.graph.viewport!
|
||||
handleUpdateWorkflowCanvas({
|
||||
nodes,
|
||||
edges,
|
||||
viewport,
|
||||
})
|
||||
featuresStore?.setState({ features: publishedWorkflow.features })
|
||||
workflowStore.getState().setPublishedAt(publishedWorkflow.created_at)
|
||||
workflowStore.getState().setEnvironmentVariables(publishedWorkflow.environment_variables || [])
|
||||
}, [featuresStore, handleUpdateWorkflowCanvas, workflowStore])
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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 })),
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -289,6 +289,11 @@ export enum WorkflowRunningStatus {
|
|||
Stopped = 'stopped',
|
||||
}
|
||||
|
||||
export enum WorkflowVersion {
|
||||
Draft = 'draft',
|
||||
Latest = 'latest',
|
||||
}
|
||||
|
||||
export enum NodeRunningStatus {
|
||||
NotStart = 'not-start',
|
||||
Waiting = 'waiting',
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import I18nServer from './components/i18n-server'
|
|||
import BrowserInitor from './components/browser-initor'
|
||||
import SentryInitor from './components/sentry-initor'
|
||||
import { getLocaleOnServer } from '@/i18n/server'
|
||||
import { TanstackQueryIniter } from '@/context/query-client'
|
||||
import './styles/globals.css'
|
||||
import './styles/markdown.scss'
|
||||
|
||||
|
|
@ -46,7 +47,9 @@ const LocaleLayout = ({
|
|||
>
|
||||
<BrowserInitor>
|
||||
<SentryInitor>
|
||||
<I18nServer>{children}</I18nServer>
|
||||
<TanstackQueryIniter>
|
||||
<I18nServer>{children}</I18nServer>
|
||||
</TanstackQueryIniter>
|
||||
</SentryInitor>
|
||||
</BrowserInitor>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -47,6 +47,8 @@ const translation = {
|
|||
view: 'View',
|
||||
viewMore: 'VIEW MORE',
|
||||
regenerate: 'Regenerate',
|
||||
submit: 'Submit',
|
||||
skip: 'Skip',
|
||||
},
|
||||
errorMsg: {
|
||||
fieldRequired: '{{field}} is required',
|
||||
|
|
@ -181,8 +183,19 @@ const translation = {
|
|||
editName: 'Edit Name',
|
||||
showAppLength: 'Show {{length}} apps',
|
||||
delete: 'Delete Account',
|
||||
deleteTip: 'Deleting your account will permanently erase all your data and it cannot be recovered.',
|
||||
deleteConfirmTip: 'To confirm, please send the following from your registered email to ',
|
||||
deleteTip: 'Please note, once confirmed, as the Owner of any Workspaces, your workspaces will be scheduled in a queue for permanent deletion, and all your user data will be queued for permanent deletion.',
|
||||
deletePrivacyLinkTip: 'For more information about how we handle your data, please see our ',
|
||||
deletePrivacyLink: 'Privacy Policy.',
|
||||
deleteSuccessTip: 'Your account needs time to finish deleting. We\'ll email you when it\'s all done.',
|
||||
deleteLabel: 'To confirm, please type in your email below',
|
||||
deletePlaceholder: 'Please enter your email',
|
||||
sendVerificationButton: 'Send Verification Code',
|
||||
verificationLabel: 'Verification Code',
|
||||
verificationPlaceholder: 'Paste the 6-digit code',
|
||||
permanentlyDeleteButton: 'Permanently Delete Account',
|
||||
feedbackTitle: 'Feedback',
|
||||
feedbackLabel: 'Tell us why you deleted your account?',
|
||||
feedbackPlaceholder: 'Optional',
|
||||
},
|
||||
members: {
|
||||
team: 'Team',
|
||||
|
|
|
|||
|
|
@ -104,6 +104,8 @@ const translation = {
|
|||
branch: 'BRANCH',
|
||||
onFailure: 'On Failure',
|
||||
addFailureBranch: 'Add Fail Branch',
|
||||
loadMore: 'Load More Workflows',
|
||||
noHistory: 'No History',
|
||||
},
|
||||
env: {
|
||||
envPanelTitle: 'Environment Variables',
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ const translation = {
|
|||
advancedWarning: {
|
||||
title: 'エキスパートモードに切り替えました。PROMPTを変更すると、基本モードに戻ることはできません。',
|
||||
description: 'エキスパートモードでは、PROMPT全体を編集できます。',
|
||||
learnMore: '詳細を見る',
|
||||
learnMore: '詳細はこちら',
|
||||
ok: 'OK',
|
||||
},
|
||||
operation: {
|
||||
|
|
@ -150,7 +150,7 @@ const translation = {
|
|||
title: '会話履歴',
|
||||
description: '会話の役割に接頭辞名を設定します',
|
||||
tip: '会話履歴は有効になっていません。上記のプロンプトに <histories> を追加してください。',
|
||||
learnMore: '詳細を見る',
|
||||
learnMore: '詳細はこちら',
|
||||
editModal: {
|
||||
title: '会話役割名の編集',
|
||||
userPrefix: 'ユーザー接頭辞',
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ const translation = {
|
|||
reload: '再読み込み',
|
||||
ok: 'OK',
|
||||
log: 'ログ',
|
||||
learnMore: '詳細を見る',
|
||||
learnMore: '詳細はこちら',
|
||||
params: 'パラメータ',
|
||||
duplicate: '重複',
|
||||
rename: '名前の変更',
|
||||
|
|
@ -42,11 +42,11 @@ const translation = {
|
|||
openInNewTab: '新しいタブで開く',
|
||||
zoomOut: 'ズームアウト',
|
||||
copyImage: '画像をコピー',
|
||||
viewMore: 'もっと見る',
|
||||
view: '眺める',
|
||||
close: '閉める',
|
||||
saveAndRegenerate: '子チャンクの保存と再生成',
|
||||
regenerate: '再生',
|
||||
saveAndRegenerate: '保存して子チャンクを再生成',
|
||||
close: '閉じる',
|
||||
view: '表示',
|
||||
viewMore: 'さらに表示',
|
||||
regenerate: '再生成',
|
||||
},
|
||||
errorMsg: {
|
||||
fieldRequired: '{{field}}は必要です',
|
||||
|
|
@ -393,7 +393,7 @@ const translation = {
|
|||
configure: '設定',
|
||||
notion: {
|
||||
title: 'ノーション',
|
||||
description: '知識データソースとしてノーションを使用します。',
|
||||
description: 'ナレッジデータソースとしてノーションを使用します。',
|
||||
connectedWorkspace: '接続済みワークスペース',
|
||||
addWorkspace: 'ワークスペースの追加',
|
||||
connected: '接続済み',
|
||||
|
|
@ -480,10 +480,10 @@ const translation = {
|
|||
documents: 'ドキュメント',
|
||||
hitTesting: '検索テスト',
|
||||
settings: '設定',
|
||||
emptyTip: '関連付けられた知識がありません。アプリケーションやプラグインに移動して関連付けを完了してください。',
|
||||
emptyTip: 'このナレッジはどのアプリケーションにも統合されていません。ドキュメントを参照してガイダンスを確認してください。',
|
||||
viewDoc: 'ドキュメントを表示',
|
||||
relatedApp: '関連アプリ',
|
||||
noRelatedApp: 'リンクされたアプリはありません',
|
||||
noRelatedApp: '関連付けられたアプリはありません',
|
||||
},
|
||||
voiceInput: {
|
||||
speaking: '今話しています...',
|
||||
|
|
@ -508,7 +508,7 @@ const translation = {
|
|||
conversationNameCanNotEmpty: '会話名は必須です',
|
||||
citation: {
|
||||
title: '引用',
|
||||
linkToDataset: '知識へのリンク',
|
||||
linkToDataset: 'ナレッジへのリンク',
|
||||
characters: '文字数:',
|
||||
hitCount: '検索回数:',
|
||||
vectorHash: 'ベクトルハッシュ:',
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ const translation = {
|
|||
creation: 'ナレッジの作成',
|
||||
update: 'データの追加',
|
||||
},
|
||||
one: 'データソースの選択',
|
||||
two: 'テキストの前処理とクリーニング',
|
||||
three: '実行して完了',
|
||||
one: 'データソース',
|
||||
two: 'テキスト進行中',
|
||||
three: '実行と完成',
|
||||
},
|
||||
error: {
|
||||
unavailable: 'このナレッジは利用できません',
|
||||
|
|
@ -16,6 +16,11 @@ const translation = {
|
|||
apiKeyPlaceholder: 'firecrawl.devからのAPIキー',
|
||||
getApiKeyLinkText: 'firecrawl.devからAPIキーを取得する',
|
||||
},
|
||||
jinaReader: {
|
||||
getApiKeyLinkText: '無料のAPIキーを jina.ai で取得',
|
||||
apiKeyPlaceholder: 'jina.ai からの API キー',
|
||||
configJinaReader: 'Jina Readerの設定',
|
||||
},
|
||||
stepOne: {
|
||||
filePreview: 'ファイルプレビュー',
|
||||
pagePreview: 'ページプレビュー',
|
||||
|
|
@ -42,6 +47,7 @@ const translation = {
|
|||
notionSyncTitle: 'Notionが接続されていません',
|
||||
notionSyncTip: 'Notionと同期するには、まずNotionへの接続が必要です。',
|
||||
connect: '接続する',
|
||||
cancel: 'キャンセル',
|
||||
button: '次へ',
|
||||
emptyDatasetCreation: '空のナレッジを作成します',
|
||||
modal: {
|
||||
|
|
@ -87,7 +93,6 @@ const translation = {
|
|||
jinaReaderNotConfiguredDescription: '無料のAPIキーを入力してJina Readerを設定します。',
|
||||
useSitemapTooltip: 'サイトマップに沿ってサイトをクロールします。そうでない場合、Jina Readerはページの関連性に基づいて繰り返しクロールし、ページ数は少なくなりますが、高品質のページが得られます。',
|
||||
},
|
||||
cancel: 'キャンセル',
|
||||
},
|
||||
stepTwo: {
|
||||
segmentation: 'チャンク設定',
|
||||
|
|
@ -95,6 +100,16 @@ const translation = {
|
|||
autoDescription: 'チャンクと前処理ルールを自動的に設定します。初めてのユーザーはこれを選択することをおすすめします。',
|
||||
custom: 'カスタム',
|
||||
customDescription: 'チャンクのルール、チャンクの長さ、前処理ルールなどをカスタマイズします。',
|
||||
general: '一般',
|
||||
generalTip: '標準的なテキスト分割モードです。検索とコンテキスト抽出に同じチャンクを使用します。',
|
||||
parentChild: '親子',
|
||||
parentChildTip: '親子モードでは、子チャンクを検索に、親チャンクをコンテキスト抽出に使用します。',
|
||||
parentChunkForContext: 'コンテキスト用親チャンク',
|
||||
childChunkForRetrieval: '検索用子チャンク',
|
||||
paragraph: '段落',
|
||||
paragraphTip: '区切り文字と最大チャンク長に基づいてテキストを段落に分割し、分割されたテキストを検索用の親チャンクとして使用します。',
|
||||
fullDoc: '全文',
|
||||
fullDocTip: 'ドキュメント全体を親チャンクとして使用し、直接検索します。パフォーマンス上の理由から、10000トークンを超えるテキストは自動的に切り捨てられます。',
|
||||
separator: 'セグメント識別子',
|
||||
separatorPlaceholder: '例えば改行(\\\\n)や特殊なセパレータ(例:「***」)',
|
||||
maxLength: '最大チャンク長',
|
||||
|
|
@ -105,19 +120,22 @@ const translation = {
|
|||
removeExtraSpaces: '連続するスペース、改行、タブを置換する',
|
||||
removeUrlEmails: 'すべてのURLとメールアドレスを削除する',
|
||||
removeStopwords: '「a」「an」「the」などのストップワードを削除する',
|
||||
preview: '確認&プレビュー',
|
||||
preview: 'プレビュー',
|
||||
previewChunk: 'チュンクをプレビュー',
|
||||
reset: 'リセット',
|
||||
indexMode: 'インデックスモード',
|
||||
indexMode: 'インデックス方法',
|
||||
qualified: '高品質',
|
||||
recommend: 'おすすめ',
|
||||
qualifiedTip: 'ユーザーのクエリに対してより高い精度を提供するために、デフォルトのシステム埋め込みインターフェースを呼び出して処理します。',
|
||||
highQualityTip: '高品質モードで埋め込みを終了したら、経済的モードに戻すことはできません。',
|
||||
recommend: '推奨',
|
||||
qualifiedTip: '埋め込みモデルを呼び出してドキュメントを処理し、より正確な検索を行うと、LLMが高品質の回答を生成するのに役立ちます。',
|
||||
warning: 'モデルプロバイダのAPIキーを設定してください。',
|
||||
click: '設定に移動',
|
||||
economical: '経済的',
|
||||
economicalTip: 'オフラインのベクトルエンジン、キーワードインデックスなどを使用して、トークンを消費せずに精度を低下させます。',
|
||||
economicalTip: '検索時にチャンクあたり10個のキーワードを使用することで、精度は低下しますが、トークン消費を抑えられます。',
|
||||
QATitle: '質問と回答形式でセグメント化',
|
||||
QATip: 'このオプションを有効にすると、追加のトークンが消費されます',
|
||||
QALanguage: '使用言語',
|
||||
useQALanguage: 'Q&A形式で分割',
|
||||
estimateCost: '見積もり',
|
||||
estimateSegment: '推定チャンク数',
|
||||
segmentCount: 'チャンク',
|
||||
|
|
@ -149,32 +167,19 @@ const translation = {
|
|||
datasetSettingLink: 'ナレッジ設定',
|
||||
separatorTip: '区切り文字は、テキストを区切るために使用される文字です。\\n\\n と \\n は、段落と行を区切るために一般的に使用される区切り記号です。カンマ (\\n\\n,\\n) と組み合わせると、最大チャンク長を超えると、段落は行で区切られます。自分で定義した特別な区切り文字を使用することもできます(例:***)。',
|
||||
maxLengthCheck: 'チャンクの最大長は {{limit}} 未満にする必要があります',
|
||||
useQALanguage: 'Q&A 形式を使用したチャンク',
|
||||
previewChunkTip: '左側の「Preview Chunk」ボタンをクリックして、プレビューをロードします',
|
||||
qaSwitchHighQualityTipTitle: 'Q&A 形式には高品質のインデックス作成方法が必要',
|
||||
qaSwitchHighQualityTipContent: '現在、Q&A 形式のチャンク化をサポートしているのは、高品質のインデックス メソッドのみです。高品質モードに切り替えますか?',
|
||||
childChunkForRetrieval: '取得用の子チャンク',
|
||||
fullDoc: 'フルドキュメント',
|
||||
parentChildDelimiterTip: '区切り文字は、テキストを区切るために使用される文字です。\\n\\n は、元のドキュメントを大きな親チャンクに分割する場合に推奨されます。また、自分で定義した特別な区切り文字を使用することもできます。',
|
||||
general: '全般',
|
||||
switch: 'スイッチ',
|
||||
parentChild: '親子',
|
||||
parentChildChunkDelimiterTip: '区切り文字は、テキストを区切るために使用される文字です。\\n は、親チャンクを小さな子チャンクに分割する場合に推奨されます。また、自分で定義した特別な区切り文字を使用することもできます。',
|
||||
generalTip: '一般的なテキストチャンクモードでは、取得されたチャンクとリコールされたチャンクは同じです。',
|
||||
previewChunk: 'プレビューチャンク',
|
||||
parentChunkForContext: 'コンテキストの親チャンク',
|
||||
notAvailableForQA: 'Q&Aインデックスでは使用できません',
|
||||
paragraph: '段落',
|
||||
notAvailableForParentChild: '親子インデックスでは使用できません',
|
||||
fullDocTip: 'ドキュメント全体が親チャンクとして使用され、直接取得されます。パフォーマンス上の理由から、10000トークンを超えるテキストは自動的に切り捨てられることに注意してください。',
|
||||
previewChunkCount: '{{カウント}}推定チャンク',
|
||||
paragraphTip: 'このモードでは、区切り記号とチャンクの最大長に基づいてテキストを段落に分割し、分割されたテキストを取得用の親チャンクとして使用します。',
|
||||
highQualityTip: '高品質モードでの埋め込みが完了すると、経済モードに戻すことはできません。',
|
||||
parentChildTip: '親子モードを使用する場合、子チャンクは取得に使用され、親チャンクはコンテキストとしての再呼び出しに使用されます。',
|
||||
previewChunkTip: 'プレビューを読み込むには、左側の \'チュンクをプレビュー\' ボタンをクリックしてください',
|
||||
previewChunkCount: '推定チャンク数: {{count}}',
|
||||
switch: '切り替え',
|
||||
qaSwitchHighQualityTipTitle: 'Q&A形式には高品質なインデックスが必要です',
|
||||
qaSwitchHighQualityTipContent: '現在、高品質なインデックス作成のみがQ&A形式の分割をサポートしています。高品質モードに切り替えますか?',
|
||||
notAvailableForParentChild: '親子インデックスでは利用できません',
|
||||
notAvailableForQA: 'Q&Aインデックスでは利用できません',
|
||||
parentChildDelimiterTip: '区切り文字とは、テキストを分割するために使用される文字です。\\n\\n は、元のドキュメントを大きな親チャンクに分割する際におすすめです。独自の区切り文字も使用できます。',
|
||||
parentChildChunkDelimiterTip: '区切り文字とは、テキストを分割するために使用される文字です。\\n は、親チャンクを小さな子チャンクに分割する際におすすめです。独自の区切り文字も使用できます。',
|
||||
},
|
||||
stepThree: {
|
||||
creationTitle: '🎉 ナレッジが作成されました',
|
||||
creationContent: 'ナレッジの名前は自動的に設定されましたが、いつでも変更できます',
|
||||
creationContent: 'ナレッジの名前は自動的に設定されましたが、いつでも変更できます。',
|
||||
label: 'ナレッジ名',
|
||||
additionTitle: '🎉 ドキュメントがアップロードされました',
|
||||
additionP1: 'ドキュメントはナレッジにアップロードされました',
|
||||
|
|
@ -189,15 +194,10 @@ const translation = {
|
|||
modelButtonConfirm: '確認',
|
||||
modelButtonCancel: 'キャンセル',
|
||||
},
|
||||
jinaReader: {
|
||||
getApiKeyLinkText: '無料のAPIキーを jina.ai で取得',
|
||||
apiKeyPlaceholder: 'jina.ai からの API キー',
|
||||
configJinaReader: 'Jina Readerの設定',
|
||||
},
|
||||
otherDataSource: {
|
||||
description: '現在、Difyのナレッジベースには限られたデータソースしかありません。データソースをDifyナレッジベースに投稿することは、すべてのユーザーにとってプラットフォームの柔軟性とパワーを向上させるのに役立つ素晴らしい方法です。私たちのコントリビューションガイドは、簡単に始めることができます。詳細については、以下のリンクをクリックしてください。',
|
||||
title: '他のデータソースに接続しますか?',
|
||||
learnMore: '詳細情報',
|
||||
title: '他のデータソースと接続しますか?',
|
||||
description: '現在、Difyのナレッジベースには利用できるデータソースが限られています。Difyのナレッジベースにデータソースを提供いただくことは、プラットフォームの柔軟性と能力を向上させる上で非常に有益です。貢献ガイドをご用意していますので、ぜひご協力ください。詳細については、以下のリンクをクリックしてください。',
|
||||
learnMore: '詳細はこちら',
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,25 +2,26 @@ const translation = {
|
|||
list: {
|
||||
title: 'ドキュメント',
|
||||
desc: 'ナレッジのすべてのファイルがここに表示され、ナレッジ全体がDifyの引用やチャットプラグインを介してリンクされるか、インデックス化されることができます。',
|
||||
learnMore: '詳細はこちら',
|
||||
addFile: 'ファイルを追加',
|
||||
addPages: 'ページを追加',
|
||||
addUrl: 'URLを追加',
|
||||
table: {
|
||||
header: {
|
||||
fileName: 'ファイル名',
|
||||
chunkingMode: 'チャンキングモード',
|
||||
words: '単語数',
|
||||
hitCount: '検索回数',
|
||||
uploadTime: 'アップロード時間',
|
||||
status: 'ステータス',
|
||||
action: 'アクション',
|
||||
chunkingMode: 'チャンクモード',
|
||||
},
|
||||
rename: '名前を変更',
|
||||
name: '名前',
|
||||
},
|
||||
action: {
|
||||
uploadFile: '新しいファイルをアップロード',
|
||||
settings: 'セグメント設定',
|
||||
settings: 'チャンク設定',
|
||||
addButton: 'チャンクを追加',
|
||||
add: 'チャンクを追加',
|
||||
batchAdd: '一括追加',
|
||||
|
|
@ -78,7 +79,6 @@ const translation = {
|
|||
error: 'インポートエラー',
|
||||
ok: 'OK',
|
||||
},
|
||||
learnMore: '詳細情報',
|
||||
},
|
||||
metadata: {
|
||||
title: 'メタデータ',
|
||||
|
|
@ -318,29 +318,46 @@ const translation = {
|
|||
completed: '埋め込みが完了しました',
|
||||
error: '埋め込みエラー',
|
||||
docName: 'ドキュメントの前処理',
|
||||
mode: 'セグメンテーションルール',
|
||||
segmentLength: 'チャンクの長さ',
|
||||
textCleaning: 'テキストの前処理',
|
||||
mode: 'チャンキングモード',
|
||||
segmentLength: '最大なチャンクの長さ',
|
||||
textCleaning: 'テキストの前処理ルール',
|
||||
segments: '段落',
|
||||
highQuality: '高品質モード',
|
||||
economy: '経済モード',
|
||||
estimate: '推定消費量',
|
||||
stop: '処理を停止',
|
||||
resume: '処理を再開',
|
||||
pause: '処理を一時停止',
|
||||
resume: '再開',
|
||||
automatic: '自動',
|
||||
custom: 'カスタム',
|
||||
hierarchical: '親子チャンキング',
|
||||
previewTip: '埋め込みが完了した後、段落のプレビューが利用可能になります',
|
||||
parentMaxTokens: '親',
|
||||
hierarchical: '親子',
|
||||
pause: '休止',
|
||||
childMaxTokens: '子供',
|
||||
childMaxTokens: '子',
|
||||
},
|
||||
segment: {
|
||||
paragraphs: '段落',
|
||||
chunks_one: 'チャンク',
|
||||
chunks_other: 'チャンク',
|
||||
parentChunks_one: '親チャンク',
|
||||
parentChunks_other: '親チャンク',
|
||||
childChunks_one: '子チャンク',
|
||||
childChunks_other: '子チャンク',
|
||||
searchResults_zero: '検索結果',
|
||||
searchResults_one: '検索結果',
|
||||
searchResults_other: '検索結果',
|
||||
empty: 'チャンクが見つかりません',
|
||||
clearFilter: 'フィルターをクリア',
|
||||
chunk: 'チャンク',
|
||||
parentChunk: '親チャンク',
|
||||
newChunk: '新しいチャンク',
|
||||
childChunk: '子チャンク',
|
||||
newChildChunk: '新しい子チャンク',
|
||||
keywords: 'キーワード',
|
||||
addKeyWord: 'キーワードを追加',
|
||||
keywordError: 'キーワードの最大長は20です',
|
||||
characters: '文字',
|
||||
characters_one: '文字',
|
||||
characters_other: '文字',
|
||||
hitCount: '検索回数',
|
||||
vectorHash: 'ベクトルハッシュ: ',
|
||||
questionPlaceholder: 'ここに質問を追加',
|
||||
|
|
@ -349,46 +366,28 @@ const translation = {
|
|||
answerEmpty: '回答は空にできません',
|
||||
contentPlaceholder: 'ここに内容を追加',
|
||||
contentEmpty: '内容は空にできません',
|
||||
newTextSegment: '新しいテキストセグメント',
|
||||
newQaSegment: '新しいQ&Aセグメント',
|
||||
delete: 'このチャンクを削除しますか?',
|
||||
searchResults_other: '業績',
|
||||
edited: '編集',
|
||||
parentChunk: '親チャンク',
|
||||
regeneratingTitle: '子チャンクの再生成',
|
||||
collapseChunks: 'チャンクの折りたたみ',
|
||||
characters_other: '文字',
|
||||
childChunk: '子チャンク',
|
||||
regenerationSuccessMessage: 'このウィンドウは閉じることができます。',
|
||||
editChildChunk: '子チャンクの編集',
|
||||
clearFilter: 'フィルターをクリア',
|
||||
chunkDetail: 'チャンクの詳細',
|
||||
regenerationSuccessTitle: '再生完了',
|
||||
parentChunks_one: '親チャンク',
|
||||
newChunk: '新しいチャンク',
|
||||
childChunks_other: '子チャンク',
|
||||
searchResults_zero: '結果',
|
||||
addChildChunk: '子チャンクを追加',
|
||||
searchResults_one: '結果',
|
||||
regeneratingMessage: 'これには少し時間がかかる場合がありますので、お待ちください...',
|
||||
empty: 'チャンクが見つかりません',
|
||||
editedAt: 'で編集',
|
||||
addAnother: '別のものを追加',
|
||||
chunkAdded: '1チャンク追加',
|
||||
childChunks_one: '子チャンク',
|
||||
regenerationConfirmMessage: '子チャンクを再生成すると、編集されたチャンクや新しく追加されたチャンクなど、現在の子チャンクが上書きされます。再生は元に戻せません。',
|
||||
newChildChunk: '新しい子チャンク',
|
||||
childChunkAdded: '子チャンクが1つ追加',
|
||||
regenerationConfirmTitle: '子チャンクを再生成しますか?',
|
||||
expandChunks: 'チャンクの展開',
|
||||
chunks_one: 'チャンク',
|
||||
editChunk: 'チャンクの編集',
|
||||
editParentChunk: '親チャンクの編集',
|
||||
parentChunks_other: '親チャンク',
|
||||
characters_one: '文字',
|
||||
chunks_other: 'チャンク',
|
||||
newTextSegment: '新しいテキストチャンク',
|
||||
newQaSegment: '新しいQ&Aチャンク',
|
||||
addChunk: 'チャンクを追加',
|
||||
chunk: 'チャンク',
|
||||
addChildChunk: '子チャンクを追加',
|
||||
addAnother: '続けて追加',
|
||||
delete: 'このチャンクを削除しますか?',
|
||||
chunkAdded: 'チャンクを追加しました',
|
||||
childChunkAdded: '子チャンクを追加しました',
|
||||
editChunk: 'チャンクを編集',
|
||||
editParentChunk: '親チャンクを編集',
|
||||
editChildChunk: '子チャンクを編集',
|
||||
chunkDetail: 'チャンクの詳細',
|
||||
regenerationConfirmTitle: '子チャンクを再生成しますか?',
|
||||
regenerationConfirmMessage: '再生成された子チャンクは、編集済みまたは新規追加の子チャンクを含め、現在の子チャンクを上書きします。この操作は取り消せません。',
|
||||
regeneratingTitle: '子チャンクを生成中',
|
||||
regeneratingMessage: '子チャンクの生成には時間がかかります、しばらくお待ちください。',
|
||||
regenerationSuccessTitle: '子チャンクの再生成が完了しました',
|
||||
regenerationSuccessMessage: 'ウィンドウを閉じても大丈夫です',
|
||||
edited: '編集済み',
|
||||
editedAt: '編集日時',
|
||||
expandChunks: 'チャンクを展開',
|
||||
collapseChunks: 'チャンクを折りたたむ',
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ const translation = {
|
|||
title: '検索テスト',
|
||||
desc: '与えられたクエリテキストに基づいたナレッジのヒット効果をテストします。',
|
||||
dateTimeFormat: 'MM/DD/YYYY hh:mm A',
|
||||
recents: '最近の結果',
|
||||
records: '記録',
|
||||
table: {
|
||||
header: {
|
||||
source: 'ソース',
|
||||
|
|
@ -18,18 +18,17 @@ const translation = {
|
|||
testing: 'テスト中',
|
||||
},
|
||||
hit: {
|
||||
title: '検索結果パラグラフ',
|
||||
title: '取得したチャンク{{num}}個',
|
||||
emptyTip: '検索テストの結果がここに表示されます。',
|
||||
},
|
||||
noRecentTip: '最近のクエリ結果はありません。',
|
||||
viewChart: 'ベクトルチャートを表示',
|
||||
settingTitle: '取得設定',
|
||||
viewDetail: '詳細を表示',
|
||||
records: '誌',
|
||||
hitChunks: '{{num}}子チャンクをヒット',
|
||||
open: '開ける',
|
||||
keyword: 'キーワード',
|
||||
chunkDetail: 'チャンクの詳細',
|
||||
hitChunks: '{{num}}個の子チャンクをヒット',
|
||||
open: '開く',
|
||||
keyword: 'キーワード',
|
||||
}
|
||||
|
||||
export default translation
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ const translation = {
|
|||
nameError: '名前は空にできません',
|
||||
desc: 'ナレッジの説明',
|
||||
descInfo: 'ナレッジの内容を概説するための明確なテキストの説明を書いてください。この説明は、複数のナレッジから推論を選択する際の基準として使用されます。',
|
||||
descPlaceholder: 'このナレッジに含まれる内容を説明してください。詳細な説明は、AIがナレッジの内容にタイムリーにアクセスできるようにします。空の場合、Difyはデフォルトのヒット戦略を使用します。',
|
||||
descPlaceholder: 'このデータセットの内容を記述してください。詳細に記述することで、AIがデータセットの内容に迅速にアクセスできるようになります。空欄の場合、LangGeniusはデフォルトの検索方法を使用します。',
|
||||
helpText: '適切なデータセットの説明を作成する方法を学びましょう。',
|
||||
descWrite: '良いナレッジの説明の書き方を学ぶ。',
|
||||
permissions: '権限',
|
||||
permissionsOnlyMe: '自分のみ',
|
||||
|
|
@ -16,15 +17,16 @@ const translation = {
|
|||
me: '(あなた様)',
|
||||
indexMethod: 'インデックス方法',
|
||||
indexMethodHighQuality: '高品質',
|
||||
indexMethodHighQualityTip: 'ユーザーがクエリを実行する際により高い精度を提供するために、Embeddingモデルを呼び出して処理を行う。',
|
||||
indexMethodHighQualityTip: 'より正確な検索のため、埋め込みモデルを呼び出してドキュメントを処理することで、LLMは高品質な回答を生成できます。',
|
||||
upgradeHighQualityTip: '高品質モードにアップグレードすると、経済的モードには戻せません。',
|
||||
indexMethodEconomy: '経済的',
|
||||
indexMethodEconomyTip: 'オフラインのベクトルエンジン、キーワードインデックスなどを使用して精度を低下させることなく、トークンを消費せずに処理します。',
|
||||
indexMethodEconomyTip: 'チャンクあたり10個のキーワードを検索に使用します。トークンは消費しませんが、検索精度は低下します。',
|
||||
embeddingModel: '埋め込みモデル',
|
||||
embeddingModelTip: '埋め込みモデルを変更するには、',
|
||||
embeddingModelTipLink: '設定',
|
||||
retrievalSetting: {
|
||||
title: '検索設定',
|
||||
learnMore: '詳細を見る',
|
||||
learnMore: '詳細はこちら',
|
||||
description: ' 検索方法についての詳細',
|
||||
longDescription: ' 検索方法についての詳細については、いつでもナレッジの設定で変更できます。',
|
||||
},
|
||||
|
|
@ -32,9 +34,7 @@ const translation = {
|
|||
externalKnowledgeID: '外部ナレッジID',
|
||||
retrievalSettings: '取得設定',
|
||||
externalKnowledgeAPI: '外部ナレッジAPI',
|
||||
upgradeHighQualityTip: 'ハイクオリティモードにアップグレードすると、エコノミーモードに戻すことはできません',
|
||||
indexMethodChangeToEconomyDisabledTip: '本社からECOへのダウングレードは対象外です',
|
||||
helpText: '適切なデータセットの説明を書く方法を学びます。',
|
||||
indexMethodChangeToEconomyDisabledTip: 'HQからECOへのダウングレードはできません。',
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,74 @@
|
|||
const translation = {
|
||||
knowledge: 'ナレッジ',
|
||||
chunkingMode: {
|
||||
general: '一般',
|
||||
parentChild: '親子',
|
||||
},
|
||||
parentMode: {
|
||||
paragraph: '段落',
|
||||
fullDoc: '全体',
|
||||
},
|
||||
externalTag: '外部',
|
||||
externalAPI: '外部API',
|
||||
externalAPIPanelTitle: '外部ナレッジ連携API',
|
||||
externalKnowledgeId: '外部ナレッジID',
|
||||
externalKnowledgeName: '外部ナレッジ名',
|
||||
externalKnowledgeDescription: 'ナレッジの説明',
|
||||
externalKnowledgeIdPlaceholder: 'ナレッジIDを入力',
|
||||
externalKnowledgeNamePlaceholder: 'ナレッジベース名を入力',
|
||||
externalKnowledgeDescriptionPlaceholder: 'このナレッジベースの説明(任意)',
|
||||
learnHowToWriteGoodKnowledgeDescription: '効果的なナレッジの説明の書き方',
|
||||
externalAPIPanelDescription: '外部ナレッジ連携APIは、Dify外のナレッジベースと連携し、そこからナレッジを取得するために使用します。',
|
||||
externalAPIPanelDocumentation: '外部ナレッジ連携APIの作成方法',
|
||||
localDocs: 'ローカルドキュメント',
|
||||
documentCount: ' ドキュメント',
|
||||
wordCount: ' k 単語',
|
||||
appCount: ' リンクされたアプリ',
|
||||
createDataset: 'ナレッジを作成',
|
||||
createNewExternalAPI: '新しい外部ナレッジ連携APIを作成',
|
||||
noExternalKnowledge: '外部ナレッジ連携APIがありません。ここをクリックして作成してください',
|
||||
createExternalAPI: '外部ナレッジ連携APIを追加',
|
||||
editExternalAPIFormTitle: '外部ナレッジ連携APIを編集',
|
||||
editExternalAPITooltipTitle: '連携中のナレッジ',
|
||||
editExternalAPIConfirmWarningContent: {
|
||||
front: 'この外部ナレッジ連携APIは',
|
||||
end: '件の外部ナレッジと連携しており、この変更はすべてに適用されます。変更を保存しますか?',
|
||||
},
|
||||
editExternalAPIFormWarning: {
|
||||
front: 'この外部APIは',
|
||||
end: '件の外部ナレッジと連携しています',
|
||||
},
|
||||
deleteExternalAPIConfirmWarningContent: {
|
||||
title: {
|
||||
front: '削除',
|
||||
end: 'しますか?',
|
||||
},
|
||||
content: {
|
||||
front: 'この外部ナレッジ連携APIは',
|
||||
end: '件の外部ナレッジと連携しています。このAPIを削除すると、すべて無効になります。このAPIを削除しますか?',
|
||||
},
|
||||
noConnectionContent: 'このAPIを削除しますか?',
|
||||
},
|
||||
selectExternalKnowledgeAPI: {
|
||||
placeholder: '外部ナレッジ連携APIを選択',
|
||||
},
|
||||
connectDataset: '外部ナレッジベースと連携',
|
||||
connectDatasetIntro: {
|
||||
title: '外部ナレッジベースとの連携方法',
|
||||
content: {
|
||||
front: '外部ナレッジベースと連携するには、まず外部APIを作成する必要があります。以下の手順を参照し、',
|
||||
link: '外部APIの作成方法',
|
||||
end: 'をご確認ください。次に、対応するナレッジIDを左側のフォームに入力してください。すべての情報が正しければ、連携ボタンをクリックすると、自動的にナレッジベースの検索テストに移動します。',
|
||||
},
|
||||
learnMore: '詳細はこちら',
|
||||
},
|
||||
connectHelper: {
|
||||
helper1: 'APIとナレッジベースIDを使って外部ナレッジベースと連携します。現在、',
|
||||
helper2: '検索機能のみがサポートされています。',
|
||||
helper3: 'この機能を使用する前に、',
|
||||
helper4: 'ヘルプドキュメント',
|
||||
helper5: 'をよくお読みください。',
|
||||
},
|
||||
createDatasetIntro: '独自のテキストデータをインポートするか、LLMコンテキストの強化のためにWebhookを介してリアルタイムでデータを書き込むことができます。',
|
||||
deleteDatasetConfirmTitle: 'このナレッジを削除しますか?',
|
||||
deleteDatasetConfirmContent:
|
||||
|
|
@ -21,7 +86,23 @@ const translation = {
|
|||
unavailable: '利用不可',
|
||||
unavailableTip: '埋め込みモデルが利用できません。デフォルトの埋め込みモデルを設定する必要があります',
|
||||
datasets: 'ナレッジ',
|
||||
datasetsApi: 'API',
|
||||
datasetsApi: 'API ACCESS',
|
||||
externalKnowledgeForm: {
|
||||
connect: '連携',
|
||||
cancel: 'キャンセル',
|
||||
},
|
||||
externalAPIForm: {
|
||||
name: '名前',
|
||||
endpoint: 'APIエンドポイント',
|
||||
apiKey: 'APIキー',
|
||||
save: '保存',
|
||||
cancel: 'キャンセル',
|
||||
edit: '編集',
|
||||
encrypted: {
|
||||
front: 'APIトークンは',
|
||||
end: '技術で暗号化され、安全に保存されます。',
|
||||
},
|
||||
},
|
||||
retrieval: {
|
||||
semantic_search: {
|
||||
title: 'ベクトル検索',
|
||||
|
|
@ -34,7 +115,7 @@ const translation = {
|
|||
hybrid_search: {
|
||||
title: 'ハイブリッド検索',
|
||||
description: '全文検索とベクトル検索を同時に実行し、ユーザーのクエリに最適なマッチを選択するためにRerank付けを行います。RerankモデルAPIの設定が必要です。',
|
||||
recommend: 'おすすめ',
|
||||
recommend: '推奨',
|
||||
},
|
||||
invertedIndex: {
|
||||
title: '転置インデックス',
|
||||
|
|
@ -43,8 +124,10 @@ const translation = {
|
|||
change: '変更',
|
||||
changeRetrievalMethod: '検索方法の変更',
|
||||
},
|
||||
docsFailedNotice: 'ドキュメントのインデックスに失敗しました',
|
||||
docsFailedNotice: 'ドキュメントのインデックス作成に失敗しました',
|
||||
retry: '再試行',
|
||||
documentsDisabled: '{{num}}件のドキュメントが無効 - 30日以上非アクティブ',
|
||||
enable: '有効化',
|
||||
indexingTechnique: {
|
||||
high_quality: '高品質',
|
||||
economy: '経済',
|
||||
|
|
@ -55,8 +138,11 @@ const translation = {
|
|||
hybrid_search: 'ハイブリッド検索',
|
||||
invertedIndex: '転置',
|
||||
},
|
||||
mixtureHighQualityAndEconomicTip: '高品質なナレッジベースと経済的なナレッジベースを混在させるには、Rerankモデルを構成する必要がある。',
|
||||
inconsistentEmbeddingModelTip: '選択されたナレッジベースが一貫性のない埋め込みモデルで構成されている場合、Rerankモデルの構成が必要です。',
|
||||
defaultRetrievalTip: 'デフォルトでは、マルチパス検索が使用されます。複数のナレッジベースから情報を取得した後、再ランキングを行います。',
|
||||
mixtureHighQualityAndEconomicTip: '高品質なナレッジベースとコスト重視のナレッジベースを混在させるには、Rerankモデルが必要です。',
|
||||
inconsistentEmbeddingModelTip: '選択されたナレッジベースの埋め込みモデルに一貫性がない場合、Rerankモデルが必要です。',
|
||||
mixtureInternalAndExternalTip: '内部ナレッジと外部ナレッジを混在させるには、Rerankモデルが必要です。',
|
||||
allExternalTip: '外部ナレッジのみを使用する場合、Rerankモデルを有効にするかを選択できます。有効にしない場合、検索結果はスコアに基づいてソートされます。異なるナレッジベースで検索戦略が一貫していないと、結果が不正確になる可能性があります。',
|
||||
retrievalSettings: '検索設定',
|
||||
rerankSettings: 'Rerank設定',
|
||||
weightedScore: {
|
||||
|
|
@ -69,103 +155,17 @@ const translation = {
|
|||
keyword: 'キーワード',
|
||||
},
|
||||
nTo1RetrievalLegacy: '製品計画によると、N-to-1 Retrievalは9月に正式に廃止される予定です。それまでは通常通り使用できます。',
|
||||
nTo1RetrievalLegacyLink: '詳細を見る',
|
||||
nTo1RetrievalLegacyLink: '詳細はこちら',
|
||||
nTo1RetrievalLegacyLinkText: ' N-to-1 retrievalは9月に正式に廃止されます。',
|
||||
defaultRetrievalTip: 'デフォルトでは、マルチパス取得が使用されます。ナレッジは複数のナレッジ ベースから取得され、再ランク付けされます。',
|
||||
editExternalAPIConfirmWarningContent: {
|
||||
front: 'この外部ナレッジAPIは、',
|
||||
end: '外部の知識、そしてこの変更はそれらすべてに適用されます。この変更を保存してもよろしいですか?',
|
||||
},
|
||||
editExternalAPIFormWarning: {
|
||||
end: '外部の知識',
|
||||
front: 'この外部APIはにリンクされています',
|
||||
},
|
||||
deleteExternalAPIConfirmWarningContent: {
|
||||
title: {
|
||||
end: '?',
|
||||
front: '削除',
|
||||
},
|
||||
content: {
|
||||
front: 'この外部ナレッジAPIは、',
|
||||
end: '外部の知識。このAPIを削除すると、それらすべてが無効になります。この API を削除してもよろしいですか ?',
|
||||
},
|
||||
noConnectionContent: 'この API を削除してもよろしいですか ?',
|
||||
},
|
||||
selectExternalKnowledgeAPI: {
|
||||
placeholder: '外部ナレッジ API を選択する',
|
||||
},
|
||||
connectDatasetIntro: {
|
||||
content: {
|
||||
link: '外部 API の作成方法を学ぶ',
|
||||
front: '外部ナレッジ ベースに接続するには、まず外部 API を作成する必要があります。よくお読みになり、以下を参照してください。',
|
||||
end: '.次に、対応するナレッジIDを見つけて、左側のフォームに入力します。すべての情報が正しい場合は、接続ボタンをクリックした後、ナレッジベースの検索テストに自動的にジャンプします。',
|
||||
},
|
||||
title: '外部ナレッジベースに接続する方法',
|
||||
learnMore: '詳細を見る',
|
||||
},
|
||||
connectHelper: {
|
||||
helper2: '取得機能のみがサポートされています',
|
||||
helper3: '.次のことを強くお勧めします。',
|
||||
helper4: 'ヘルプドキュメントを読む',
|
||||
helper5: 'この機能を使用する前に慎重に。',
|
||||
helper1: 'APIとナレッジベースIDを介して外部ナレッジベースに接続します。',
|
||||
},
|
||||
externalKnowledgeForm: {
|
||||
cancel: 'キャンセル',
|
||||
connect: '繋ぐ',
|
||||
},
|
||||
externalAPIForm: {
|
||||
encrypted: {
|
||||
front: 'APIトークンは暗号化され、',
|
||||
end: 'テクノロジー。',
|
||||
},
|
||||
apiKey: 'APIキー',
|
||||
name: '名前',
|
||||
edit: '編集',
|
||||
save: 'セーブ',
|
||||
cancel: 'キャンセル',
|
||||
endpoint: 'API エンドポイント',
|
||||
},
|
||||
externalTag: '外',
|
||||
editExternalAPITooltipTitle: 'リンクされた知識',
|
||||
externalKnowledgeName: '外部ナレッジ名',
|
||||
externalAPI: '外部 API',
|
||||
externalAPIPanelDocumentation: 'External Knowledge API の作成方法を学ぶ',
|
||||
editExternalAPIFormTitle: '外部ナレッジ API の編集',
|
||||
externalAPIPanelTitle: '外部ナレッジAPI',
|
||||
externalKnowledgeId: '外部ナレッジID',
|
||||
connectDataset: '外部ナレッジベースへの接続',
|
||||
externalKnowledgeIdPlaceholder: 'ナレッジIDを入力してください',
|
||||
createNewExternalAPI: '新しい外部ナレッジ API を作成する',
|
||||
noExternalKnowledge: 'External Knowledge APIはまだありませんので、こちらをクリックして作成してください',
|
||||
mixtureInternalAndExternalTip: '再ランク付けモデルは、内部知識と外部知識の混合に必要です。',
|
||||
learnHowToWriteGoodKnowledgeDescription: '良い知識の説明を書く方法を学ぶ',
|
||||
externalKnowledgeNamePlaceholder: 'ナレッジベースの名前を入力してください',
|
||||
externalKnowledgeDescription: 'ナレッジの説明',
|
||||
createExternalAPI: '外部ナレッジ API を追加する',
|
||||
externalKnowledgeDescriptionPlaceholder: 'このナレッジベースの内容を説明する(オプション)',
|
||||
allExternalTip: '外部ナレッジのみを使用する場合、ユーザーは Rerank モデルを有効にするかどうかを選択できます。有効にしない場合、取得されたチャンクはスコアに基づいて並べ替えられます。異なるナレッジベースの検索戦略に一貫性がない場合、不正確になります。',
|
||||
externalAPIPanelDescription: '外部ナレッジAPIは、Difyの外部のナレッジベースに接続し、そのナレッジベースからナレッジを取得するために使用されます。',
|
||||
chunkingMode: {
|
||||
general: '全般',
|
||||
parentChild: '親子',
|
||||
},
|
||||
parentMode: {
|
||||
fullDoc: 'フルドキュメント',
|
||||
paragraph: '段落',
|
||||
},
|
||||
batchAction: {
|
||||
delete: '削除',
|
||||
selected: '入選',
|
||||
archive: 'アーカイブ',
|
||||
enable: 'エネーブル',
|
||||
selected: '選択済み',
|
||||
enable: '有効にする',
|
||||
disable: '無効にする',
|
||||
archive: 'アーカイブ',
|
||||
delete: '削除',
|
||||
cancel: 'キャンセル',
|
||||
},
|
||||
documentsDisabled: '{{num}}ドキュメントが無効 - 30日以上非アクティブ',
|
||||
localDocs: 'ローカルドキュメント',
|
||||
enable: 'エネーブル',
|
||||
preprocessDocument: '{{数値}}ドキュメントの前処理',
|
||||
preprocessDocument: '{{num}}件のドキュメントを前処理',
|
||||
}
|
||||
|
||||
export default translation
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ const translation = {
|
|||
apps: {
|
||||
title: 'Difyによるアプリの探索',
|
||||
description: 'これらのテンプレートアプリを即座に使用するか、テンプレートに基づいて独自のアプリをカスタマイズしてください。',
|
||||
allCategories: 'おすすめ',
|
||||
allCategories: '推奨',
|
||||
},
|
||||
appCard: {
|
||||
addToWorkspace: 'ワークスペースに追加',
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ const translation = {
|
|||
featuresDescription: 'Webアプリのユーザーエクスペリエンスを強化する',
|
||||
ImageUploadLegacyTip: '開始フォームでファイルタイプ変数を作成できるようになりました。まもなく、画像アップロード機能のサポートは終了いたします。',
|
||||
fileUploadTip: '画像アップロード機能がファイルのアップロード機能にアップグレードされました。',
|
||||
featuresDocLink: '詳細を見る',
|
||||
featuresDocLink: '詳細はこちら',
|
||||
debugAndPreview: 'プレビュー',
|
||||
restart: '再起動',
|
||||
currentDraft: '現在の下書き',
|
||||
|
|
@ -60,7 +60,7 @@ const translation = {
|
|||
viewOnly: '表示のみ',
|
||||
showRunHistory: '実行履歴を表示',
|
||||
enableJinja: 'Jinjaテンプレートのサポートを有効にする',
|
||||
learnMore: '詳細を見る',
|
||||
learnMore: '詳細はこちら',
|
||||
copy: 'コピー',
|
||||
duplicate: '複製',
|
||||
addBlock: 'ブロックを追加',
|
||||
|
|
@ -323,18 +323,18 @@ const translation = {
|
|||
tip: 'ノードが例外を検出したときにトリガーされる例外処理戦略。',
|
||||
},
|
||||
retry: {
|
||||
retry: 'リトライ',
|
||||
retry: '再試行',
|
||||
retryOnFailure: '失敗時の再試行',
|
||||
maxRetries: '最大再試行回数',
|
||||
retryInterval: '再試行間隔',
|
||||
retrying: '再試行。。。',
|
||||
retryFailed: '再試行に失敗しました',
|
||||
times: '倍',
|
||||
ms: 'さん',
|
||||
times: '回',
|
||||
ms: 'ms',
|
||||
retryTimes: '失敗時に{{times}}回再試行',
|
||||
retrySuccessful: '再試行に成功しました',
|
||||
retries: '{{num}} 回の再試行',
|
||||
retryFailedTimes: '{{times}}回のリトライが失敗しました',
|
||||
retryFailedTimes: '{{times}}回の再試行が失敗しました',
|
||||
},
|
||||
},
|
||||
start: {
|
||||
|
|
@ -669,7 +669,7 @@ const translation = {
|
|||
text: '抽出されたテキスト',
|
||||
},
|
||||
inputVar: '入力変数',
|
||||
learnMore: '詳細を見る',
|
||||
learnMore: '詳細はこちら',
|
||||
supportFileTypes: 'サポートするファイルタイプ: {{types}}。',
|
||||
},
|
||||
listFilter: {
|
||||
|
|
|
|||
|
|
@ -47,6 +47,8 @@ const translation = {
|
|||
view: '查看',
|
||||
viewMore: '查看更多',
|
||||
regenerate: '重新生成',
|
||||
submit: '提交',
|
||||
skip: '跳过',
|
||||
},
|
||||
errorMsg: {
|
||||
fieldRequired: '{{field}} 为必填项',
|
||||
|
|
@ -181,8 +183,19 @@ const translation = {
|
|||
editName: '编辑名字',
|
||||
showAppLength: '显示 {{length}} 个应用',
|
||||
delete: '删除账户',
|
||||
deleteTip: '删除账户后,所有数据将被永久删除且不可恢复。',
|
||||
deleteConfirmTip: '请将以下内容通过您的账户邮箱发送到 ',
|
||||
deleteTip: '请注意,一旦确认,作为任何空间的所有者,您的空间将被安排进入永久删除队列,您的所有用户数据也将被排入永久删除队列。',
|
||||
deletePrivacyLinkTip: '有关我们如何处理您的数据的更多信息,请参阅我们的',
|
||||
deletePrivacyLink: '隐私政策',
|
||||
deleteSuccessTip: '删除账户需要一些时间。完成后,我们会通过邮件通知您。',
|
||||
deleteLabel: '请输入您的邮箱以确认',
|
||||
deletePlaceholder: '输入您的邮箱...',
|
||||
sendVerificationButton: '发送验证码',
|
||||
verificationLabel: '验证码',
|
||||
verificationPlaceholder: '输入 6 位数字验证码',
|
||||
permanentlyDeleteButton: '永久删除',
|
||||
feedbackTitle: '反馈',
|
||||
feedbackLabel: '请告诉我们您为什么删除账户?',
|
||||
feedbackPlaceholder: '选填',
|
||||
},
|
||||
members: {
|
||||
team: '团队',
|
||||
|
|
|
|||
|
|
@ -104,6 +104,8 @@ const translation = {
|
|||
onFailure: '异常时',
|
||||
addFailureBranch: '添加异常分支',
|
||||
openInExplore: '在“探索”中打开',
|
||||
loadMore: '加载更多',
|
||||
noHistory: '没有历史版本',
|
||||
},
|
||||
env: {
|
||||
envPanelTitle: '环境变量',
|
||||
|
|
|
|||
|
|
@ -339,3 +339,12 @@ export const sendResetPasswordCode = (email: string, language = 'en-US') =>
|
|||
|
||||
export const verifyResetPasswordCode = (body: { email: string;code: string;token: string }) =>
|
||||
post<CommonResponse & { is_valid: boolean }>('/forgot-password/validity', { body })
|
||||
|
||||
export const sendDeleteAccountCode = () =>
|
||||
get<CommonResponse & { data: string }>('/account/delete/verify')
|
||||
|
||||
export const verifyDeleteAccountCode = (body: { code: string;token: string }) =>
|
||||
post<CommonResponse & { is_valid: boolean }>('/account/delete', { body })
|
||||
|
||||
export const submitDeleteAccountFeedback = (body: { feedback: string;email: string }) =>
|
||||
post<CommonResponse>('/account/delete/feedback', { body })
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { CommonResponse } from '@/models/common'
|
|||
import type {
|
||||
ChatRunHistoryResponse,
|
||||
ConversationVariableResponse,
|
||||
FetchWorkflowDraftPageResponse,
|
||||
FetchWorkflowDraftResponse,
|
||||
NodesDefaultConfigsResponse,
|
||||
WorkflowRunHistoryResponse,
|
||||
|
|
@ -14,7 +15,10 @@ export const fetchWorkflowDraft = (url: string) => {
|
|||
return get(url, {}, { silent: true }) as Promise<FetchWorkflowDraftResponse>
|
||||
}
|
||||
|
||||
export const syncWorkflowDraft = ({ url, params }: { url: string; params: Pick<FetchWorkflowDraftResponse, 'graph' | 'features' | 'environment_variables' | 'conversation_variables'> }) => {
|
||||
export const syncWorkflowDraft = ({ url, params }: {
|
||||
url: string
|
||||
params: Pick<FetchWorkflowDraftResponse, 'graph' | 'features' | 'environment_variables' | 'conversation_variables'>
|
||||
}) => {
|
||||
return post<CommonResponse & { updated_at: number; hash: string }>(url, { body: params }, { silent: true })
|
||||
}
|
||||
|
||||
|
|
@ -46,6 +50,10 @@ export const fetchPublishedWorkflow: Fetcher<FetchWorkflowDraftResponse, string>
|
|||
return get<FetchWorkflowDraftResponse>(url)
|
||||
}
|
||||
|
||||
export const fetchPublishedAllWorkflow: Fetcher<FetchWorkflowDraftPageResponse, string> = (url) => {
|
||||
return get<FetchWorkflowDraftPageResponse>(url)
|
||||
}
|
||||
|
||||
export const stopWorkflowRun = (url: string) => {
|
||||
return post<CommonResponse>(url)
|
||||
}
|
||||
|
|
@ -61,6 +69,9 @@ export const updateWorkflowDraftFromDSL = (appId: string, data: string) => {
|
|||
return post<FetchWorkflowDraftResponse>(`apps/${appId}/workflows/draft/import`, { body: { data } })
|
||||
}
|
||||
|
||||
export const fetchCurrentValueOfConversationVariable: Fetcher<ConversationVariableResponse, { url: string; params: { conversation_id: string } }> = ({ url, params }) => {
|
||||
export const fetchCurrentValueOfConversationVariable: Fetcher<ConversationVariableResponse, {
|
||||
url: string
|
||||
params: { conversation_id: string }
|
||||
}> = ({ url, params }) => {
|
||||
return get<ConversationVariableResponse>(url, { params })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,5 @@
|
|||
import type { Viewport } from 'reactflow'
|
||||
import type {
|
||||
BlockEnum,
|
||||
ConversationVariable,
|
||||
Edge,
|
||||
EnvironmentVariable,
|
||||
Node,
|
||||
} from '@/app/components/workflow/types'
|
||||
import type { BlockEnum, ConversationVariable, Edge, EnvironmentVariable, Node } from '@/app/components/workflow/types'
|
||||
import type { TransferMethod } from '@/types/app'
|
||||
import type { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
|
||||
|
||||
|
|
@ -79,6 +73,15 @@ export type FetchWorkflowDraftResponse = {
|
|||
tool_published: boolean
|
||||
environment_variables?: EnvironmentVariable[]
|
||||
conversation_variables?: ConversationVariable[]
|
||||
version: string
|
||||
}
|
||||
|
||||
export type VersionHistory = FetchWorkflowDraftResponse
|
||||
|
||||
export type FetchWorkflowDraftPageResponse = {
|
||||
items: VersionHistory[]
|
||||
has_more: boolean
|
||||
page: number
|
||||
}
|
||||
|
||||
export type NodeTracingListResponse = {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
import { MAX_VAR_KEY_LENGTH, VAR_ITEM_TEMPLATE, VAR_ITEM_TEMPLATE_IN_WORKFLOW, getMaxVarNameLength } from '@/config'
|
||||
import { CONTEXT_PLACEHOLDER_TEXT, HISTORY_PLACEHOLDER_TEXT, PRE_PROMPT_PLACEHOLDER_TEXT, QUERY_PLACEHOLDER_TEXT } from '@/app/components/base/prompt-editor/constants'
|
||||
import {
|
||||
CONTEXT_PLACEHOLDER_TEXT,
|
||||
HISTORY_PLACEHOLDER_TEXT,
|
||||
PRE_PROMPT_PLACEHOLDER_TEXT,
|
||||
QUERY_PLACEHOLDER_TEXT,
|
||||
} from '@/app/components/base/prompt-editor/constants'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
|
||||
const otherAllowedRegex = /^[a-zA-Z0-9_]+$/
|
||||
|
||||
export const getNewVar = (key: string, type: string) => {
|
||||
const { max_length, ...rest } = VAR_ITEM_TEMPLATE
|
||||
const { ...rest } = VAR_ITEM_TEMPLATE
|
||||
if (type !== 'string') {
|
||||
return {
|
||||
...rest,
|
||||
|
|
|
|||
Loading…
Reference in New Issue