Refactor/merge v1 (#19069)

Signed-off-by: -LAN- <laipz8200@outlook.com>
Co-authored-by: Hash Brown <hi@xzd.me>
Co-authored-by: crazywoola <427733928@qq.com>
Co-authored-by: GareArc <chen4851@purdue.edu>
Co-authored-by: Byron.wang <byron@dify.ai>
Co-authored-by: Joel <iamjoel007@gmail.com>
Co-authored-by: -LAN- <laipz8200@outlook.com>
Co-authored-by: Garfield Dai <dai.hai@foxmail.com>
Co-authored-by: KVOJJJin <jzongcode@gmail.com>
Co-authored-by: Alexi.F <654973939@qq.com>
Co-authored-by: Xiyuan Chen <52963600+GareArc@users.noreply.github.com>
Co-authored-by: kautsar_masuara <61046989+izon-masuara@users.noreply.github.com>
Co-authored-by: achmad-kautsar <achmad.kautsar@insignia.co.id>
Co-authored-by: Xin Zhang <sjhpzx@gmail.com>
Co-authored-by: kelvintsim <83445753+kelvintsim@users.noreply.github.com>
Co-authored-by: zxhlyh <jasonapring2015@outlook.com>
Co-authored-by: Zixuan Cheng <61724187+Theysua@users.noreply.github.com>
pull/19898/head
NFish 1 year ago committed by GitHub
parent 62347206c0
commit 2480abb792
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -6,8 +6,9 @@ on:
- "main" - "main"
- "deploy/dev" - "deploy/dev"
- "deploy/enterprise" - "deploy/enterprise"
tags: - "e-300"
- "*" release:
types: [published]
concurrency: concurrency:
group: build-push-${{ github.head_ref || github.run_id }} group: build-push-${{ github.head_ref || github.run_id }}

@ -0,0 +1,3 @@
{
"MD024": false
}

@ -0,0 +1,32 @@
# Changelog
All notable changes to Dify will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.15.7] - 2025-04-27
### Added
- Added support for GPT-4.1 in model providers (#18912)
- Added support for Amazon Bedrock DeepSeek-R1 model (#18908)
- Added support for Amazon Bedrock Claude Sonnet 3.7 model (#18788)
- Refined version compatibility logic in app DSL service
### Fixed
- Fixed issue with creating apps from template categories (#18807, #18868)
- Fixed DSL version check when creating apps from explore templates (#18872, #18878)
## [0.15.6] - 2025-04-22
### Security
- Fixed clickjacking vulnerability (#18552)
- Fixed reset password security issue (#18366)
- Updated reset password token when email code verification succeeds (#18362)
### Fixed
- Fixed Vertex AI Gemini 2.0 Flash 001 schema (#18405)

@ -2,30 +2,28 @@ import uuid
from typing import cast from typing import cast
from flask_login import current_user # type: ignore from flask_login import current_user # type: ignore
from flask_restful import Resource, inputs, marshal, marshal_with, reqparse # type: ignore from flask_restful import (Resource, inputs, marshal, # type: ignore
marshal_with, reqparse)
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from werkzeug.exceptions import BadRequest, Forbidden, abort from werkzeug.exceptions import BadRequest, Forbidden, abort
from controllers.console import api from controllers.console import api
from controllers.console.app.wraps import get_app_model from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import ( from controllers.console.wraps import (account_initialization_required,
account_initialization_required,
cloud_edition_billing_resource_check, cloud_edition_billing_resource_check,
enterprise_license_required, enterprise_license_required,
setup_required, setup_required)
)
from core.ops.ops_trace_manager import OpsTraceManager from core.ops.ops_trace_manager import OpsTraceManager
from extensions.ext_database import db from extensions.ext_database import db
from fields.app_fields import ( from fields.app_fields import (app_detail_fields, app_detail_fields_with_site,
app_detail_fields, app_pagination_fields)
app_detail_fields_with_site,
app_pagination_fields,
)
from libs.login import login_required from libs.login import login_required
from models import Account, App from models import Account, App
from services.app_dsl_service import AppDslService, ImportMode from services.app_dsl_service import AppDslService, ImportMode
from services.app_service import AppService from services.app_service import AppService
from services.enterprise.enterprise_service import EnterpriseService
from services.feature_service import FeatureService
ALLOW_CREATE_APP_MODES = ["chat", "agent-chat", "advanced-chat", "workflow", "completion"] ALLOW_CREATE_APP_MODES = ["chat", "agent-chat", "advanced-chat", "workflow", "completion"]
@ -75,7 +73,17 @@ class AppListApi(Resource):
if not app_pagination: if not app_pagination:
return {"data": [], "total": 0, "page": 1, "limit": 20, "has_more": False} return {"data": [], "total": 0, "page": 1, "limit": 20, "has_more": False}
return marshal(app_pagination, app_pagination_fields) if FeatureService.get_system_features().webapp_auth.enabled:
app_ids = [str(app.id) for app in app_pagination.items]
res = EnterpriseService.WebAppAuth.batch_get_app_access_mode_by_id(app_ids=app_ids)
if len(res) != len(app_ids):
raise BadRequest("Invalid app id in webapp auth")
for app in app_pagination.items:
if str(app.id) in res:
app.access_mode = res[str(app.id)].access_mode
return marshal(app_pagination, app_pagination_fields), 200
@setup_required @setup_required
@login_required @login_required
@ -119,6 +127,10 @@ class AppApi(Resource):
app_model = app_service.get_app(app_model) app_model = app_service.get_app(app_model)
if FeatureService.get_system_features().webapp_auth.enabled:
app_setting = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=str(app_model.id))
app_model.access_mode = app_setting.access_mode
return app_model return app_model
@setup_required @setup_required

@ -24,7 +24,7 @@ from libs.password import hash_password, valid_password
from models.account import Account from models.account import Account
from services.account_service import AccountService, TenantService from services.account_service import AccountService, TenantService
from services.errors.account import AccountRegisterError from services.errors.account import AccountRegisterError
from services.errors.workspace import WorkSpaceNotAllowedCreateError from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError
from services.feature_service import FeatureService from services.feature_service import FeatureService
@ -119,6 +119,9 @@ class ForgotPasswordResetApi(Resource):
if not reset_data: if not reset_data:
raise InvalidTokenError() raise InvalidTokenError()
# Must use token in reset phase # Must use token in reset phase
if reset_data.get("phase", "") != "reset":
raise InvalidTokenError()
# Must use token in reset phase
if reset_data.get("phase", "") != "reset": if reset_data.get("phase", "") != "reset":
raise InvalidTokenError() raise InvalidTokenError()

@ -21,6 +21,7 @@ from controllers.console.error import (
AccountNotFound, AccountNotFound,
EmailSendIpLimitError, EmailSendIpLimitError,
NotAllowedCreateWorkspace, NotAllowedCreateWorkspace,
WorkspacesLimitExceeded,
) )
from controllers.console.wraps import email_password_login_enabled, setup_required from controllers.console.wraps import email_password_login_enabled, setup_required
from events.tenant_event import tenant_was_created from events.tenant_event import tenant_was_created
@ -30,7 +31,7 @@ from models.account import Account
from services.account_service import AccountService, RegisterService, TenantService from services.account_service import AccountService, RegisterService, TenantService
from services.billing_service import BillingService from services.billing_service import BillingService
from services.errors.account import AccountRegisterError from services.errors.account import AccountRegisterError
from services.errors.workspace import WorkSpaceNotAllowedCreateError from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError
from services.feature_service import FeatureService from services.feature_service import FeatureService
@ -88,6 +89,11 @@ class LoginApi(Resource):
# SELF_HOSTED only have one workspace # SELF_HOSTED only have one workspace
tenants = TenantService.get_join_tenants(account) tenants = TenantService.get_join_tenants(account)
if len(tenants) == 0: if len(tenants) == 0:
system_features = FeatureService.get_system_features()
if system_features.is_allow_create_workspace and not system_features.license.workspaces.is_available():
raise WorkspacesLimitExceeded()
else:
return { return {
"result": "fail", "result": "fail",
"data": "workspace not found, please contact system admin to invite you to join in a workspace", "data": "workspace not found, please contact system admin to invite you to join in a workspace",
@ -198,6 +204,9 @@ class EmailCodeLoginApi(Resource):
if account: if account:
tenant = TenantService.get_join_tenants(account) tenant = TenantService.get_join_tenants(account)
if not tenant: if not tenant:
workspaces = FeatureService.get_system_features().license.workspaces
if not workspaces.is_available():
raise WorkspacesLimitExceeded()
if not FeatureService.get_system_features().is_allow_create_workspace: if not FeatureService.get_system_features().is_allow_create_workspace:
raise NotAllowedCreateWorkspace() raise NotAllowedCreateWorkspace()
else: else:
@ -215,6 +224,8 @@ class EmailCodeLoginApi(Resource):
return NotAllowedCreateWorkspace() return NotAllowedCreateWorkspace()
except AccountRegisterError as are: except AccountRegisterError as are:
raise AccountInFreezeError() raise AccountInFreezeError()
except WorkspacesLimitExceededError:
raise WorkspacesLimitExceeded()
token_pair = AccountService.login(account, ip_address=extract_remote_ip(request)) token_pair = AccountService.login(account, ip_address=extract_remote_ip(request))
AccountService.reset_login_error_rate_limit(args["email"]) AccountService.reset_login_error_rate_limit(args["email"])
return {"result": "success", "data": token_pair.model_dump()} return {"result": "success", "data": token_pair.model_dump()}

@ -46,6 +46,18 @@ class NotAllowedCreateWorkspace(BaseHTTPException):
code = 400 code = 400
class WorkspaceMembersLimitExceeded(BaseHTTPException):
error_code = "limit_exceeded"
description = "Unable to add member because the maximum workspace's member limit was exceeded"
code = 400
class WorkspacesLimitExceeded(BaseHTTPException):
error_code = "limit_exceeded"
description = "Unable to create workspace because the maximum workspace limit was exceeded"
code = 400
class AccountBannedError(BaseHTTPException): class AccountBannedError(BaseHTTPException):
error_code = "account_banned" error_code = "account_banned"
description = "Account is banned." description = "Account is banned."

@ -23,3 +23,9 @@ class AppSuggestedQuestionsAfterAnswerDisabledError(BaseHTTPException):
error_code = "app_suggested_questions_after_answer_disabled" error_code = "app_suggested_questions_after_answer_disabled"
description = "Function Suggested questions after answer disabled." description = "Function Suggested questions after answer disabled."
code = 403 code = 403
class AppAccessDeniedError(BaseHTTPException):
error_code = "access_denied"
description = "App access denied."
code = 403

@ -1,20 +1,26 @@
import logging
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import Any
from flask import request from flask import request
from flask_login import current_user # type: ignore from flask_login import current_user # type: ignore
from flask_restful import Resource, inputs, marshal_with, reqparse # type: ignore from flask_restful import (Resource, inputs, marshal_with, # type: ignore
reqparse)
from sqlalchemy import and_ from sqlalchemy import and_
from werkzeug.exceptions import BadRequest, Forbidden, NotFound from werkzeug.exceptions import BadRequest, Forbidden, NotFound
from controllers.console import api from controllers.console import api
from controllers.console.explore.wraps import InstalledAppResource from controllers.console.explore.wraps import InstalledAppResource
from controllers.console.wraps import account_initialization_required, cloud_edition_billing_resource_check from controllers.console.wraps import (account_initialization_required,
cloud_edition_billing_resource_check)
from extensions.ext_database import db from extensions.ext_database import db
from fields.installed_app_fields import installed_app_list_fields from fields.installed_app_fields import installed_app_list_fields
from libs.login import login_required from libs.login import login_required
from models import App, InstalledApp, RecommendedApp from models import App, InstalledApp, RecommendedApp
from services.account_service import TenantService from services.account_service import TenantService
from services.app_service import AppService
from services.enterprise.enterprise_service import EnterpriseService
from services.feature_service import FeatureService
class InstalledAppsListApi(Resource): class InstalledAppsListApi(Resource):
@ -48,6 +54,23 @@ class InstalledAppsListApi(Resource):
for installed_app in installed_apps for installed_app in installed_apps
if installed_app.app is not None if installed_app.app is not None
] ]
# filter out apps that user doesn't have access to
if FeatureService.get_system_features().webapp_auth.enabled:
user_id = current_user.id
res = []
for installed_app in installed_app_list:
app_code = AppService.get_app_code_by_id(str(installed_app["app"].id))
if EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp(
user_id=user_id,
app_code=app_code,
):
res.append(installed_app)
installed_app_list = res
logging.info(
f"installed_app_list: {installed_app_list}, user_id: {user_id}"
)
installed_app_list.sort( installed_app_list.sort(
key=lambda app: ( key=lambda app: (
-app["is_pinned"], -app["is_pinned"],

@ -4,10 +4,14 @@ from flask_login import current_user # type: ignore
from flask_restful import Resource # type: ignore from flask_restful import Resource # type: ignore
from werkzeug.exceptions import NotFound from werkzeug.exceptions import NotFound
from controllers.console.explore.error import AppAccessDeniedError
from controllers.console.wraps import account_initialization_required from controllers.console.wraps import account_initialization_required
from extensions.ext_database import db from extensions.ext_database import db
from libs.login import login_required from libs.login import login_required
from models import InstalledApp from models import InstalledApp
from services.app_service import AppService
from services.enterprise.enterprise_service import EnterpriseService
from services.feature_service import FeatureService
def installed_app_required(view=None): def installed_app_required(view=None):
@ -48,6 +52,30 @@ def installed_app_required(view=None):
return decorator return decorator
def user_allowed_to_access_app(view=None):
def decorator(view):
@wraps(view)
def decorated(installed_app: InstalledApp, *args, **kwargs):
feature = FeatureService.get_system_features()
if feature.webapp_auth.enabled:
app_id = installed_app.app_id
app_code = AppService.get_app_code_by_id(app_id)
res = EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp(
user_id=str(current_user.id),
app_code=app_code,
)
if not res:
raise AppAccessDeniedError()
return view(installed_app, *args, **kwargs)
return decorated
if view:
return decorator(view)
return decorator
class InstalledAppResource(Resource): class InstalledAppResource(Resource):
# must be reversed if there are multiple decorators # must be reversed if there are multiple decorators
method_decorators = [installed_app_required, account_initialization_required, login_required]
method_decorators = [user_allowed_to_access_app, installed_app_required, account_initialization_required, login_required]

@ -6,6 +6,7 @@ from flask_restful import Resource, abort, marshal_with, reqparse # type: ignor
import services import services
from configs import dify_config from configs import dify_config
from controllers.console import api from controllers.console import api
from controllers.console.error import WorkspaceMembersLimitExceeded
from controllers.console.wraps import ( from controllers.console.wraps import (
account_initialization_required, account_initialization_required,
cloud_edition_billing_resource_check, cloud_edition_billing_resource_check,
@ -17,6 +18,7 @@ from libs.login import login_required
from models.account import Account, TenantAccountRole from models.account import Account, TenantAccountRole
from services.account_service import RegisterService, TenantService from services.account_service import RegisterService, TenantService
from services.errors.account import AccountAlreadyInTenantError from services.errors.account import AccountAlreadyInTenantError
from services.feature_service import FeatureService
class MemberListApi(Resource): class MemberListApi(Resource):
@ -54,6 +56,12 @@ class MemberInviteEmailApi(Resource):
inviter = current_user inviter = current_user
invitation_results = [] invitation_results = []
console_web_url = dify_config.CONSOLE_WEB_URL console_web_url = dify_config.CONSOLE_WEB_URL
workspace_members = FeatureService.get_features(tenant_id=inviter.current_tenant.id).workspace_members
if not workspace_members.is_available(len(invitee_emails)):
raise WorkspaceMembersLimitExceeded()
for invitee_email in invitee_emails: for invitee_email in invitee_emails:
try: try:
token = RegisterService.invite_new_member( token = RegisterService.invite_new_member(

@ -44,6 +44,17 @@ def only_edition_cloud(view):
return decorated return decorated
def only_enterprise_edition(view):
@wraps(view)
def decorated(*args, **kwargs):
if not dify_config.ENTERPRISE_ENABLED:
abort(404)
return view(*args, **kwargs)
return decorated
def only_edition_self_hosted(view): def only_edition_self_hosted(view):
@wraps(view) @wraps(view)
def decorated(*args, **kwargs): def decorated(*args, **kwargs):

@ -6,4 +6,5 @@ bp = Blueprint("inner_api", __name__, url_prefix="/inner/api")
api = ExternalApi(bp) api = ExternalApi(bp)
from .plugin import plugin from .plugin import plugin
from . import mail
from .workspace import workspace from .workspace import workspace

@ -0,0 +1,27 @@
from flask_restful import (
Resource, # type: ignore
reqparse,
)
from controllers.console.wraps import setup_required
from controllers.inner_api import api
from controllers.inner_api.wraps import inner_api_only
from services.enterprise.mail_service import DifyMail, EnterpriseMailService
class EnterpriseMail(Resource):
@setup_required
@inner_api_only
def post(self):
parser = reqparse.RequestParser()
parser.add_argument("to", type=str, action="append", required=True)
parser.add_argument("subject", type=str, required=True)
parser.add_argument("body", type=str, required=True)
parser.add_argument("substitutions", type=dict, required=False)
args = parser.parse_args()
EnterpriseMailService.send_mail(DifyMail(**args))
return {"message": "success"}, 200
api.add_resource(EnterpriseMail, "/enterprise/mail")

@ -1,12 +1,16 @@
from flask_restful import marshal_with # type: ignore
from flask import request
from flask_restful import Resource, marshal_with, reqparse # type: ignore
from controllers.common import fields from controllers.common import fields
from controllers.web import api from controllers.web import api
from controllers.web.error import AppUnavailableError from controllers.web.error import AppUnavailableError
from controllers.web.wraps import WebApiResource from controllers.web.wraps import WebApiResource
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
from libs.passport import PassportService
from models.model import App, AppMode from models.model import App, AppMode
from services.app_service import AppService from services.app_service import AppService
from services.enterprise.enterprise_service import EnterpriseService
class AppParameterApi(WebApiResource): class AppParameterApi(WebApiResource):
@ -40,5 +44,51 @@ class AppMeta(WebApiResource):
return AppService().get_app_meta(app_model) return AppService().get_app_meta(app_model)
class AppAccessMode(Resource):
def get(self):
parser = reqparse.RequestParser()
parser.add_argument("appId", type=str, required=True, location="args")
args = parser.parse_args()
app_id = args["appId"]
res = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id)
return {"accessMode": res.access_mode}
class AppWebAuthPermission(Resource):
def get(self):
user_id = "visitor"
try:
auth_header = request.headers.get("Authorization")
if auth_header is None:
raise
if " " not in auth_header:
raise
auth_scheme, tk = auth_header.split(None, 1)
auth_scheme = auth_scheme.lower()
if auth_scheme != "bearer":
raise
decoded = PassportService().verify(tk)
user_id = decoded.get("user_id", "visitor")
except Exception as e:
pass
parser = reqparse.RequestParser()
parser.add_argument("appId", type=str, required=True, location="args")
args = parser.parse_args()
app_id = args["appId"]
app_code = AppService.get_app_code_by_id(app_id)
res = EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp(str(user_id), app_code)
return {"result": res}
api.add_resource(AppParameterApi, "/parameters") api.add_resource(AppParameterApi, "/parameters")
api.add_resource(AppMeta, "/meta") api.add_resource(AppMeta, "/meta")
# webapp auth apis
api.add_resource(AppAccessMode, "/webapp/access-mode")
api.add_resource(AppWebAuthPermission, "/webapp/permission")

@ -121,9 +121,15 @@ class UnsupportedFileTypeError(BaseHTTPException):
code = 415 code = 415
class WebSSOAuthRequiredError(BaseHTTPException): class WebAppAuthRequiredError(BaseHTTPException):
error_code = "web_sso_auth_required" error_code = "web_sso_auth_required"
description = "Web SSO authentication required." description = "Web app authentication required."
code = 401
class WebAppAuthAccessDeniedError(BaseHTTPException):
error_code = "web_app_access_denied"
description = "You do not have permission to access this web app."
code = 401 code = 401

@ -0,0 +1,121 @@
from flask import request
from flask_restful import Resource, reqparse
from jwt import InvalidTokenError # type: ignore
from web import api
from werkzeug.exceptions import BadRequest
import services
from controllers.console.auth.error import EmailCodeError, EmailOrPasswordMismatchError, InvalidEmailError
from controllers.console.error import AccountBannedError, AccountNotFound
from controllers.console.wraps import setup_required
from libs.helper import email
from libs.password import valid_password
from services.account_service import AccountService
from services.webapp_auth_service import WebAppAuthService
class LoginApi(Resource):
"""Resource for web app email/password login."""
def post(self):
"""Authenticate user and login."""
parser = reqparse.RequestParser()
parser.add_argument("email", type=email, required=True, location="json")
parser.add_argument("password", type=valid_password, required=True, location="json")
args = parser.parse_args()
app_code = request.headers.get("X-App-Code")
if app_code is None:
raise BadRequest("X-App-Code header is missing.")
try:
account = WebAppAuthService.authenticate(args["email"], args["password"])
except services.errors.account.AccountLoginError:
raise AccountBannedError()
except services.errors.account.AccountPasswordError:
raise EmailOrPasswordMismatchError()
except services.errors.account.AccountNotFoundError:
raise AccountNotFound()
WebAppAuthService._validate_user_accessibility(account=account, app_code=app_code)
end_user = WebAppAuthService.create_end_user(email=args["email"], app_code=app_code)
token = WebAppAuthService.login(account=account, app_code=app_code, end_user_id=end_user.id)
return {"result": "success", "token": token}
# class LogoutApi(Resource):
# @setup_required
# def get(self):
# account = cast(Account, flask_login.current_user)
# if isinstance(account, flask_login.AnonymousUserMixin):
# return {"result": "success"}
# flask_login.logout_user()
# return {"result": "success"}
class EmailCodeLoginSendEmailApi(Resource):
@setup_required
def post(self):
parser = reqparse.RequestParser()
parser.add_argument("email", type=email, required=True, location="json")
parser.add_argument("language", type=str, required=False, location="json")
args = parser.parse_args()
if args["language"] is not None and args["language"] == "zh-Hans":
language = "zh-Hans"
else:
language = "en-US"
account = WebAppAuthService.get_user_through_email(args["email"])
if account is None:
raise AccountNotFound()
else:
token = WebAppAuthService.send_email_code_login_email(account=account, language=language)
return {"result": "success", "data": token}
class EmailCodeLoginApi(Resource):
@setup_required
def post(self):
parser = reqparse.RequestParser()
parser.add_argument("email", type=str, required=True, location="json")
parser.add_argument("code", type=str, required=True, location="json")
parser.add_argument("token", type=str, required=True, location="json")
args = parser.parse_args()
user_email = args["email"]
app_code = request.headers.get("X-App-Code")
if app_code is None:
raise BadRequest("X-App-Code header is missing.")
token_data = WebAppAuthService.get_email_code_login_data(args["token"])
if token_data is None:
raise InvalidTokenError()
if token_data["email"] != args["email"]:
raise InvalidEmailError()
if token_data["code"] != args["code"]:
raise EmailCodeError()
WebAppAuthService.revoke_email_code_login_token(args["token"])
account = WebAppAuthService.get_user_through_email(user_email)
if not account:
raise AccountNotFound()
WebAppAuthService._validate_user_accessibility(account=account, app_code=app_code)
end_user = WebAppAuthService.create_end_user(email=user_email, app_code=app_code)
token = WebAppAuthService.login(account=account, app_code=app_code, end_user_id=end_user.id)
AccountService.reset_login_error_rate_limit(args["email"])
return {"result": "success", "token": token}
api.add_resource(LoginApi, "/login")
# api.add_resource(LogoutApi, "/logout")
api.add_resource(EmailCodeLoginSendEmailApi, "/email-code-login")
api.add_resource(EmailCodeLoginApi, "/email-code-login/validity")

@ -5,7 +5,7 @@ from flask_restful import Resource # type: ignore
from werkzeug.exceptions import NotFound, Unauthorized from werkzeug.exceptions import NotFound, Unauthorized
from controllers.web import api from controllers.web import api
from controllers.web.error import WebSSOAuthRequiredError from controllers.web.error import WebAppAuthRequiredError
from extensions.ext_database import db from extensions.ext_database import db
from libs.passport import PassportService from libs.passport import PassportService
from models.model import App, EndUser, Site from models.model import App, EndUser, Site
@ -24,10 +24,10 @@ class PassportResource(Resource):
if app_code is None: if app_code is None:
raise Unauthorized("X-App-Code header is missing.") raise Unauthorized("X-App-Code header is missing.")
if system_features.sso_enforced_for_web: if system_features.webapp_auth.enabled:
app_web_sso_enabled = EnterpriseService.get_app_web_sso_enabled(app_code).get("enabled", False) app_settings = EnterpriseService.WebAppAuth.get_app_access_mode_by_code(app_code=app_code)
if app_web_sso_enabled: if not app_settings or not app_settings.access_mode == "public":
raise WebSSOAuthRequiredError() raise WebAppAuthRequiredError()
# get site from db and check if it is normal # get site from db and check if it is normal
site = db.session.query(Site).filter(Site.code == app_code, Site.status == "normal").first() site = db.session.query(Site).filter(Site.code == app_code, Site.status == "normal").first()

@ -4,7 +4,7 @@ from flask import request
from flask_restful import Resource # type: ignore from flask_restful import Resource # type: ignore
from werkzeug.exceptions import BadRequest, NotFound, Unauthorized from werkzeug.exceptions import BadRequest, NotFound, Unauthorized
from controllers.web.error import WebSSOAuthRequiredError from controllers.web.error import WebAppAuthAccessDeniedError, WebAppAuthRequiredError
from extensions.ext_database import db from extensions.ext_database import db
from libs.passport import PassportService from libs.passport import PassportService
from models.model import App, EndUser, Site from models.model import App, EndUser, Site
@ -57,35 +57,53 @@ def decode_jwt_token():
if not end_user: if not end_user:
raise NotFound() raise NotFound()
_validate_web_sso_token(decoded, system_features, app_code) # for enterprise webapp auth
app_web_auth_enabled = False
if system_features.webapp_auth.enabled:
app_web_auth_enabled = (
EnterpriseService.WebAppAuth.get_app_access_mode_by_code(app_code=app_code).access_mode != "public"
)
_validate_webapp_token(decoded, app_web_auth_enabled, system_features.webapp_auth.enabled)
_validate_user_accessibility(decoded, app_code, app_web_auth_enabled, system_features.webapp_auth.enabled)
return app_model, end_user return app_model, end_user
except Unauthorized as e: except Unauthorized as e:
if system_features.sso_enforced_for_web: if system_features.webapp_auth.enabled:
app_web_sso_enabled = EnterpriseService.get_app_web_sso_enabled(app_code).get("enabled", False) app_web_auth_enabled = (
if app_web_sso_enabled: EnterpriseService.WebAppAuth.get_app_access_mode_by_code(app_code=app_code).access_mode != "public"
raise WebSSOAuthRequiredError() )
if app_web_auth_enabled:
raise WebAppAuthRequiredError()
raise Unauthorized(e.description) raise Unauthorized(e.description)
def _validate_web_sso_token(decoded, system_features, app_code): def _validate_webapp_token(decoded, app_web_auth_enabled: bool, system_webapp_auth_enabled: bool):
app_web_sso_enabled = False # Check if authentication is enforced for web app, and if the token source is not webapp,
# raise an error and redirect to login
# Check if SSO is enforced for web, and if the token source is not SSO, raise an error and redirect to SSO login if system_webapp_auth_enabled and app_web_auth_enabled:
if system_features.sso_enforced_for_web:
app_web_sso_enabled = EnterpriseService.get_app_web_sso_enabled(app_code).get("enabled", False)
if app_web_sso_enabled:
source = decoded.get("token_source") source = decoded.get("token_source")
if not source or source != "sso": if not source or source != "webapp":
raise WebSSOAuthRequiredError() raise WebAppAuthRequiredError()
# Check if SSO is not enforced for web, and if the token source is SSO, # Check if authentication is not enforced for web, and if the token source is webapp,
# raise an error and redirect to normal passport login # raise an error and redirect to normal passport login
if not system_features.sso_enforced_for_web or not app_web_sso_enabled: if not system_webapp_auth_enabled or not app_web_auth_enabled:
source = decoded.get("token_source") source = decoded.get("token_source")
if source and source == "sso": if source and source == "webapp":
raise Unauthorized("sso token expired.") raise Unauthorized("webapp token expired.")
def _validate_user_accessibility(decoded, app_code, app_web_auth_enabled: bool, system_webapp_auth_enabled: bool):
if system_webapp_auth_enabled and app_web_auth_enabled:
# Check if the user is allowed to access the web app
user_id = decoded.get("user_id")
if not user_id:
raise WebAppAuthRequiredError()
if not EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp(user_id, app_code=app_code):
raise WebAppAuthAccessDeniedError()
class WebApiResource(Resource): class WebApiResource(Resource):

@ -107,7 +107,6 @@ class CotAgentRunner(BaseAgentRunner, ABC):
# recalc llm max tokens # recalc llm max tokens
prompt_messages = self._organize_prompt_messages() prompt_messages = self._organize_prompt_messages()
self.recalc_llm_max_tokens(self.model_config, prompt_messages)
# invoke model # invoke model
chunks = model_instance.invoke_llm( chunks = model_instance.invoke_llm(
prompt_messages=prompt_messages, prompt_messages=prompt_messages,

@ -85,7 +85,6 @@ class FunctionCallAgentRunner(BaseAgentRunner):
# recalc llm max tokens # recalc llm max tokens
prompt_messages = self._organize_prompt_messages() prompt_messages = self._organize_prompt_messages()
self.recalc_llm_max_tokens(self.model_config, prompt_messages)
# invoke model # invoke model
chunks: Union[Generator[LLMResultChunk, None, None], LLMResult] = model_instance.invoke_llm( chunks: Union[Generator[LLMResultChunk, None, None], LLMResult] = model_instance.invoke_llm(
prompt_messages=prompt_messages, prompt_messages=prompt_messages,

@ -15,7 +15,6 @@ from core.app.features.annotation_reply.annotation_reply import AnnotationReplyF
from core.app.features.hosting_moderation.hosting_moderation import HostingModerationFeature from core.app.features.hosting_moderation.hosting_moderation import HostingModerationFeature
from core.external_data_tool.external_data_fetch import ExternalDataFetch from core.external_data_tool.external_data_fetch import ExternalDataFetch
from core.memory.token_buffer_memory import TokenBufferMemory from core.memory.token_buffer_memory import TokenBufferMemory
from core.model_manager import ModelInstance
from core.model_runtime.entities.llm_entities import LLMResult, LLMResultChunk, LLMResultChunkDelta, LLMUsage from core.model_runtime.entities.llm_entities import LLMResult, LLMResultChunk, LLMResultChunkDelta, LLMUsage
from core.model_runtime.entities.message_entities import ( from core.model_runtime.entities.message_entities import (
AssistantPromptMessage, AssistantPromptMessage,
@ -35,106 +34,6 @@ if TYPE_CHECKING:
class AppRunner: class AppRunner:
def get_pre_calculate_rest_tokens(
self,
app_record: App,
model_config: ModelConfigWithCredentialsEntity,
prompt_template_entity: PromptTemplateEntity,
inputs: Mapping[str, str],
files: Sequence["File"],
query: Optional[str] = None,
) -> int:
"""
Get pre calculate rest tokens
:param app_record: app record
:param model_config: model config entity
:param prompt_template_entity: prompt template entity
:param inputs: inputs
:param files: files
:param query: query
:return:
"""
# Invoke model
model_instance = ModelInstance(
provider_model_bundle=model_config.provider_model_bundle, model=model_config.model
)
model_context_tokens = model_config.model_schema.model_properties.get(ModelPropertyKey.CONTEXT_SIZE)
max_tokens = 0
for parameter_rule in model_config.model_schema.parameter_rules:
if parameter_rule.name == "max_tokens" or (
parameter_rule.use_template and parameter_rule.use_template == "max_tokens"
):
max_tokens = (
model_config.parameters.get(parameter_rule.name)
or model_config.parameters.get(parameter_rule.use_template or "")
) or 0
if model_context_tokens is None:
return -1
if max_tokens is None:
max_tokens = 0
# get prompt messages without memory and context
prompt_messages, stop = self.organize_prompt_messages(
app_record=app_record,
model_config=model_config,
prompt_template_entity=prompt_template_entity,
inputs=inputs,
files=files,
query=query,
)
prompt_tokens = model_instance.get_llm_num_tokens(prompt_messages)
rest_tokens: int = model_context_tokens - max_tokens - prompt_tokens
if rest_tokens < 0:
raise InvokeBadRequestError(
"Query or prefix prompt is too long, you can reduce the prefix prompt, "
"or shrink the max token, or switch to a llm with a larger token limit size."
)
return rest_tokens
def recalc_llm_max_tokens(
self, model_config: ModelConfigWithCredentialsEntity, prompt_messages: list[PromptMessage]
):
# recalc max_tokens if sum(prompt_token + max_tokens) over model token limit
model_instance = ModelInstance(
provider_model_bundle=model_config.provider_model_bundle, model=model_config.model
)
model_context_tokens = model_config.model_schema.model_properties.get(ModelPropertyKey.CONTEXT_SIZE)
max_tokens = 0
for parameter_rule in model_config.model_schema.parameter_rules:
if parameter_rule.name == "max_tokens" or (
parameter_rule.use_template and parameter_rule.use_template == "max_tokens"
):
max_tokens = (
model_config.parameters.get(parameter_rule.name)
or model_config.parameters.get(parameter_rule.use_template or "")
) or 0
if model_context_tokens is None:
return -1
if max_tokens is None:
max_tokens = 0
prompt_tokens = model_instance.get_llm_num_tokens(prompt_messages)
if prompt_tokens + max_tokens > model_context_tokens:
max_tokens = max(model_context_tokens - prompt_tokens, 16)
for parameter_rule in model_config.model_schema.parameter_rules:
if parameter_rule.name == "max_tokens" or (
parameter_rule.use_template and parameter_rule.use_template == "max_tokens"
):
model_config.parameters[parameter_rule.name] = max_tokens
def organize_prompt_messages( def organize_prompt_messages(
self, self,
app_record: App, app_record: App,

@ -194,9 +194,6 @@ class ChatAppRunner(AppRunner):
if hosting_moderation_result: if hosting_moderation_result:
return return
# Re-calculate the max tokens if sum(prompt_token + max_tokens) over model token limit
self.recalc_llm_max_tokens(model_config=application_generate_entity.model_conf, prompt_messages=prompt_messages)
# Invoke model # Invoke model
model_instance = ModelInstance( model_instance = ModelInstance(
provider_model_bundle=application_generate_entity.model_conf.provider_model_bundle, provider_model_bundle=application_generate_entity.model_conf.provider_model_bundle,

@ -152,9 +152,6 @@ class CompletionAppRunner(AppRunner):
if hosting_moderation_result: if hosting_moderation_result:
return return
# Re-calculate the max tokens if sum(prompt_token + max_tokens) over model token limit
self.recalc_llm_max_tokens(model_config=application_generate_entity.model_conf, prompt_messages=prompt_messages)
# Invoke model # Invoke model
model_instance = ModelInstance( model_instance = ModelInstance(
provider_model_bundle=application_generate_entity.model_conf.provider_model_bundle, provider_model_bundle=application_generate_entity.model_conf.provider_model_bundle,

@ -26,7 +26,7 @@ class TokenBufferMemory:
self.model_instance = model_instance self.model_instance = model_instance
def get_history_prompt_messages( def get_history_prompt_messages(
self, max_token_limit: int = 2000, message_limit: Optional[int] = None self, max_token_limit: int = 100000, message_limit: Optional[int] = None
) -> Sequence[PromptMessage]: ) -> Sequence[PromptMessage]:
""" """
Get history prompt messages. Get history prompt messages.

@ -0,0 +1,115 @@
model: us.anthropic.claude-3-7-sonnet-20250219-v1:0
label:
en_US: Claude 3.7 Sonnet(US.Cross Region Inference)
icon: icon_s_en.svg
model_type: llm
features:
- agent-thought
- vision
- tool-call
- stream-tool-call
model_properties:
mode: chat
context_size: 200000
# docs: https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-anthropic-claude-messages.html
parameter_rules:
- name: enable_cache
label:
zh_Hans: 启用提示缓存
en_US: Enable Prompt Cache
type: boolean
required: false
default: true
help:
zh_Hans: 启用提示缓存可以提高性能并降低成本。Claude 3.7 Sonnet支持在system、messages和tools字段中使用缓存检查点。
en_US: Enable prompt caching to improve performance and reduce costs. Claude 3.7 Sonnet supports cache checkpoints in system, messages, and tools fields.
- name: reasoning_type
label:
zh_Hans: 推理配置
en_US: Reasoning Type
type: boolean
required: false
default: false
placeholder:
zh_Hans: 设置推理配置
en_US: Set reasoning configuration
help:
zh_Hans: 控制模型的推理能力。启用时temperature将固定为1且top_p将被禁用。
en_US: Controls the model's reasoning capability. When enabled, temperature will be fixed to 1 and top_p will be disabled.
- name: reasoning_budget
show_on:
- variable: reasoning_type
value: true
label:
zh_Hans: 推理预算
en_US: Reasoning Budget
type: int
default: 1024
min: 0
max: 128000
help:
zh_Hans: 推理的预算限制最小1024必须小于max_tokens。仅在推理类型为enabled时可用。
en_US: Budget limit for reasoning (minimum 1024), must be less than max_tokens. Only available when reasoning type is enabled.
- name: max_tokens
use_template: max_tokens
required: true
label:
zh_Hans: 最大token数
en_US: Max Tokens
type: int
default: 8192
min: 1
max: 128000
help:
zh_Hans: 停止前生成的最大令牌数。请注意Anthropic Claude 模型可能会在达到 max_tokens 的值之前停止生成令牌。不同的 Anthropic Claude 模型对此参数具有不同的最大值。
en_US: The maximum number of tokens to generate before stopping. Note that Anthropic Claude models might stop generating tokens before reaching the value of max_tokens. Different Anthropic Claude models have different maximum values for this parameter.
- name: temperature
use_template: temperature
required: false
label:
zh_Hans: 模型温度
en_US: Model Temperature
type: float
default: 1
min: 0.0
max: 1.0
help:
zh_Hans: 生成内容的随机性。当推理功能启用时该值将被固定为1。
en_US: The amount of randomness injected into the response. When reasoning is enabled, this value will be fixed to 1.
- name: top_p
show_on:
- variable: reasoning_type
value: disabled
use_template: top_p
label:
zh_Hans: Top P
en_US: Top P
required: false
type: float
default: 0.999
min: 0.000
max: 1.000
help:
zh_Hans: 在核采样中的概率阈值。当推理功能启用时,该参数将被禁用。
en_US: The probability threshold in nucleus sampling. When reasoning is enabled, this parameter will be disabled.
- name: top_k
label:
zh_Hans: 取样数量
en_US: Top k
required: false
type: int
default: 0
min: 0
# tip docs from aws has error, max value is 500
max: 500
help:
zh_Hans: 对于每个后续标记,仅从前 K 个选项中进行采样。使用 top_k 删除长尾低概率响应。
en_US: Only sample from the top K options for each subsequent token. Use top_k to remove long tail low probability responses.
- name: response_format
use_template: response_format
pricing:
input: '0.003'
output: '0.015'
unit: '0.001'
currency: USD

@ -0,0 +1,896 @@
# standard import
import base64
import json
import logging
from collections.abc import Generator
from typing import Optional, Union, cast
# 3rd import
import boto3 # type: ignore
from botocore.config import Config # type: ignore
from botocore.exceptions import ( # type: ignore
ClientError,
EndpointConnectionError,
NoRegionError,
ServiceNotInRegionError,
UnknownServiceError,
)
# local import
from core.model_runtime.callbacks.base_callback import Callback
from core.model_runtime.entities.llm_entities import LLMResult, LLMResultChunk, LLMResultChunkDelta
from core.model_runtime.entities.message_entities import (
AssistantPromptMessage,
ImagePromptMessageContent,
PromptMessage,
PromptMessageContentType,
PromptMessageTool,
SystemPromptMessage,
TextPromptMessageContent,
ToolPromptMessage,
UserPromptMessage,
)
from core.model_runtime.errors.invoke import (
InvokeAuthorizationError,
InvokeBadRequestError,
InvokeConnectionError,
InvokeError,
InvokeRateLimitError,
InvokeServerUnavailableError,
)
from core.model_runtime.errors.validate import CredentialsValidateFailedError
from core.model_runtime.model_providers.__base.large_language_model import LargeLanguageModel
from core.model_runtime.model_providers.bedrock.get_bedrock_client import get_bedrock_client
logger = logging.getLogger(__name__)
ANTHROPIC_BLOCK_MODE_PROMPT = """You should always follow the instructions and output a valid {{block}} object.
The structure of the {{block}} object you can found in the instructions, use {"answer": "$your_answer"} as the default structure
if you are not sure about the structure.
<instructions>
{{instructions}}
</instructions>
""" # noqa: E501
class BedrockLargeLanguageModel(LargeLanguageModel):
# please refer to the documentation: https://docs.aws.amazon.com/bedrock/latest/userguide/conversation-inference.html
# TODO There is invoke issue: context limit on Cohere Model, will add them after fixed.
CONVERSE_API_ENABLED_MODEL_INFO = [
{"prefix": "anthropic.claude-v2", "support_system_prompts": True, "support_tool_use": False},
{"prefix": "us.deepseek", "support_system_prompts": True, "support_tool_use": False},
{"prefix": "anthropic.claude-v1", "support_system_prompts": True, "support_tool_use": False},
{"prefix": "us.anthropic.claude-3", "support_system_prompts": True, "support_tool_use": True},
{"prefix": "eu.anthropic.claude-3", "support_system_prompts": True, "support_tool_use": True},
{"prefix": "anthropic.claude-3", "support_system_prompts": True, "support_tool_use": True},
{"prefix": "us.meta.llama3-2", "support_system_prompts": True, "support_tool_use": True},
{"prefix": "meta.llama", "support_system_prompts": True, "support_tool_use": False},
{"prefix": "mistral.mistral-7b-instruct", "support_system_prompts": False, "support_tool_use": False},
{"prefix": "mistral.mixtral-8x7b-instruct", "support_system_prompts": False, "support_tool_use": False},
{"prefix": "mistral.mistral-large", "support_system_prompts": True, "support_tool_use": True},
{"prefix": "mistral.mistral-small", "support_system_prompts": True, "support_tool_use": True},
{"prefix": "cohere.command-r", "support_system_prompts": True, "support_tool_use": True},
{"prefix": "amazon.titan", "support_system_prompts": False, "support_tool_use": False},
{"prefix": "ai21.jamba-1-5", "support_system_prompts": True, "support_tool_use": False},
{"prefix": "amazon.nova", "support_system_prompts": True, "support_tool_use": False},
{"prefix": "us.amazon.nova", "support_system_prompts": True, "support_tool_use": False},
]
@staticmethod
def _find_model_info(model_id):
for model in BedrockLargeLanguageModel.CONVERSE_API_ENABLED_MODEL_INFO:
if model_id.startswith(model["prefix"]):
return model
logger.info(f"current model id: {model_id} did not support by Converse API")
return None
def _code_block_mode_wrapper(
self,
model: str,
credentials: dict,
prompt_messages: list[PromptMessage],
model_parameters: dict,
tools: Optional[list[PromptMessageTool]] = None,
stop: Optional[list[str]] = None,
stream: bool = True,
user: Optional[str] = None,
callbacks: Optional[list[Callback]] = None,
) -> Union[LLMResult, Generator]:
"""
Code block mode wrapper for invoking large language model
"""
if model_parameters.get("response_format"):
stop = stop or []
if "```\n" not in stop:
stop.append("```\n")
if "\n```" not in stop:
stop.append("\n```")
response_format = model_parameters.pop("response_format")
format_prompt = SystemPromptMessage(
content=ANTHROPIC_BLOCK_MODE_PROMPT.replace("{{instructions}}", prompt_messages[0].content).replace(
"{{block}}", response_format
)
)
if len(prompt_messages) > 0 and isinstance(prompt_messages[0], SystemPromptMessage):
prompt_messages[0] = format_prompt
else:
prompt_messages.insert(0, format_prompt)
prompt_messages.append(AssistantPromptMessage(content=f"\n```{response_format}"))
return self._invoke(model, credentials, prompt_messages, model_parameters, tools, stop, stream, user)
def _invoke(
self,
model: str,
credentials: dict,
prompt_messages: list[PromptMessage],
model_parameters: dict,
tools: Optional[list[PromptMessageTool]] = None,
stop: Optional[list[str]] = None,
stream: bool = True,
user: Optional[str] = None,
) -> Union[LLMResult, Generator]:
"""
Invoke large language model
:param model: model name
:param credentials: model credentials
:param prompt_messages: prompt messages
:param model_parameters: model parameters
:param tools: tools for tool calling
:param stop: stop words
:param stream: is stream response
:param user: unique user id
:return: full response or stream response chunk generator result
"""
model_info = BedrockLargeLanguageModel._find_model_info(model)
if model_info:
model_info["model"] = model
# invoke models via boto3 converse API
return self._generate_with_converse(
model_info, credentials, prompt_messages, model_parameters, stop, stream, user, tools
)
# invoke other models via boto3 client
return self._generate(model, credentials, prompt_messages, model_parameters, stop, stream, user)
def _generate_with_converse(
self,
model_info: dict,
credentials: dict,
prompt_messages: list[PromptMessage],
model_parameters: dict,
stop: Optional[list[str]] = None,
stream: bool = True,
user: Optional[str] = None,
tools: Optional[list[PromptMessageTool]] = None,
) -> Union[LLMResult, Generator]:
"""
Invoke large language model with converse API
:param model_info: model information
:param credentials: model credentials
:param prompt_messages: prompt messages
:param model_parameters: model parameters
:param stop: stop words
:param stream: is stream response
:return: full response or stream response chunk generator result
"""
bedrock_client = get_bedrock_client("bedrock-runtime", credentials)
system, prompt_message_dicts = self._convert_converse_prompt_messages(prompt_messages)
inference_config, additional_model_fields = self._convert_converse_api_model_parameters(model_parameters, stop)
parameters = {
"modelId": model_info["model"],
"messages": prompt_message_dicts,
"inferenceConfig": inference_config,
"additionalModelRequestFields": additional_model_fields,
}
if model_info["support_system_prompts"] and system and len(system) > 0:
parameters["system"] = system
if model_info["support_tool_use"] and tools:
parameters["toolConfig"] = self._convert_converse_tool_config(tools=tools)
try:
# for issue #10976
conversations_list = parameters["messages"]
# if two consecutive user messages found, combine them into one message
for i in range(len(conversations_list) - 2, -1, -1):
if conversations_list[i]["role"] == conversations_list[i + 1]["role"]:
conversations_list[i]["content"].extend(conversations_list.pop(i + 1)["content"])
if stream:
response = bedrock_client.converse_stream(**parameters)
return self._handle_converse_stream_response(
model_info["model"], credentials, response, prompt_messages
)
else:
response = bedrock_client.converse(**parameters)
return self._handle_converse_response(model_info["model"], credentials, response, prompt_messages)
except ClientError as ex:
error_code = ex.response["Error"]["Code"]
full_error_msg = f"{error_code}: {ex.response['Error']['Message']}"
raise self._map_client_to_invoke_error(error_code, full_error_msg)
except (EndpointConnectionError, NoRegionError, ServiceNotInRegionError) as ex:
raise InvokeConnectionError(str(ex))
except UnknownServiceError as ex:
raise InvokeServerUnavailableError(str(ex))
except Exception as ex:
raise InvokeError(str(ex))
def _handle_converse_response(
self, model: str, credentials: dict, response: dict, prompt_messages: list[PromptMessage]
) -> LLMResult:
"""
Handle llm chat response
:param model: model name
:param credentials: credentials
:param response: response
:param prompt_messages: prompt messages
:return: full response chunk generator result
"""
response_content = response["output"]["message"]["content"]
# transform assistant message to prompt message
if response["stopReason"] == "tool_use":
tool_calls = []
text, tool_use = self._extract_tool_use(response_content)
tool_call = AssistantPromptMessage.ToolCall(
id=tool_use["toolUseId"],
type="function",
function=AssistantPromptMessage.ToolCall.ToolCallFunction(
name=tool_use["name"], arguments=json.dumps(tool_use["input"])
),
)
tool_calls.append(tool_call)
assistant_prompt_message = AssistantPromptMessage(content=text, tool_calls=tool_calls)
else:
assistant_prompt_message = AssistantPromptMessage(content=response_content[0]["text"])
# calculate num tokens
if response["usage"]:
# transform usage
prompt_tokens = response["usage"]["inputTokens"]
completion_tokens = response["usage"]["outputTokens"]
else:
# calculate num tokens
prompt_tokens = self.get_num_tokens(model, credentials, prompt_messages)
completion_tokens = self.get_num_tokens(model, credentials, [assistant_prompt_message])
# transform usage
usage = self._calc_response_usage(model, credentials, prompt_tokens, completion_tokens)
result = LLMResult(
model=model,
prompt_messages=prompt_messages,
message=assistant_prompt_message,
usage=usage,
)
return result
def _extract_tool_use(self, content: dict) -> tuple[str, dict]:
tool_use = {}
text = ""
for item in content:
if "toolUse" in item:
tool_use = item["toolUse"]
elif "text" in item:
text = item["text"]
else:
raise ValueError(f"Got unknown item: {item}")
return text, tool_use
def _handle_converse_stream_response(
self,
model: str,
credentials: dict,
response: dict,
prompt_messages: list[PromptMessage],
) -> Generator:
"""
Handle llm chat stream response
:param model: model name
:param credentials: credentials
:param response: response
:param prompt_messages: prompt messages
:return: full response or stream response chunk generator result
"""
try:
full_assistant_content = ""
return_model = None
input_tokens = 0
output_tokens = 0
finish_reason = None
index = 0
tool_calls: list[AssistantPromptMessage.ToolCall] = []
tool_use = {}
for chunk in response["stream"]:
if "messageStart" in chunk:
return_model = model
elif "messageStop" in chunk:
finish_reason = chunk["messageStop"]["stopReason"]
elif "contentBlockStart" in chunk:
tool = chunk["contentBlockStart"]["start"]["toolUse"]
tool_use["toolUseId"] = tool["toolUseId"]
tool_use["name"] = tool["name"]
elif "metadata" in chunk:
input_tokens = chunk["metadata"]["usage"]["inputTokens"]
output_tokens = chunk["metadata"]["usage"]["outputTokens"]
usage = self._calc_response_usage(model, credentials, input_tokens, output_tokens)
yield LLMResultChunk(
model=return_model,
prompt_messages=prompt_messages,
delta=LLMResultChunkDelta(
index=index,
message=AssistantPromptMessage(content="", tool_calls=tool_calls),
finish_reason=finish_reason,
usage=usage,
),
)
elif "contentBlockDelta" in chunk:
delta = chunk["contentBlockDelta"]["delta"]
if "text" in delta:
chunk_text = delta["text"] or ""
full_assistant_content += chunk_text
assistant_prompt_message = AssistantPromptMessage(
content=chunk_text or "",
)
index = chunk["contentBlockDelta"]["contentBlockIndex"]
yield LLMResultChunk(
model=model,
prompt_messages=prompt_messages,
delta=LLMResultChunkDelta(
index=index + 1,
message=assistant_prompt_message,
),
)
elif "toolUse" in delta:
if "input" not in tool_use:
tool_use["input"] = ""
tool_use["input"] += delta["toolUse"]["input"]
elif "contentBlockStop" in chunk:
if "input" in tool_use:
tool_call = AssistantPromptMessage.ToolCall(
id=tool_use["toolUseId"],
type="function",
function=AssistantPromptMessage.ToolCall.ToolCallFunction(
name=tool_use["name"], arguments=tool_use["input"]
),
)
tool_calls.append(tool_call)
tool_use = {}
except Exception as ex:
raise InvokeError(str(ex))
def _convert_converse_api_model_parameters(
self, model_parameters: dict, stop: Optional[list[str]] = None
) -> tuple[dict, dict]:
inference_config = {}
additional_model_fields = {}
if "max_tokens" in model_parameters:
inference_config["maxTokens"] = model_parameters["max_tokens"]
if "temperature" in model_parameters:
inference_config["temperature"] = model_parameters["temperature"]
if "top_p" in model_parameters:
inference_config["topP"] = model_parameters["temperature"]
if stop:
inference_config["stopSequences"] = stop
if "top_k" in model_parameters:
additional_model_fields["top_k"] = model_parameters["top_k"]
return inference_config, additional_model_fields
def _convert_converse_prompt_messages(self, prompt_messages: list[PromptMessage]) -> tuple[str, list[dict]]:
"""
Convert prompt messages to dict list and system
"""
system = []
prompt_message_dicts = []
for message in prompt_messages:
if isinstance(message, SystemPromptMessage):
message.content = message.content.strip()
system.append({"text": message.content})
else:
prompt_message_dicts.append(self._convert_prompt_message_to_dict(message))
return system, prompt_message_dicts
def _convert_converse_tool_config(self, tools: Optional[list[PromptMessageTool]] = None) -> dict:
tool_config = {}
configs = []
if tools:
for tool in tools:
configs.append(
{
"toolSpec": {
"name": tool.name,
"description": tool.description,
"inputSchema": {"json": tool.parameters},
}
}
)
tool_config["tools"] = configs
return tool_config
def _convert_prompt_message_to_dict(self, message: PromptMessage) -> dict:
"""
Convert PromptMessage to dict
"""
if isinstance(message, UserPromptMessage):
message = cast(UserPromptMessage, message)
if isinstance(message.content, str):
message_dict = {"role": "user", "content": [{"text": message.content}]}
else:
sub_messages = []
for message_content in message.content:
if message_content.type == PromptMessageContentType.TEXT:
message_content = cast(TextPromptMessageContent, message_content)
sub_message_dict = {"text": message_content.data}
sub_messages.append(sub_message_dict)
elif message_content.type == PromptMessageContentType.IMAGE:
message_content = cast(ImagePromptMessageContent, message_content)
data_split = message_content.data.split(";base64,")
mime_type = data_split[0].replace("data:", "")
base64_data = data_split[1]
image_content = base64.b64decode(base64_data)
if mime_type not in {"image/jpeg", "image/png", "image/gif", "image/webp"}:
raise ValueError(
f"Unsupported image type {mime_type}, "
f"only support image/jpeg, image/png, image/gif, and image/webp"
)
sub_message_dict = {
"image": {"format": mime_type.replace("image/", ""), "source": {"bytes": image_content}}
}
sub_messages.append(sub_message_dict)
message_dict = {"role": "user", "content": sub_messages}
elif isinstance(message, AssistantPromptMessage):
message = cast(AssistantPromptMessage, message)
if message.tool_calls:
message_dict = {
"role": "assistant",
"content": [
{
"toolUse": {
"toolUseId": message.tool_calls[0].id,
"name": message.tool_calls[0].function.name,
"input": json.loads(message.tool_calls[0].function.arguments),
}
}
],
}
else:
message_dict = {"role": "assistant", "content": [{"text": message.content}]}
elif isinstance(message, SystemPromptMessage):
message = cast(SystemPromptMessage, message)
message_dict = [{"text": message.content}]
elif isinstance(message, ToolPromptMessage):
message = cast(ToolPromptMessage, message)
message_dict = {
"role": "user",
"content": [
{
"toolResult": {
"toolUseId": message.tool_call_id,
"content": [{"json": {"text": message.content}}],
}
}
],
}
else:
raise ValueError(f"Got unknown type {message}")
return message_dict
def get_num_tokens(
self,
model: str,
credentials: dict,
prompt_messages: list[PromptMessage] | str,
tools: Optional[list[PromptMessageTool]] = None,
) -> int:
"""
Get number of tokens for given prompt messages
:param model: model name
:param credentials: model credentials
:param prompt_messages: prompt messages or message string
:param tools: tools for tool calling
:return:md = genai.GenerativeModel(model)
"""
prefix = model.split(".")[0]
model_name = model.split(".")[1]
if isinstance(prompt_messages, str):
prompt = prompt_messages
else:
prompt = self._convert_messages_to_prompt(prompt_messages, prefix, model_name)
return self._get_num_tokens_by_gpt2(prompt)
def validate_credentials(self, model: str, credentials: dict) -> None:
"""
Validate model credentials
:param model: model name
:param credentials: model credentials
:return:
"""
required_params = {}
if "anthropic" in model:
required_params = {
"max_tokens": 32,
}
elif "ai21" in model:
# ValidationException: Malformed input request: #/temperature: expected type: Number,
# found: Null#/maxTokens: expected type: Integer, found: Null#/topP: expected type: Number, found: Null,
# please reformat your input and try again.
required_params = {
"temperature": 0.7,
"topP": 0.9,
"maxTokens": 32,
}
try:
ping_message = UserPromptMessage(content="ping")
self._invoke(
model=model,
credentials=credentials,
prompt_messages=[ping_message],
model_parameters=required_params,
stream=False,
)
except ClientError as ex:
error_code = ex.response["Error"]["Code"]
full_error_msg = f"{error_code}: {ex.response['Error']['Message']}"
raise CredentialsValidateFailedError(str(self._map_client_to_invoke_error(error_code, full_error_msg)))
except Exception as ex:
raise CredentialsValidateFailedError(str(ex))
def _convert_one_message_to_text(
self, message: PromptMessage, model_prefix: str, model_name: Optional[str] = None
) -> str:
"""
Convert a single message to a string.
:param message: PromptMessage to convert.
:return: String representation of the message.
"""
human_prompt_prefix = ""
human_prompt_postfix = ""
ai_prompt = ""
content = message.content
if isinstance(message, UserPromptMessage):
body = content
if isinstance(content, list):
body = "".join([c.data for c in content if c.type == PromptMessageContentType.TEXT])
message_text = f"{human_prompt_prefix} {body} {human_prompt_postfix}"
elif isinstance(message, AssistantPromptMessage):
message_text = f"{ai_prompt} {content}"
elif isinstance(message, SystemPromptMessage):
message_text = content
elif isinstance(message, ToolPromptMessage):
message_text = f"{human_prompt_prefix} {message.content}"
else:
raise ValueError(f"Got unknown type {message}")
return message_text
def _convert_messages_to_prompt(
self, messages: list[PromptMessage], model_prefix: str, model_name: Optional[str] = None
) -> str:
"""
Format a list of messages into a full prompt for the Anthropic, Amazon and Llama models
:param messages: List of PromptMessage to combine.
:param model_name: specific model name.Optional,just to distinguish llama2 and llama3
:return: Combined string with necessary human_prompt and ai_prompt tags.
"""
if not messages:
return ""
messages = messages.copy() # don't mutate the original list
if not isinstance(messages[-1], AssistantPromptMessage):
messages.append(AssistantPromptMessage(content=""))
text = "".join(self._convert_one_message_to_text(message, model_prefix, model_name) for message in messages)
# trim off the trailing ' ' that might come from the "Assistant: "
return text.rstrip()
def _create_payload(
self,
model: str,
prompt_messages: list[PromptMessage],
model_parameters: dict,
stop: Optional[list[str]] = None,
stream: bool = True,
):
"""
Create payload for bedrock api call depending on model provider
"""
payload = {}
model_prefix = model.split(".")[0]
model_name = model.split(".")[1]
if model_prefix == "ai21":
payload["temperature"] = model_parameters.get("temperature")
payload["topP"] = model_parameters.get("topP")
payload["maxTokens"] = model_parameters.get("maxTokens")
payload["prompt"] = self._convert_messages_to_prompt(prompt_messages, model_prefix)
if model_parameters.get("presencePenalty"):
payload["presencePenalty"] = {model_parameters.get("presencePenalty")}
if model_parameters.get("frequencyPenalty"):
payload["frequencyPenalty"] = {model_parameters.get("frequencyPenalty")}
if model_parameters.get("countPenalty"):
payload["countPenalty"] = {model_parameters.get("countPenalty")}
elif model_prefix == "cohere":
payload = {**model_parameters}
payload["prompt"] = prompt_messages[0].content
payload["stream"] = stream
else:
raise ValueError(f"Got unknown model prefix {model_prefix}")
return payload
def _generate(
self,
model: str,
credentials: dict,
prompt_messages: list[PromptMessage],
model_parameters: dict,
stop: Optional[list[str]] = None,
stream: bool = True,
user: Optional[str] = None,
) -> Union[LLMResult, Generator]:
"""
Invoke large language model
:param model: model name
:param credentials: credentials kwargs
:param prompt_messages: prompt messages
:param model_parameters: model parameters
:param stop: stop words
:param stream: is stream response
:param user: unique user id
:return: full response or stream response chunk generator result
"""
client_config = Config(region_name=credentials["aws_region"])
runtime_client = boto3.client(
service_name="bedrock-runtime",
config=client_config,
aws_access_key_id=credentials.get("aws_access_key_id"),
aws_secret_access_key=credentials.get("aws_secret_access_key"),
)
model_prefix = model.split(".")[0]
payload = self._create_payload(model, prompt_messages, model_parameters, stop, stream)
# need workaround for ai21 models which doesn't support streaming
if stream and model_prefix != "ai21":
invoke = runtime_client.invoke_model_with_response_stream
else:
invoke = runtime_client.invoke_model
try:
body_jsonstr = json.dumps(payload)
response = invoke(modelId=model, contentType="application/json", accept="*/*", body=body_jsonstr)
except ClientError as ex:
error_code = ex.response["Error"]["Code"]
full_error_msg = f"{error_code}: {ex.response['Error']['Message']}"
raise self._map_client_to_invoke_error(error_code, full_error_msg)
except (EndpointConnectionError, NoRegionError, ServiceNotInRegionError) as ex:
raise InvokeConnectionError(str(ex))
except UnknownServiceError as ex:
raise InvokeServerUnavailableError(str(ex))
except Exception as ex:
raise InvokeError(str(ex))
if stream:
return self._handle_generate_stream_response(model, credentials, response, prompt_messages)
return self._handle_generate_response(model, credentials, response, prompt_messages)
def _handle_generate_response(
self, model: str, credentials: dict, response: dict, prompt_messages: list[PromptMessage]
) -> LLMResult:
"""
Handle llm response
:param model: model name
:param credentials: credentials
:param response: response
:param prompt_messages: prompt messages
:return: llm response
"""
response_body = json.loads(response.get("body").read().decode("utf-8"))
finish_reason = response_body.get("error")
if finish_reason is not None:
raise InvokeError(finish_reason)
# get output text and calculate num tokens based on model / provider
model_prefix = model.split(".")[0]
if model_prefix == "ai21":
output = response_body.get("completions")[0].get("data").get("text")
prompt_tokens = len(response_body.get("prompt").get("tokens"))
completion_tokens = len(response_body.get("completions")[0].get("data").get("tokens"))
elif model_prefix == "cohere":
output = response_body.get("generations")[0].get("text")
prompt_tokens = self.get_num_tokens(model, credentials, prompt_messages)
completion_tokens = self.get_num_tokens(model, credentials, output or "")
else:
raise ValueError(f"Got unknown model prefix {model_prefix} when handling block response")
# construct assistant message from output
assistant_prompt_message = AssistantPromptMessage(content=output)
# calculate usage
usage = self._calc_response_usage(model, credentials, prompt_tokens, completion_tokens)
# construct response
result = LLMResult(
model=model,
prompt_messages=prompt_messages,
message=assistant_prompt_message,
usage=usage,
)
return result
def _handle_generate_stream_response(
self, model: str, credentials: dict, response: dict, prompt_messages: list[PromptMessage]
) -> Generator:
"""
Handle llm stream response
:param model: model name
:param credentials: credentials
:param response: response
:param prompt_messages: prompt messages
:return: llm response chunk generator result
"""
model_prefix = model.split(".")[0]
if model_prefix == "ai21":
response_body = json.loads(response.get("body").read().decode("utf-8"))
content = response_body.get("completions")[0].get("data").get("text")
finish_reason = response_body.get("completions")[0].get("finish_reason")
prompt_tokens = len(response_body.get("prompt").get("tokens"))
completion_tokens = len(response_body.get("completions")[0].get("data").get("tokens"))
usage = self._calc_response_usage(model, credentials, prompt_tokens, completion_tokens)
yield LLMResultChunk(
model=model,
prompt_messages=prompt_messages,
delta=LLMResultChunkDelta(
index=0, message=AssistantPromptMessage(content=content), finish_reason=finish_reason, usage=usage
),
)
return
stream = response.get("body")
if not stream:
raise InvokeError("No response body")
index = -1
for event in stream:
chunk = event.get("chunk")
if not chunk:
exception_name = next(iter(event))
full_ex_msg = f"{exception_name}: {event[exception_name]['message']}"
raise self._map_client_to_invoke_error(exception_name, full_ex_msg)
payload = json.loads(chunk.get("bytes").decode())
model_prefix = model.split(".")[0]
if model_prefix == "cohere":
content_delta = payload.get("text")
finish_reason = payload.get("finish_reason")
else:
raise ValueError(f"Got unknown model prefix {model_prefix} when handling stream response")
# transform assistant message to prompt message
assistant_prompt_message = AssistantPromptMessage(
content=content_delta or "",
)
index += 1
if not finish_reason:
yield LLMResultChunk(
model=model,
prompt_messages=prompt_messages,
delta=LLMResultChunkDelta(index=index, message=assistant_prompt_message),
)
else:
# get num tokens from metrics in last chunk
prompt_tokens = payload["amazon-bedrock-invocationMetrics"]["inputTokenCount"]
completion_tokens = payload["amazon-bedrock-invocationMetrics"]["outputTokenCount"]
# transform usage
usage = self._calc_response_usage(model, credentials, prompt_tokens, completion_tokens)
yield LLMResultChunk(
model=model,
prompt_messages=prompt_messages,
delta=LLMResultChunkDelta(
index=index, message=assistant_prompt_message, finish_reason=finish_reason, usage=usage
),
)
@property
def _invoke_error_mapping(self) -> dict[type[InvokeError], list[type[Exception]]]:
"""
Map model invoke error to unified error
The key is the ermd = genai.GenerativeModel(model) error type thrown to the caller
The value is the md = genai.GenerativeModel(model) error type thrown by the model,
which needs to be converted into a unified error type for the caller.
:return: Invoke emd = genai.GenerativeModel(model) error mapping
"""
return {
InvokeConnectionError: [],
InvokeServerUnavailableError: [],
InvokeRateLimitError: [],
InvokeAuthorizationError: [],
InvokeBadRequestError: [],
}
def _map_client_to_invoke_error(self, error_code: str, error_msg: str) -> type[InvokeError]:
"""
Map client error to invoke error
:param error_code: error code
:param error_msg: error message
:return: invoke error
"""
if error_code == "AccessDeniedException":
return InvokeAuthorizationError(error_msg)
elif error_code in {"ResourceNotFoundException", "ValidationException"}:
return InvokeBadRequestError(error_msg)
elif error_code in {"ThrottlingException", "ServiceQuotaExceededException"}:
return InvokeRateLimitError(error_msg)
elif error_code in {
"ModelTimeoutException",
"ModelErrorException",
"InternalServerException",
"ModelNotReadyException",
}:
return InvokeServerUnavailableError(error_msg)
elif error_code == "ModelStreamErrorException":
return InvokeConnectionError(error_msg)
return InvokeError(error_msg)

@ -0,0 +1,63 @@
model: us.deepseek.r1-v1:0
label:
en_US: DeepSeek-R1(US.Cross Region Inference)
icon: icon_s_en.svg
model_type: llm
features:
- agent-thought
- vision
- tool-call
- stream-tool-call
model_properties:
mode: chat
context_size: 32768
parameter_rules:
- name: max_tokens
use_template: max_tokens
required: true
label:
zh_Hans: 最大token数
en_US: Max Tokens
type: int
default: 8192
min: 1
max: 128000
help:
zh_Hans: 停止前生成的最大令牌数。
en_US: The maximum number of tokens to generate before stopping.
- name: temperature
use_template: temperature
required: false
label:
zh_Hans: 模型温度
en_US: Model Temperature
type: float
default: 1
min: 0.0
max: 1.0
help:
zh_Hans: 生成内容的随机性。当推理功能启用时该值将被固定为1。
en_US: The amount of randomness injected into the response. When reasoning is enabled, this value will be fixed to 1.
- name: top_p
show_on:
- variable: reasoning_type
value: disabled
use_template: top_p
label:
zh_Hans: Top P
en_US: Top P
required: false
type: float
default: 0.999
min: 0.000
max: 1.000
help:
zh_Hans: 在核采样中的概率阈值。当推理功能启用时,该参数将被禁用。
en_US: The probability threshold in nucleus sampling. When reasoning is enabled, this parameter will be disabled.
- name: response_format
use_template: response_format
pricing:
input: '0.001'
output: '0.005'
unit: '0.001'
currency: USD

@ -0,0 +1,28 @@
import logging
from core.model_runtime.entities.model_entities import ModelType
from core.model_runtime.errors.validate import CredentialsValidateFailedError
from core.model_runtime.model_providers.__base.model_provider import ModelProvider
logger = logging.getLogger(__name__)
class GoogleProvider(ModelProvider):
def validate_provider_credentials(self, credentials: dict) -> None:
"""
Validate provider credentials
if validate failed, raise exception
:param credentials: provider credentials, credentials form defined in `provider_credential_schema`.
"""
try:
model_instance = self.get_model_instance(ModelType.LLM)
# Use `gemini-2.0-flash` model for validate,
model_instance.validate_credentials(model="gemini-2.0-flash", credentials=credentials)
except CredentialsValidateFailedError as ex:
raise ex
except Exception as ex:
logger.exception(f"{self.get_provider_schema().provider} credentials validate failed")
raise ex

@ -0,0 +1,21 @@
- gemini-2.0-flash-001
- gemini-2.0-flash-exp
- gemini-2.0-pro-exp-02-05
- gemini-2.0-flash-thinking-exp-1219
- gemini-2.0-flash-thinking-exp-01-21
- gemini-1.5-pro
- gemini-1.5-pro-latest
- gemini-1.5-pro-001
- gemini-1.5-pro-002
- gemini-1.5-pro-exp-0801
- gemini-1.5-pro-exp-0827
- gemini-1.5-flash
- gemini-1.5-flash-latest
- gemini-1.5-flash-001
- gemini-1.5-flash-002
- gemini-1.5-flash-exp-0827
- gemini-1.5-flash-8b-exp-0827
- gemini-1.5-flash-8b-exp-0924
- gemini-exp-1206
- gemini-exp-1121
- gemini-exp-1114

@ -0,0 +1,33 @@
- gpt-4.1
- o1
- o1-2024-12-17
- o1-mini
- o1-mini-2024-09-12
- o3-mini
- o3-mini-2025-01-31
- gpt-4
- gpt-4o
- gpt-4o-2024-05-13
- gpt-4o-2024-08-06
- gpt-4o-2024-11-20
- chatgpt-4o-latest
- gpt-4o-mini
- gpt-4o-mini-2024-07-18
- gpt-4-turbo
- gpt-4-turbo-2024-04-09
- gpt-4-turbo-preview
- gpt-4-32k
- gpt-4-1106-preview
- gpt-4-0125-preview
- gpt-4-vision-preview
- gpt-3.5-turbo
- gpt-3.5-turbo-16k
- gpt-3.5-turbo-16k-0613
- gpt-3.5-turbo-0125
- gpt-3.5-turbo-1106
- gpt-3.5-turbo-0613
- gpt-3.5-turbo-instruct
- gpt-4o-audio-preview
- o1-preview
- o1-preview-2024-09-12
- text-davinci-003

@ -0,0 +1,60 @@
model: gpt-4.1
label:
zh_Hans: gpt-4.1
en_US: gpt-4.1
model_type: llm
features:
- multi-tool-call
- agent-thought
- stream-tool-call
- vision
model_properties:
mode: chat
context_size: 1047576
parameter_rules:
- name: temperature
use_template: temperature
- name: top_p
use_template: top_p
- name: presence_penalty
use_template: presence_penalty
- name: frequency_penalty
use_template: frequency_penalty
- name: max_tokens
use_template: max_tokens
default: 512
min: 1
max: 32768
- name: reasoning_effort
label:
zh_Hans: 推理工作
en_US: Reasoning Effort
type: string
help:
zh_Hans: 限制推理模型的推理工作
en_US: Constrains effort on reasoning for reasoning models
required: false
options:
- low
- medium
- high
- name: response_format
label:
zh_Hans: 回复格式
en_US: Response Format
type: string
help:
zh_Hans: 指定模型必须输出的格式
en_US: specifying the format that the model must output
required: false
options:
- text
- json_object
- json_schema
- name: json_schema
use_template: json_schema
pricing:
input: '2.00'
output: '8.00'
unit: '0.000001'
currency: USD

File diff suppressed because it is too large Load Diff

@ -0,0 +1,37 @@
model: gemini-2.0-flash-001
label:
en_US: Gemini 2.0 Flash 001
model_type: llm
features:
- agent-thought
- vision
model_properties:
mode: chat
context_size: 1048576
parameter_rules:
- name: temperature
use_template: temperature
- name: top_p
use_template: top_p
- name: top_k
label:
en_US: Top k
type: int
help:
en_US: Only sample from the top K options for each subsequent token.
required: false
- name: presence_penalty
use_template: presence_penalty
- name: frequency_penalty
use_template: frequency_penalty
- name: max_output_tokens
use_template: max_tokens
required: true
default: 8192
min: 1
max: 8192
pricing:
input: '0.00'
output: '0.00'
unit: '0.000001'
currency: USD

@ -0,0 +1,80 @@
- google
- bing
- perplexity
- duckduckgo
- searchapi
- serper
- searxng
- websearch
- tavily
- stackexchange
- pubmed
- arxiv
- aws
- nominatim
- devdocs
- spider
- firecrawl
- brave
- crossref
- jina
- webscraper
- dalle
- azuredalle
- stability
- stablediffusion
- cogview
- comfyui
- getimgai
- siliconflow
- spark
- stepfun
- xinference
- alphavantage
- yahoo
- openweather
- gaode
- aippt
- chart
- youtube
- did
- dingtalk
- discord
- feishu
- feishu_base
- feishu_document
- feishu_message
- feishu_wiki
- feishu_task
- feishu_calendar
- feishu_spreadsheet
- lark_base
- lark_document
- lark_message_and_group
- lark_wiki
- lark_task
- lark_calendar
- lark_spreadsheet
- slack
- twilio
- wecom
- wikipedia
- code
- wolframalpha
- maths
- github
- gitlab
- time
- vectorizer
- qrcode
- tianditu
- aliyuque
- google_translate
- hap
- json_process
- judge0ce
- novitaai
- onebot
- regex
- trello
- fal

@ -199,7 +199,7 @@ class CodeNode(BaseNode[CodeNodeData]):
if output_config.type == "object": if output_config.type == "object":
# check if output is object # check if output is object
if not isinstance(result.get(output_name), dict): if not isinstance(result.get(output_name), dict):
if result[output_name] is None: if result.get(output_name) is None:
transformed_result[output_name] = None transformed_result[output_name] = None
else: else:
raise OutputValidationError( raise OutputValidationError(

@ -1193,14 +1193,12 @@ def _handle_memory_chat_mode(
*, *,
memory: TokenBufferMemory | None, memory: TokenBufferMemory | None,
memory_config: MemoryConfig | None, memory_config: MemoryConfig | None,
model_config: ModelConfigWithCredentialsEntity, model_config: ModelConfigWithCredentialsEntity, # TODO(-LAN-): Needs to remove
) -> Sequence[PromptMessage]: ) -> Sequence[PromptMessage]:
memory_messages: Sequence[PromptMessage] = [] memory_messages: Sequence[PromptMessage] = []
# Get messages from memory for chat model # Get messages from memory for chat model
if memory and memory_config: if memory and memory_config:
rest_tokens = _calculate_rest_token(prompt_messages=[], model_config=model_config)
memory_messages = memory.get_history_prompt_messages( memory_messages = memory.get_history_prompt_messages(
max_token_limit=rest_tokens,
message_limit=memory_config.window.size if memory_config.window.enabled else None, message_limit=memory_config.window.size if memory_config.window.enabled else None,
) )
return memory_messages return memory_messages

@ -63,6 +63,7 @@ app_detail_fields = {
"created_at": TimestampField, "created_at": TimestampField,
"updated_by": fields.String, "updated_by": fields.String,
"updated_at": TimestampField, "updated_at": TimestampField,
"access_mode": fields.String,
} }
prompt_config_fields = { prompt_config_fields = {
@ -98,6 +99,7 @@ app_partial_fields = {
"updated_by": fields.String, "updated_by": fields.String,
"updated_at": TimestampField, "updated_at": TimestampField,
"tags": fields.List(fields.Nested(tag_fields)), "tags": fields.List(fields.Nested(tag_fields)),
"access_mode": fields.String,
} }
@ -176,6 +178,7 @@ app_detail_fields_with_site = {
"updated_by": fields.String, "updated_by": fields.String,
"updated_at": TimestampField, "updated_at": TimestampField,
"deleted_tools": fields.List(fields.Nested(deleted_tool_fields)), "deleted_tools": fields.List(fields.Nested(deleted_tool_fields)),
"access_mode": fields.String,
} }

12392
api/poetry.lock generated

File diff suppressed because it is too large Load Diff

@ -93,6 +93,163 @@ dependencies = [
default-groups = ["storage", "tools", "vdb"] default-groups = ["storage", "tools", "vdb"]
[dependency-groups] [dependency-groups]
############################################################
# [ Main ] Dependency group
############################################################
[tool.poetry.dependencies]
anthropic = "~0.23.1"
authlib = "1.3.1"
azure-ai-inference = "~1.0.0b8"
azure-ai-ml = "~1.20.0"
azure-identity = "1.16.1"
beautifulsoup4 = "4.12.2"
boto3 = "1.36.12"
bs4 = "~0.0.1"
cachetools = "~5.3.0"
celery = "~5.4.0"
chardet = "~5.1.0"
cohere = "~5.2.4"
dashscope = { version = "~1.17.0", extras = ["tokenizer"] }
fal-client = "0.5.6"
flask = "~3.1.0"
flask-compress = "~1.17"
flask-cors = "~4.0.0"
flask-login = "~0.6.3"
flask-migrate = "~4.0.7"
flask-restful = "~0.3.10"
flask-sqlalchemy = "~3.1.1"
gevent = "~24.11.1"
gmpy2 = "~2.2.1"
google-ai-generativelanguage = "0.6.9"
google-api-core = "2.18.0"
google-api-python-client = "2.90.0"
google-auth = "2.29.0"
google-auth-httplib2 = "0.2.0"
google-cloud-aiplatform = "1.49.0"
google-generativeai = "0.8.1"
googleapis-common-protos = "1.63.0"
gunicorn = "~23.0.0"
httpx = { version = "~0.27.0", extras = ["socks"] }
huggingface-hub = "~0.16.4"
jieba = "0.42.1"
langfuse = "~2.51.3"
langsmith = "~0.1.77"
mailchimp-transactional = "~1.0.50"
markdown = "~3.5.1"
nomic = "~3.1.2"
novita-client = "~0.5.7"
numpy = "~1.26.4"
oci = "~2.135.1"
openai = "~1.61.0"
openpyxl = "~3.1.5"
opik = "~1.3.4"
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"
pydantic-settings = "~2.6.0"
pydantic_extra_types = "~2.9.0"
pyjwt = "~2.8.0"
pypdfium2 = "~4.30.0"
python = ">=3.11,<3.13"
python-docx = "~1.1.0"
python-dotenv = "1.0.1"
pyyaml = "~6.0.1"
readabilipy = "0.2.0"
redis = { version = "~5.0.3", extras = ["hiredis"] }
replicate = "~0.22.0"
resend = "~0.7.0"
sagemaker = "~2.231.0"
scikit-learn = "~1.5.1"
sentry-sdk = { version = "~1.44.1", extras = ["flask"] }
sqlalchemy = "~2.0.29"
starlette = "0.41.0"
tencentcloud-sdk-python-hunyuan = "~3.0.1294"
tiktoken = "^0.9.0"
tokenizers = "~0.15.0"
transformers = "~4.35.0"
unstructured = { version = "~0.16.1", extras = ["docx", "epub", "md", "msg", "ppt", "pptx"] }
validators = "0.21.0"
volcengine-python-sdk = {extras = ["ark"], version = "~1.0.98"}
websocket-client = "~1.7.0"
xinference-client = "0.15.2"
yarl = "~1.18.3"
youtube-transcript-api = "~0.6.2"
zhipuai = "~2.1.5"
# Before adding new dependency, consider place it in alphabet order (a-z) and suitable group.
############################################################
# [ Indirect ] dependency group
# Related transparent dependencies with pinned version
# required by main implementations
############################################################
[tool.poetry.group.indirect.dependencies]
kaleido = "0.2.1"
rank-bm25 = "~0.2.2"
safetensors = "~0.4.3"
############################################################
# [ Tools ] dependency group
############################################################
[tool.poetry.group.tools.dependencies]
arxiv = "2.1.0"
cloudscraper = "1.2.71"
duckduckgo-search = "~6.3.0"
jsonpath-ng = "1.6.1"
matplotlib = "~3.8.2"
mplfonts = "~0.0.8"
newspaper3k = "0.2.8"
nltk = "3.9.1"
numexpr = "~2.9.0"
pydub = "~0.25.1"
qrcode = "~7.4.2"
twilio = "~9.0.4"
vanna = { version = "0.7.5", extras = ["postgres", "mysql", "clickhouse", "duckdb", "oracle"] }
wikipedia = "1.4.0"
yfinance = "~0.2.40"
############################################################
# [ Storage ] dependency group
# Required for storage clients
############################################################
[tool.poetry.group.storage.dependencies]
azure-storage-blob = "12.13.0"
bce-python-sdk = "~0.9.23"
cos-python-sdk-v5 = "1.9.30"
esdk-obs-python = "3.24.6.1"
google-cloud-storage = "2.16.0"
opendal = "~0.45.12"
oss2 = "2.18.5"
supabase = "~2.8.1"
tos = "~2.7.1"
############################################################
# [ VDB ] dependency group
# Required by vector store clients
############################################################
[tool.poetry.group.vdb.dependencies]
alibabacloud_gpdb20160503 = "~3.8.0"
alibabacloud_tea_openapi = "~0.3.9"
chromadb = "0.5.20"
clickhouse-connect = "~0.7.16"
couchbase = "~4.3.0"
elasticsearch = "8.14.0"
opensearch-py = "2.4.0"
oracledb = "~2.2.1"
pgvecto-rs = { version = "~0.2.1", extras = ['sqlalchemy'] }
pgvector = "0.2.5"
pymilvus = "~2.5.0"
pymochow = "1.3.1"
pyobvector = "~0.1.6"
qdrant-client = "1.7.3"
tcvectordb = "1.3.2"
tidb-vector = "0.0.9"
upstash-vector = "0.6.0"
volcengine-compat = "~1.0.156"
weaviate-client = "~3.21.0"
############################################################ ############################################################
# [ Dev ] dependency group # [ Dev ] dependency group

@ -49,7 +49,7 @@ from services.errors.account import (
RoleAlreadyAssignedError, RoleAlreadyAssignedError,
TenantNotFoundError, TenantNotFoundError,
) )
from services.errors.workspace import WorkSpaceNotAllowedCreateError from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError
from services.feature_service import FeatureService from services.feature_service import FeatureService
from tasks.delete_account_task import delete_account_task from tasks.delete_account_task import delete_account_task
from tasks.mail_account_deletion_task import send_account_deletion_verification_code from tasks.mail_account_deletion_task import send_account_deletion_verification_code
@ -622,6 +622,10 @@ class TenantService:
if not FeatureService.get_system_features().is_allow_create_workspace and not is_setup: if not FeatureService.get_system_features().is_allow_create_workspace and not is_setup:
raise WorkSpaceNotAllowedCreateError() raise WorkSpaceNotAllowedCreateError()
workspaces = FeatureService.get_system_features().license.workspaces
if not workspaces.is_available():
raise WorkspacesLimitExceededError()
if name: if name:
tenant = TenantService.create_tenant(name=name, is_setup=is_setup) tenant = TenantService.create_tenant(name=name, is_setup=is_setup)
else: else:
@ -914,7 +918,10 @@ class RegisterService:
if open_id is not None and provider is not None: if open_id is not None and provider is not None:
AccountService.link_account_integrate(provider, open_id, account) AccountService.link_account_integrate(provider, open_id, account)
if FeatureService.get_system_features().is_allow_create_workspace and create_workspace_required: if (FeatureService.get_system_features().is_allow_create_workspace
and create_workspace_required
and FeatureService.get_system_features().license.workspaces.is_available()
):
tenant = TenantService.create_tenant(f"{account.name}'s Workspace") tenant = TenantService.create_tenant(f"{account.name}'s Workspace")
TenantService.create_tenant_member(tenant, account, role="owner") TenantService.create_tenant_member(tenant, account, role="owner")
account.current_tenant = tenant account.current_tenant = tenant

@ -18,8 +18,10 @@ from core.tools.utils.configuration import ToolParameterConfigurationManager
from events.app_event import app_was_created from events.app_event import app_was_created
from extensions.ext_database import db from extensions.ext_database import db
from models.account import Account from models.account import Account
from models.model import App, AppMode, AppModelConfig from models.model import App, AppMode, AppModelConfig, Site
from models.tools import ApiToolProvider from models.tools import ApiToolProvider
from services.enterprise.enterprise_service import EnterpriseService
from services.feature_service import FeatureService
from services.tag_service import TagService from services.tag_service import TagService
from tasks.remove_app_and_related_data_task import remove_app_and_related_data_task from tasks.remove_app_and_related_data_task import remove_app_and_related_data_task
@ -155,6 +157,10 @@ class AppService:
app_was_created.send(app, account=account) app_was_created.send(app, account=account)
if FeatureService.get_system_features().webapp_auth.enabled:
# update web app setting as private
EnterpriseService.WebAppAuth.update_app_access_mode(app.id, "private")
return app return app
def get_app(self, app: App) -> App: def get_app(self, app: App) -> App:
@ -307,6 +313,10 @@ class AppService:
db.session.delete(app) db.session.delete(app)
db.session.commit() db.session.commit()
# clean up web app settings
if FeatureService.get_system_features().webapp_auth.enabled:
EnterpriseService.WebAppAuth.cleanup_webapp(app.id)
# Trigger asynchronous deletion of app and related data # Trigger asynchronous deletion of app and related data
remove_app_and_related_data_task.delay(tenant_id=app.tenant_id, app_id=app.id) remove_app_and_related_data_task.delay(tenant_id=app.tenant_id, app_id=app.id)
@ -373,3 +383,15 @@ class AppService:
meta["tool_icons"][tool_name] = {"background": "#252525", "content": "\ud83d\ude01"} meta["tool_icons"][tool_name] = {"background": "#252525", "content": "\ud83d\ude01"}
return meta return meta
@staticmethod
def get_app_code_by_id(app_id: str) -> str:
"""
Get app code by app id
:param app_id: app id
:return: app code
"""
site = db.session.query(Site).filter(Site.app_id == app_id).first()
if not site:
raise ValueError(f"App with id {app_id} not found")
return str(site.code)

@ -1,11 +1,91 @@
from pydantic import BaseModel, Field
from services.enterprise.base import EnterpriseRequest from services.enterprise.base import EnterpriseRequest
class WebAppSettings(BaseModel):
access_mode: str = Field(
description="Access mode for the web app. Can be 'public' or 'private'",
default="private",
alias="accessMode",
)
class EnterpriseService: class EnterpriseService:
@classmethod @classmethod
def get_info(cls): def get_info(cls):
return EnterpriseRequest.send_request("GET", "/info") return EnterpriseRequest.send_request("GET", "/info")
@classmethod @classmethod
def get_app_web_sso_enabled(cls, app_code): def get_workspace_info(cls, tenant_id:str):
return EnterpriseRequest.send_request("GET", f"/app-sso-setting?appCode={app_code}") return EnterpriseRequest.send_request("GET", f"/workspace/{tenant_id}/info")
class WebAppAuth:
@classmethod
def is_user_allowed_to_access_webapp(cls, user_id: str, app_code: str) -> bool:
params = {"userId": user_id, "appCode": app_code}
data = EnterpriseRequest.send_request("GET", "/webapp/permission", params=params)
return data.get("result", False)
@classmethod
def get_app_access_mode_by_id(cls, app_id: str) -> WebAppSettings:
if not app_id:
raise ValueError("app_id must be provided.")
params = {"appId": app_id}
data = EnterpriseRequest.send_request("GET", "/webapp/access-mode/id", params=params)
if not data:
raise ValueError("No data found.")
return WebAppSettings(**data)
@classmethod
def batch_get_app_access_mode_by_id(cls, app_ids: list[str]) -> dict[str, WebAppSettings]:
if not app_ids:
return {}
body = {"appIds": app_ids}
data: dict[str, str] = EnterpriseRequest.send_request("POST", "/webapp/access-mode/batch/id", json=body)
if not data:
raise ValueError("No data found.")
if not isinstance(data["accessModes"], dict):
raise ValueError("Invalid data format.")
ret = {}
for key, value in data["accessModes"].items():
curr = WebAppSettings()
curr.access_mode = value
ret[key] = curr
return ret
@classmethod
def get_app_access_mode_by_code(cls, app_code: str) -> WebAppSettings:
if not app_code:
raise ValueError("app_code must be provided.")
params = {"appCode": app_code}
data = EnterpriseRequest.send_request("GET", "/webapp/access-mode/code", params=params)
if not data:
raise ValueError("No data found.")
return WebAppSettings(**data)
@classmethod
def update_app_access_mode(cls, app_id: str, access_mode: str) -> bool:
if not app_id:
raise ValueError("app_id must be provided.")
if access_mode not in ["public", "private", "private_all"]:
raise ValueError("access_mode must be either 'public', 'private', or 'private_all'")
data = {"appId": app_id, "accessMode": access_mode}
response = EnterpriseRequest.send_request("POST", "/webapp/access-mode", json=data)
return response.get("result", False)
@classmethod
def cleanup_webapp(cls, app_id: str):
if not app_id:
raise ValueError("app_id must be provided.")
body = {"appId": app_id}
EnterpriseRequest.send_request("DELETE", "/webapp/clean", json=body)

@ -0,0 +1,18 @@
from pydantic import BaseModel
from tasks.mail_enterprise_task import send_enterprise_email_task
class DifyMail(BaseModel):
to: list[str]
subject: str
body: str
substitutions: dict[str, str] = {}
class EnterpriseMailService:
@classmethod
def send_mail(cls, mail: DifyMail):
send_enterprise_email_task.delay(
to=mail.to, subject=mail.subject, body=mail.body, substitutions=mail.substitutions
)

@ -7,3 +7,7 @@ class WorkSpaceNotAllowedCreateError(BaseServiceError):
class WorkSpaceNotFoundError(BaseServiceError): class WorkSpaceNotFoundError(BaseServiceError):
pass pass
class WorkspacesLimitExceededError(BaseServiceError):
pass

@ -1,6 +1,6 @@
from enum import StrEnum from enum import StrEnum
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict, Field
from configs import dify_config from configs import dify_config
from services.billing_service import BillingService from services.billing_service import BillingService
@ -27,6 +27,32 @@ class LimitationModel(BaseModel):
limit: int = 0 limit: int = 0
class LicenseLimitationModel(BaseModel):
"""
- enabled: whether this limit is enforced
- size: current usage count
- limit: maximum allowed count; 0 means unlimited
"""
enabled: bool = Field(False, description="Whether this limit is currently active")
size: int = Field(0, description="Number of resources already consumed")
limit: int = Field(0, description="Maximum number of resources allowed; 0 means no limit")
def is_available(self, required: int = 1) -> bool:
"""
Determine whether the requested amount can be allocated.
Returns True if:
- this limit is not active, or
- the limit is zero (unlimited), or
- there is enough remaining quota.
"""
if not self.enabled or self.limit == 0:
return True
return (self.limit - self.size) >= required
class LicenseStatus(StrEnum): class LicenseStatus(StrEnum):
NONE = "none" NONE = "none"
INACTIVE = "inactive" INACTIVE = "inactive"
@ -39,6 +65,27 @@ class LicenseStatus(StrEnum):
class LicenseModel(BaseModel): class LicenseModel(BaseModel):
status: LicenseStatus = LicenseStatus.NONE status: LicenseStatus = LicenseStatus.NONE
expired_at: str = "" expired_at: str = ""
workspaces: LicenseLimitationModel = LicenseLimitationModel(enabled=False, size=0, limit=0)
class BrandingModel(BaseModel):
enabled: bool = False
application_title: str = ""
login_page_logo: str = ""
workspace_logo: str = ""
favicon: str = ""
class WebAppAuthSSOModel(BaseModel):
protocol: str = ""
class WebAppAuthModel(BaseModel):
enabled: bool = False
allow_sso: bool = False
sso_config: WebAppAuthSSOModel = WebAppAuthSSOModel()
allow_email_code_login: bool = False
allow_email_password_login: bool = False
class FeatureModel(BaseModel): class FeatureModel(BaseModel):
@ -54,6 +101,8 @@ class FeatureModel(BaseModel):
can_replace_logo: bool = False can_replace_logo: bool = False
model_load_balancing_enabled: bool = False model_load_balancing_enabled: bool = False
dataset_operator_enabled: bool = False dataset_operator_enabled: bool = False
webapp_copyright_enabled: bool = False
workspace_members: LicenseLimitationModel = LicenseLimitationModel(enabled=False, size=0, limit=0)
# pydantic configs # pydantic configs
model_config = ConfigDict(protected_namespaces=()) model_config = ConfigDict(protected_namespaces=())
@ -68,9 +117,6 @@ class KnowledgeRateLimitModel(BaseModel):
class SystemFeatureModel(BaseModel): class SystemFeatureModel(BaseModel):
sso_enforced_for_signin: bool = False sso_enforced_for_signin: bool = False
sso_enforced_for_signin_protocol: str = "" sso_enforced_for_signin_protocol: str = ""
sso_enforced_for_web: bool = False
sso_enforced_for_web_protocol: str = ""
enable_web_sso_switch_component: bool = False
enable_marketplace: bool = False enable_marketplace: bool = False
max_plugin_package_size: int = dify_config.PLUGIN_MAX_PACKAGE_SIZE max_plugin_package_size: int = dify_config.PLUGIN_MAX_PACKAGE_SIZE
enable_email_code_login: bool = False enable_email_code_login: bool = False
@ -80,6 +126,8 @@ class SystemFeatureModel(BaseModel):
is_allow_create_workspace: bool = False is_allow_create_workspace: bool = False
is_email_setup: bool = False is_email_setup: bool = False
license: LicenseModel = LicenseModel() license: LicenseModel = LicenseModel()
branding: BrandingModel = BrandingModel()
webapp_auth: WebAppAuthModel = WebAppAuthModel()
class FeatureService: class FeatureService:
@ -92,6 +140,10 @@ class FeatureService:
if dify_config.BILLING_ENABLED and tenant_id: if dify_config.BILLING_ENABLED and tenant_id:
cls._fulfill_params_from_billing_api(features, tenant_id) cls._fulfill_params_from_billing_api(features, tenant_id)
if dify_config.ENTERPRISE_ENABLED:
features.webapp_copyright_enabled = True
cls._fulfill_params_from_workspace_info(features, tenant_id)
return features return features
@classmethod @classmethod
@ -111,8 +163,8 @@ class FeatureService:
cls._fulfill_system_params_from_env(system_features) cls._fulfill_system_params_from_env(system_features)
if dify_config.ENTERPRISE_ENABLED: if dify_config.ENTERPRISE_ENABLED:
system_features.enable_web_sso_switch_component = True system_features.branding.enabled = True
system_features.webapp_auth.enabled = True
cls._fulfill_params_from_enterprise(system_features) cls._fulfill_params_from_enterprise(system_features)
if dify_config.MARKETPLACE_ENABLED: if dify_config.MARKETPLACE_ENABLED:
@ -136,6 +188,14 @@ class FeatureService:
features.dataset_operator_enabled = dify_config.DATASET_OPERATOR_ENABLED features.dataset_operator_enabled = dify_config.DATASET_OPERATOR_ENABLED
features.education.enabled = dify_config.EDUCATION_ENABLED features.education.enabled = dify_config.EDUCATION_ENABLED
@classmethod
def _fulfill_params_from_workspace_info(cls, features: FeatureModel, tenant_id: str):
workspace_info = EnterpriseService.get_workspace_info(tenant_id)
if "WorkspaceMembers" in workspace_info:
features.workspace_members.size = workspace_info["WorkspaceMembers"]["used"]
features.workspace_members.limit = workspace_info["WorkspaceMembers"]["limit"]
features.workspace_members.enabled = workspace_info["WorkspaceMembers"]["enabled"]
@classmethod @classmethod
def _fulfill_params_from_billing_api(cls, features: FeatureModel, tenant_id: str): def _fulfill_params_from_billing_api(cls, features: FeatureModel, tenant_id: str):
billing_info = BillingService.get_info(tenant_id) billing_info = BillingService.get_info(tenant_id)
@ -145,6 +205,9 @@ class FeatureService:
features.billing.subscription.interval = billing_info["subscription"]["interval"] features.billing.subscription.interval = billing_info["subscription"]["interval"]
features.education.activated = billing_info["subscription"].get("education", False) features.education.activated = billing_info["subscription"].get("education", False)
if features.billing.subscription.plan != "sandbox":
features.webapp_copyright_enabled = True
if "members" in billing_info: if "members" in billing_info:
features.members.size = billing_info["members"]["size"] features.members.size = billing_info["members"]["size"]
features.members.limit = billing_info["members"]["limit"] features.members.limit = billing_info["members"]["limit"]
@ -178,38 +241,53 @@ class FeatureService:
features.knowledge_rate_limit = billing_info["knowledge_rate_limit"]["limit"] features.knowledge_rate_limit = billing_info["knowledge_rate_limit"]["limit"]
@classmethod @classmethod
def _fulfill_params_from_enterprise(cls, features): def _fulfill_params_from_enterprise(cls, features: SystemFeatureModel):
enterprise_info = EnterpriseService.get_info() enterprise_info = EnterpriseService.get_info()
if "sso_enforced_for_signin" in enterprise_info: if "SSOEnforcedForSignin" in enterprise_info:
features.sso_enforced_for_signin = enterprise_info["sso_enforced_for_signin"] features.sso_enforced_for_signin = enterprise_info["SSOEnforcedForSignin"]
if "sso_enforced_for_signin_protocol" in enterprise_info: if "SSOEnforcedForSigninProtocol" in enterprise_info:
features.sso_enforced_for_signin_protocol = enterprise_info["sso_enforced_for_signin_protocol"] features.sso_enforced_for_signin_protocol = enterprise_info["SSOEnforcedForSigninProtocol"]
if "sso_enforced_for_web" in enterprise_info: if "EnableEmailCodeLogin" in enterprise_info:
features.sso_enforced_for_web = enterprise_info["sso_enforced_for_web"] features.enable_email_code_login = enterprise_info["EnableEmailCodeLogin"]
if "sso_enforced_for_web_protocol" in enterprise_info: if "EnableEmailPasswordLogin" in enterprise_info:
features.sso_enforced_for_web_protocol = enterprise_info["sso_enforced_for_web_protocol"] features.enable_email_password_login = enterprise_info["EnableEmailPasswordLogin"]
if "enable_email_code_login" in enterprise_info: if "IsAllowRegister" in enterprise_info:
features.enable_email_code_login = enterprise_info["enable_email_code_login"] features.is_allow_register = enterprise_info["IsAllowRegister"]
if "enable_email_password_login" in enterprise_info: if "IsAllowCreateWorkspace" in enterprise_info:
features.enable_email_password_login = enterprise_info["enable_email_password_login"] features.is_allow_create_workspace = enterprise_info["IsAllowCreateWorkspace"]
if "is_allow_register" in enterprise_info: if "Branding" in enterprise_info:
features.is_allow_register = enterprise_info["is_allow_register"] features.branding.application_title = enterprise_info["Branding"].get("applicationTitle", "")
features.branding.login_page_logo = enterprise_info["Branding"].get("loginPageLogo", "")
features.branding.workspace_logo = enterprise_info["Branding"].get("workspaceLogo", "")
features.branding.favicon = enterprise_info["Branding"].get("favicon", "")
if "is_allow_create_workspace" in enterprise_info: if "WebAppAuth" in enterprise_info:
features.is_allow_create_workspace = enterprise_info["is_allow_create_workspace"] features.webapp_auth.allow_sso = enterprise_info["WebAppAuth"].get("allowSso", False)
features.webapp_auth.allow_email_code_login = enterprise_info["WebAppAuth"].get(
"allowEmailCodeLogin", False
)
features.webapp_auth.allow_email_password_login = enterprise_info["WebAppAuth"].get(
"allowEmailPasswordLogin", False
)
features.webapp_auth.sso_config.protocol = enterprise_info.get("SSOEnforcedForWebProtocol", "")
if "license" in enterprise_info: if "License" in enterprise_info:
license_info = enterprise_info["license"] license_info = enterprise_info["License"]
if "status" in license_info: if "status" in license_info:
features.license.status = LicenseStatus(license_info.get("status", LicenseStatus.INACTIVE)) features.license.status = LicenseStatus(license_info.get("status", LicenseStatus.INACTIVE))
if "expired_at" in license_info: if "expiredAt" in license_info:
features.license.expired_at = license_info["expired_at"] features.license.expired_at = license_info["expiredAt"]
if "workspaces" in license_info:
features.license.workspaces.enabled = license_info["workspaces"]["enabled"]
features.license.workspaces.limit = license_info["workspaces"]["limit"]
features.license.workspaces.size = license_info["workspaces"]["used"]

@ -0,0 +1,137 @@
import random
from datetime import UTC, datetime, timedelta
from typing import Any, Optional, cast
from werkzeug.exceptions import NotFound, Unauthorized
from configs import dify_config
from controllers.web.error import WebAppAuthAccessDeniedError
from extensions.ext_database import db
from libs.helper import TokenManager
from libs.passport import PassportService
from libs.password import compare_password
from models.account import Account, AccountStatus
from models.model import App, EndUser, Site
from services.enterprise.enterprise_service import EnterpriseService
from services.errors.account import AccountLoginError, AccountNotFoundError, AccountPasswordError
from services.feature_service import FeatureService
from tasks.mail_email_code_login import send_email_code_login_mail_task
class WebAppAuthService:
"""Service for web app authentication."""
@staticmethod
def authenticate(email: str, password: str) -> Account:
"""authenticate account with email and password"""
account = Account.query.filter_by(email=email).first()
if not account:
raise AccountNotFoundError()
if account.status == AccountStatus.BANNED.value:
raise AccountLoginError("Account is banned.")
if account.password is None or not compare_password(password, account.password, account.password_salt):
raise AccountPasswordError("Invalid email or password.")
return cast(Account, account)
@classmethod
def login(cls, account: Account, app_code: str, end_user_id: str) -> str:
site = db.session.query(Site).filter(Site.code == app_code).first()
if not site:
raise NotFound("Site not found.")
access_token = cls._get_account_jwt_token(account=account, site=site, end_user_id=end_user_id)
return access_token
@classmethod
def get_user_through_email(cls, email: str):
account = db.session.query(Account).filter(Account.email == email).first()
if not account:
return None
if account.status == AccountStatus.BANNED.value:
raise Unauthorized("Account is banned.")
return account
@classmethod
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.")
code = "".join([str(random.randint(0, 9)) for _ in range(6)])
token = TokenManager.generate_token(
account=account, email=email, token_type="webapp_email_code_login", additional_data={"code": code}
)
send_email_code_login_mail_task.delay(
language=language,
to=account.email if account else email,
code=code,
)
return token
@classmethod
def get_email_code_login_data(cls, token: str) -> Optional[dict[str, Any]]:
return TokenManager.get_token_data(token, "webapp_email_code_login")
@classmethod
def revoke_email_code_login_token(cls, token: str):
TokenManager.revoke_token(token, "webapp_email_code_login")
@classmethod
def create_end_user(cls, app_code, email) -> EndUser:
site = db.session.query(Site).filter(Site.code == app_code).first()
app_model = db.session.query(App).filter(App.id == site.app_id).first()
end_user = EndUser(
tenant_id=app_model.tenant_id,
app_id=app_model.id,
type="browser",
is_anonymous=False,
session_id=email,
name="enterpriseuser",
external_user_id="enterpriseuser",
)
db.session.add(end_user)
db.session.commit()
return end_user
@classmethod
def _validate_user_accessibility(cls, account: Account, app_code: str):
"""Check if the user is allowed to access the app."""
system_features = FeatureService.get_system_features()
if system_features.webapp_auth.enabled:
app_settings = EnterpriseService.WebAppAuth.get_app_access_mode_by_code(app_code=app_code)
if (
app_settings.access_mode != "public"
and not EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp(account.id, app_code=app_code)
):
raise WebAppAuthAccessDeniedError()
@classmethod
def _get_account_jwt_token(cls, account: Account, site: Site, end_user_id: str) -> str:
exp_dt = datetime.now(UTC) + timedelta(hours=dify_config.WebAppSessionTimeoutInHours * 24)
exp = int(exp_dt.timestamp())
payload = {
"iss": site.id,
"sub": "Web API Passport",
"app_id": site.app_id,
"app_code": site.code,
"user_id": account.id,
"end_user_id": end_user_id,
"token_source": "webapp",
"exp": exp,
}
token: str = PassportService().issue(payload)
return token

@ -6,6 +6,7 @@ from celery import shared_task # type: ignore
from flask import render_template from flask import render_template
from extensions.ext_mail import mail from extensions.ext_mail import mail
from services.feature_service import FeatureService
@shared_task(queue="mail") @shared_task(queue="mail")
@ -25,10 +26,24 @@ def send_email_code_login_mail_task(language: str, to: str, code: str):
# send email code login mail using different languages # send email code login mail using different languages
try: try:
if language == "zh-Hans": if language == "zh-Hans":
html_content = render_template("email_code_login_mail_template_zh-CN.html", to=to, code=code) template = "email_code_login_mail_template_zh-CN.html"
system_features = FeatureService.get_system_features()
if system_features.branding.enabled:
application_title = system_features.branding.application_title
template = "without-brand/email_code_login_mail_template_zh-CN.html"
html_content = render_template(template, to=to, code=code, application_title=application_title)
else:
html_content = render_template(template, to=to, code=code)
mail.send(to=to, subject="邮箱验证码", html=html_content) mail.send(to=to, subject="邮箱验证码", html=html_content)
else: else:
html_content = render_template("email_code_login_mail_template_en-US.html", to=to, code=code) template = "email_code_login_mail_template_en-US.html"
system_features = FeatureService.get_system_features()
if system_features.branding.enabled:
application_title = system_features.branding.application_title
template = "without-brand/email_code_login_mail_template_en-US.html"
html_content = render_template(template, to=to, code=code, application_title=application_title)
else:
html_content = render_template(template, to=to, code=code)
mail.send(to=to, subject="Email Code", html=html_content) mail.send(to=to, subject="Email Code", html=html_content)
end_at = time.perf_counter() end_at = time.perf_counter()

@ -0,0 +1,33 @@
import logging
import time
import click
from celery import shared_task # type: ignore
from flask import render_template_string
from extensions.ext_mail import mail
@shared_task(queue="mail")
def send_enterprise_email_task(to, subject, body, substitutions):
if not mail.is_inited():
return
logging.info(click.style("Start enterprise mail to {} with subject {}".format(to, subject), fg="green"))
start_at = time.perf_counter()
try:
html_content = render_template_string(body, **substitutions)
if isinstance(to, list):
for t in to:
mail.send(to=t, subject=subject, html=html_content)
else:
mail.send(to=to, subject=subject, html=html_content)
end_at = time.perf_counter()
logging.info(
click.style("Send enterprise mail to {} succeeded: latency: {}".format(to, end_at - start_at), fg="green")
)
except Exception:
logging.exception("Send enterprise mail to {} failed".format(to))

@ -7,6 +7,7 @@ from flask import render_template
from configs import dify_config from configs import dify_config
from extensions.ext_mail import mail from extensions.ext_mail import mail
from services.feature_service import FeatureService
@shared_task(queue="mail") @shared_task(queue="mail")
@ -33,21 +34,43 @@ def send_invite_member_mail_task(language: str, to: str, token: str, inviter_nam
try: try:
url = f"{dify_config.CONSOLE_WEB_URL}/activate?token={token}" url = f"{dify_config.CONSOLE_WEB_URL}/activate?token={token}"
if language == "zh-Hans": if language == "zh-Hans":
template = "invite_member_mail_template_zh-CN.html"
system_features = FeatureService.get_system_features()
if system_features.branding.enabled:
application_title = system_features.branding.application_title
template = "without-brand/invite_member_mail_template_zh-CN.html"
html_content = render_template( html_content = render_template(
"invite_member_mail_template_zh-CN.html", template,
to=to, to=to,
inviter_name=inviter_name, inviter_name=inviter_name,
workspace_name=workspace_name, workspace_name=workspace_name,
url=url, url=url,
application_title=application_title,
)
mail.send(to=to, subject=f"立即加入 {application_title} 工作空间", html=html_content)
else:
html_content = render_template(
template, to=to, inviter_name=inviter_name, workspace_name=workspace_name, url=url
) )
mail.send(to=to, subject="立即加入 Dify 工作空间", html=html_content) mail.send(to=to, subject="立即加入 Dify 工作空间", html=html_content)
else: else:
template = "invite_member_mail_template_en-US.html"
system_features = FeatureService.get_system_features()
if system_features.branding.enabled:
application_title = system_features.branding.application_title
template = "without-brand/invite_member_mail_template_en-US.html"
html_content = render_template( html_content = render_template(
"invite_member_mail_template_en-US.html", template,
to=to, to=to,
inviter_name=inviter_name, inviter_name=inviter_name,
workspace_name=workspace_name, workspace_name=workspace_name,
url=url, url=url,
application_title=application_title,
)
mail.send(to=to, subject=f"Join {application_title} Workspace Now", html=html_content)
else:
html_content = render_template(
template, to=to, inviter_name=inviter_name, workspace_name=workspace_name, url=url
) )
mail.send(to=to, subject="Join Dify Workspace Now", html=html_content) mail.send(to=to, subject="Join Dify Workspace Now", html=html_content)

@ -6,6 +6,7 @@ from celery import shared_task # type: ignore
from flask import render_template from flask import render_template
from extensions.ext_mail import mail from extensions.ext_mail import mail
from services.feature_service import FeatureService
@shared_task(queue="mail") @shared_task(queue="mail")
@ -25,10 +26,26 @@ def send_reset_password_mail_task(language: str, to: str, code: str):
# send reset password mail using different languages # send reset password mail using different languages
try: try:
if language == "zh-Hans": if language == "zh-Hans":
html_content = render_template("reset_password_mail_template_zh-CN.html", to=to, code=code) template = "reset_password_mail_template_zh-CN.html"
system_features = FeatureService.get_system_features()
if system_features.branding.enabled:
application_title = system_features.branding.application_title
template = "without-brand/reset_password_mail_template_zh-CN.html"
html_content = render_template(template, to=to, code=code, application_title=application_title)
mail.send(to=to, subject=f"设置您的 {application_title} 密码", html=html_content)
else:
html_content = render_template(template, to=to, code=code)
mail.send(to=to, subject="设置您的 Dify 密码", html=html_content) mail.send(to=to, subject="设置您的 Dify 密码", html=html_content)
else: else:
html_content = render_template("reset_password_mail_template_en-US.html", to=to, code=code) template = "reset_password_mail_template_en-US.html"
system_features = FeatureService.get_system_features()
if system_features.branding.enabled:
application_title = system_features.branding.application_title
template = "without-brand/reset_password_mail_template_en-US.html"
html_content = render_template(template, to=to, code=code, application_title=application_title)
mail.send(to=to, subject=f"Set Your {application_title} Password", html=html_content)
else:
html_content = render_template(template, to=to, code=code)
mail.send(to=to, subject="Set Your Dify Password", html=html_content) mail.send(to=to, subject="Set Your Dify Password", html=html_content)
end_at = time.perf_counter() end_at = time.perf_counter()

@ -0,0 +1,70 @@
<!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;
height: 360px;
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;
}
</style>
</head>
<body>
<div class="container">
<p class="title">Your login code for {{application_title}}</p>
<p class="description">Copy and paste this code, this code will only be valid for the next 5 minutes.</p>
<div class="code-content">
<span class="code">{{code}}</span>
</div>
<p class="tips">If you didn't request a login, don't worry. You can safely ignore this email.</p>
</div>
</body>
</html>

@ -0,0 +1,70 @@
<!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;
height: 360px;
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;
}
</style>
</head>
<body>
<div class="container">
<p class="title">{{application_title}} 的登录验证码</p>
<p class="description">复制并粘贴此验证码,注意验证码仅在接下来的 5 分钟内有效。</p>
<div class="code-content">
<span class="code">{{code}}</span>
</div>
<p class="tips">如果您没有请求登录,请不要担心。您可以安全地忽略此电子邮件。</p>
</div>
</body>
</html>

@ -0,0 +1,69 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: 'Arial', sans-serif;
line-height: 16pt;
color: #374151;
background-color: #E5E7EB;
margin: 0;
padding: 0;
}
.container {
width: 100%;
max-width: 560px;
margin: 40px auto;
padding: 20px;
background-color: #F3F4F6;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
margin-bottom: 20px;
}
.header img {
max-width: 100px;
height: auto;
}
.button {
display: inline-block;
padding: 12px 24px;
background-color: #2970FF;
color: white;
text-decoration: none;
border-radius: 4px;
text-align: center;
transition: background-color 0.3s ease;
}
.button:hover {
background-color: #265DD4;
}
.footer {
font-size: 0.9em;
color: #777777;
margin-top: 30px;
}
.content {
margin-top: 20px;
}
</style>
</head>
<body>
<div class="container">
<div class="content">
<p>Dear {{ to }},</p>
<p>{{ inviter_name }} is pleased to invite you to join our workspace on {{application_title}}, a platform specifically designed for LLM application development. On {{application_title}}, you can explore, create, and collaborate to build and operate AI applications.</p>
<p>Click the button below to log in to {{application_title}} and join the workspace.</p>
<p style="text-align: center;"><a style="color: #fff; text-decoration: none" class="button" href="{{ url }}">Login Here</a></p>
</div>
<div class="footer">
<p>Best regards,</p>
<p>{{application_title}} Team</p>
<p>Please do not reply directly to this email; it is automatically sent by the system.</p>
</div>
</div>
</body>
</html>

@ -0,0 +1,69 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: 'Arial', sans-serif;
line-height: 16pt;
color: #374151;
background-color: #E5E7EB;
margin: 0;
padding: 0;
}
.container {
width: 100%;
max-width: 560px;
margin: 40px auto;
padding: 20px;
background-color: #F3F4F6;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
margin-bottom: 20px;
}
.header img {
max-width: 100px;
height: auto;
}
.button {
display: inline-block;
padding: 12px 24px;
background-color: #2970FF;
color: white;
text-decoration: none;
border-radius: 4px;
text-align: center;
transition: background-color 0.3s ease;
}
.button:hover {
background-color: #265DD4;
}
.footer {
font-size: 0.9em;
color: #777777;
margin-top: 30px;
}
.content {
margin-top: 20px;
}
</style>
</head>
<body>
<div class="container">
<div class="content">
<p>尊敬的 {{ to }}</p>
<p>{{ inviter_name }} 现邀请您加入我们在 {{application_title}} 的工作区,这是一个专为 LLM 应用开发而设计的平台。在 {{application_title}} 上,您可以探索、创造和合作,构建和运营 AI 应用。</p>
<p>点击下方按钮即可登录 {{application_title}} 并且加入空间。</p>
<p style="text-align: center;"><a style="color: #fff; text-decoration: none" class="button" href="{{ url }}">在此登录</a></p>
</div>
<div class="footer">
<p>此致,</p>
<p>{{application_title}} 团队</p>
<p>请不要直接回复此电子邮件;由系统自动发送。</p>
</div>
</div>
</body>
</html>

@ -0,0 +1,70 @@
<!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;
height: 360px;
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;
}
</style>
</head>
<body>
<div class="container">
<p class="title">Set your {{application_title}} password</p>
<p class="description">Copy and paste this code, this code will only be valid for the next 5 minutes.</p>
<div class="code-content">
<span class="code">{{code}}</span>
</div>
<p class="tips">If you didn't request, don't worry. You can safely ignore this email.</p>
</div>
</body>
</html>

@ -0,0 +1,70 @@
<!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;
height: 360px;
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;
}
</style>
</head>
<body>
<div class="container">
<p class="title">设置您的 {{application_title}} 账户密码</p>
<p class="description">复制并粘贴此验证码,注意验证码仅在接下来的 5 分钟内有效。</p>
<div class="code-content">
<span class="code">{{code}}</span>
</div>
<p class="tips">如果您没有请求,请不要担心。您可以安全地忽略此电子邮件。</p>
</div>
</body>
</html>

@ -2,25 +2,22 @@
import type { FC } from 'react' import type { FC } from 'react'
import React from 'react' import React from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useContext, useContextSelector } from 'use-context-selector' import { useContext } from 'use-context-selector'
import AppCard from '@/app/components/app/overview/appCard' import AppCard from '@/app/components/app/overview/appCard'
import Loading from '@/app/components/base/loading' import Loading from '@/app/components/base/loading'
import { ToastContext } from '@/app/components/base/toast' import { ToastContext } from '@/app/components/base/toast'
import { import {
fetchAppDetail, fetchAppDetail,
fetchAppSSO,
updateAppSSO,
updateAppSiteAccessToken, updateAppSiteAccessToken,
updateAppSiteConfig, updateAppSiteConfig,
updateAppSiteStatus, updateAppSiteStatus,
} from '@/service/apps' } from '@/service/apps'
import type { App, AppSSO } from '@/types/app' import type { App } from '@/types/app'
import type { UpdateAppSiteCodeResponse } from '@/models/app' import type { UpdateAppSiteCodeResponse } from '@/models/app'
import { asyncRunSafe } from '@/utils' import { asyncRunSafe } from '@/utils'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import type { IAppCardProps } from '@/app/components/app/overview/appCard' import type { IAppCardProps } from '@/app/components/app/overview/appCard'
import { useStore as useAppStore } from '@/app/components/app/store' import { useStore as useAppStore } from '@/app/components/app/store'
import AppContext from '@/context/app-context'
export type ICardViewProps = { export type ICardViewProps = {
appId: string appId: string
@ -33,19 +30,12 @@ const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => {
const { notify } = useContext(ToastContext) const { notify } = useContext(ToastContext)
const appDetail = useAppStore(state => state.appDetail) const appDetail = useAppStore(state => state.appDetail)
const setAppDetail = useAppStore(state => state.setAppDetail) const setAppDetail = useAppStore(state => state.setAppDetail)
const systemFeatures = useContextSelector(AppContext, state => state.systemFeatures)
const updateAppDetail = async () => { const updateAppDetail = async () => {
try { try {
const res = await fetchAppDetail({ url: '/apps', id: appId }) const res = await fetchAppDetail({ url: '/apps', id: appId })
if (systemFeatures.enable_web_sso_switch_component) {
const ssoRes = await fetchAppSSO({ appId })
setAppDetail({ ...res, enable_sso: ssoRes.enabled })
}
else {
setAppDetail({ ...res }) setAppDetail({ ...res })
} }
}
catch (error) { console.error(error) } catch (error) { console.error(error) }
} }
@ -95,16 +85,6 @@ const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => {
if (!err) if (!err)
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1') localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
if (systemFeatures.enable_web_sso_switch_component) {
const [sso_err] = await asyncRunSafe<AppSSO>(
updateAppSSO({ id: appId, enabled: Boolean(params.enable_sso) }) as Promise<AppSSO>,
)
if (sso_err) {
handleCallbackResult(sso_err)
return
}
}
handleCallbackResult(err) handleCallbackResult(err)
} }

@ -2,7 +2,9 @@
import type { FC } from 'react' import type { FC } from 'react'
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useTranslation } from 'react-i18next'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import useDocumentTitle from '@/hooks/use-document-title'
export type IAppDetail = { export type IAppDetail = {
children: React.ReactNode children: React.ReactNode
@ -11,12 +13,13 @@ export type IAppDetail = {
const AppDetail: FC<IAppDetail> = ({ children }) => { const AppDetail: FC<IAppDetail> = ({ children }) => {
const router = useRouter() const router = useRouter()
const { isCurrentWorkspaceDatasetOperator } = useAppContext() const { isCurrentWorkspaceDatasetOperator } = useAppContext()
const { t } = useTranslation()
useDocumentTitle(t('common.menus.appDetail'))
useEffect(() => { useEffect(() => {
if (isCurrentWorkspaceDatasetOperator) if (isCurrentWorkspaceDatasetOperator)
return router.replace('/datasets') return router.replace('/datasets')
// eslint-disable-next-line react-hooks/exhaustive-deps }, [isCurrentWorkspaceDatasetOperator, router])
}, [isCurrentWorkspaceDatasetOperator])
return ( return (
<> <>

@ -4,7 +4,8 @@ import { useContext, useContextSelector } from 'use-context-selector'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { RiMoreFill } from '@remixicon/react' import { RiBuildingLine, RiGlobalLine, RiLockLine, RiMoreFill } from '@remixicon/react'
import cn from '@/utils/classnames'
import type { App } from '@/types/app' import type { App } from '@/types/app'
import Confirm from '@/app/components/base/confirm' import Confirm from '@/app/components/base/confirm'
import Toast, { ToastContext } from '@/app/components/base/toast' import Toast, { ToastContext } from '@/app/components/base/toast'
@ -30,7 +31,9 @@ import DSLExportConfirmModal from '@/app/components/workflow/dsl-export-confirm-
import { fetchWorkflowDraft } from '@/service/workflow' import { fetchWorkflowDraft } from '@/service/workflow'
import { fetchInstalledAppList } from '@/service/explore' import { fetchInstalledAppList } from '@/service/explore'
import { AppTypeIcon } from '@/app/components/app/type-selector' import { AppTypeIcon } from '@/app/components/app/type-selector'
import cn from '@/utils/classnames' import Tooltip from '@/app/components/base/tooltip'
import AccessControl from '@/app/components/app/app-access-control'
import { AccessMode } from '@/models/access-control'
export type AppCardProps = { export type AppCardProps = {
app: App app: App
@ -53,6 +56,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
const [showDuplicateModal, setShowDuplicateModal] = useState(false) const [showDuplicateModal, setShowDuplicateModal] = useState(false)
const [showSwitchModal, setShowSwitchModal] = useState<boolean>(false) const [showSwitchModal, setShowSwitchModal] = useState<boolean>(false)
const [showConfirmDelete, setShowConfirmDelete] = useState(false) const [showConfirmDelete, setShowConfirmDelete] = useState(false)
const [showAccessControl, setShowAccessControl] = useState(false)
const [secretEnvList, setSecretEnvList] = useState<EnvironmentVariable[]>([]) const [secretEnvList, setSecretEnvList] = useState<EnvironmentVariable[]>([])
const onConfirmDelete = useCallback(async () => { const onConfirmDelete = useCallback(async () => {
@ -71,8 +75,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
}) })
} }
setShowConfirmDelete(false) setShowConfirmDelete(false)
// eslint-disable-next-line react-hooks/exhaustive-deps }, [app.id, mutateApps, notify, onPlanInfoChanged, onRefresh, t])
}, [app.id])
const onEdit: CreateAppModalProps['onConfirm'] = useCallback(async ({ const onEdit: CreateAppModalProps['onConfirm'] = useCallback(async ({
name, name,
@ -176,6 +179,13 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
setShowSwitchModal(false) setShowSwitchModal(false)
} }
const onUpdateAccessControl = useCallback(() => {
if (onRefresh)
onRefresh()
mutateApps()
setShowAccessControl(false)
}, [onRefresh, mutateApps, setShowAccessControl])
const Operations = (props: HtmlContentProps) => { const Operations = (props: HtmlContentProps) => {
const onMouseLeave = async () => { const onMouseLeave = async () => {
props.onClose?.() props.onClose?.()
@ -210,6 +220,12 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
e.preventDefault() e.preventDefault()
setShowConfirmDelete(true) setShowConfirmDelete(true)
} }
const onClickAccessControl = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
props.onClick?.()
e.preventDefault()
setShowAccessControl(true)
}
const onClickInstalledApp = async (e: React.MouseEvent<HTMLButtonElement>) => { const onClickInstalledApp = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation() e.stopPropagation()
props.onClick?.() props.onClick?.()
@ -253,6 +269,14 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
<span className='system-sm-regular text-text-secondary'>{t('app.openInExplore')}</span> <span className='system-sm-regular text-text-secondary'>{t('app.openInExplore')}</span>
</button> </button>
<Divider className="!my-1" /> <Divider className="!my-1" />
{
isCurrentWorkspaceEditor && <>
<button className='mx-1 flex h-9 cursor-pointer items-center rounded-lg px-3 py-2 hover:bg-state-base-hover' onClick={onClickAccessControl}>
<span className='text-sm leading-5 text-text-secondary'>{t('app.accessControl')}</span>
</button>
<Divider />
</>
}
<div <div
className='group mx-1 flex h-8 w-[calc(100%_-_8px)] cursor-pointer items-center gap-2 rounded-lg px-3 py-[6px] hover:bg-state-destructive-hover' className='group mx-1 flex h-8 w-[calc(100%_-_8px)] cursor-pointer items-center gap-2 rounded-lg px-3 py-[6px] hover:bg-state-destructive-hover'
onClick={onClickDelete} onClick={onClickDelete}
@ -302,6 +326,17 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
{app.mode === 'completion' && <div className='truncate'>{t('app.types.completion').toUpperCase()}</div>} {app.mode === 'completion' && <div className='truncate'>{t('app.types.completion').toUpperCase()}</div>}
</div> </div>
</div> </div>
<div className='flex h-5 w-5 shrink-0 items-center justify-center'>
{app.access_mode === AccessMode.PUBLIC && <Tooltip asChild={false} popupContent={t('app.accessItemsDescription.anyone')}>
<RiGlobalLine className='h-4 w-4 text-text-accent' />
</Tooltip>}
{app.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && <Tooltip asChild={false} popupContent={t('app.accessItemsDescription.specific')}>
<RiLockLine className='h-4 w-4 text-text-quaternary' />
</Tooltip>}
{app.access_mode === AccessMode.ORGANIZATION && <Tooltip asChild={false} popupContent={t('app.accessItemsDescription.organization')}>
<RiBuildingLine className='h-4 w-4 text-text-quaternary' />
</Tooltip>}
</div>
</div> </div>
<div className='title-wrapper h-[90px] px-[14px] text-xs leading-normal text-text-tertiary'> <div className='title-wrapper h-[90px] px-[14px] text-xs leading-normal text-text-tertiary'>
<div <div
@ -358,7 +393,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
popupClassName={ popupClassName={
(app.mode === 'completion' || app.mode === 'chat') (app.mode === 'completion' || app.mode === 'chat')
? '!w-[256px] translate-x-[-224px]' ? '!w-[256px] translate-x-[-224px]'
: '!w-[160px] translate-x-[-128px]' : '!w-[216px] translate-x-[-128px]'
} }
className={'!z-20 h-fit'} className={'!z-20 h-fit'}
/> />
@ -419,6 +454,9 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
onClose={() => setSecretEnvList([])} onClose={() => setSecretEnvList([])}
/> />
)} )}
{showAccessControl && (
<AccessControl app={app} onConfirm={onUpdateAccessControl} onClose={() => setShowAccessControl(false)} />
)}
</> </>
) )
} }

@ -1,24 +1,22 @@
'use client' 'use client'
import { useContextSelector } from 'use-context-selector'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { RiDiscordFill, RiGithubFill } from '@remixicon/react' import { RiDiscordFill, RiGithubFill } from '@remixicon/react'
import Link from 'next/link' import Link from 'next/link'
import style from '../list.module.css' import style from '../list.module.css'
import Apps from './Apps' import Apps from './Apps'
import AppContext from '@/context/app-context'
import { LicenseStatus } from '@/types/feature'
import { useEducationInit } from '@/app/education-apply/hooks' import { useEducationInit } from '@/app/education-apply/hooks'
import { useGlobalPublicStore } from '@/context/global-public-context'
import useDocumentTitle from '@/hooks/use-document-title'
const AppList = () => { const AppList = () => {
const { t } = useTranslation() const { t } = useTranslation()
useEducationInit() useEducationInit()
const { systemFeatures } = useGlobalPublicStore()
const systemFeatures = useContextSelector(AppContext, v => v.systemFeatures) useDocumentTitle(t('common.menus.apps'))
return ( return (
<div className='relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body'> <div className='relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body'>
<Apps /> <Apps />
{systemFeatures.license.status === LicenseStatus.NONE && <footer className='shrink-0 grow-0 px-12 py-6'> {!systemFeatures.branding.enabled && <footer className='shrink-0 grow-0 px-12 py-6'>
<h3 className='text-gradient text-xl font-semibold leading-tight'>{t('app.join')}</h3> <h3 className='text-gradient text-xl font-semibold leading-tight'>{t('app.join')}</h3>
<p className='system-sm-regular mt-1 text-text-tertiary'>{t('app.communityIntro')}</p> <p className='system-sm-regular mt-1 text-text-tertiary'>{t('app.communityIntro')}</p>
<div className='mt-3 flex items-center gap-2'> <div className='mt-3 flex items-center gap-2'>

@ -29,9 +29,11 @@ import { useTabSearchParams } from '@/hooks/use-tab-searchparams'
import { useStore as useTagStore } from '@/app/components/base/tag-management/store' import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import { useExternalApiPanel } from '@/context/external-api-panel-context' import { useExternalApiPanel } from '@/context/external-api-panel-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
const Container = () => { const Container = () => {
const { t } = useTranslation() const { t } = useTranslation()
const { systemFeatures } = useGlobalPublicStore()
const router = useRouter() const router = useRouter()
const { currentWorkspace, isCurrentWorkspaceOwner } = useAppContext() const { currentWorkspace, isCurrentWorkspaceOwner } = useAppContext()
const showTagManagementModal = useTagStore(s => s.showTagManagementModal) const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
@ -125,7 +127,7 @@ const Container = () => {
{activeTab === 'dataset' && ( {activeTab === 'dataset' && (
<> <>
<Datasets containerRef={containerRef} tags={tagIDs} keywords={searchKeywords} includeAll={includeAll} /> <Datasets containerRef={containerRef} tags={tagIDs} keywords={searchKeywords} includeAll={includeAll} />
<DatasetFooter /> {!systemFeatures.branding.enabled && <DatasetFooter />}
{showTagManagementModal && ( {showTagManagementModal && (
<TagManagementModal type='knowledge' show={showTagManagementModal} /> <TagManagementModal type='knowledge' show={showTagManagementModal} />
)} )}

@ -3,12 +3,12 @@
import { useCallback, useEffect, useRef } from 'react' import { useCallback, useEffect, useRef } from 'react'
import useSWRInfinite from 'swr/infinite' import useSWRInfinite from 'swr/infinite'
import { debounce } from 'lodash-es' import { debounce } from 'lodash-es'
import { useTranslation } from 'react-i18next'
import NewDatasetCard from './NewDatasetCard' import NewDatasetCard from './NewDatasetCard'
import DatasetCard from './DatasetCard' import DatasetCard from './DatasetCard'
import type { DataSetListResponse, FetchDatasetsParams } from '@/models/datasets' import type { DataSetListResponse, FetchDatasetsParams } from '@/models/datasets'
import { fetchDatasets } from '@/service/datasets' import { fetchDatasets } from '@/service/datasets'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import { useTranslation } from 'react-i18next'
const getKey = ( const getKey = (
pageIndex: number, pageIndex: number,
@ -48,6 +48,7 @@ const Datasets = ({
keywords, keywords,
includeAll, includeAll,
}: Props) => { }: Props) => {
const { t } = useTranslation()
const { isCurrentWorkspaceEditor } = useAppContext() const { isCurrentWorkspaceEditor } = useAppContext()
const { data, isLoading, setSize, mutate } = useSWRInfinite( const { data, isLoading, setSize, mutate } = useSWRInfinite(
(pageIndex: number, previousPageData: DataSetListResponse) => getKey(pageIndex, previousPageData, tags, keywords, includeAll), (pageIndex: number, previousPageData: DataSetListResponse) => getKey(pageIndex, previousPageData, tags, keywords, includeAll),
@ -57,8 +58,6 @@ const Datasets = ({
const loadingStateRef = useRef(false) const loadingStateRef = useRef(false)
const anchorRef = useRef<HTMLAnchorElement>(null) const anchorRef = useRef<HTMLAnchorElement>(null)
const { t } = useTranslation()
useEffect(() => { useEffect(() => {
loadingStateRef.current = isLoading loadingStateRef.current = isLoading
document.title = `${t('dataset.knowledge')} - Dify` document.title = `${t('dataset.knowledge')} - Dify`
@ -87,7 +86,7 @@ const Datasets = ({
return ( return (
<nav className='grid shrink-0 grow grid-cols-1 content-start gap-4 px-12 pt-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4'> <nav className='grid shrink-0 grow grid-cols-1 content-start gap-4 px-12 pt-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4'>
{ isCurrentWorkspaceEditor && <NewDatasetCard ref={anchorRef} /> } {isCurrentWorkspaceEditor && <NewDatasetCard ref={anchorRef} />}
{data?.map(({ data: datasets }) => datasets.map(dataset => ( {data?.map(({ data: datasets }) => datasets.map(dataset => (
<DatasetCard key={dataset.id} dataset={dataset} onSuccess={mutate} />), <DatasetCard key={dataset.id} dataset={dataset} onSuccess={mutate} />),
))} ))}

@ -1,6 +1,11 @@
'use client'
import { useTranslation } from 'react-i18next'
import Container from './Container' import Container from './Container'
import useDocumentTitle from '@/hooks/use-document-title'
const AppList = async () => { const AppList = () => {
const { t } = useTranslation()
useDocumentTitle(t('common.menus.datasets'))
return <Container /> return <Container />
} }

@ -11,7 +11,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, PropertyInstructi
<div> <div>
### Authentication ### Authentication
Service API of Dify authenticates using an `API-Key`. Service API authenticates using an `API-Key`.
It is suggested that developers store the `API-Key` in the backend instead of sharing or storing it in the client side to avoid the leakage of the `API-Key`, which may lead to property loss. It is suggested that developers store the `API-Key` in the backend instead of sharing or storing it in the client side to avoid the leakage of the `API-Key`, which may lead to property loss.

@ -11,7 +11,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, PropertyInstructi
<div> <div>
### 鉴权 ### 鉴权
Dify Service API 使用 `API-Key` 进行鉴权。 Service API 使用 `API-Key` 进行鉴权。
建议开发者把 `API-Key` 放在后端存储,而非分享或者放在客户端存储,以免 `API-Key` 泄露,导致财产损失。 建议开发者把 `API-Key` 放在后端存储,而非分享或者放在客户端存储,以免 `API-Key` 泄露,导致财产损失。

@ -1,11 +1,13 @@
import type { FC } from 'react' 'use client'
import type { FC, PropsWithChildren } from 'react'
import React from 'react' import React from 'react'
import { useTranslation } from 'react-i18next'
import ExploreClient from '@/app/components/explore' import ExploreClient from '@/app/components/explore'
export type IAppDetail = { import useDocumentTitle from '@/hooks/use-document-title'
children: React.ReactNode
}
const AppDetail: FC<IAppDetail> = ({ children }) => { const ExploreLayout: FC<PropsWithChildren> = ({ children }) => {
const { t } = useTranslation()
useDocumentTitle(t('common.menus.explore'))
return ( return (
<ExploreClient> <ExploreClient>
{children} {children}
@ -13,4 +15,4 @@ const AppDetail: FC<IAppDetail> = ({ children }) => {
) )
} }
export default React.memo(AppDetail) export default React.memo(ExploreLayout)

@ -30,9 +30,4 @@ const Layout = ({ children }: { children: ReactNode }) => {
</> </>
) )
} }
export const metadata = {
title: 'Dify',
}
export default Layout export default Layout

@ -1,22 +1,16 @@
'use client' 'use client'
import type { FC } from 'react' import type { FC } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useTranslation } from 'react-i18next'
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import ToolProviderList from '@/app/components/tools/provider-list' import ToolProviderList from '@/app/components/tools/provider-list'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import useDocumentTitle from '@/hooks/use-document-title'
const Layout: FC = () => { const ToolsList: FC = () => {
const { t } = useTranslation()
const router = useRouter() const router = useRouter()
const { isCurrentWorkspaceDatasetOperator } = useAppContext() const { isCurrentWorkspaceDatasetOperator } = useAppContext()
const { t } = useTranslation()
useEffect(() => { useDocumentTitle(t('common.menus.tools'))
if (typeof window !== 'undefined')
document.title = `${t('tools.title')} - Dify`
if (isCurrentWorkspaceDatasetOperator)
return router.replace('/datasets')
}, [isCurrentWorkspaceDatasetOperator, router, t])
useEffect(() => { useEffect(() => {
if (isCurrentWorkspaceDatasetOperator) if (isCurrentWorkspaceDatasetOperator)
@ -25,4 +19,4 @@ const Layout: FC = () => {
return <ToolProviderList /> return <ToolProviderList />
} }
export default React.memo(Layout) export default React.memo(ToolsList)

@ -1,14 +1,21 @@
'use client' 'use client'
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import type { FC } from 'react' import type { FC } from 'react'
import React, { useEffect } from 'react' import React, { useCallback, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { RiDoorLockLine } from '@remixicon/react'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
import Toast from '@/app/components/base/toast' import Toast from '@/app/components/base/toast'
import { fetchSystemFeatures, fetchWebOAuth2SSOUrl, fetchWebOIDCSSOUrl, fetchWebSAMLSSOUrl } from '@/service/share' import { fetchWebOAuth2SSOUrl, fetchWebOIDCSSOUrl, fetchWebSAMLSSOUrl } from '@/service/share'
import { setAccessToken } from '@/app/components/share/utils' import { setAccessToken } from '@/app/components/share/utils'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { SSOProtocol } from '@/types/feature'
import Loading from '@/app/components/base/loading' import Loading from '@/app/components/base/loading'
import AppUnavailable from '@/app/components/base/app-unavailable'
const WebSSOForm: FC = () => { const WebSSOForm: FC = () => {
const { t } = useTranslation()
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const searchParams = useSearchParams() const searchParams = useSearchParams()
const router = useRouter() const router = useRouter()
@ -23,15 +30,15 @@ const WebSSOForm: FC = () => {
}) })
} }
const getAppCodeFromRedirectUrl = () => { const getAppCodeFromRedirectUrl = useCallback(() => {
const appCode = redirectUrl?.split('/').pop() const appCode = redirectUrl?.split('/').pop()
if (!appCode) if (!appCode)
return null return null
return appCode return appCode
} }, [redirectUrl])
const processTokenAndRedirect = async () => { const processTokenAndRedirect = useCallback(async () => {
const appCode = getAppCodeFromRedirectUrl() const appCode = getAppCodeFromRedirectUrl()
if (!appCode || !tokenFromUrl || !redirectUrl) { if (!appCode || !tokenFromUrl || !redirectUrl) {
showErrorToast('redirect url or app code or token is invalid.') showErrorToast('redirect url or app code or token is invalid.')
@ -40,48 +47,47 @@ const WebSSOForm: FC = () => {
await setAccessToken(appCode, tokenFromUrl) await setAccessToken(appCode, tokenFromUrl)
router.push(redirectUrl) router.push(redirectUrl)
} }, [getAppCodeFromRedirectUrl, redirectUrl, router, tokenFromUrl])
const handleSSOLogin = async (protocol: string) => { const handleSSOLogin = useCallback(async () => {
const appCode = getAppCodeFromRedirectUrl() const appCode = getAppCodeFromRedirectUrl()
if (!appCode || !redirectUrl) { if (!appCode || !redirectUrl) {
showErrorToast('redirect url or app code is invalid.') showErrorToast('redirect url or app code is invalid.')
return return
} }
switch (protocol) { switch (systemFeatures.webapp_auth.sso_config.protocol) {
case 'saml': { case SSOProtocol.SAML: {
const samlRes = await fetchWebSAMLSSOUrl(appCode, redirectUrl) const samlRes = await fetchWebSAMLSSOUrl(appCode, redirectUrl)
router.push(samlRes.url) router.push(samlRes.url)
break break
} }
case 'oidc': { case SSOProtocol.OIDC: {
const oidcRes = await fetchWebOIDCSSOUrl(appCode, redirectUrl) const oidcRes = await fetchWebOIDCSSOUrl(appCode, redirectUrl)
router.push(oidcRes.url) router.push(oidcRes.url)
break break
} }
case 'oauth2': { case SSOProtocol.OAuth2: {
const oauth2Res = await fetchWebOAuth2SSOUrl(appCode, redirectUrl) const oauth2Res = await fetchWebOAuth2SSOUrl(appCode, redirectUrl)
router.push(oauth2Res.url) router.push(oauth2Res.url)
break break
} }
case '':
break
default: default:
showErrorToast('SSO protocol is not supported.') showErrorToast('SSO protocol is not supported.')
} }
} }, [getAppCodeFromRedirectUrl, redirectUrl, router, systemFeatures.webapp_auth.sso_config.protocol])
useEffect(() => { useEffect(() => {
const init = async () => { const init = async () => {
const res = await fetchSystemFeatures()
const protocol = res.sso_enforced_for_web_protocol
if (message) { if (message) {
showErrorToast(message) showErrorToast(message)
return return
} }
if (!tokenFromUrl) { if (!tokenFromUrl) {
await handleSSOLogin(protocol) await handleSSOLogin()
return return
} }
@ -89,15 +95,45 @@ const WebSSOForm: FC = () => {
} }
init() init()
}, [message, tokenFromUrl]) // Added dependencies to useEffect }, [message, processTokenAndRedirect, tokenFromUrl, handleSSOLogin])
if (tokenFromUrl)
return <div className='flex h-full items-center justify-center'><Loading /></div>
if (message) {
return <div className='flex h-full items-center justify-center'>
<AppUnavailable code={'App Unavailable'} unknownReason={message} />
</div>
}
if (systemFeatures.webapp_auth.enabled) {
if (systemFeatures.webapp_auth.allow_sso) {
return ( return (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<div className={cn('flex w-full grow flex-col items-center justify-center', 'px-6', 'md:px-[108px]')}> <div className={cn('flex w-full grow flex-col items-center justify-center', 'px-6', 'md:px-[108px]')}>
<Loading type='area' /> <Loading />
</div> </div>
</div> </div>
) )
}
return <div className="flex h-full items-center justify-center">
<div className="rounded-lg bg-gradient-to-r from-workflow-workflow-progress-bg-1 to-workflow-workflow-progress-bg-2 p-4">
<div className='shadows-shadow-lg mb-2 flex h-10 w-10 items-center justify-center rounded-xl bg-components-card-bg shadow'>
<RiDoorLockLine className='h-5 w-5' />
</div>
<p className='system-sm-medium text-text-primary'>{t('login.webapp.noLoginMethod')}</p>
<p className='system-xs-regular mt-1 text-text-tertiary'>{t('login.webapp.noLoginMethodTip')}</p>
</div>
<div className="relative my-2 py-2">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className='h-px w-full bg-gradient-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent'></div>
</div>
</div>
</div>
}
else {
return <div className="flex h-full items-center justify-center">
<p className='system-xs-regular text-text-tertiary'>{t('login.webapp.disabled')}</p>
</div>
}
} }
export default React.memo(WebSSOForm) export default React.memo(WebSSOForm)

@ -20,6 +20,7 @@ import AppIcon from '@/app/components/base/app-icon'
import { IS_CE_EDITION } from '@/config' import { IS_CE_EDITION } from '@/config'
import Input from '@/app/components/base/input' import Input from '@/app/components/base/input'
import PremiumBadge from '@/app/components/base/premium-badge' import PremiumBadge from '@/app/components/base/premium-badge'
import { useGlobalPublicStore } from '@/context/global-public-context'
const titleClassName = ` const titleClassName = `
system-sm-semibold text-text-secondary system-sm-semibold text-text-secondary
@ -32,7 +33,7 @@ const validPassword = /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/
export default function AccountPage() { export default function AccountPage() {
const { t } = useTranslation() const { t } = useTranslation()
const { systemFeatures } = useAppContext() const { systemFeatures } = useGlobalPublicStore()
const { mutateUserProfile, userProfile, apps } = useAppContext() const { mutateUserProfile, userProfile, apps } = useAppContext()
const { isEducationAccount } = useProviderContext() const { isEducationAccount } = useProviderContext()
const { notify } = useContext(ToastContext) const { notify } = useContext(ToastContext)
@ -138,7 +139,7 @@ export default function AccountPage() {
<h4 className='title-2xl-semi-bold text-text-primary'>{t('common.account.myAccount')}</h4> <h4 className='title-2xl-semi-bold text-text-primary'>{t('common.account.myAccount')}</h4>
</div> </div>
<div className='mb-8 flex items-center rounded-xl bg-gradient-to-r from-background-gradient-bg-fill-chat-bg-2 to-background-gradient-bg-fill-chat-bg-1 p-6'> <div className='mb-8 flex items-center rounded-xl bg-gradient-to-r from-background-gradient-bg-fill-chat-bg-2 to-background-gradient-bg-fill-chat-bg-1 p-6'>
<AvatarWithEdit avatar={userProfile.avatar_url} name={userProfile.name} onSave={ mutateUserProfile } size={64} /> <AvatarWithEdit avatar={userProfile.avatar_url} name={userProfile.name} onSave={mutateUserProfile} size={64} />
<div className='ml-4'> <div className='ml-4'>
<p className='system-xl-semibold text-text-primary'> <p className='system-xl-semibold text-text-primary'>
{userProfile.name} {userProfile.name}

@ -32,9 +32,4 @@ const Layout = ({ children }: { children: ReactNode }) => {
</> </>
) )
} }
export const metadata = {
title: 'Dify',
}
export default Layout export default Layout

@ -1,6 +1,11 @@
'use client'
import { useTranslation } from 'react-i18next'
import AccountPage from './account-page' import AccountPage from './account-page'
import useDocumentTitle from '@/hooks/use-document-title'
export default function Account() { export default function Account() {
const { t } = useTranslation()
useDocumentTitle(t('common.menus.account'))
return <div className='mx-auto w-full max-w-[640px] px-6 pt-12'> return <div className='mx-auto w-full max-w-[640px] px-6 pt-12'>
<AccountPage /> <AccountPage />
</div> </div>

@ -7,8 +7,10 @@ import Button from '@/app/components/base/button'
import { invitationCheck } from '@/service/common' import { invitationCheck } from '@/service/common'
import Loading from '@/app/components/base/loading' import Loading from '@/app/components/base/loading'
import useDocumentTitle from '@/hooks/use-document-title'
const ActivateForm = () => { const ActivateForm = () => {
useDocumentTitle('')
const router = useRouter() const router = useRouter()
const { t } = useTranslation() const { t } = useTranslation()
const searchParams = useSearchParams() const searchParams = useSearchParams()

@ -1,17 +1,20 @@
'use client'
import React from 'react' import React from 'react'
import Header from '../signin/_header' import Header from '../signin/_header'
import ActivateForm from './activateForm' import ActivateForm from './activateForm'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
import { useGlobalPublicStore } from '@/context/global-public-context'
const Activate = () => { const Activate = () => {
const { systemFeatures } = useGlobalPublicStore()
return ( return (
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}> <div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
<div className={cn('flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}> <div className={cn('flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
<Header /> <Header />
<ActivateForm /> <ActivateForm />
<div className='px-8 py-6 text-sm font-normal text-text-tertiary'> {!systemFeatures.branding.enabled && <div className='px-8 py-6 text-sm font-normal text-text-tertiary'>
© {new Date().getFullYear()} LangGenius, Inc. All rights reserved. © {new Date().getFullYear()} LangGenius, Inc. All rights reserved.
</div> </div>}
</div> </div>
</div> </div>
) )

@ -6,19 +6,19 @@ import {
RiDeleteBinLine, RiDeleteBinLine,
RiEditLine, RiEditLine,
RiEqualizer2Line, RiEqualizer2Line,
RiFileCopy2Line,
RiFileDownloadLine, RiFileDownloadLine,
RiFileUploadLine, RiFileUploadLine,
} from '@remixicon/react' } from '@remixicon/react'
import AppIcon from '../base/app-icon' import AppIcon from '../base/app-icon'
import SwitchAppModal from '../app/switch-app-modal' import SwitchAppModal from '../app/switch-app-modal'
import AccessControl from '../app/app-access-control'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
import Confirm from '@/app/components/base/confirm' import Confirm from '@/app/components/base/confirm'
import { useStore as useAppStore } from '@/app/components/app/store' import { useStore as useAppStore } from '@/app/components/app/store'
import { ToastContext } from '@/app/components/base/toast' import { ToastContext } from '@/app/components/base/toast'
import AppsContext, { useAppContext } from '@/context/app-context' import AppsContext, { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context' import { useProviderContext } from '@/context/provider-context'
import { copyApp, deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps' import { copyApp, deleteApp, exportAppConfig, fetchAppDetail, updateAppInfo } from '@/service/apps'
import DuplicateAppModal from '@/app/components/app/duplicate-modal' import DuplicateAppModal from '@/app/components/app/duplicate-modal'
import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal' import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal'
import CreateAppModal from '@/app/components/explore/create-app-modal' import CreateAppModal from '@/app/components/explore/create-app-modal'
@ -32,6 +32,7 @@ import { fetchWorkflowDraft } from '@/service/workflow'
import ContentDialog from '@/app/components/base/content-dialog' import ContentDialog from '@/app/components/base/content-dialog'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import CardView from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/cardView' import CardView from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/cardView'
import Divider from '../base/divider'
export type IAppInfoProps = { export type IAppInfoProps = {
expand: boolean expand: boolean
@ -50,6 +51,7 @@ const AppInfo = ({ expand }: IAppInfoProps) => {
const [showConfirmDelete, setShowConfirmDelete] = useState(false) const [showConfirmDelete, setShowConfirmDelete] = useState(false)
const [showSwitchModal, setShowSwitchModal] = useState<boolean>(false) const [showSwitchModal, setShowSwitchModal] = useState<boolean>(false)
const [showImportDSLModal, setShowImportDSLModal] = useState<boolean>(false) const [showImportDSLModal, setShowImportDSLModal] = useState<boolean>(false)
const [showAccessControl, setShowAccessControl] = useState<boolean>(false)
const [secretEnvList, setSecretEnvList] = useState<EnvironmentVariable[]>([]) const [secretEnvList, setSecretEnvList] = useState<EnvironmentVariable[]>([])
const mutateApps = useContextSelector( const mutateApps = useContextSelector(
@ -177,6 +179,19 @@ const AppInfo = ({ expand }: IAppInfoProps) => {
setShowConfirmDelete(false) setShowConfirmDelete(false)
}, [appDetail, mutateApps, notify, onPlanInfoChanged, replace, setAppDetail, t]) }, [appDetail, mutateApps, notify, onPlanInfoChanged, replace, setAppDetail, t])
const handleClickAccessControl = useCallback(() => {
if (!appDetail)
return
setShowAccessControl(true)
setOpen(false)
}, [appDetail])
const handleAccessControlUpdate = useCallback(() => {
fetchAppDetail({ url: '/apps', id: appDetail!.id }).then((res) => {
setAppDetail(res)
setShowAccessControl(false)
})
}, [appDetail, setAppDetail])
const { isCurrentWorkspaceEditor } = useAppContext() const { isCurrentWorkspaceEditor } = useAppContext()
if (!appDetail) if (!appDetail)
@ -262,10 +277,8 @@ const AppInfo = ({ expand }: IAppInfoProps) => {
onClick={() => { onClick={() => {
setOpen(false) setOpen(false)
setShowDuplicateModal(true) setShowDuplicateModal(true)
}} }}>
> <span className='text-sm leading-5 text-gray-700'>{t('app.duplicate')}</span>
<RiFileCopy2Line className='h-3.5 w-3.5 text-components-button-secondary-text' />
<span className='system-xs-medium text-components-button-secondary-text'>{t('app.duplicate')}</span>
</Button> </Button>
<Button <Button
size={'small'} size={'small'}
@ -301,6 +314,11 @@ const AppInfo = ({ expand }: IAppInfoProps) => {
className='flex grow flex-col gap-2 overflow-auto px-2 py-1' className='flex grow flex-col gap-2 overflow-auto px-2 py-1'
/> />
</div> </div>
<Divider />
{/* TODO update style figma */}
<div className='mx-1 flex h-9 cursor-pointer items-center rounded-lg px-3 py-2 hover:bg-gray-50' onClick={handleClickAccessControl}>
<span className='text-sm leading-5 text-gray-700'>{t('app.accessControl')}</span>
</div>
<div className='flex min-h-fit shrink-0 flex-col items-start justify-center gap-3 self-stretch border-t-[0.5px] border-divider-subtle p-2'> <div className='flex min-h-fit shrink-0 flex-col items-start justify-center gap-3 self-stretch border-t-[0.5px] border-divider-subtle p-2'>
<Button <Button
size={'medium'} size={'medium'}
@ -375,6 +393,11 @@ const AppInfo = ({ expand }: IAppInfoProps) => {
onClose={() => setSecretEnvList([])} onClose={() => setSecretEnvList([])}
/> />
)} )}
{
showAccessControl && <AccessControl app={appDetail}
onConfirm={handleAccessControlUpdate}
onClose={() => { setShowAccessControl(false) }} />
}
</div> </div>
) )
} }

@ -16,7 +16,7 @@ export type IAppDetailNavProps = {
desc: string desc: string
isExternal?: boolean isExternal?: boolean
icon: string icon: string
icon_background: string icon_background: string | null
navigation: Array<{ navigation: Array<{
name: string name: string
href: string href: string

@ -0,0 +1,61 @@
import { Fragment, useCallback } from 'react'
import type { ReactNode } from 'react'
import { Dialog, Transition } from '@headlessui/react'
import { RiCloseLine } from '@remixicon/react'
import cn from '@/utils/classnames'
type DialogProps = {
className?: string
children: ReactNode
show: boolean
onClose?: () => void
}
const AccessControlDialog = ({
className,
children,
show,
onClose,
}: DialogProps) => {
const close = useCallback(() => {
onClose?.()
}, [onClose])
return (
<Transition appear show={show} as={Fragment}>
<Dialog as="div" open={true} className="relative z-20" onClose={() => null}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-background-overlay bg-opacity-25" />
</Transition.Child>
<div className="fixed inset-0 flex items-center justify-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className={cn('relative h-auto min-h-[323px] w-[600px] overflow-y-auto rounded-2xl bg-components-panel-bg p-0 shadow-xl transition-all', className)}>
<div onClick={() => close()} className="absolute right-5 top-5 z-10 flex h-8 w-8 cursor-pointer items-center justify-center">
<RiCloseLine className='h-5 w-5' />
</div>
{children}
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition >
)
}
export default AccessControlDialog

@ -0,0 +1,30 @@
'use client'
import type { FC, PropsWithChildren } from 'react'
import useAccessControlStore from '../../../../context/access-control-store'
import type { AccessMode } from '@/models/access-control'
type AccessControlItemProps = PropsWithChildren<{
type: AccessMode
}>
const AccessControlItem: FC<AccessControlItemProps> = ({ type, children }) => {
const { currentMenu, setCurrentMenu } = useAccessControlStore(s => ({ currentMenu: s.currentMenu, setCurrentMenu: s.setCurrentMenu }))
if (currentMenu !== type) {
return <div
className="cursor-pointer rounded-[10px] border-[1px]
border-components-option-card-option-border bg-components-option-card-option-bg
hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover"
onClick={() => setCurrentMenu(type)} >
{children}
</div>
}
return <div className="rounded-[10px] border-[1.5px]
border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-sm">
{children}
</div>
}
AccessControlItem.displayName = 'AccessControlItem'
export default AccessControlItem

@ -0,0 +1,204 @@
'use client'
import { RiAddCircleFill, RiArrowRightSLine, RiOrganizationChart } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useDebounce } from 'ahooks'
import { FloatingOverlay } from '@floating-ui/react'
import Avatar from '../../base/avatar'
import Button from '../../base/button'
import Checkbox from '../../base/checkbox'
import Input from '../../base/input'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../../base/portal-to-follow-elem'
import Loading from '../../base/loading'
import useAccessControlStore from '../../../../context/access-control-store'
import classNames from '@/utils/classnames'
import { useSearchForWhiteListCandidates } from '@/service/access-control'
import type { AccessControlAccount, AccessControlGroup, Subject, SubjectAccount, SubjectGroup } from '@/models/access-control'
import { SubjectType } from '@/models/access-control'
import { useSelector } from '@/context/app-context'
export default function AddMemberOrGroupDialog() {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [keyword, setKeyword] = useState('')
const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb)
const debouncedKeyword = useDebounce(keyword, { wait: 500 })
const lastAvailableGroup = selectedGroupsForBreadcrumb[selectedGroupsForBreadcrumb.length - 1]
const { isLoading, isFetchingNextPage, fetchNextPage, data } = useSearchForWhiteListCandidates({ keyword: debouncedKeyword, groupId: lastAvailableGroup?.id, resultsPerPage: 10 }, open)
const handleKeywordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setKeyword(e.target.value)
}
const anchorRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const hasMore = data?.pages?.[0].hasMore ?? false
let observer: IntersectionObserver | undefined
if (anchorRef.current) {
observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !isLoading && hasMore)
fetchNextPage()
}, { rootMargin: '20px' })
observer.observe(anchorRef.current)
}
return () => observer?.disconnect()
}, [isLoading, fetchNextPage, anchorRef, data])
return <PortalToFollowElem open={open} onOpenChange={setOpen} offset={{ crossAxis: 300 }} placement='bottom-end'>
<PortalToFollowElemTrigger asChild>
<Button variant='ghost-accent' size='small' className='flex shrink-0 items-center gap-x-0.5' onClick={() => setOpen(!open)}>
<RiAddCircleFill className='h-4 w-4' />
<span>{t('common.operation.add')}</span>
</Button>
</PortalToFollowElemTrigger>
{open && <FloatingOverlay />}
<PortalToFollowElemContent className='z-[25]'>
<div className='relative flex max-h-[400px] w-[400px] flex-col overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]'>
<div className='z-1 sticky top-0 bg-components-panel-bg-blur p-2 pb-0.5 backdrop-blur-[5px]'>
<Input value={keyword} onChange={handleKeywordChange} showLeftIcon placeholder={t('app.accessControlDialog.operateGroupAndMember.searchPlaceholder') as string} />
</div>
{
isLoading
? <div className='p-1'><Loading /></div>
: (data?.pages?.length ?? 0) > 0
? <>
<div className='flex h-7 items-center px-2 py-0.5'>
<SelectedGroupsBreadCrumb />
</div>
<div className='p-1'>
{renderGroupOrMember(data?.pages ?? [])}
{isFetchingNextPage && <Loading />}
</div>
<div ref={anchorRef} className='h-0'> </div>
</>
: <div className='flex h-7 items-center justify-center px-2 py-0.5'>
<span className='system-xs-regular text-text-tertiary'>{t('app.accessControlDialog.operateGroupAndMember.noResult')}</span>
</div>
}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
}
type GroupOrMemberData = { subjects: Subject[]; currPage: number }[]
function renderGroupOrMember(data: GroupOrMemberData) {
return data?.map((page) => {
return <div key={`search_group_member_page_${page.currPage}`}>
{page.subjects?.map((item, index) => {
if (item.subjectType === SubjectType.GROUP)
return <GroupItem key={index} group={(item as SubjectGroup).groupData} />
return <MemberItem key={index} member={(item as SubjectAccount).accountData} />
})}
</div>
}) ?? null
}
function SelectedGroupsBreadCrumb() {
const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb)
const setSelectedGroupsForBreadcrumb = useAccessControlStore(s => s.setSelectedGroupsForBreadcrumb)
const { t } = useTranslation()
const handleBreadCrumbClick = useCallback((index: number) => {
const newGroups = selectedGroupsForBreadcrumb.slice(0, index + 1)
setSelectedGroupsForBreadcrumb(newGroups)
}, [setSelectedGroupsForBreadcrumb, selectedGroupsForBreadcrumb])
const handleReset = useCallback(() => {
setSelectedGroupsForBreadcrumb([])
}, [setSelectedGroupsForBreadcrumb])
return <div className='flex h-7 items-center gap-x-0.5 px-2 py-0.5'>
<span className={classNames('system-xs-regular text-text-tertiary', selectedGroupsForBreadcrumb.length > 0 && 'text-text-accent cursor-pointer')} onClick={handleReset}>{t('app.accessControlDialog.operateGroupAndMember.allMembers')}</span>
{selectedGroupsForBreadcrumb.map((group, index) => {
return <div key={index} className='system-xs-regular flex items-center gap-x-0.5 text-text-tertiary'>
<span>/</span>
<span className={index === selectedGroupsForBreadcrumb.length - 1 ? '' : 'cursor-pointer text-text-accent'} onClick={() => handleBreadCrumbClick(index)}>{group.name}</span>
</div>
})}
</div>
}
type GroupItemProps = {
group: AccessControlGroup
}
function GroupItem({ group }: GroupItemProps) {
const { t } = useTranslation()
const specificGroups = useAccessControlStore(s => s.specificGroups)
const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups)
const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb)
const setSelectedGroupsForBreadcrumb = useAccessControlStore(s => s.setSelectedGroupsForBreadcrumb)
const isChecked = specificGroups.some(g => g.id === group.id)
const handleCheckChange = useCallback(() => {
if (!isChecked) {
const newGroups = [...specificGroups, group]
setSpecificGroups(newGroups)
}
else {
const newGroups = specificGroups.filter(g => g.id !== group.id)
setSpecificGroups(newGroups)
}
}, [specificGroups, setSpecificGroups, group, isChecked])
const handleExpandClick = useCallback(() => {
setSelectedGroupsForBreadcrumb([...selectedGroupsForBreadcrumb, group])
}, [selectedGroupsForBreadcrumb, setSelectedGroupsForBreadcrumb, group])
return <BaseItem>
<Checkbox checked={isChecked} className='h-4 w-4 shrink-0' onCheck={handleCheckChange} />
<div className='item-center flex grow'>
<div className='mr-2 h-5 w-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid'>
<div className='bg-access-app-icon-mask-bg flex h-full w-full items-center justify-center'>
<RiOrganizationChart className='h-[14px] w-[14px] text-components-avatar-shape-fill-stop-0' />
</div>
</div>
<p className='system-sm-medium mr-1 text-text-secondary'>{group.name}</p>
<p className='system-xs-regular text-text-tertiary'>{group.groupSize}</p>
</div>
<Button size="small" disabled={isChecked} variant='ghost-accent'
className='flex shrink-0 items-center justify-between px-1.5 py-1' onClick={handleExpandClick}>
<span className='px-[3px]'>{t('app.accessControlDialog.operateGroupAndMember.expand')}</span>
<RiArrowRightSLine className='h-4 w-4' />
</Button>
</BaseItem>
}
type MemberItemProps = {
member: AccessControlAccount
}
function MemberItem({ member }: MemberItemProps) {
const currentUser = useSelector(s => s.userProfile)
const { t } = useTranslation()
const specificMembers = useAccessControlStore(s => s.specificMembers)
const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers)
const isChecked = specificMembers.some(m => m.id === member.id)
const handleCheckChange = useCallback(() => {
if (!isChecked) {
const newMembers = [...specificMembers, member]
setSpecificMembers(newMembers)
}
else {
const newMembers = specificMembers.filter(m => m.id !== member.id)
setSpecificMembers(newMembers)
}
}, [specificMembers, setSpecificMembers, member, isChecked])
return <BaseItem className='pr-3'>
<Checkbox checked={isChecked} className='h-4 w-4 shrink-0' onCheck={handleCheckChange} />
<div className='flex grow items-center'>
<div className='mr-2 h-5 w-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid'>
<div className='bg-access-app-icon-mask-bg flex h-full w-full items-center justify-center'>
<Avatar className='h-[14px] w-[14px]' textClassName='text-[12px]' avatar={null} name={member.name} />
</div>
</div>
<p className='system-sm-medium mr-1 text-text-secondary'>{member.name}</p>
{currentUser.email === member.email && <p className='system-xs-regular text-text-tertiary'>({t('common.you')})</p>}
</div>
<p className='system-xs-regular text-text-quaternary'>{member.email}</p>
</BaseItem>
}
type BaseItemProps = {
className?: string
children: React.ReactNode
}
function BaseItem({ children, className }: BaseItemProps) {
return <div className={classNames('p-1 pl-2 flex items-center space-x-2 hover:rounded-lg hover:bg-state-base-hover cursor-pointer', className)}>
{children}
</div>
}

@ -0,0 +1,102 @@
'use client'
import { Dialog } from '@headlessui/react'
import { RiBuildingLine, RiGlobalLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useCallback, useEffect } from 'react'
import Button from '../../base/button'
import Toast from '../../base/toast'
import useAccessControlStore from '../../../../context/access-control-store'
import AccessControlDialog from './access-control-dialog'
import AccessControlItem from './access-control-item'
import SpecificGroupsOrMembers, { WebAppSSONotEnabledTip } from './specific-groups-or-members'
import { useGlobalPublicStore } from '@/context/global-public-context'
import type { App } from '@/types/app'
import type { Subject } from '@/models/access-control'
import { AccessMode, SubjectType } from '@/models/access-control'
import { useUpdateAccessMode } from '@/service/access-control'
type AccessControlProps = {
app: App
onClose: () => void
onConfirm?: () => void
}
export default function AccessControl(props: AccessControlProps) {
const { app, onClose, onConfirm } = props
const { t } = useTranslation()
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const setAppId = useAccessControlStore(s => s.setAppId)
const specificGroups = useAccessControlStore(s => s.specificGroups)
const specificMembers = useAccessControlStore(s => s.specificMembers)
const currentMenu = useAccessControlStore(s => s.currentMenu)
const setCurrentMenu = useAccessControlStore(s => s.setCurrentMenu)
const hideTip = systemFeatures.webapp_auth.enabled
&& (systemFeatures.webapp_auth.allow_sso
|| systemFeatures.webapp_auth.allow_email_password_login
|| systemFeatures.webapp_auth.allow_email_code_login)
useEffect(() => {
setAppId(app.id)
setCurrentMenu(app.access_mode ?? AccessMode.SPECIFIC_GROUPS_MEMBERS)
}, [app, setAppId, setCurrentMenu])
const { isPending, mutateAsync: updateAccessMode } = useUpdateAccessMode()
const handleConfirm = useCallback(async () => {
const submitData: {
appId: string
accessMode: AccessMode
subjects?: Pick<Subject, 'subjectId' | 'subjectType'>[]
} = { appId: app.id, accessMode: currentMenu }
if (currentMenu === AccessMode.SPECIFIC_GROUPS_MEMBERS) {
const subjects: Pick<Subject, 'subjectId' | 'subjectType'>[] = []
specificGroups.forEach((group) => {
subjects.push({ subjectId: group.id, subjectType: SubjectType.GROUP })
})
specificMembers.forEach((member) => {
subjects.push({
subjectId: member.id,
subjectType: SubjectType.ACCOUNT,
})
})
submitData.subjects = subjects
}
await updateAccessMode(submitData)
Toast.notify({ type: 'success', message: t('app.accessControlDialog.updateSuccess') })
onConfirm?.()
}, [updateAccessMode, app, specificGroups, specificMembers, t, onConfirm, currentMenu])
return <AccessControlDialog show onClose={onClose}>
<div className='flex flex-col gap-y-3'>
<div className='pb-3 pl-6 pr-14 pt-6'>
<Dialog.Title className='title-2xl-semi-bold text-text-primary'>{t('app.accessControlDialog.title')}</Dialog.Title>
<Dialog.Description className='system-xs-regular mt-1 text-text-tertiary'>{t('app.accessControlDialog.description')}</Dialog.Description>
</div>
<div className='flex flex-col gap-y-1 px-6 pb-3'>
<div className='leading-6'>
<p className='system-sm-medium'>{t('app.accessControlDialog.accessLabel')}</p>
</div>
<AccessControlItem type={AccessMode.ORGANIZATION}>
<div className='flex items-center p-3'>
<div className='flex grow items-center gap-x-2'>
<RiBuildingLine className='h-4 w-4 text-text-primary' />
<p className='system-sm-medium text-text-primary'>{t('app.accessControlDialog.accessItems.organization')}</p>
</div>
{!hideTip && <WebAppSSONotEnabledTip />}
</div>
</AccessControlItem>
<AccessControlItem type={AccessMode.SPECIFIC_GROUPS_MEMBERS}>
<SpecificGroupsOrMembers />
</AccessControlItem>
<AccessControlItem type={AccessMode.PUBLIC}>
<div className='flex items-center gap-x-2 p-3'>
<RiGlobalLine className='h-4 w-4 text-text-primary' />
<p className='system-sm-medium text-text-primary'>{t('app.accessControlDialog.accessItems.anyone')}</p>
</div>
</AccessControlItem>
</div>
<div className='flex items-center justify-end gap-x-2 p-6 pt-5'>
<Button onClick={onClose}>{t('common.operation.cancel')}</Button>
<Button disabled={isPending} loading={isPending} variant='primary' onClick={handleConfirm}>{t('common.operation.confirm')}</Button>
</div>
</div>
</AccessControlDialog>
}

@ -0,0 +1,139 @@
'use client'
import { RiAlertFill, RiCloseCircleFill, RiLockLine, RiOrganizationChart } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useCallback, useEffect } from 'react'
import Avatar from '../../base/avatar'
import Divider from '../../base/divider'
import Tooltip from '../../base/tooltip'
import Loading from '../../base/loading'
import useAccessControlStore from '../../../../context/access-control-store'
import AddMemberOrGroupDialog from './add-member-or-group-pop'
import { useGlobalPublicStore } from '@/context/global-public-context'
import type { AccessControlAccount, AccessControlGroup } from '@/models/access-control'
import { AccessMode } from '@/models/access-control'
import { useAppWhiteListSubjects } from '@/service/access-control'
export default function SpecificGroupsOrMembers() {
const currentMenu = useAccessControlStore(s => s.currentMenu)
const appId = useAccessControlStore(s => s.appId)
const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups)
const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers)
const { t } = useTranslation()
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const hideTip = systemFeatures.webapp_auth.enabled
&& (systemFeatures.webapp_auth.allow_sso
|| systemFeatures.webapp_auth.allow_email_password_login
|| systemFeatures.webapp_auth.allow_email_code_login)
const { isPending, data } = useAppWhiteListSubjects(appId, Boolean(appId) && currentMenu === AccessMode.SPECIFIC_GROUPS_MEMBERS)
useEffect(() => {
setSpecificGroups(data?.groups ?? [])
setSpecificMembers(data?.members ?? [])
}, [data, setSpecificGroups, setSpecificMembers])
if (currentMenu !== AccessMode.SPECIFIC_GROUPS_MEMBERS) {
return <div className='flex items-center p-3'>
<div className='flex grow items-center gap-x-2'>
<RiLockLine className='h-4 w-4 text-text-primary' />
<p className='system-sm-medium text-text-primary'>{t('app.accessControlDialog.accessItems.specific')}</p>
</div>
{!hideTip && <WebAppSSONotEnabledTip />}
</div>
}
return <div>
<div className='flex items-center gap-x-1 p-3'>
<div className='flex grow items-center gap-x-1'>
<RiLockLine className='h-4 w-4 text-text-primary' />
<p className='system-sm-medium text-text-primary'>{t('app.accessControlDialog.accessItems.specific')}</p>
</div>
<div className='flex items-center gap-x-1'>
{!hideTip && <>
<WebAppSSONotEnabledTip />
<Divider className='ml-2 mr-0 h-[14px]' type="vertical" />
</>}
<AddMemberOrGroupDialog />
</div>
</div>
<div className='px-1 pb-1'>
<div className='flex max-h-[400px] flex-col gap-y-2 overflow-y-auto rounded-lg bg-background-section p-2'>
{isPending ? <Loading /> : <RenderGroupsAndMembers />}
</div>
</div>
</div >
}
function RenderGroupsAndMembers() {
const { t } = useTranslation()
const specificGroups = useAccessControlStore(s => s.specificGroups)
const specificMembers = useAccessControlStore(s => s.specificMembers)
if (specificGroups.length <= 0 && specificMembers.length <= 0)
return <div className='px-2 pb-1.5 pt-5'><p className='system-xs-regular text-center text-text-tertiary'>{t('app.accessControlDialog.noGroupsOrMembers')}</p></div>
return <>
<p className='system-2xs-medium-uppercase sticky top-0 text-text-tertiary'>{t('app.accessControlDialog.groups', { count: specificGroups.length ?? 0 })}</p>
<div className='flex flex-row flex-wrap gap-1'>
{specificGroups.map((group, index) => <GroupItem key={index} group={group} />)}
</div>
<p className='system-2xs-medium-uppercase sticky top-0 text-text-tertiary'>{t('app.accessControlDialog.members', { count: specificMembers.length ?? 0 })}</p>
<div className='flex flex-row flex-wrap gap-1'>
{specificMembers.map((member, index) => <MemberItem key={index} member={member} />)}
</div>
</>
}
type GroupItemProps = {
group: AccessControlGroup
}
function GroupItem({ group }: GroupItemProps) {
const specificGroups = useAccessControlStore(s => s.specificGroups)
const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups)
const handleRemoveGroup = useCallback(() => {
setSpecificGroups(specificGroups.filter(g => g.id !== group.id))
}, [group, setSpecificGroups, specificGroups])
return <BaseItem icon={<RiOrganizationChart className='h-[14px] w-[14px] text-components-avatar-shape-fill-stop-0' />}
onRemove={handleRemoveGroup}>
<p className='system-xs-regular text-text-primary'>{group.name}</p>
<p className='system-xs-regular text-text-tertiary'>{group.groupSize}</p>
</BaseItem>
}
type MemberItemProps = {
member: AccessControlAccount
}
function MemberItem({ member }: MemberItemProps) {
const specificMembers = useAccessControlStore(s => s.specificMembers)
const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers)
const handleRemoveMember = useCallback(() => {
setSpecificMembers(specificMembers.filter(m => m.id !== member.id))
}, [member, setSpecificMembers, specificMembers])
return <BaseItem icon={<Avatar className='h-[14px] w-[14px]' textClassName='text-[12px]' avatar={null} name={member.name} />}
onRemove={handleRemoveMember}>
<p className='system-xs-regular text-text-primary'>{member.name}</p>
</BaseItem>
}
type BaseItemProps = {
icon: React.ReactNode
children: React.ReactNode
onRemove?: () => void
}
function BaseItem({ icon, onRemove, children }: BaseItemProps) {
return <div className='group flex flex-row items-center gap-x-1 rounded-full border-[0.5px] bg-components-badge-white-to-dark p-1 pr-1.5 shadow-xs'>
<div className='h-5 w-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid'>
<div className='bg-access-app-icon-mask-bg flex h-full w-full items-center justify-center'>
{icon}
</div>
</div>
{children}
<div className='flex h-4 w-4 cursor-pointer items-center justify-center' onClick={onRemove}>
<RiCloseCircleFill className='h-[14px] w-[14px] text-text-quaternary' />
</div>
</div>
}
export function WebAppSSONotEnabledTip() {
const { t } = useTranslation()
return <Tooltip asChild={false} popupContent={t('app.accessControlDialog.webAppSSONotEnabledTip')}>
<RiAlertFill className='h-4 w-4 shrink-0 text-text-warning-secondary' />
</Tooltip>
}

@ -1,21 +1,28 @@
import { import {
memo, memo,
useCallback, useCallback,
useEffect,
useState, useState,
} from 'react' } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { import {
RiArrowDownSLine, RiArrowDownSLine,
RiArrowRightSLine,
RiLockLine,
RiPlanetLine, RiPlanetLine,
RiPlayCircleLine, RiPlayCircleLine,
RiPlayList2Line, RiPlayList2Line,
RiTerminalBoxLine, RiTerminalBoxLine,
} from '@remixicon/react' } from '@remixicon/react'
import { useKeyPress } from 'ahooks' import { useKeyPress } from 'ahooks'
import { getKeyboardKeyCodeBySystem } from '../../workflow/utils'
import Toast from '../../base/toast' import Toast from '../../base/toast'
import type { ModelAndParameter } from '../configuration/debug/types' import type { ModelAndParameter } from '../configuration/debug/types'
import { getKeyboardKeyCodeBySystem } from '../../workflow/utils' import Divider from '../../base/divider'
import AccessControl from '../app-access-control'
import Loading from '../../base/loading'
import Tooltip from '../../base/tooltip'
import SuggestedAction from './suggested-action' import SuggestedAction from './suggested-action'
import PublishWithMultipleModel from './publish-with-multiple-model' import PublishWithMultipleModel from './publish-with-multiple-model'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
@ -34,6 +41,9 @@ import WorkflowToolConfigureButton from '@/app/components/tools/workflow-tool/co
import type { InputVar } from '@/app/components/workflow/types' import type { InputVar } from '@/app/components/workflow/types'
import { appDefaultIconBackground } from '@/config' import { appDefaultIconBackground } from '@/config'
import type { PublishWorkflowParams } from '@/types/workflow' import type { PublishWorkflowParams } from '@/types/workflow'
import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control'
import { AccessMode } from '@/models/access-control'
import { fetchAppDetail } from '@/service/apps'
export type AppPublisherProps = { export type AppPublisherProps = {
disabled?: boolean disabled?: boolean
@ -74,11 +84,32 @@ const AppPublisher = ({
const [published, setPublished] = useState(false) const [published, setPublished] = useState(false)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const appDetail = useAppStore(state => state.appDetail) const appDetail = useAppStore(state => state.appDetail)
const setAppDetail = useAppStore(s => s.setAppDetail)
const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {} const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {}
const appMode = (appDetail?.mode !== 'completion' && appDetail?.mode !== 'workflow') ? 'chat' : appDetail.mode const appMode = (appDetail?.mode !== 'completion' && appDetail?.mode !== 'workflow') ? 'chat' : appDetail.mode
const appURL = `${appBaseURL}/${basePath}/${appMode}/${accessToken}` const appURL = `${appBaseURL}/${basePath}/${appMode}/${accessToken}`
const isChatApp = ['chat', 'agent-chat', 'completion'].includes(appDetail?.mode || '') const isChatApp = ['chat', 'agent-chat', 'completion'].includes(appDetail?.mode || '')
const { data: useCanAccessApp, isLoading: isGettingUserCanAccessApp, refetch } = useGetUserCanAccessApp({ appId: appDetail?.id, enabled: false })
const { data: appAccessSubjects, isLoading: isGettingAppWhiteListSubjects } = useAppWhiteListSubjects(appDetail?.id, open && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS)
useEffect(() => {
if (open && appDetail)
refetch()
}, [open, appDetail, refetch])
const [showAppAccessControl, setShowAppAccessControl] = useState(false)
const [isAppAccessSet, setIsAppAccessSet] = useState(true)
useEffect(() => {
if (appDetail && appAccessSubjects) {
if (appDetail.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && appAccessSubjects.groups?.length === 0 && appAccessSubjects.members?.length === 0)
setIsAppAccessSet(false)
else
setIsAppAccessSet(true)
}
else {
setIsAppAccessSet(true)
}
}, [appAccessSubjects, appDetail])
const language = useGetLanguage() const language = useGetLanguage()
const formatTimeFromNow = useCallback((time: number) => { const formatTimeFromNow = useCallback((time: number) => {
return dayjs(time).locale(language === 'zh_Hans' ? 'zh-cn' : language.replace('_', '-')).fromNow() return dayjs(time).locale(language === 'zh_Hans' ? 'zh-cn' : language.replace('_', '-')).fromNow()
@ -99,7 +130,7 @@ const AppPublisher = ({
await onRestore?.() await onRestore?.()
setOpen(false) setOpen(false)
} }
catch {} catch { }
}, [onRestore]) }, [onRestore])
const handleTrigger = useCallback(() => { const handleTrigger = useCallback(() => {
@ -130,6 +161,13 @@ const AppPublisher = ({
} }
}, [appDetail?.id]) }, [appDetail?.id])
const handleAccessControlUpdate = useCallback(() => {
fetchAppDetail({ url: '/apps', id: appDetail!.id }).then((res) => {
setAppDetail(res)
setShowAppAccessControl(false)
})
}, [appDetail, setAppDetail])
const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false) const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false)
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => { useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => {
@ -223,7 +261,33 @@ const AppPublisher = ({
) )
} }
</div> </div>
<div className='border-t-[0.5px] border-t-divider-regular p-4 pt-3'> {(isGettingUserCanAccessApp || isGettingAppWhiteListSubjects)
? <div className='py-2'><Loading /></div>
: <>
<Divider className='my-0' />
<div className='p-4 pt-3'>
<div className='flex h-6 items-center'>
<p className='system-xs-medium text-text-tertiary'>{t('app.publishApp.title')}</p>
</div>
<div className='flex h-8 cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal py-1 pl-2.5 pr-2 hover:bg-primary-50 hover:text-text-accent'
onClick={() => {
setShowAppAccessControl(true)
}}>
<div className='flex grow items-center gap-x-1.5 pr-1'>
<RiLockLine className='h-4 w-4 shrink-0 text-text-secondary' />
{appDetail?.access_mode === AccessMode.ORGANIZATION && <p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.organization')}</p>}
{appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && <p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.specific')}</p>}
{appDetail?.access_mode === AccessMode.PUBLIC && <p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.anyone')}</p>}
</div>
{!isAppAccessSet && <p className='system-xs-regular shrink-0 text-text-tertiary'>{t('app.publishApp.notSet')}</p>}
<div className='flex h-4 w-4 shrink-0 items-center justify-center'>
<RiArrowRightSLine className='h-4 w-4 text-text-quaternary' />
</div>
</div>
{!isAppAccessSet && <p className='system-xs-regular mt-1 text-text-warning'>{t('app.publishApp.notSetDesc')}</p>}
</div>
<div className='flex flex-col gap-y-1 border-t-[0.5px] border-t-divider-regular p-4 pt-3'>
<Tooltip triggerClassName='flex' disabled={useCanAccessApp?.result} popupContent={t('app.noAccessPermission')} asChild={false}>
<SuggestedAction <SuggestedAction
disabled={!publishedAt} disabled={!publishedAt}
link={appURL} link={appURL}
@ -231,8 +295,10 @@ const AppPublisher = ({
> >
{t('workflow.common.runApp')} {t('workflow.common.runApp')}
</SuggestedAction> </SuggestedAction>
</Tooltip>
{appDetail?.mode === 'workflow' || appDetail?.mode === 'completion' {appDetail?.mode === 'workflow' || appDetail?.mode === 'completion'
? ( ? (
<Tooltip triggerClassName='flex' disabled={useCanAccessApp?.result} popupContent={t('app.noAccessPermission')} asChild={false}>
<SuggestedAction <SuggestedAction
disabled={!publishedAt} disabled={!publishedAt}
link={`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`} link={`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`}
@ -240,6 +306,7 @@ const AppPublisher = ({
> >
{t('workflow.common.batchRunApp')} {t('workflow.common.batchRunApp')}
</SuggestedAction> </SuggestedAction>
</Tooltip>
) )
: ( : (
<SuggestedAction <SuggestedAction
@ -253,6 +320,7 @@ const AppPublisher = ({
{t('workflow.common.embedIntoSite')} {t('workflow.common.embedIntoSite')}
</SuggestedAction> </SuggestedAction>
)} )}
<Tooltip triggerClassName='flex' disabled={useCanAccessApp?.result} popupContent={t('app.noAccessPermission')} asChild={false}>
<SuggestedAction <SuggestedAction
onClick={() => { onClick={() => {
publishedAt && handleOpenInExplore() publishedAt && handleOpenInExplore()
@ -262,6 +330,8 @@ const AppPublisher = ({
> >
{t('workflow.common.openInExplore')} {t('workflow.common.openInExplore')}
</SuggestedAction> </SuggestedAction>
</Tooltip>
<div className='flex'>
<SuggestedAction <SuggestedAction
disabled={!publishedAt} disabled={!publishedAt}
link='./develop' link='./develop'
@ -269,7 +339,9 @@ const AppPublisher = ({
> >
{t('workflow.common.accessAPIReference')} {t('workflow.common.accessAPIReference')}
</SuggestedAction> </SuggestedAction>
</div>
{appDetail?.mode === 'workflow' && ( {appDetail?.mode === 'workflow' && (
<div className='flex' >
<WorkflowToolConfigureButton <WorkflowToolConfigureButton
disabled={!publishedAt} disabled={!publishedAt}
published={!!toolPublished} published={!!toolPublished}
@ -285,8 +357,10 @@ const AppPublisher = ({
handlePublish={handlePublish} handlePublish={handlePublish}
onRefreshData={onRefreshData} onRefreshData={onRefreshData}
/> />
</div>
)} )}
</div> </div>
</>}
</div> </div>
</PortalToFollowElemContent> </PortalToFollowElemContent>
<EmbeddedModal <EmbeddedModal
@ -296,9 +370,9 @@ const AppPublisher = ({
appBaseUrl={appBaseURL} appBaseUrl={appBaseURL}
accessToken={accessToken} accessToken={accessToken}
/> />
{showAppAccessControl && <AccessControl app={appDetail!} onConfirm={handleAccessControlUpdate} onClose={() => { setShowAppAccessControl(false) }} />}
</PortalToFollowElem > </PortalToFollowElem >
</> </>)
)
} }
export default memo(AppPublisher) export default memo(AppPublisher)

@ -8,7 +8,13 @@ export type SuggestedActionProps = PropsWithChildren<HTMLProps<HTMLAnchorElement
disabled?: boolean disabled?: boolean
}> }>
const SuggestedAction = ({ icon, link, disabled, children, className, ...props }: SuggestedActionProps) => ( const SuggestedAction = ({ icon, link, disabled, children, className, onClick, ...props }: SuggestedActionProps) => {
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
if (disabled)
return
onClick?.(e)
}
return (
<a <a
href={disabled ? undefined : link} href={disabled ? undefined : link}
target='_blank' target='_blank'
@ -18,12 +24,14 @@ const SuggestedAction = ({ icon, link, disabled, children, className, ...props }
disabled ? 'shadow-xs opacity-30 cursor-not-allowed' : 'text-text-secondary hover:bg-state-accent-hover hover:text-text-accent cursor-pointer', disabled ? 'shadow-xs opacity-30 cursor-not-allowed' : 'text-text-secondary hover:bg-state-accent-hover hover:text-text-accent cursor-pointer',
className, className,
)} )}
onClick={handleClick}
{...props} {...props}
> >
<div className='relative h-4 w-4'>{icon}</div> <div className='relative h-4 w-4'>{icon}</div>
<div className='system-sm-medium shrink grow basis-0'>{children}</div> <div className='system-sm-medium shrink grow basis-0'>{children}</div>
<RiArrowRightUpLine className='h-3.5 w-3.5' /> <RiArrowRightUpLine className='h-3.5 w-3.5' />
</a> </a>
) )
}
export default SuggestedAction export default SuggestedAction

@ -4,7 +4,7 @@ import React, { useCallback, useEffect, useState } from 'react'
import { RiArrowRightSLine, RiCloseLine } from '@remixicon/react' import { RiArrowRightSLine, RiCloseLine } from '@remixicon/react'
import Link from 'next/link' import Link from 'next/link'
import { Trans, useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
import { useContext, useContextSelector } from 'use-context-selector' import { useContext } from 'use-context-selector'
import { SparklesSoft } from '@/app/components/base/icons/src/public/common' import { SparklesSoft } from '@/app/components/base/icons/src/public/common'
import Modal from '@/app/components/base/modal' import Modal from '@/app/components/base/modal'
import ActionButton from '@/app/components/base/action-button' import ActionButton from '@/app/components/base/action-button'
@ -21,7 +21,6 @@ import type { AppIconType, AppSSO, Language } from '@/types/app'
import { useToastContext } from '@/app/components/base/toast' import { useToastContext } from '@/app/components/base/toast'
import { LanguagesSupported, languages } from '@/i18n/language' import { LanguagesSupported, languages } from '@/i18n/language'
import Tooltip from '@/app/components/base/tooltip' import Tooltip from '@/app/components/base/tooltip'
import AppContext, { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context' import { useProviderContext } from '@/context/provider-context'
import { useModalContext } from '@/context/modal-context' import { useModalContext } from '@/context/modal-context'
import type { AppIconSelection } from '@/app/components/base/app-icon-picker' import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
@ -65,8 +64,6 @@ const SettingsModal: FC<ISettingsModalProps> = ({
onClose, onClose,
onSave, onSave,
}) => { }) => {
const systemFeatures = useContextSelector(AppContext, state => state.systemFeatures)
const { isCurrentWorkspaceEditor } = useAppContext()
const { notify } = useToastContext() const { notify } = useToastContext()
const [isShowMore, setIsShowMore] = useState(false) const [isShowMore, setIsShowMore] = useState(false)
const { const {
@ -110,7 +107,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
: { type: 'emoji', icon, background: icon_background! }, : { type: 'emoji', icon, background: icon_background! },
) )
const { enableBilling, plan } = useProviderContext() const { enableBilling, plan, webappCopyrightEnabled } = useProviderContext()
const { setShowPricingModal, setShowAccountSettingModal } = useModalContext() const { setShowPricingModal, setShowAccountSettingModal } = useModalContext()
const isFreePlan = plan.type === 'sandbox' const isFreePlan = plan.type === 'sandbox'
const handlePlanClick = useCallback(() => { const handlePlanClick = useCallback(() => {
@ -138,7 +135,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
setAppIcon(icon_type === 'image' setAppIcon(icon_type === 'image'
? { type: 'image', url: icon_url!, fileId: icon } ? { type: 'image', url: icon_url!, fileId: icon }
: { type: 'emoji', icon, background: icon_background! }) : { type: 'emoji', icon, background: icon_background! })
}, [appInfo]) }, [appInfo, chat_color_theme, chat_color_theme_inverted, copyright, custom_disclaimer, default_language, description, icon, icon_background, icon_type, icon_url, privacy_policy, show_workflow_steps, title, use_icon_as_answer_icon])
const onHide = () => { const onHide = () => {
onClose() onClose()
@ -188,7 +185,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
chat_color_theme: inputInfo.chatColorTheme, chat_color_theme: inputInfo.chatColorTheme,
chat_color_theme_inverted: inputInfo.chatColorThemeInverted, chat_color_theme_inverted: inputInfo.chatColorThemeInverted,
prompt_public: false, prompt_public: false,
copyright: isFreePlan copyright: !webappCopyrightEnabled
? '' ? ''
: inputInfo.copyrightSwitchValue : inputInfo.copyrightSwitchValue
? inputInfo.copyright ? inputInfo.copyright
@ -336,28 +333,6 @@ const SettingsModal: FC<ISettingsModalProps> = ({
</div> </div>
<p className='body-xs-regular pb-0.5 text-text-tertiary'>{t(`${prefixSettings}.workflow.showDesc`)}</p> <p className='body-xs-regular pb-0.5 text-text-tertiary'>{t(`${prefixSettings}.workflow.showDesc`)}</p>
</div> </div>
{/* SSO */}
{systemFeatures.enable_web_sso_switch_component && (
<>
<Divider className="my-0 h-px" />
<div className='w-full'>
<p className='system-xs-medium-uppercase mb-1 text-text-tertiary'>{t(`${prefixSettings}.sso.label`)}</p>
<div className='flex items-center justify-between'>
<div className={cn('system-sm-semibold py-1 text-text-secondary')}>{t(`${prefixSettings}.sso.title`)}</div>
<Tooltip
disabled={systemFeatures.sso_enforced_for_web}
popupContent={
<div className='w-[180px]'>{t(`${prefixSettings}.sso.tooltip`)}</div>
}
asChild={false}
>
<Switch disabled={!systemFeatures.sso_enforced_for_web || !isCurrentWorkspaceEditor} defaultValue={systemFeatures.sso_enforced_for_web && inputInfo.enable_sso} onChange={v => setInputInfo({ ...inputInfo, enable_sso: v })}></Switch>
</Tooltip>
</div>
<p className='body-xs-regular pb-0.5 text-text-tertiary'>{t(`${prefixSettings}.sso.description`)}</p>
</div>
</>
)}
{/* more settings switch */} {/* more settings switch */}
<Divider className="my-0 h-px" /> <Divider className="my-0 h-px" />
{!isShowMore && ( {!isShowMore && (
@ -392,14 +367,14 @@ const SettingsModal: FC<ISettingsModalProps> = ({
)} )}
</div> </div>
<Tooltip <Tooltip
disabled={!isFreePlan} disabled={webappCopyrightEnabled}
popupContent={ popupContent={
<div className='w-[260px]'>{t(`${prefixSettings}.more.copyrightTooltip`)}</div> <div className='w-[180px]'>{t(`${prefixSettings}.more.copyrightTooltip`)}</div>
} }
asChild={false} asChild={false}
> >
<Switch <Switch
disabled={isFreePlan} disabled={!webappCopyrightEnabled}
defaultValue={inputInfo.copyrightSwitchValue} defaultValue={inputInfo.copyrightSwitchValue}
onChange={v => setInputInfo({ ...inputInfo, copyrightSwitchValue: v })} onChange={v => setInputInfo({ ...inputInfo, copyrightSwitchValue: v })}
/> />
@ -450,7 +425,6 @@ const SettingsModal: FC<ISettingsModalProps> = ({
<Button className='mr-2' onClick={onHide}>{t('common.operation.cancel')}</Button> <Button className='mr-2' onClick={onHide}>{t('common.operation.cancel')}</Button>
<Button variant='primary' onClick={onClickSave} loading={saveLoading}>{t('common.operation.save')}</Button> <Button variant='primary' onClick={onClickSave} loading={saveLoading}>{t('common.operation.save')}</Button>
</div> </div>
{showAppIconPicker && ( {showAppIconPicker && (
<div onClick={e => e.stopPropagation()}> <div onClick={e => e.stopPropagation()}>
<AppIconPicker <AppIconPicker

@ -4,7 +4,7 @@ import React from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
type IAppUnavailableProps = { type IAppUnavailableProps = {
code?: number code?: number | string
isUnknownReason?: boolean isUnknownReason?: boolean
unknownReason?: string unknownReason?: string
} }

@ -16,12 +16,15 @@ import type {
ConversationItem, ConversationItem,
} from '@/models/share' } from '@/models/share'
import { noop } from 'lodash-es' import { noop } from 'lodash-es'
import { AccessMode } from '@/models/access-control'
export type ChatWithHistoryContextValue = { export type ChatWithHistoryContextValue = {
appInfoError?: any appInfoError?: any
appInfoLoading?: boolean appInfoLoading?: boolean
appMeta?: AppMeta appMeta?: AppMeta
appData?: AppData appData?: AppData
accessMode?: AccessMode
userCanAccess?: boolean
appParams?: ChatConfig appParams?: ChatConfig
appChatListDataLoading?: boolean appChatListDataLoading?: boolean
currentConversationId: string currentConversationId: string
@ -60,6 +63,8 @@ export type ChatWithHistoryContextValue = {
} }
export const ChatWithHistoryContext = createContext<ChatWithHistoryContextValue>({ export const ChatWithHistoryContext = createContext<ChatWithHistoryContextValue>({
accessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
userCanAccess: false,
currentConversationId: '', currentConversationId: '',
appPrevChatTree: [], appPrevChatTree: [],
pinnedConversationList: [], pinnedConversationList: [],

@ -43,6 +43,7 @@ import { useAppFavicon } from '@/hooks/use-app-favicon'
import { InputVarType } from '@/app/components/workflow/types' import { InputVarType } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app' import { TransferMethod } from '@/types/app'
import { noop } from 'lodash-es' import { noop } from 'lodash-es'
import { useGetAppAccessMode, useGetUserCanAccessApp } from '@/service/access-control'
function getFormattedChatList(messages: any[]) { function getFormattedChatList(messages: any[]) {
const newChatList: ChatItem[] = [] const newChatList: ChatItem[] = []
@ -73,6 +74,8 @@ function getFormattedChatList(messages: any[]) {
export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
const isInstalledApp = useMemo(() => !!installedAppInfo, [installedAppInfo]) const isInstalledApp = useMemo(() => !!installedAppInfo, [installedAppInfo])
const { data: appInfo, isLoading: appInfoLoading, error: appInfoError } = useSWR(installedAppInfo ? null : 'appInfo', fetchAppInfo) const { data: appInfo, isLoading: appInfoLoading, error: appInfoError } = useSWR(installedAppInfo ? null : 'appInfo', fetchAppInfo)
const { isPending: isGettingAccessMode, data: appAccessMode } = useGetAppAccessMode({ appId: installedAppInfo?.app.id || appInfo?.app_id, isInstalledApp })
const { isPending: isCheckingPermission, data: userCanAccessResult } = useGetUserCanAccessApp({ appId: installedAppInfo?.app.id || appInfo?.app_id, isInstalledApp })
useAppFavicon({ useAppFavicon({
enable: !installedAppInfo, enable: !installedAppInfo,
@ -447,7 +450,9 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
return { return {
appInfoError, appInfoError,
appInfoLoading, appInfoLoading: appInfoLoading || isGettingAccessMode || isCheckingPermission,
accessMode: appAccessMode?.accessMode,
userCanAccess: userCanAccessResult?.result,
isInstalledApp, isInstalledApp,
appId, appId,
currentConversationId, currentConversationId,

@ -28,6 +28,7 @@ const ChatWithHistory: FC<ChatWithHistoryProps> = ({
className, className,
}) => { }) => {
const { const {
userCanAccess,
appInfoError, appInfoError,
appData, appData,
appInfoLoading, appInfoLoading,
@ -58,6 +59,8 @@ const ChatWithHistory: FC<ChatWithHistoryProps> = ({
<Loading type='app' /> <Loading type='app' />
) )
} }
if (!userCanAccess)
return <AppUnavailable code={403} unknownReason='no permission.' />
if (appInfoError) { if (appInfoError) {
return ( return (
@ -124,6 +127,8 @@ const ChatWithHistoryWrap: FC<ChatWithHistoryWrapProps> = ({
const { const {
appInfoError, appInfoError,
appInfoLoading, appInfoLoading,
accessMode,
userCanAccess,
appData, appData,
appParams, appParams,
appMeta, appMeta,
@ -166,6 +171,8 @@ const ChatWithHistoryWrap: FC<ChatWithHistoryWrapProps> = ({
appInfoError, appInfoError,
appInfoLoading, appInfoLoading,
appData, appData,
accessMode,
userCanAccess,
appParams, appParams,
appMeta, appMeta,
appChatListDataLoading, appChatListDataLoading,

@ -19,6 +19,7 @@ import RenameModal from '@/app/components/base/chat/chat-with-history/sidebar/re
import LogoSite from '@/app/components/base/logo/logo-site' import LogoSite from '@/app/components/base/logo/logo-site'
import type { ConversationItem } from '@/models/share' import type { ConversationItem } from '@/models/share'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
import { AccessMode } from '@/models/access-control'
type Props = { type Props = {
isPanel?: boolean isPanel?: boolean
@ -27,6 +28,8 @@ type Props = {
const Sidebar = ({ isPanel }: Props) => { const Sidebar = ({ isPanel }: Props) => {
const { t } = useTranslation() const { t } = useTranslation()
const { const {
isInstalledApp,
accessMode,
appData, appData,
handleNewConversation, handleNewConversation,
pinnedConversationList, pinnedConversationList,
@ -136,7 +139,7 @@ const Sidebar = ({ isPanel }: Props) => {
)} )}
</div> </div>
<div className='flex shrink-0 items-center justify-between p-3'> <div className='flex shrink-0 items-center justify-between p-3'>
<MenuDropdown placement='top-start' data={appData?.site} /> <MenuDropdown hideLogout={isInstalledApp || accessMode === AccessMode.PUBLIC} placement='top-start' data={appData?.site} />
{/* powered by */} {/* powered by */}
<div className='shrink-0'> <div className='shrink-0'>
{!appData?.custom_config?.remove_webapp_brand && ( {!appData?.custom_config?.remove_webapp_brand && (
@ -153,7 +156,6 @@ const Sidebar = ({ isPanel }: Props) => {
</div> </div>
)} )}
</div> </div>
</div>
{!!showConfirm && ( {!!showConfirm && (
<Confirm <Confirm
title={t('share.chat.deleteConversation.title')} title={t('share.chat.deleteConversation.title')}
@ -173,6 +175,7 @@ const Sidebar = ({ isPanel }: Props) => {
/> />
)} )}
</div> </div>
</div>
) )
} }

@ -15,8 +15,11 @@ import type {
ConversationItem, ConversationItem,
} from '@/models/share' } from '@/models/share'
import { noop } from 'lodash-es' import { noop } from 'lodash-es'
import { AccessMode } from '@/models/access-control'
export type EmbeddedChatbotContextValue = { export type EmbeddedChatbotContextValue = {
accessMode?: AccessMode
userCanAccess?: boolean
appInfoError?: any appInfoError?: any
appInfoLoading?: boolean appInfoLoading?: boolean
appMeta?: AppMeta appMeta?: AppMeta
@ -53,6 +56,8 @@ export type EmbeddedChatbotContextValue = {
} }
export const EmbeddedChatbotContext = createContext<EmbeddedChatbotContextValue>({ export const EmbeddedChatbotContext = createContext<EmbeddedChatbotContextValue>({
userCanAccess: false,
accessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
currentConversationId: '', currentConversationId: '',
appPrevChatList: [], appPrevChatList: [],
pinnedConversationList: [], pinnedConversationList: [],

@ -36,6 +36,7 @@ import { InputVarType } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app' import { TransferMethod } from '@/types/app'
import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils' import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils'
import { noop } from 'lodash-es' import { noop } from 'lodash-es'
import { useGetAppAccessMode, useGetUserCanAccessApp } from '@/service/access-control'
function getFormattedChatList(messages: any[]) { function getFormattedChatList(messages: any[]) {
const newChatList: ChatItem[] = [] const newChatList: ChatItem[] = []
@ -66,6 +67,8 @@ function getFormattedChatList(messages: any[]) {
export const useEmbeddedChatbot = () => { export const useEmbeddedChatbot = () => {
const isInstalledApp = false const isInstalledApp = false
const { data: appInfo, isLoading: appInfoLoading, error: appInfoError } = useSWR('appInfo', fetchAppInfo) const { data: appInfo, isLoading: appInfoLoading, error: appInfoError } = useSWR('appInfo', fetchAppInfo)
const { isPending: isGettingAccessMode, data: appAccessMode } = useGetAppAccessMode({ appId: appInfo?.app_id, isInstalledApp })
const { isPending: isCheckingPermission, data: userCanAccessResult } = useGetUserCanAccessApp({ appId: appInfo?.app_id, isInstalledApp })
const appData = useMemo(() => { const appData = useMemo(() => {
return appInfo return appInfo
@ -364,7 +367,9 @@ export const useEmbeddedChatbot = () => {
return { return {
appInfoError, appInfoError,
appInfoLoading, appInfoLoading: appInfoLoading || isGettingAccessMode || isCheckingPermission,
accessMode: appAccessMode?.accessMode,
userCanAccess: userCanAccessResult?.result,
isInstalledApp, isInstalledApp,
allowResetChat, allowResetChat,
appId, appId,

@ -24,6 +24,7 @@ import cn from '@/utils/classnames'
const Chatbot = () => { const Chatbot = () => {
const { const {
userCanAccess,
isMobile, isMobile,
allowResetChat, allowResetChat,
appInfoError, appInfoError,
@ -66,6 +67,9 @@ const Chatbot = () => {
) )
} }
if (!userCanAccess)
return <AppUnavailable code={403} unknownReason='no permission.' />
if (appInfoError) { if (appInfoError) {
return ( return (
<> <>
@ -137,6 +141,8 @@ const EmbeddedChatbotWrapper = () => {
appInfoError, appInfoError,
appInfoLoading, appInfoLoading,
appData, appData,
accessMode,
userCanAccess,
appParams, appParams,
appMeta, appMeta,
appChatListDataLoading, appChatListDataLoading,
@ -168,6 +174,8 @@ const EmbeddedChatbotWrapper = () => {
} = useEmbeddedChatbot() } = useEmbeddedChatbot()
return <EmbeddedChatbotContext.Provider value={{ return <EmbeddedChatbotContext.Provider value={{
userCanAccess,
accessMode,
appInfoError, appInfoError,
appInfoLoading, appInfoLoading,
appData, appData,

@ -1,6 +1,8 @@
import { UUID_NIL } from './constants' import { UUID_NIL } from './constants'
import type { IChatItem } from './chat/type' import type { IChatItem } from './chat/type'
import type { ChatItem, ChatItemInTree } from './types' import type { ChatItem, ChatItemInTree } from './types'
import { addFileInfos, sortAgentSorts } from '../../tools/utils'
import { getProcessedFilesFromResponse } from '../file-uploader/utils'
async function decodeBase64AndDecompress(base64String: string) { async function decodeBase64AndDecompress(base64String: string) {
try { try {
@ -40,6 +42,58 @@ async function getProcessedSystemVariablesFromUrlParams(): Promise<Record<string
) )
return systemVariables return systemVariables
} }
function appendQAToChatList(chatList: ChatItem[], item: any) {
// we append answer first and then question since will reverse the whole chatList later
const answerFiles = item.message_files?.filter((file: any) => file.belongs_to === 'assistant') || []
chatList.push({
id: item.id,
content: item.answer,
agent_thoughts: addFileInfos(item.agent_thoughts ? sortAgentSorts(item.agent_thoughts) : item.agent_thoughts, item.message_files),
feedback: item.feedback,
isAnswer: true,
citation: item.retriever_resources,
message_files: getProcessedFilesFromResponse(answerFiles.map((item: any) => ({ ...item, related_id: item.id }))),
})
const questionFiles = item.message_files?.filter((file: any) => file.belongs_to === 'user') || []
chatList.push({
id: `question-${item.id}`,
content: item.query,
isAnswer: false,
message_files: getProcessedFilesFromResponse(questionFiles.map((item: any) => ({ ...item, related_id: item.id }))),
})
}
/**
* Computes the latest thread messages from all messages of the conversation.
* Same logic as backend codebase `api/core/prompt/utils/extract_thread_messages.py`
*
* @param fetchedMessages - The history chat list data from the backend, sorted by created_at in descending order. This includes all flattened history messages of the conversation.
* @returns An array of ChatItems representing the latest thread.
*/
function getPrevChatList(fetchedMessages: any[]) {
const ret: ChatItem[] = []
let nextMessageId = null
for (const item of fetchedMessages) {
if (!item.parent_message_id) {
appendQAToChatList(ret, item)
break
}
if (!nextMessageId) {
appendQAToChatList(ret, item)
nextMessageId = item.parent_message_id
}
else {
if (item.id === nextMessageId || nextMessageId === UUID_NIL) {
appendQAToChatList(ret, item)
nextMessageId = item.parent_message_id
}
}
}
return ret.reverse()
}
function isValidGeneratedAnswer(item?: ChatItem | ChatItemInTree): boolean { function isValidGeneratedAnswer(item?: ChatItem | ChatItemInTree): boolean {
return !!item && item.isAnswer && !item.id.startsWith('answer-placeholder-') && !item.isOpeningStatement return !!item && item.isAnswer && !item.id.startsWith('answer-placeholder-') && !item.isOpeningStatement
@ -187,6 +241,7 @@ export {
getProcessedInputsFromUrlParams, getProcessedInputsFromUrlParams,
getProcessedSystemVariablesFromUrlParams, getProcessedSystemVariablesFromUrlParams,
isValidGeneratedAnswer, isValidGeneratedAnswer,
getPrevChatList,
getLastAnswer, getLastAnswer,
buildChatItemTree, buildChatItemTree,
getThreadMessages, getThreadMessages,

@ -2,6 +2,7 @@
import type { FC } from 'react' import type { FC } from 'react'
import { basePath } from '@/utils/var' import { basePath } from '@/utils/var'
import classNames from '@/utils/classnames' import classNames from '@/utils/classnames'
import { useGlobalPublicStore } from '@/context/global-public-context'
type LogoSiteProps = { type LogoSiteProps = {
className?: string className?: string
@ -10,9 +11,15 @@ type LogoSiteProps = {
const LogoSite: FC<LogoSiteProps> = ({ const LogoSite: FC<LogoSiteProps> = ({
className, className,
}) => { }) => {
const { systemFeatures } = useGlobalPublicStore()
let src = `${basePath}/logo/logo.png`
if (systemFeatures.branding.enabled)
src = systemFeatures.branding.workspace_logo
return ( return (
<img <img
src={`${basePath}/logo/logo.png`} src={src}
className={classNames('block w-[22.651px] h-[24.5px]', className)} className={classNames('block w-[22.651px] h-[24.5px]', className)}
alt='logo' alt='logo'
/> />

@ -476,15 +476,15 @@ const Flowchart = React.forwardRef((props: {
'bg-white': currentTheme === Theme.light, 'bg-white': currentTheme === Theme.light,
'bg-slate-900': currentTheme === Theme.dark, 'bg-slate-900': currentTheme === Theme.dark,
}), }),
mermaidDiv: cn('mermaid cursor-pointer h-auto w-full relative', { mermaidDiv: cn('mermaid relative h-auto w-full cursor-pointer', {
'bg-white': currentTheme === Theme.light, 'bg-white': currentTheme === Theme.light,
'bg-slate-900': currentTheme === Theme.dark, 'bg-slate-900': currentTheme === Theme.dark,
}), }),
errorMessage: cn('py-4 px-[26px]', { errorMessage: cn('px-[26px] py-4', {
'text-red-500': currentTheme === Theme.light, 'text-red-500': currentTheme === Theme.light,
'text-red-400': currentTheme === Theme.dark, 'text-red-400': currentTheme === Theme.dark,
}), }),
errorIcon: cn('w-6 h-6', { errorIcon: cn('h-6 w-6', {
'text-red-500': currentTheme === Theme.light, 'text-red-500': currentTheme === Theme.light,
'text-red-400': currentTheme === Theme.dark, 'text-red-400': currentTheme === Theme.dark,
}), }),
@ -492,7 +492,7 @@ const Flowchart = React.forwardRef((props: {
'text-gray-700': currentTheme === Theme.light, 'text-gray-700': currentTheme === Theme.light,
'text-gray-300': currentTheme === Theme.dark, 'text-gray-300': currentTheme === Theme.dark,
}), }),
themeToggle: cn('flex items-center justify-center w-10 h-10 rounded-full transition-all duration-300 shadow-md backdrop-blur-sm', { themeToggle: cn('flex h-10 w-10 items-center justify-center rounded-full shadow-md backdrop-blur-sm transition-all duration-300', {
'bg-white/80 hover:bg-white hover:shadow-lg text-gray-700 border border-gray-200': currentTheme === Theme.light, 'bg-white/80 hover:bg-white hover:shadow-lg text-gray-700 border border-gray-200': currentTheme === Theme.light,
'bg-slate-800/80 hover:bg-slate-700 hover:shadow-lg text-yellow-300 border border-slate-600': currentTheme === Theme.dark, 'bg-slate-800/80 hover:bg-slate-700 hover:shadow-lg text-yellow-300 border border-slate-600': currentTheme === Theme.dark,
}), }),
@ -501,7 +501,7 @@ const Flowchart = React.forwardRef((props: {
// Style classes for look options // Style classes for look options
const getLookButtonClass = (lookType: 'classic' | 'handDrawn') => { const getLookButtonClass = (lookType: 'classic' | 'handDrawn') => {
return cn( return cn(
'flex items-center justify-center mb-4 w-[calc((100%-8px)/2)] h-8 rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg cursor-pointer system-sm-medium text-text-secondary', 'system-sm-medium mb-4 flex h-8 w-[calc((100%-8px)/2)] cursor-pointer items-center justify-center rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg text-text-secondary',
look === lookType && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary', look === lookType && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary',
currentTheme === Theme.dark && 'border-slate-600 bg-slate-800 text-slate-300', currentTheme === Theme.dark && 'border-slate-600 bg-slate-800 text-slate-300',
look === lookType && currentTheme === Theme.dark && 'border-blue-500 bg-slate-700 text-white', look === lookType && currentTheme === Theme.dark && 'border-blue-500 bg-slate-700 text-white',
@ -512,7 +512,7 @@ const Flowchart = React.forwardRef((props: {
<div ref={ref as React.RefObject<HTMLDivElement>} className={themeClasses.container}> <div ref={ref as React.RefObject<HTMLDivElement>} className={themeClasses.container}>
<div className={themeClasses.segmented}> <div className={themeClasses.segmented}>
<div className="msh-segmented-group"> <div className="msh-segmented-group">
<label className="msh-segmented-item flex items-center space-x-1 m-2 w-[200px]"> <label className="msh-segmented-item m-2 flex w-[200px] items-center space-x-1">
<div <div
key='classic' key='classic'
className={getLookButtonClass('classic')} className={getLookButtonClass('classic')}
@ -534,7 +534,7 @@ const Flowchart = React.forwardRef((props: {
<div ref={containerRef} style={{ position: 'absolute', visibility: 'hidden', height: 0, overflow: 'hidden' }} /> <div ref={containerRef} style={{ position: 'absolute', visibility: 'hidden', height: 0, overflow: 'hidden' }} />
{isLoading && !svgCode && ( {isLoading && !svgCode && (
<div className='py-4 px-[26px]'> <div className='px-[26px] py-4'>
<LoadingAnim type='text'/> <LoadingAnim type='text'/>
{!isCodeComplete && ( {!isCodeComplete && (
<div className="mt-2 text-sm text-gray-500"> <div className="mt-2 text-sm text-gray-500">
@ -546,7 +546,7 @@ const Flowchart = React.forwardRef((props: {
{svgCode && ( {svgCode && (
<div className={themeClasses.mermaidDiv} style={{ objectFit: 'cover' }} onClick={() => setImagePreviewUrl(svgCode)}> <div className={themeClasses.mermaidDiv} style={{ objectFit: 'cover' }} onClick={() => setImagePreviewUrl(svgCode)}>
<div className="absolute left-2 bottom-2 z-[100]"> <div className="absolute bottom-2 left-2 z-[100]">
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()

@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { SVG } from '@svgdotjs/svg.js' import { SVG } from '@svgdotjs/svg.js'
import ImagePreview from '@/app/components/base/image-uploader/image-preview'
import DOMPurify from 'dompurify' import DOMPurify from 'dompurify'
import ImagePreview from '@/app/components/base/image-uploader/image-preview'
export const SVGRenderer = ({ content }: { content: string }) => { export const SVGRenderer = ({ content }: { content: string }) => {
const svgRef = useRef<HTMLDivElement>(null) const svgRef = useRef<HTMLDivElement>(null)

@ -92,6 +92,7 @@ const Tooltip: FC<TooltipProps> = ({
}} }}
onMouseLeave={() => triggerMethod === 'hover' && handleLeave(true)} onMouseLeave={() => triggerMethod === 'hover' && handleLeave(true)}
asChild={asChild} asChild={asChild}
className={!asChild ? triggerClassName : ''}
> >
{children || <div data-testid={triggerTestId} className={triggerClassName || 'h-3.5 w-3.5 shrink-0 p-[1px]'}><RiQuestionLine className='h-full w-full text-text-quaternary hover:text-text-tertiary' /></div>} {children || <div data-testid={triggerTestId} className={triggerClassName || 'h-3.5 w-3.5 shrink-0 p-[1px]'}><RiQuestionLine className='h-full w-full text-text-quaternary hover:text-text-tertiary' /></div>}
</PortalToFollowElemTrigger> </PortalToFollowElemTrigger>

@ -94,6 +94,11 @@ export type CurrentPlanInfoBackend = {
education: { education: {
enabled: boolean enabled: boolean
activated: boolean activated: boolean
},
webapp_copyright_enabled: boolean
workspace_members: {
size: number
limit: number
} }
} }

@ -21,6 +21,7 @@ import VectorSpaceFull from '@/app/components/billing/vector-space-full'
import classNames from '@/utils/classnames' import classNames from '@/utils/classnames'
import { Icon3Dots } from '@/app/components/base/icons/src/vender/line/others' import { Icon3Dots } from '@/app/components/base/icons/src/vender/line/others'
import { ENABLE_WEBSITE_FIRECRAWL, ENABLE_WEBSITE_JINAREADER, ENABLE_WEBSITE_WATERCRAWL } from '@/config' import { ENABLE_WEBSITE_FIRECRAWL, ENABLE_WEBSITE_JINAREADER, ENABLE_WEBSITE_WATERCRAWL } from '@/config'
type IStepOneProps = { type IStepOneProps = {
datasetId?: string datasetId?: string
dataSourceType?: DataSourceType dataSourceType?: DataSourceType
@ -45,7 +46,8 @@ type IStepOneProps = {
type NotionConnectorProps = { type NotionConnectorProps = {
onSetting: () => void onSetting: () => void
} }
export const NotionConnector = ({ onSetting }: NotionConnectorProps) => { export const NotionConnector = (props: NotionConnectorProps) => {
const { onSetting } = props
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
@ -162,7 +164,7 @@ const StepOne = ({
> >
<span className={cn(s.datasetIcon)} /> <span className={cn(s.datasetIcon)} />
<span <span
title={t('datasetCreation.stepOne.dataSourceType.file')} title={t('datasetCreation.stepOne.dataSourceType.file')!}
className='truncate' className='truncate'
> >
{t('datasetCreation.stepOne.dataSourceType.file')} {t('datasetCreation.stepOne.dataSourceType.file')}
@ -185,7 +187,7 @@ const StepOne = ({
> >
<span className={cn(s.datasetIcon, s.notion)} /> <span className={cn(s.datasetIcon, s.notion)} />
<span <span
title={t('datasetCreation.stepOne.dataSourceType.notion')} title={t('datasetCreation.stepOne.dataSourceType.notion')!}
className='truncate' className='truncate'
> >
{t('datasetCreation.stepOne.dataSourceType.notion')} {t('datasetCreation.stepOne.dataSourceType.notion')}
@ -203,7 +205,7 @@ const StepOne = ({
> >
<span className={cn(s.datasetIcon, s.web)} /> <span className={cn(s.datasetIcon, s.web)} />
<span <span
title={t('datasetCreation.stepOne.dataSourceType.web')} title={t('datasetCreation.stepOne.dataSourceType.web')!}
className='truncate' className='truncate'
> >
{t('datasetCreation.stepOne.dataSourceType.web')} {t('datasetCreation.stepOne.dataSourceType.web')}

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

Loading…
Cancel
Save