mirror of
https://github.com/langgenius/dify.git
synced 2026-01-08 15:24:14 +00:00
Compare commits
17 Commits
1.8.0
...
fix/model-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1859c280f | ||
|
|
56c51f0112 | ||
|
|
4adf85d7d4 | ||
|
|
7995ff1410 | ||
|
|
d2f234757b | ||
|
|
bf34437186 | ||
|
|
47f02eec96 | ||
|
|
06dd4d6e00 | ||
|
|
fbceda7b66 | ||
|
|
9d6ce3065d | ||
|
|
bb718acadf | ||
|
|
4cd00efe3b | ||
|
|
22b11e4b43 | ||
|
|
2a29c61041 | ||
|
|
34b041e9f0 | ||
|
|
917ed8cf84 | ||
|
|
85b0b8373b |
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
npm add -g pnpm@10.15.0
|
||||
corepack enable
|
||||
cd web && pnpm install
|
||||
pipx install uv
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import logging
|
||||
from flask_login import current_user
|
||||
from flask_restx import Resource, fields, marshal_with, reqparse
|
||||
from flask_restx.inputs import int_range
|
||||
from sqlalchemy import exists, select
|
||||
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound
|
||||
|
||||
from controllers.console import api
|
||||
@@ -94,21 +95,18 @@ class ChatMessageListApi(Resource):
|
||||
.all()
|
||||
)
|
||||
|
||||
has_more = False
|
||||
if len(history_messages) == args["limit"]:
|
||||
current_page_first_message = history_messages[-1]
|
||||
rest_count = (
|
||||
db.session.query(Message)
|
||||
.where(
|
||||
|
||||
has_more = db.session.scalar(
|
||||
select(
|
||||
exists().where(
|
||||
Message.conversation_id == conversation.id,
|
||||
Message.created_at < current_page_first_message.created_at,
|
||||
Message.id != current_page_first_message.id,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
if rest_count > 0:
|
||||
has_more = True
|
||||
)
|
||||
|
||||
history_messages = list(reversed(history_messages))
|
||||
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
from flask import Blueprint
|
||||
from flask_restx import Namespace
|
||||
|
||||
from libs.external_api import ExternalApi
|
||||
|
||||
from .files import FileApi
|
||||
from .remote_files import RemoteFileInfoApi, RemoteFileUploadApi
|
||||
|
||||
bp = Blueprint("web", __name__, url_prefix="/api")
|
||||
api = ExternalApi(bp)
|
||||
|
||||
# Files
|
||||
api.add_resource(FileApi, "/files/upload")
|
||||
api = ExternalApi(
|
||||
bp,
|
||||
version="1.0",
|
||||
title="Web API",
|
||||
description="Public APIs for web applications including file uploads, chat interactions, and app management",
|
||||
doc="/docs", # Enable Swagger UI at /api/docs
|
||||
)
|
||||
|
||||
# Remote files
|
||||
api.add_resource(RemoteFileInfoApi, "/remote-files/<path:url>")
|
||||
api.add_resource(RemoteFileUploadApi, "/remote-files/upload")
|
||||
# Create namespace
|
||||
web_ns = Namespace("web", description="Web application API operations", path="/")
|
||||
|
||||
from . import (
|
||||
app,
|
||||
@@ -21,11 +22,15 @@ from . import (
|
||||
completion,
|
||||
conversation,
|
||||
feature,
|
||||
files,
|
||||
forgot_password,
|
||||
login,
|
||||
message,
|
||||
passport,
|
||||
remote_files,
|
||||
saved_message,
|
||||
site,
|
||||
workflow,
|
||||
)
|
||||
|
||||
api.add_namespace(web_ns)
|
||||
|
||||
@@ -5,7 +5,7 @@ from flask_restx import Resource, marshal_with, reqparse
|
||||
from werkzeug.exceptions import Unauthorized
|
||||
|
||||
from controllers.common import fields
|
||||
from controllers.web import api
|
||||
from controllers.web import web_ns
|
||||
from controllers.web.error import AppUnavailableError
|
||||
from controllers.web.wraps import WebApiResource
|
||||
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
|
||||
@@ -19,9 +19,22 @@ from services.webapp_auth_service import WebAppAuthService
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@web_ns.route("/parameters")
|
||||
class AppParameterApi(WebApiResource):
|
||||
"""Resource for app variables."""
|
||||
|
||||
@web_ns.doc("Get App Parameters")
|
||||
@web_ns.doc(description="Retrieve the parameters for a specific app.")
|
||||
@web_ns.doc(
|
||||
responses={
|
||||
200: "Success",
|
||||
400: "Bad Request",
|
||||
401: "Unauthorized",
|
||||
403: "Forbidden",
|
||||
404: "App Not Found",
|
||||
500: "Internal Server Error",
|
||||
}
|
||||
)
|
||||
@marshal_with(fields.parameters_fields)
|
||||
def get(self, app_model: App, end_user):
|
||||
"""Retrieve app parameters."""
|
||||
@@ -44,13 +57,42 @@ class AppParameterApi(WebApiResource):
|
||||
return get_parameters_from_feature_dict(features_dict=features_dict, user_input_form=user_input_form)
|
||||
|
||||
|
||||
@web_ns.route("/meta")
|
||||
class AppMeta(WebApiResource):
|
||||
@web_ns.doc("Get App Meta")
|
||||
@web_ns.doc(description="Retrieve the metadata for a specific app.")
|
||||
@web_ns.doc(
|
||||
responses={
|
||||
200: "Success",
|
||||
400: "Bad Request",
|
||||
401: "Unauthorized",
|
||||
403: "Forbidden",
|
||||
404: "App Not Found",
|
||||
500: "Internal Server Error",
|
||||
}
|
||||
)
|
||||
def get(self, app_model: App, end_user):
|
||||
"""Get app meta"""
|
||||
return AppService().get_app_meta(app_model)
|
||||
|
||||
|
||||
@web_ns.route("/webapp/access-mode")
|
||||
class AppAccessMode(Resource):
|
||||
@web_ns.doc("Get App Access Mode")
|
||||
@web_ns.doc(description="Retrieve the access mode for a web application (public or restricted).")
|
||||
@web_ns.doc(
|
||||
params={
|
||||
"appId": {"description": "Application ID", "type": "string", "required": False},
|
||||
"appCode": {"description": "Application code", "type": "string", "required": False},
|
||||
}
|
||||
)
|
||||
@web_ns.doc(
|
||||
responses={
|
||||
200: "Success",
|
||||
400: "Bad Request",
|
||||
500: "Internal Server Error",
|
||||
}
|
||||
)
|
||||
def get(self):
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument("appId", type=str, required=False, location="args")
|
||||
@@ -74,7 +116,19 @@ class AppAccessMode(Resource):
|
||||
return {"accessMode": res.access_mode}
|
||||
|
||||
|
||||
@web_ns.route("/webapp/permission")
|
||||
class AppWebAuthPermission(Resource):
|
||||
@web_ns.doc("Check App Permission")
|
||||
@web_ns.doc(description="Check if user has permission to access a web application.")
|
||||
@web_ns.doc(params={"appId": {"description": "Application ID", "type": "string", "required": True}})
|
||||
@web_ns.doc(
|
||||
responses={
|
||||
200: "Success",
|
||||
400: "Bad Request",
|
||||
401: "Unauthorized",
|
||||
500: "Internal Server Error",
|
||||
}
|
||||
)
|
||||
def get(self):
|
||||
user_id = "visitor"
|
||||
try:
|
||||
@@ -112,10 +166,3 @@ class AppWebAuthPermission(Resource):
|
||||
if WebAppAuthService.is_app_require_permission_check(app_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(AppMeta, "/meta")
|
||||
# webapp auth apis
|
||||
api.add_resource(AppAccessMode, "/webapp/access-mode")
|
||||
api.add_resource(AppWebAuthPermission, "/webapp/permission")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
|
||||
from flask import request
|
||||
from flask_restx import fields, marshal_with, reqparse
|
||||
from werkzeug.exceptions import InternalServerError
|
||||
|
||||
import services
|
||||
@@ -32,7 +33,26 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AudioApi(WebApiResource):
|
||||
audio_to_text_response_fields = {
|
||||
"text": fields.String,
|
||||
}
|
||||
|
||||
@marshal_with(audio_to_text_response_fields)
|
||||
@api.doc("Audio to Text")
|
||||
@api.doc(description="Convert audio file to text using speech-to-text service.")
|
||||
@api.doc(
|
||||
responses={
|
||||
200: "Success",
|
||||
400: "Bad Request",
|
||||
401: "Unauthorized",
|
||||
403: "Forbidden",
|
||||
413: "Audio file too large",
|
||||
415: "Unsupported audio type",
|
||||
500: "Internal Server Error",
|
||||
}
|
||||
)
|
||||
def post(self, app_model: App, end_user):
|
||||
"""Convert audio to text"""
|
||||
file = request.files["file"]
|
||||
|
||||
try:
|
||||
@@ -66,9 +86,25 @@ class AudioApi(WebApiResource):
|
||||
|
||||
|
||||
class TextApi(WebApiResource):
|
||||
def post(self, app_model: App, end_user):
|
||||
from flask_restx import reqparse
|
||||
text_to_audio_response_fields = {
|
||||
"audio_url": fields.String,
|
||||
"duration": fields.Float,
|
||||
}
|
||||
|
||||
@marshal_with(text_to_audio_response_fields)
|
||||
@api.doc("Text to Audio")
|
||||
@api.doc(description="Convert text to audio using text-to-speech service.")
|
||||
@api.doc(
|
||||
responses={
|
||||
200: "Success",
|
||||
400: "Bad Request",
|
||||
401: "Unauthorized",
|
||||
403: "Forbidden",
|
||||
500: "Internal Server Error",
|
||||
}
|
||||
)
|
||||
def post(self, app_model: App, end_user):
|
||||
"""Convert text to audio"""
|
||||
try:
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument("message_id", type=str, required=False, location="json")
|
||||
|
||||
@@ -36,6 +36,32 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# define completion api for user
|
||||
class CompletionApi(WebApiResource):
|
||||
@api.doc("Create Completion Message")
|
||||
@api.doc(description="Create a completion message for text generation applications.")
|
||||
@api.doc(
|
||||
params={
|
||||
"inputs": {"description": "Input variables for the completion", "type": "object", "required": True},
|
||||
"query": {"description": "Query text for completion", "type": "string", "required": False},
|
||||
"files": {"description": "Files to be processed", "type": "array", "required": False},
|
||||
"response_mode": {
|
||||
"description": "Response mode: blocking or streaming",
|
||||
"type": "string",
|
||||
"enum": ["blocking", "streaming"],
|
||||
"required": False,
|
||||
},
|
||||
"retriever_from": {"description": "Source of retriever", "type": "string", "required": False},
|
||||
}
|
||||
)
|
||||
@api.doc(
|
||||
responses={
|
||||
200: "Success",
|
||||
400: "Bad Request",
|
||||
401: "Unauthorized",
|
||||
403: "Forbidden",
|
||||
404: "App Not Found",
|
||||
500: "Internal Server Error",
|
||||
}
|
||||
)
|
||||
def post(self, app_model, end_user):
|
||||
if app_model.mode != "completion":
|
||||
raise NotCompletionAppError()
|
||||
@@ -81,6 +107,19 @@ class CompletionApi(WebApiResource):
|
||||
|
||||
|
||||
class CompletionStopApi(WebApiResource):
|
||||
@api.doc("Stop Completion Message")
|
||||
@api.doc(description="Stop a running completion message task.")
|
||||
@api.doc(params={"task_id": {"description": "Task ID to stop", "type": "string", "required": True}})
|
||||
@api.doc(
|
||||
responses={
|
||||
200: "Success",
|
||||
400: "Bad Request",
|
||||
401: "Unauthorized",
|
||||
403: "Forbidden",
|
||||
404: "Task Not Found",
|
||||
500: "Internal Server Error",
|
||||
}
|
||||
)
|
||||
def post(self, app_model, end_user, task_id):
|
||||
if app_model.mode != "completion":
|
||||
raise NotCompletionAppError()
|
||||
@@ -91,6 +130,34 @@ class CompletionStopApi(WebApiResource):
|
||||
|
||||
|
||||
class ChatApi(WebApiResource):
|
||||
@api.doc("Create Chat Message")
|
||||
@api.doc(description="Create a chat message for conversational applications.")
|
||||
@api.doc(
|
||||
params={
|
||||
"inputs": {"description": "Input variables for the chat", "type": "object", "required": True},
|
||||
"query": {"description": "User query/message", "type": "string", "required": True},
|
||||
"files": {"description": "Files to be processed", "type": "array", "required": False},
|
||||
"response_mode": {
|
||||
"description": "Response mode: blocking or streaming",
|
||||
"type": "string",
|
||||
"enum": ["blocking", "streaming"],
|
||||
"required": False,
|
||||
},
|
||||
"conversation_id": {"description": "Conversation UUID", "type": "string", "required": False},
|
||||
"parent_message_id": {"description": "Parent message UUID", "type": "string", "required": False},
|
||||
"retriever_from": {"description": "Source of retriever", "type": "string", "required": False},
|
||||
}
|
||||
)
|
||||
@api.doc(
|
||||
responses={
|
||||
200: "Success",
|
||||
400: "Bad Request",
|
||||
401: "Unauthorized",
|
||||
403: "Forbidden",
|
||||
404: "App Not Found",
|
||||
500: "Internal Server Error",
|
||||
}
|
||||
)
|
||||
def post(self, app_model, end_user):
|
||||
app_mode = AppMode.value_of(app_model.mode)
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
|
||||
@@ -141,6 +208,19 @@ class ChatApi(WebApiResource):
|
||||
|
||||
|
||||
class ChatStopApi(WebApiResource):
|
||||
@api.doc("Stop Chat Message")
|
||||
@api.doc(description="Stop a running chat message task.")
|
||||
@api.doc(params={"task_id": {"description": "Task ID to stop", "type": "string", "required": True}})
|
||||
@api.doc(
|
||||
responses={
|
||||
200: "Success",
|
||||
400: "Bad Request",
|
||||
401: "Unauthorized",
|
||||
403: "Forbidden",
|
||||
404: "Task Not Found",
|
||||
500: "Internal Server Error",
|
||||
}
|
||||
)
|
||||
def post(self, app_model, end_user, task_id):
|
||||
app_mode = AppMode.value_of(app_model.mode)
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from flask_restx import marshal_with, reqparse
|
||||
from flask_restx import fields, marshal_with, reqparse
|
||||
from flask_restx.inputs import int_range
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import NotFound
|
||||
@@ -58,6 +58,11 @@ class ConversationListApi(WebApiResource):
|
||||
|
||||
|
||||
class ConversationApi(WebApiResource):
|
||||
delete_response_fields = {
|
||||
"result": fields.String,
|
||||
}
|
||||
|
||||
@marshal_with(delete_response_fields)
|
||||
def delete(self, app_model, end_user, c_id):
|
||||
app_mode = AppMode.value_of(app_model.mode)
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
|
||||
@@ -94,6 +99,11 @@ class ConversationRenameApi(WebApiResource):
|
||||
|
||||
|
||||
class ConversationPinApi(WebApiResource):
|
||||
pin_response_fields = {
|
||||
"result": fields.String,
|
||||
}
|
||||
|
||||
@marshal_with(pin_response_fields)
|
||||
def patch(self, app_model, end_user, c_id):
|
||||
app_mode = AppMode.value_of(app_model.mode)
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
|
||||
@@ -110,6 +120,11 @@ class ConversationPinApi(WebApiResource):
|
||||
|
||||
|
||||
class ConversationUnPinApi(WebApiResource):
|
||||
unpin_response_fields = {
|
||||
"result": fields.String,
|
||||
}
|
||||
|
||||
@marshal_with(unpin_response_fields)
|
||||
def patch(self, app_model, end_user, c_id):
|
||||
app_mode = AppMode.value_of(app_model.mode)
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
from flask_restx import Resource
|
||||
|
||||
from controllers.web import api
|
||||
from controllers.web import web_ns
|
||||
from services.feature_service import FeatureService
|
||||
|
||||
|
||||
@web_ns.route("/system-features")
|
||||
class SystemFeatureApi(Resource):
|
||||
@web_ns.doc("get_system_features")
|
||||
@web_ns.doc(description="Get system feature flags and configuration")
|
||||
@web_ns.doc(responses={200: "System features retrieved successfully", 500: "Internal server error"})
|
||||
def get(self):
|
||||
"""Get system feature flags and configuration.
|
||||
|
||||
Returns the current system feature flags and configuration
|
||||
that control various functionalities across the platform.
|
||||
|
||||
Returns:
|
||||
dict: System feature configuration object
|
||||
"""
|
||||
return FeatureService.get_system_features().model_dump()
|
||||
|
||||
|
||||
api.add_resource(SystemFeatureApi, "/system-features")
|
||||
|
||||
@@ -9,14 +9,50 @@ from controllers.common.errors import (
|
||||
TooManyFilesError,
|
||||
UnsupportedFileTypeError,
|
||||
)
|
||||
from controllers.web import web_ns
|
||||
from controllers.web.wraps import WebApiResource
|
||||
from fields.file_fields import file_fields
|
||||
from fields.file_fields import build_file_model
|
||||
from services.file_service import FileService
|
||||
|
||||
|
||||
@web_ns.route("/files/upload")
|
||||
class FileApi(WebApiResource):
|
||||
@marshal_with(file_fields)
|
||||
@web_ns.doc("upload_file")
|
||||
@web_ns.doc(description="Upload a file for use in web applications")
|
||||
@web_ns.doc(
|
||||
responses={
|
||||
201: "File uploaded successfully",
|
||||
400: "Bad request - invalid file or parameters",
|
||||
413: "File too large",
|
||||
415: "Unsupported file type",
|
||||
}
|
||||
)
|
||||
@marshal_with(build_file_model(web_ns))
|
||||
def post(self, app_model, end_user):
|
||||
"""Upload a file for use in web applications.
|
||||
|
||||
Accepts file uploads for use within web applications, supporting
|
||||
multiple file types with automatic validation and storage.
|
||||
|
||||
Args:
|
||||
app_model: The associated application model
|
||||
end_user: The end user uploading the file
|
||||
|
||||
Form Parameters:
|
||||
file: The file to upload (required)
|
||||
source: Optional source type (datasets or None)
|
||||
|
||||
Returns:
|
||||
dict: File information including ID, URL, and metadata
|
||||
int: HTTP status code 201 for success
|
||||
|
||||
Raises:
|
||||
NoFileUploadedError: No file provided in request
|
||||
TooManyFilesError: Multiple files provided (only one allowed)
|
||||
FilenameNotExistsError: File has no filename
|
||||
FileTooLargeError: File exceeds size limit
|
||||
UnsupportedFileTypeError: File type not supported
|
||||
"""
|
||||
if "file" not in request.files:
|
||||
raise NoFileUploadedError()
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ from controllers.console.auth.error import (
|
||||
)
|
||||
from controllers.console.error import EmailSendIpLimitError
|
||||
from controllers.console.wraps import email_password_login_enabled, only_edition_enterprise, setup_required
|
||||
from controllers.web import api
|
||||
from controllers.web import web_ns
|
||||
from extensions.ext_database import db
|
||||
from libs.helper import email, extract_remote_ip
|
||||
from libs.password import hash_password, valid_password
|
||||
@@ -24,10 +24,21 @@ from models.account import Account
|
||||
from services.account_service import AccountService
|
||||
|
||||
|
||||
@web_ns.route("/forgot-password")
|
||||
class ForgotPasswordSendEmailApi(Resource):
|
||||
@only_edition_enterprise
|
||||
@setup_required
|
||||
@email_password_login_enabled
|
||||
@web_ns.doc("send_forgot_password_email")
|
||||
@web_ns.doc(description="Send password reset email")
|
||||
@web_ns.doc(
|
||||
responses={
|
||||
200: "Password reset email sent successfully",
|
||||
400: "Bad request - invalid email format",
|
||||
404: "Account not found",
|
||||
429: "Too many requests - rate limit exceeded",
|
||||
}
|
||||
)
|
||||
def post(self):
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument("email", type=email, required=True, location="json")
|
||||
@@ -54,10 +65,16 @@ class ForgotPasswordSendEmailApi(Resource):
|
||||
return {"result": "success", "data": token}
|
||||
|
||||
|
||||
@web_ns.route("/forgot-password/validity")
|
||||
class ForgotPasswordCheckApi(Resource):
|
||||
@only_edition_enterprise
|
||||
@setup_required
|
||||
@email_password_login_enabled
|
||||
@web_ns.doc("check_forgot_password_token")
|
||||
@web_ns.doc(description="Verify password reset token validity")
|
||||
@web_ns.doc(
|
||||
responses={200: "Token is valid", 400: "Bad request - invalid token format", 401: "Invalid or expired token"}
|
||||
)
|
||||
def post(self):
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument("email", type=str, required=True, location="json")
|
||||
@@ -94,10 +111,21 @@ class ForgotPasswordCheckApi(Resource):
|
||||
return {"is_valid": True, "email": token_data.get("email"), "token": new_token}
|
||||
|
||||
|
||||
@web_ns.route("/forgot-password/resets")
|
||||
class ForgotPasswordResetApi(Resource):
|
||||
@only_edition_enterprise
|
||||
@setup_required
|
||||
@email_password_login_enabled
|
||||
@web_ns.doc("reset_password")
|
||||
@web_ns.doc(description="Reset user password with verification token")
|
||||
@web_ns.doc(
|
||||
responses={
|
||||
200: "Password reset successfully",
|
||||
400: "Bad request - invalid parameters or password mismatch",
|
||||
401: "Invalid or expired token",
|
||||
404: "Account not found",
|
||||
}
|
||||
)
|
||||
def post(self):
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument("token", type=str, required=True, nullable=False, location="json")
|
||||
@@ -141,8 +169,3 @@ class ForgotPasswordResetApi(Resource):
|
||||
account.password = base64.b64encode(password_hashed).decode()
|
||||
account.password_salt = base64.b64encode(salt).decode()
|
||||
session.commit()
|
||||
|
||||
|
||||
api.add_resource(ForgotPasswordSendEmailApi, "/forgot-password")
|
||||
api.add_resource(ForgotPasswordCheckApi, "/forgot-password/validity")
|
||||
api.add_resource(ForgotPasswordResetApi, "/forgot-password/resets")
|
||||
|
||||
@@ -9,18 +9,30 @@ from controllers.console.auth.error import (
|
||||
)
|
||||
from controllers.console.error import AccountBannedError
|
||||
from controllers.console.wraps import only_edition_enterprise, setup_required
|
||||
from controllers.web import api
|
||||
from controllers.web import web_ns
|
||||
from libs.helper import email
|
||||
from libs.password import valid_password
|
||||
from services.account_service import AccountService
|
||||
from services.webapp_auth_service import WebAppAuthService
|
||||
|
||||
|
||||
@web_ns.route("/login")
|
||||
class LoginApi(Resource):
|
||||
"""Resource for web app email/password login."""
|
||||
|
||||
@setup_required
|
||||
@only_edition_enterprise
|
||||
@web_ns.doc("web_app_login")
|
||||
@web_ns.doc(description="Authenticate user for web application access")
|
||||
@web_ns.doc(
|
||||
responses={
|
||||
200: "Authentication successful",
|
||||
400: "Bad request - invalid email or password format",
|
||||
401: "Authentication failed - email or password mismatch",
|
||||
403: "Account banned or login disabled",
|
||||
404: "Account not found",
|
||||
}
|
||||
)
|
||||
def post(self):
|
||||
"""Authenticate user and login."""
|
||||
parser = reqparse.RequestParser()
|
||||
@@ -51,9 +63,19 @@ class LoginApi(Resource):
|
||||
# return {"result": "success"}
|
||||
|
||||
|
||||
@web_ns.route("/email-code-login")
|
||||
class EmailCodeLoginSendEmailApi(Resource):
|
||||
@setup_required
|
||||
@only_edition_enterprise
|
||||
@web_ns.doc("send_email_code_login")
|
||||
@web_ns.doc(description="Send email verification code for login")
|
||||
@web_ns.doc(
|
||||
responses={
|
||||
200: "Email code sent successfully",
|
||||
400: "Bad request - invalid email format",
|
||||
404: "Account not found",
|
||||
}
|
||||
)
|
||||
def post(self):
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument("email", type=email, required=True, location="json")
|
||||
@@ -74,9 +96,20 @@ class EmailCodeLoginSendEmailApi(Resource):
|
||||
return {"result": "success", "data": token}
|
||||
|
||||
|
||||
@web_ns.route("/email-code-login/validity")
|
||||
class EmailCodeLoginApi(Resource):
|
||||
@setup_required
|
||||
@only_edition_enterprise
|
||||
@web_ns.doc("verify_email_code_login")
|
||||
@web_ns.doc(description="Verify email code and complete login")
|
||||
@web_ns.doc(
|
||||
responses={
|
||||
200: "Email code verified and login successful",
|
||||
400: "Bad request - invalid code or token",
|
||||
401: "Invalid token or expired code",
|
||||
404: "Account not found",
|
||||
}
|
||||
)
|
||||
def post(self):
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument("email", type=str, required=True, location="json")
|
||||
@@ -104,9 +137,3 @@ class EmailCodeLoginApi(Resource):
|
||||
token = WebAppAuthService.login(account=account)
|
||||
AccountService.reset_login_error_rate_limit(args["email"])
|
||||
return {"result": "success", "data": {"access_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")
|
||||
|
||||
@@ -85,6 +85,11 @@ class MessageListApi(WebApiResource):
|
||||
|
||||
|
||||
class MessageFeedbackApi(WebApiResource):
|
||||
feedback_response_fields = {
|
||||
"result": fields.String,
|
||||
}
|
||||
|
||||
@marshal_with(feedback_response_fields)
|
||||
def post(self, app_model, end_user, message_id):
|
||||
message_id = str(message_id)
|
||||
|
||||
@@ -152,6 +157,11 @@ class MessageMoreLikeThisApi(WebApiResource):
|
||||
|
||||
|
||||
class MessageSuggestedQuestionApi(WebApiResource):
|
||||
suggested_questions_response_fields = {
|
||||
"data": fields.List(fields.String),
|
||||
}
|
||||
|
||||
@marshal_with(suggested_questions_response_fields)
|
||||
def get(self, app_model, end_user, message_id):
|
||||
app_mode = AppMode.value_of(app_model.mode)
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
|
||||
|
||||
@@ -7,7 +7,7 @@ from sqlalchemy import func, select
|
||||
from werkzeug.exceptions import NotFound, Unauthorized
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.web import api
|
||||
from controllers.web import web_ns
|
||||
from controllers.web.error import WebAppAuthRequiredError
|
||||
from extensions.ext_database import db
|
||||
from libs.passport import PassportService
|
||||
@@ -17,9 +17,19 @@ from services.feature_service import FeatureService
|
||||
from services.webapp_auth_service import WebAppAuthService, WebAppAuthType
|
||||
|
||||
|
||||
@web_ns.route("/passport")
|
||||
class PassportResource(Resource):
|
||||
"""Base resource for passport."""
|
||||
|
||||
@web_ns.doc("get_passport")
|
||||
@web_ns.doc(description="Get authentication passport for web application access")
|
||||
@web_ns.doc(
|
||||
responses={
|
||||
200: "Passport retrieved successfully",
|
||||
401: "Unauthorized - missing app code or invalid authentication",
|
||||
404: "Application or user not found",
|
||||
}
|
||||
)
|
||||
def get(self):
|
||||
system_features = FeatureService.get_system_features()
|
||||
app_code = request.headers.get("X-App-Code")
|
||||
@@ -94,9 +104,6 @@ class PassportResource(Resource):
|
||||
}
|
||||
|
||||
|
||||
api.add_resource(PassportResource, "/passport")
|
||||
|
||||
|
||||
def decode_enterprise_webapp_user_id(jwt_token: str | None):
|
||||
"""
|
||||
Decode the enterprise user session from the Authorization header.
|
||||
|
||||
@@ -10,16 +10,44 @@ from controllers.common.errors import (
|
||||
RemoteFileUploadError,
|
||||
UnsupportedFileTypeError,
|
||||
)
|
||||
from controllers.web import web_ns
|
||||
from controllers.web.wraps import WebApiResource
|
||||
from core.file import helpers as file_helpers
|
||||
from core.helper import ssrf_proxy
|
||||
from fields.file_fields import file_fields_with_signed_url, remote_file_info_fields
|
||||
from fields.file_fields import build_file_with_signed_url_model, build_remote_file_info_model
|
||||
from services.file_service import FileService
|
||||
|
||||
|
||||
@web_ns.route("/remote-files/<path:url>")
|
||||
class RemoteFileInfoApi(WebApiResource):
|
||||
@marshal_with(remote_file_info_fields)
|
||||
@web_ns.doc("get_remote_file_info")
|
||||
@web_ns.doc(description="Get information about a remote file")
|
||||
@web_ns.doc(
|
||||
responses={
|
||||
200: "Remote file information retrieved successfully",
|
||||
400: "Bad request - invalid URL",
|
||||
404: "Remote file not found",
|
||||
500: "Failed to fetch remote file",
|
||||
}
|
||||
)
|
||||
@marshal_with(build_remote_file_info_model(web_ns))
|
||||
def get(self, app_model, end_user, url):
|
||||
"""Get information about a remote file.
|
||||
|
||||
Retrieves basic information about a file located at a remote URL,
|
||||
including content type and content length.
|
||||
|
||||
Args:
|
||||
app_model: The associated application model
|
||||
end_user: The end user making the request
|
||||
url: URL-encoded path to the remote file
|
||||
|
||||
Returns:
|
||||
dict: Remote file information including type and length
|
||||
|
||||
Raises:
|
||||
HTTPException: If the remote file cannot be accessed
|
||||
"""
|
||||
decoded_url = urllib.parse.unquote(url)
|
||||
resp = ssrf_proxy.head(decoded_url)
|
||||
if resp.status_code != httpx.codes.OK:
|
||||
@@ -32,9 +60,42 @@ class RemoteFileInfoApi(WebApiResource):
|
||||
}
|
||||
|
||||
|
||||
@web_ns.route("/remote-files/upload")
|
||||
class RemoteFileUploadApi(WebApiResource):
|
||||
@marshal_with(file_fields_with_signed_url)
|
||||
def post(self, app_model, end_user): # Add app_model and end_user parameters
|
||||
@web_ns.doc("upload_remote_file")
|
||||
@web_ns.doc(description="Upload a file from a remote URL")
|
||||
@web_ns.doc(
|
||||
responses={
|
||||
201: "Remote file uploaded successfully",
|
||||
400: "Bad request - invalid URL or parameters",
|
||||
413: "File too large",
|
||||
415: "Unsupported file type",
|
||||
500: "Failed to fetch remote file",
|
||||
}
|
||||
)
|
||||
@marshal_with(build_file_with_signed_url_model(web_ns))
|
||||
def post(self, app_model, end_user):
|
||||
"""Upload a file from a remote URL.
|
||||
|
||||
Downloads a file from the provided remote URL and uploads it
|
||||
to the platform storage for use in web applications.
|
||||
|
||||
Args:
|
||||
app_model: The associated application model
|
||||
end_user: The end user making the request
|
||||
|
||||
JSON Parameters:
|
||||
url: The remote URL to download the file from (required)
|
||||
|
||||
Returns:
|
||||
dict: File information including ID, signed URL, and metadata
|
||||
int: HTTP status code 201 for success
|
||||
|
||||
Raises:
|
||||
RemoteFileUploadError: Failed to fetch file from remote URL
|
||||
FileTooLargeError: File exceeds size limit
|
||||
UnsupportedFileTypeError: File type not supported
|
||||
"""
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument("url", type=str, required=True, help="URL is required")
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -30,6 +30,10 @@ class SavedMessageListApi(WebApiResource):
|
||||
"data": fields.List(fields.Nested(message_fields)),
|
||||
}
|
||||
|
||||
post_response_fields = {
|
||||
"result": fields.String,
|
||||
}
|
||||
|
||||
@marshal_with(saved_message_infinite_scroll_pagination_fields)
|
||||
def get(self, app_model, end_user):
|
||||
if app_model.mode != "completion":
|
||||
@@ -42,6 +46,7 @@ class SavedMessageListApi(WebApiResource):
|
||||
|
||||
return SavedMessageService.pagination_by_last_id(app_model, end_user, args["last_id"], args["limit"])
|
||||
|
||||
@marshal_with(post_response_fields)
|
||||
def post(self, app_model, end_user):
|
||||
if app_model.mode != "completion":
|
||||
raise NotCompletionAppError()
|
||||
@@ -59,6 +64,11 @@ class SavedMessageListApi(WebApiResource):
|
||||
|
||||
|
||||
class SavedMessageApi(WebApiResource):
|
||||
delete_response_fields = {
|
||||
"result": fields.String,
|
||||
}
|
||||
|
||||
@marshal_with(delete_response_fields)
|
||||
def delete(self, app_model, end_user, message_id):
|
||||
message_id = str(message_id)
|
||||
|
||||
|
||||
@@ -53,6 +53,18 @@ class AppSiteApi(WebApiResource):
|
||||
"custom_config": fields.Raw(attribute="custom_config"),
|
||||
}
|
||||
|
||||
@api.doc("Get App Site Info")
|
||||
@api.doc(description="Retrieve app site information and configuration.")
|
||||
@api.doc(
|
||||
responses={
|
||||
200: "Success",
|
||||
400: "Bad Request",
|
||||
401: "Unauthorized",
|
||||
403: "Forbidden",
|
||||
404: "App Not Found",
|
||||
500: "Internal Server Error",
|
||||
}
|
||||
)
|
||||
@marshal_with(app_fields)
|
||||
def get(self, app_model, end_user):
|
||||
"""Retrieve app site info."""
|
||||
|
||||
@@ -30,6 +30,24 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WorkflowRunApi(WebApiResource):
|
||||
@api.doc("Run Workflow")
|
||||
@api.doc(description="Execute a workflow with provided inputs and files.")
|
||||
@api.doc(
|
||||
params={
|
||||
"inputs": {"description": "Input variables for the workflow", "type": "object", "required": True},
|
||||
"files": {"description": "Files to be processed by the workflow", "type": "array", "required": False},
|
||||
}
|
||||
)
|
||||
@api.doc(
|
||||
responses={
|
||||
200: "Success",
|
||||
400: "Bad Request",
|
||||
401: "Unauthorized",
|
||||
403: "Forbidden",
|
||||
404: "App Not Found",
|
||||
500: "Internal Server Error",
|
||||
}
|
||||
)
|
||||
def post(self, app_model: App, end_user: EndUser):
|
||||
"""
|
||||
Run workflow
|
||||
@@ -67,6 +85,23 @@ class WorkflowRunApi(WebApiResource):
|
||||
|
||||
|
||||
class WorkflowTaskStopApi(WebApiResource):
|
||||
@api.doc("Stop Workflow Task")
|
||||
@api.doc(description="Stop a running workflow task.")
|
||||
@api.doc(
|
||||
params={
|
||||
"task_id": {"description": "Task ID to stop", "type": "string", "required": True},
|
||||
}
|
||||
)
|
||||
@api.doc(
|
||||
responses={
|
||||
200: "Success",
|
||||
400: "Bad Request",
|
||||
401: "Unauthorized",
|
||||
403: "Forbidden",
|
||||
404: "Task Not Found",
|
||||
500: "Internal Server Error",
|
||||
}
|
||||
)
|
||||
def post(self, app_model: App, end_user: EndUser, task_id: str):
|
||||
"""
|
||||
Stop workflow task
|
||||
|
||||
@@ -3,7 +3,7 @@ import os
|
||||
import uuid
|
||||
from collections.abc import Generator, Iterable, Sequence
|
||||
from itertools import islice
|
||||
from typing import TYPE_CHECKING, Any, Optional, Union, cast
|
||||
from typing import TYPE_CHECKING, Any, Optional, Union
|
||||
|
||||
import qdrant_client
|
||||
import requests
|
||||
@@ -398,7 +398,6 @@ class TidbOnQdrantVector(BaseVector):
|
||||
|
||||
def _reload_if_needed(self):
|
||||
if isinstance(self._client, QdrantLocal):
|
||||
self._client = cast(QdrantLocal, self._client)
|
||||
self._client._load()
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -73,8 +73,8 @@ class ExtractProcessor:
|
||||
suffix = "." + match.group(1)
|
||||
else:
|
||||
suffix = ""
|
||||
# FIXME mypy: Cannot determine type of 'tempfile._get_candidate_names' better not use it here
|
||||
file_path = f"{temp_dir}/{next(tempfile._get_candidate_names())}{suffix}" # type: ignore
|
||||
# https://stackoverflow.com/questions/26541416/generate-temporary-file-names-without-creating-actual-file-in-python#comment90414256_26541521
|
||||
file_path = f"{temp_dir}/{tempfile.gettempdir()}{suffix}"
|
||||
Path(file_path).write_bytes(response.content)
|
||||
extract_setting = ExtractSetting(datasource_type="upload_file", document_model="text_model")
|
||||
if return_text:
|
||||
|
||||
@@ -8,20 +8,21 @@ from uuid import UUID
|
||||
|
||||
import numpy as np
|
||||
import pytz
|
||||
from flask_login import current_user
|
||||
|
||||
from core.file import File, FileTransferMethod, FileType
|
||||
from core.tools.entities.tool_entities import ToolInvokeMessage
|
||||
from core.tools.tool_file_manager import ToolFileManager
|
||||
from libs.login import current_user
|
||||
from models.account import Account
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def safe_json_value(v):
|
||||
if isinstance(v, datetime):
|
||||
tz_name = getattr(current_user, "timezone", None) if current_user is not None else None
|
||||
if not tz_name:
|
||||
tz_name = "UTC"
|
||||
tz_name = "UTC"
|
||||
if isinstance(current_user, Account) and current_user.timezone is not None:
|
||||
tz_name = current_user.timezone
|
||||
return v.astimezone(pytz.timezone(tz_name)).isoformat()
|
||||
elif isinstance(v, date):
|
||||
return v.isoformat()
|
||||
@@ -46,7 +47,7 @@ def safe_json_value(v):
|
||||
return v
|
||||
|
||||
|
||||
def safe_json_dict(d):
|
||||
def safe_json_dict(d: dict):
|
||||
if not isinstance(d, dict):
|
||||
raise TypeError("safe_json_dict() expects a dictionary (dict) as input")
|
||||
return {k: safe_json_value(v) for k, v in d.items()}
|
||||
|
||||
@@ -3,8 +3,6 @@ import logging
|
||||
from collections.abc import Generator
|
||||
from typing import Any, Optional, cast
|
||||
|
||||
from flask_login import current_user
|
||||
|
||||
from core.file import FILE_MODEL_IDENTITY, File, FileTransferMethod
|
||||
from core.tools.__base.tool import Tool
|
||||
from core.tools.__base.tool_runtime import ToolRuntime
|
||||
@@ -17,8 +15,8 @@ from core.tools.entities.tool_entities import (
|
||||
from core.tools.errors import ToolInvokeError
|
||||
from extensions.ext_database import db
|
||||
from factories.file_factory import build_from_mapping
|
||||
from models.account import Account
|
||||
from models.model import App, EndUser
|
||||
from libs.login import current_user
|
||||
from models.model import App
|
||||
from models.workflow import Workflow
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -81,11 +79,11 @@ class WorkflowTool(Tool):
|
||||
generator = WorkflowAppGenerator()
|
||||
assert self.runtime is not None
|
||||
assert self.runtime.invoke_from is not None
|
||||
|
||||
assert current_user is not None
|
||||
result = generator.generate(
|
||||
app_model=app,
|
||||
workflow=workflow,
|
||||
user=cast("Account | EndUser", current_user),
|
||||
user=current_user,
|
||||
args={"inputs": tool_parameters, "files": files},
|
||||
invoke_from=self.runtime.invoke_from,
|
||||
streaming=False,
|
||||
|
||||
@@ -65,7 +65,7 @@ class Storage:
|
||||
from extensions.storage.volcengine_tos_storage import VolcengineTosStorage
|
||||
|
||||
return VolcengineTosStorage
|
||||
case StorageType.SUPBASE:
|
||||
case StorageType.SUPABASE:
|
||||
from extensions.storage.supabase_storage import SupabaseStorage
|
||||
|
||||
return SupabaseStorage
|
||||
|
||||
@@ -14,4 +14,4 @@ class StorageType(StrEnum):
|
||||
S3 = "s3"
|
||||
TENCENT_COS = "tencent-cos"
|
||||
VOLCENGINE_TOS = "volcengine-tos"
|
||||
SUPBASE = "supabase"
|
||||
SUPABASE = "supabase"
|
||||
|
||||
@@ -128,10 +128,6 @@ def _build_variable_from_mapping(*, mapping: Mapping[str, Any], selector: Sequen
|
||||
return cast(Variable, result)
|
||||
|
||||
|
||||
def infer_segment_type_from_value(value: Any, /) -> SegmentType:
|
||||
return build_segment(value).value_type
|
||||
|
||||
|
||||
def build_segment(value: Any, /) -> Segment:
|
||||
# NOTE: If you have runtime type information available, consider using the `build_segment_with_type`
|
||||
# below
|
||||
|
||||
@@ -301,8 +301,8 @@ class TokenManager:
|
||||
if expiry_minutes is None:
|
||||
raise ValueError(f"Expiry minutes for {token_type} token is not set")
|
||||
token_key = cls._get_token_key(token, token_type)
|
||||
expiry_time = int(expiry_minutes * 60)
|
||||
redis_client.setex(token_key, expiry_time, json.dumps(token_data))
|
||||
expiry_seconds = int(expiry_minutes * 60)
|
||||
redis_client.setex(token_key, expiry_seconds, json.dumps(token_data))
|
||||
|
||||
if account_id:
|
||||
cls._set_current_token_for_account(account_id, token, token_type, expiry_minutes)
|
||||
@@ -336,11 +336,11 @@ class TokenManager:
|
||||
|
||||
@classmethod
|
||||
def _set_current_token_for_account(
|
||||
cls, account_id: str, token: str, token_type: str, expiry_hours: Union[int, float]
|
||||
cls, account_id: str, token: str, token_type: str, expiry_minutes: Union[int, float]
|
||||
):
|
||||
key = cls._get_account_token_key(account_id, token_type)
|
||||
expiry_time = int(expiry_hours * 60 * 60)
|
||||
redis_client.setex(key, expiry_time, token)
|
||||
expiry_seconds = int(expiry_minutes * 60)
|
||||
redis_client.setex(key, expiry_seconds, token)
|
||||
|
||||
@classmethod
|
||||
def _get_account_token_key(cls, account_id: str, token_type: str) -> str:
|
||||
|
||||
@@ -17,7 +17,7 @@ if TYPE_CHECKING:
|
||||
import sqlalchemy as sa
|
||||
from flask import request
|
||||
from flask_login import UserMixin
|
||||
from sqlalchemy import Float, Index, PrimaryKeyConstraint, String, func, text
|
||||
from sqlalchemy import Float, Index, PrimaryKeyConstraint, String, exists, func, select, text
|
||||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||
|
||||
from configs import dify_config
|
||||
@@ -1553,7 +1553,7 @@ class ApiToken(Base):
|
||||
def generate_api_key(prefix, n):
|
||||
while True:
|
||||
result = prefix + generate_string(n)
|
||||
if db.session.query(ApiToken).where(ApiToken.token == result).count() > 0:
|
||||
if db.session.scalar(select(exists().where(ApiToken.token == result))):
|
||||
continue
|
||||
return result
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any, Optional, Union
|
||||
from uuid import uuid4
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import DateTime, orm
|
||||
from sqlalchemy import DateTime, exists, orm, select
|
||||
|
||||
from core.file.constants import maybe_file_object
|
||||
from core.file.models import File
|
||||
@@ -336,12 +336,13 @@ class Workflow(Base):
|
||||
"""
|
||||
from models.tools import WorkflowToolProvider
|
||||
|
||||
return (
|
||||
db.session.query(WorkflowToolProvider)
|
||||
.where(WorkflowToolProvider.tenant_id == self.tenant_id, WorkflowToolProvider.app_id == self.app_id)
|
||||
.count()
|
||||
> 0
|
||||
stmt = select(
|
||||
exists().where(
|
||||
WorkflowToolProvider.tenant_id == self.tenant_id,
|
||||
WorkflowToolProvider.app_id == self.app_id,
|
||||
)
|
||||
)
|
||||
return db.session.execute(stmt).scalar_one()
|
||||
|
||||
@property
|
||||
def environment_variables(self) -> Sequence[StringVariable | IntegerVariable | FloatVariable | SecretVariable]:
|
||||
@@ -921,7 +922,7 @@ def _naive_utc_datetime():
|
||||
|
||||
class WorkflowDraftVariable(Base):
|
||||
"""`WorkflowDraftVariable` record variables and outputs generated during
|
||||
debugging worfklow or chatflow.
|
||||
debugging workflow or chatflow.
|
||||
|
||||
IMPORTANT: This model maintains multiple invariant rules that must be preserved.
|
||||
Do not instantiate this class directly with the constructor.
|
||||
|
||||
@@ -9,7 +9,7 @@ from collections import Counter
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
from flask_login import current_user
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import exists, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
@@ -655,10 +655,8 @@ class DatasetService:
|
||||
|
||||
@staticmethod
|
||||
def dataset_use_check(dataset_id) -> bool:
|
||||
count = db.session.query(AppDatasetJoin).filter_by(dataset_id=dataset_id).count()
|
||||
if count > 0:
|
||||
return True
|
||||
return False
|
||||
stmt = select(exists().where(AppDatasetJoin.dataset_id == dataset_id))
|
||||
return db.session.execute(stmt).scalar_one()
|
||||
|
||||
@staticmethod
|
||||
def check_dataset_permission(dataset, user):
|
||||
|
||||
@@ -5,6 +5,7 @@ from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import exists, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from configs import dify_config
|
||||
@@ -190,11 +191,14 @@ class BuiltinToolManageService:
|
||||
# update name if provided
|
||||
if name and name != db_provider.name:
|
||||
# check if the name is already used
|
||||
if (
|
||||
session.query(BuiltinToolProvider)
|
||||
.filter_by(tenant_id=tenant_id, provider=provider, name=name)
|
||||
.count()
|
||||
> 0
|
||||
if session.scalar(
|
||||
select(
|
||||
exists().where(
|
||||
BuiltinToolProvider.tenant_id == tenant_id,
|
||||
BuiltinToolProvider.provider == provider,
|
||||
BuiltinToolProvider.name == name,
|
||||
)
|
||||
)
|
||||
):
|
||||
raise ValueError(f"the credential name '{name}' is already used")
|
||||
|
||||
@@ -246,11 +250,14 @@ class BuiltinToolManageService:
|
||||
)
|
||||
else:
|
||||
# check if the name is already used
|
||||
if (
|
||||
session.query(BuiltinToolProvider)
|
||||
.filter_by(tenant_id=tenant_id, provider=provider, name=name)
|
||||
.count()
|
||||
> 0
|
||||
if session.scalar(
|
||||
select(
|
||||
exists().where(
|
||||
BuiltinToolProvider.tenant_id == tenant_id,
|
||||
BuiltinToolProvider.provider == provider,
|
||||
BuiltinToolProvider.name == name,
|
||||
)
|
||||
)
|
||||
):
|
||||
raise ValueError(f"the credential name '{name}' is already used")
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import enum
|
||||
import secrets
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any, Optional, cast
|
||||
from typing import Any, Optional
|
||||
|
||||
from werkzeug.exceptions import NotFound, Unauthorized
|
||||
|
||||
@@ -42,7 +42,7 @@ class WebAppAuthService:
|
||||
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)
|
||||
return account
|
||||
|
||||
@classmethod
|
||||
def login(cls, account: Account) -> str:
|
||||
|
||||
@@ -5,7 +5,7 @@ from collections.abc import Callable, Generator, Mapping, Sequence
|
||||
from typing import Any, Optional, cast
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import exists, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from core.app.app_config.entities import VariableEntityType
|
||||
@@ -87,15 +87,14 @@ class WorkflowService:
|
||||
)
|
||||
|
||||
def is_workflow_exist(self, app_model: App) -> bool:
|
||||
return (
|
||||
db.session.query(Workflow)
|
||||
.where(
|
||||
stmt = select(
|
||||
exists().where(
|
||||
Workflow.tenant_id == app_model.tenant_id,
|
||||
Workflow.app_id == app_model.id,
|
||||
Workflow.version == Workflow.VERSION_DRAFT,
|
||||
)
|
||||
.count()
|
||||
) > 0
|
||||
)
|
||||
return db.session.execute(stmt).scalar_one()
|
||||
|
||||
def get_draft_workflow(self, app_model: App) -> Optional[Workflow]:
|
||||
"""
|
||||
|
||||
@@ -3,6 +3,7 @@ import time
|
||||
|
||||
import click
|
||||
from celery import shared_task
|
||||
from sqlalchemy import exists, select
|
||||
|
||||
from core.rag.datasource.vdb.vector_factory import Vector
|
||||
from extensions.ext_database import db
|
||||
@@ -22,7 +23,7 @@ def disable_annotation_reply_task(job_id: str, app_id: str, tenant_id: str):
|
||||
start_at = time.perf_counter()
|
||||
# get app info
|
||||
app = db.session.query(App).where(App.id == app_id, App.tenant_id == tenant_id, App.status == "normal").first()
|
||||
annotations_count = db.session.query(MessageAnnotation).where(MessageAnnotation.app_id == app_id).count()
|
||||
annotations_exists = db.session.scalar(select(exists().where(MessageAnnotation.app_id == app_id)))
|
||||
if not app:
|
||||
logger.info(click.style(f"App not found: {app_id}", fg="red"))
|
||||
db.session.close()
|
||||
@@ -47,7 +48,7 @@ def disable_annotation_reply_task(job_id: str, app_id: str, tenant_id: str):
|
||||
)
|
||||
|
||||
try:
|
||||
if annotations_count > 0:
|
||||
if annotations_exists:
|
||||
vector = Vector(dataset, attributes=["doc_id", "annotation_id", "app_id"])
|
||||
vector.delete()
|
||||
except Exception:
|
||||
|
||||
@@ -3,15 +3,12 @@ from collections.abc import Callable
|
||||
|
||||
import pytest
|
||||
|
||||
# import monkeypatch
|
||||
from _pytest.monkeypatch import MonkeyPatch
|
||||
|
||||
from core.plugin.impl.model import PluginModelClient
|
||||
from tests.integration_tests.model_runtime.__mock.plugin_model import MockModelClass
|
||||
|
||||
|
||||
def mock_plugin_daemon(
|
||||
monkeypatch: MonkeyPatch,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> Callable[[], None]:
|
||||
"""
|
||||
mock openai module
|
||||
@@ -34,7 +31,7 @@ MOCK = os.getenv("MOCK_SWITCH", "false").lower() == "true"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup_model_mock(monkeypatch):
|
||||
def setup_model_mock(monkeypatch: pytest.MonkeyPatch):
|
||||
if MOCK:
|
||||
unpatch = mock_plugin_daemon(monkeypatch)
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ from typing import Literal
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from _pytest.monkeypatch import MonkeyPatch
|
||||
|
||||
from core.plugin.entities.plugin_daemon import PluginDaemonBasicResponse
|
||||
from core.tools.entities.common_entities import I18nObject
|
||||
@@ -53,7 +52,7 @@ MOCK_SWITCH = os.getenv("MOCK_SWITCH", "false").lower() == "true"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup_http_mock(request, monkeypatch: MonkeyPatch):
|
||||
def setup_http_mock(request, monkeypatch: pytest.MonkeyPatch):
|
||||
if MOCK_SWITCH:
|
||||
monkeypatch.setattr(requests, "request", MockedHttp.requests_request)
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ from typing import Literal
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from _pytest.monkeypatch import MonkeyPatch
|
||||
|
||||
from core.helper import ssrf_proxy
|
||||
|
||||
@@ -30,7 +29,7 @@ class MockedHttp:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup_http_mock(request, monkeypatch: MonkeyPatch):
|
||||
def setup_http_mock(request, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(ssrf_proxy, "make_request", MockedHttp.httpx_request)
|
||||
yield
|
||||
monkeypatch.undo()
|
||||
|
||||
@@ -45,6 +45,7 @@ class DifyTestContainers:
|
||||
self.postgres: Optional[PostgresContainer] = None
|
||||
self.redis: Optional[RedisContainer] = None
|
||||
self.dify_sandbox: Optional[DockerContainer] = None
|
||||
self.dify_plugin_daemon: Optional[DockerContainer] = None
|
||||
self._containers_started = False
|
||||
logger.info("DifyTestContainers initialized - ready to manage test containers")
|
||||
|
||||
@@ -110,6 +111,25 @@ class DifyTestContainers:
|
||||
except Exception as e:
|
||||
logger.warning("Failed to install uuid-ossp extension: %s", e)
|
||||
|
||||
# Create plugin database for dify-plugin-daemon
|
||||
logger.info("Creating plugin database...")
|
||||
try:
|
||||
conn = psycopg2.connect(
|
||||
host=db_host,
|
||||
port=db_port,
|
||||
user=self.postgres.username,
|
||||
password=self.postgres.password,
|
||||
database=self.postgres.dbname,
|
||||
)
|
||||
conn.autocommit = True
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("CREATE DATABASE dify_plugin;")
|
||||
cursor.close()
|
||||
conn.close()
|
||||
logger.info("Plugin database created successfully")
|
||||
except Exception as e:
|
||||
logger.warning("Failed to create plugin database: %s", e)
|
||||
|
||||
# Set up storage environment variables
|
||||
os.environ["STORAGE_TYPE"] = "opendal"
|
||||
os.environ["OPENDAL_SCHEME"] = "fs"
|
||||
@@ -151,6 +171,62 @@ class DifyTestContainers:
|
||||
wait_for_logs(self.dify_sandbox, "config init success", timeout=60)
|
||||
logger.info("Dify Sandbox container is ready and accepting connections")
|
||||
|
||||
# Start Dify Plugin Daemon container for plugin management
|
||||
# Dify Plugin Daemon provides plugin lifecycle management and execution
|
||||
logger.info("Initializing Dify Plugin Daemon container...")
|
||||
self.dify_plugin_daemon = DockerContainer(image="langgenius/dify-plugin-daemon:0.2.0-local")
|
||||
self.dify_plugin_daemon.with_exposed_ports(5002)
|
||||
self.dify_plugin_daemon.env = {
|
||||
"DB_HOST": db_host,
|
||||
"DB_PORT": str(db_port),
|
||||
"DB_USERNAME": self.postgres.username,
|
||||
"DB_PASSWORD": self.postgres.password,
|
||||
"DB_DATABASE": "dify_plugin",
|
||||
"REDIS_HOST": redis_host,
|
||||
"REDIS_PORT": str(redis_port),
|
||||
"REDIS_PASSWORD": "",
|
||||
"SERVER_PORT": "5002",
|
||||
"SERVER_KEY": "test_plugin_daemon_key",
|
||||
"MAX_PLUGIN_PACKAGE_SIZE": "52428800",
|
||||
"PPROF_ENABLED": "false",
|
||||
"DIFY_INNER_API_URL": f"http://{db_host}:5001",
|
||||
"DIFY_INNER_API_KEY": "test_inner_api_key",
|
||||
"PLUGIN_REMOTE_INSTALLING_HOST": "0.0.0.0",
|
||||
"PLUGIN_REMOTE_INSTALLING_PORT": "5003",
|
||||
"PLUGIN_WORKING_PATH": "/app/storage/cwd",
|
||||
"FORCE_VERIFYING_SIGNATURE": "false",
|
||||
"PYTHON_ENV_INIT_TIMEOUT": "120",
|
||||
"PLUGIN_MAX_EXECUTION_TIMEOUT": "600",
|
||||
"PLUGIN_STDIO_BUFFER_SIZE": "1024",
|
||||
"PLUGIN_STDIO_MAX_BUFFER_SIZE": "5242880",
|
||||
"PLUGIN_STORAGE_TYPE": "local",
|
||||
"PLUGIN_STORAGE_LOCAL_ROOT": "/app/storage",
|
||||
"PLUGIN_INSTALLED_PATH": "plugin",
|
||||
"PLUGIN_PACKAGE_CACHE_PATH": "plugin_packages",
|
||||
"PLUGIN_MEDIA_CACHE_PATH": "assets",
|
||||
}
|
||||
|
||||
try:
|
||||
self.dify_plugin_daemon.start()
|
||||
plugin_daemon_host = self.dify_plugin_daemon.get_container_host_ip()
|
||||
plugin_daemon_port = self.dify_plugin_daemon.get_exposed_port(5002)
|
||||
os.environ["PLUGIN_DAEMON_URL"] = f"http://{plugin_daemon_host}:{plugin_daemon_port}"
|
||||
os.environ["PLUGIN_DAEMON_KEY"] = "test_plugin_daemon_key"
|
||||
logger.info(
|
||||
"Dify Plugin Daemon container started successfully - Host: %s, Port: %s",
|
||||
plugin_daemon_host,
|
||||
plugin_daemon_port,
|
||||
)
|
||||
|
||||
# Wait for Dify Plugin Daemon to be ready
|
||||
logger.info("Waiting for Dify Plugin Daemon to be ready to accept connections...")
|
||||
wait_for_logs(self.dify_plugin_daemon, "start plugin manager daemon", timeout=60)
|
||||
logger.info("Dify Plugin Daemon container is ready and accepting connections")
|
||||
except Exception as e:
|
||||
logger.warning("Failed to start Dify Plugin Daemon container: %s", e)
|
||||
logger.info("Continuing without plugin daemon - some tests may be limited")
|
||||
self.dify_plugin_daemon = None
|
||||
|
||||
self._containers_started = True
|
||||
logger.info("All test containers started successfully")
|
||||
|
||||
@@ -166,7 +242,7 @@ class DifyTestContainers:
|
||||
return
|
||||
|
||||
logger.info("Stopping and cleaning up test containers...")
|
||||
containers = [self.redis, self.postgres, self.dify_sandbox]
|
||||
containers = [self.redis, self.postgres, self.dify_sandbox, self.dify_plugin_daemon]
|
||||
for container in containers:
|
||||
if container:
|
||||
try:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ from yarl import URL
|
||||
from configs.app_config import DifyConfig
|
||||
|
||||
|
||||
def test_dify_config(monkeypatch):
|
||||
def test_dify_config(monkeypatch: pytest.MonkeyPatch):
|
||||
# clear system environment variables
|
||||
os.environ.clear()
|
||||
|
||||
@@ -48,7 +48,7 @@ def test_dify_config(monkeypatch):
|
||||
|
||||
# NOTE: If there is a `.env` file in your Workspace, this test might not succeed as expected.
|
||||
# This is due to `pymilvus` loading all the variables from the `.env` file into `os.environ`.
|
||||
def test_flask_configs(monkeypatch):
|
||||
def test_flask_configs(monkeypatch: pytest.MonkeyPatch):
|
||||
flask_app = Flask("app")
|
||||
# clear system environment variables
|
||||
os.environ.clear()
|
||||
@@ -101,7 +101,7 @@ def test_flask_configs(monkeypatch):
|
||||
assert str(URL(str(config["CODE_EXECUTION_ENDPOINT"])) / "v1") == "http://127.0.0.1:8194/v1"
|
||||
|
||||
|
||||
def test_inner_api_config_exist(monkeypatch):
|
||||
def test_inner_api_config_exist(monkeypatch: pytest.MonkeyPatch):
|
||||
# Set environment variables using monkeypatch
|
||||
monkeypatch.setenv("CONSOLE_API_URL", "https://example.com")
|
||||
monkeypatch.setenv("CONSOLE_WEB_URL", "https://example.com")
|
||||
@@ -119,7 +119,7 @@ def test_inner_api_config_exist(monkeypatch):
|
||||
assert len(config.INNER_API_KEY) > 0
|
||||
|
||||
|
||||
def test_db_extras_options_merging(monkeypatch):
|
||||
def test_db_extras_options_merging(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Test that DB_EXTRAS options are properly merged with default timezone setting"""
|
||||
# Set environment variables
|
||||
monkeypatch.setenv("DB_USERNAME", "postgres")
|
||||
@@ -164,7 +164,13 @@ def test_db_extras_options_merging(monkeypatch):
|
||||
],
|
||||
)
|
||||
def test_celery_broker_url_with_special_chars_password(
|
||||
monkeypatch, broker_url, expected_host, expected_port, expected_username, expected_password, expected_db
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
broker_url,
|
||||
expected_host,
|
||||
expected_port,
|
||||
expected_username,
|
||||
expected_password,
|
||||
expected_db,
|
||||
):
|
||||
"""Test that CELERY_BROKER_URL with various formats are handled correctly."""
|
||||
from kombu.utils.url import parse_url
|
||||
|
||||
@@ -39,7 +39,7 @@ def test_page_result(text, cursor, maxlen, expected):
|
||||
# Tests: get_url
|
||||
# ---------------------------
|
||||
@pytest.fixture
|
||||
def stub_support_types(monkeypatch):
|
||||
def stub_support_types(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Stub supported content types list."""
|
||||
import core.tools.utils.web_reader_tool as mod
|
||||
|
||||
@@ -48,7 +48,7 @@ def stub_support_types(monkeypatch):
|
||||
return mod
|
||||
|
||||
|
||||
def test_get_url_unsupported_content_type(monkeypatch, stub_support_types):
|
||||
def test_get_url_unsupported_content_type(monkeypatch: pytest.MonkeyPatch, stub_support_types):
|
||||
# HEAD 200 but content-type not supported and not text/html
|
||||
def fake_head(url, headers=None, follow_redirects=True, timeout=None):
|
||||
return FakeResponse(
|
||||
@@ -62,7 +62,7 @@ def test_get_url_unsupported_content_type(monkeypatch, stub_support_types):
|
||||
assert result == "Unsupported content-type [image/png] of URL."
|
||||
|
||||
|
||||
def test_get_url_supported_binary_type_uses_extract_processor(monkeypatch, stub_support_types):
|
||||
def test_get_url_supported_binary_type_uses_extract_processor(monkeypatch: pytest.MonkeyPatch, stub_support_types):
|
||||
"""
|
||||
When content-type is in SUPPORT_URL_CONTENT_TYPES,
|
||||
should call ExtractProcessor.load_from_url and return its text.
|
||||
@@ -88,7 +88,7 @@ def test_get_url_supported_binary_type_uses_extract_processor(monkeypatch, stub_
|
||||
assert result == "PDF extracted text"
|
||||
|
||||
|
||||
def test_get_url_html_flow_with_chardet_and_readability(monkeypatch, stub_support_types):
|
||||
def test_get_url_html_flow_with_chardet_and_readability(monkeypatch: pytest.MonkeyPatch, stub_support_types):
|
||||
"""200 + text/html → GET, chardet detects encoding, readability returns article which is templated."""
|
||||
|
||||
def fake_head(url, headers=None, follow_redirects=True, timeout=None):
|
||||
@@ -121,7 +121,7 @@ def test_get_url_html_flow_with_chardet_and_readability(monkeypatch, stub_suppor
|
||||
assert "Hello world" in out
|
||||
|
||||
|
||||
def test_get_url_html_flow_empty_article_text_returns_empty(monkeypatch, stub_support_types):
|
||||
def test_get_url_html_flow_empty_article_text_returns_empty(monkeypatch: pytest.MonkeyPatch, stub_support_types):
|
||||
"""If readability returns no text, should return empty string."""
|
||||
|
||||
def fake_head(url, headers=None, follow_redirects=True, timeout=None):
|
||||
@@ -142,7 +142,7 @@ def test_get_url_html_flow_empty_article_text_returns_empty(monkeypatch, stub_su
|
||||
assert out == ""
|
||||
|
||||
|
||||
def test_get_url_403_cloudscraper_fallback(monkeypatch, stub_support_types):
|
||||
def test_get_url_403_cloudscraper_fallback(monkeypatch: pytest.MonkeyPatch, stub_support_types):
|
||||
"""HEAD 403 → use cloudscraper.get via ssrf_proxy.make_request, then proceed."""
|
||||
|
||||
def fake_head(url, headers=None, follow_redirects=True, timeout=None):
|
||||
@@ -175,7 +175,7 @@ def test_get_url_403_cloudscraper_fallback(monkeypatch, stub_support_types):
|
||||
assert "X" in out
|
||||
|
||||
|
||||
def test_get_url_head_non_200_returns_status(monkeypatch, stub_support_types):
|
||||
def test_get_url_head_non_200_returns_status(monkeypatch: pytest.MonkeyPatch, stub_support_types):
|
||||
"""HEAD returns non-200 and non-403 → should directly return code message."""
|
||||
|
||||
def fake_head(url, headers=None, follow_redirects=True, timeout=None):
|
||||
@@ -189,7 +189,7 @@ def test_get_url_head_non_200_returns_status(monkeypatch, stub_support_types):
|
||||
assert out == "URL returned status code 500."
|
||||
|
||||
|
||||
def test_get_url_content_disposition_filename_detection(monkeypatch, stub_support_types):
|
||||
def test_get_url_content_disposition_filename_detection(monkeypatch: pytest.MonkeyPatch, stub_support_types):
|
||||
"""
|
||||
If HEAD 200 with no Content-Type but Content-Disposition filename suggests a supported type,
|
||||
it should route to ExtractProcessor.load_from_url.
|
||||
@@ -213,7 +213,7 @@ def test_get_url_content_disposition_filename_detection(monkeypatch, stub_suppor
|
||||
assert out == "From ExtractProcessor via filename"
|
||||
|
||||
|
||||
def test_get_url_html_encoding_fallback_when_decode_fails(monkeypatch, stub_support_types):
|
||||
def test_get_url_html_encoding_fallback_when_decode_fails(monkeypatch: pytest.MonkeyPatch, stub_support_types):
|
||||
"""
|
||||
If chardet returns an encoding but content.decode raises, should fallback to response.text.
|
||||
"""
|
||||
@@ -250,7 +250,7 @@ def test_get_url_html_encoding_fallback_when_decode_fails(monkeypatch, stub_supp
|
||||
# ---------------------------
|
||||
|
||||
|
||||
def test_extract_using_readabilipy_field_mapping_and_defaults(monkeypatch):
|
||||
def test_extract_using_readabilipy_field_mapping_and_defaults(monkeypatch: pytest.MonkeyPatch):
|
||||
# stub readabilipy.simple_json_from_html_string
|
||||
def fake_simple_json_from_html_string(html, use_readability=True):
|
||||
return {
|
||||
@@ -271,7 +271,7 @@ def test_extract_using_readabilipy_field_mapping_and_defaults(monkeypatch):
|
||||
assert article.text[0]["text"] == "world"
|
||||
|
||||
|
||||
def test_extract_using_readabilipy_defaults_when_missing(monkeypatch):
|
||||
def test_extract_using_readabilipy_defaults_when_missing(monkeypatch: pytest.MonkeyPatch):
|
||||
def fake_simple_json_from_html_string(html, use_readability=True):
|
||||
return {} # all missing
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from core.tools.errors import ToolInvokeError
|
||||
from core.tools.workflow_as_tool.tool import WorkflowTool
|
||||
|
||||
|
||||
def test_workflow_tool_should_raise_tool_invoke_error_when_result_has_error_field(monkeypatch):
|
||||
def test_workflow_tool_should_raise_tool_invoke_error_when_result_has_error_field(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Ensure that WorkflowTool will throw a `ToolInvokeError` exception when
|
||||
`WorkflowAppGenerator.generate` returns a result with `error` key inside
|
||||
the `data` element.
|
||||
@@ -40,7 +40,7 @@ def test_workflow_tool_should_raise_tool_invoke_error_when_result_has_error_fiel
|
||||
"core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate",
|
||||
lambda *args, **kwargs: {"data": {"error": "oops"}},
|
||||
)
|
||||
monkeypatch.setattr("flask_login.current_user", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None)
|
||||
|
||||
with pytest.raises(ToolInvokeError) as exc_info:
|
||||
# WorkflowTool always returns a generator, so we need to iterate to
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.file import File, FileTransferMethod, FileType
|
||||
@@ -20,7 +21,7 @@ from models.enums import UserFrom
|
||||
from models.workflow import WorkflowType
|
||||
|
||||
|
||||
def test_http_request_node_binary_file(monkeypatch):
|
||||
def test_http_request_node_binary_file(monkeypatch: pytest.MonkeyPatch):
|
||||
data = HttpRequestNodeData(
|
||||
title="test",
|
||||
method="post",
|
||||
@@ -110,7 +111,7 @@ def test_http_request_node_binary_file(monkeypatch):
|
||||
assert result.outputs["body"] == "test"
|
||||
|
||||
|
||||
def test_http_request_node_form_with_file(monkeypatch):
|
||||
def test_http_request_node_form_with_file(monkeypatch: pytest.MonkeyPatch):
|
||||
data = HttpRequestNodeData(
|
||||
title="test",
|
||||
method="post",
|
||||
@@ -211,7 +212,7 @@ def test_http_request_node_form_with_file(monkeypatch):
|
||||
assert result.outputs["body"] == ""
|
||||
|
||||
|
||||
def test_http_request_node_form_with_multiple_files(monkeypatch):
|
||||
def test_http_request_node_form_with_multiple_files(monkeypatch: pytest.MonkeyPatch):
|
||||
data = HttpRequestNodeData(
|
||||
title="test",
|
||||
method="post",
|
||||
|
||||
@@ -43,28 +43,28 @@ def _get_test_app():
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_request_receiver(monkeypatch) -> mock.Mock:
|
||||
def mock_request_receiver(monkeypatch: pytest.MonkeyPatch) -> mock.Mock:
|
||||
mock_log_request_started = mock.Mock()
|
||||
monkeypatch.setattr(ext_request_logging, "_log_request_started", mock_log_request_started)
|
||||
return mock_log_request_started
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_response_receiver(monkeypatch) -> mock.Mock:
|
||||
def mock_response_receiver(monkeypatch: pytest.MonkeyPatch) -> mock.Mock:
|
||||
mock_log_request_finished = mock.Mock()
|
||||
monkeypatch.setattr(ext_request_logging, "_log_request_finished", mock_log_request_finished)
|
||||
return mock_log_request_finished
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_logger(monkeypatch) -> logging.Logger:
|
||||
def mock_logger(monkeypatch: pytest.MonkeyPatch) -> logging.Logger:
|
||||
_logger = mock.MagicMock(spec=logging.Logger)
|
||||
monkeypatch.setattr(ext_request_logging, "logger", _logger)
|
||||
return _logger
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def enable_request_logging(monkeypatch):
|
||||
def enable_request_logging(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(dify_config, "ENABLE_REQUEST_LOGGING", True)
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
|
||||
|
||||
def test_naive_utc_now(monkeypatch):
|
||||
def test_naive_utc_now(monkeypatch: pytest.MonkeyPatch):
|
||||
tz_aware_utc_now = datetime.datetime.now(tz=datetime.UTC)
|
||||
|
||||
def _now_func(tz: datetime.timezone | None) -> datetime.datetime:
|
||||
|
||||
@@ -143,7 +143,7 @@ def test_uuidv7_with_custom_timestamp():
|
||||
assert extracted_timestamp == custom_timestamp # Exact match for integer milliseconds
|
||||
|
||||
|
||||
def test_uuidv7_with_none_timestamp(monkeypatch):
|
||||
def test_uuidv7_with_none_timestamp(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Test UUID generation with None timestamp uses current time."""
|
||||
mock_time = 1609459200
|
||||
mock_time_func = mock.Mock(return_value=mock_time)
|
||||
|
||||
@@ -779,6 +779,12 @@ API_SENTRY_PROFILES_SAMPLE_RATE=1.0
|
||||
# If not set, Sentry error reporting will be disabled.
|
||||
WEB_SENTRY_DSN=
|
||||
|
||||
# Plugin_daemon Service Sentry DSN address, default is empty, when empty,
|
||||
# all monitoring information is not reported to Sentry.
|
||||
# If not set, Sentry error reporting will be disabled.
|
||||
PLUGIN_SENTRY_ENABLED=false
|
||||
PLUGIN_SENTRY_DSN=
|
||||
|
||||
# ------------------------------
|
||||
# Notion Integration Configuration
|
||||
# Variables can be obtained by applying for Notion integration: https://www.notion.so/my-integrations
|
||||
|
||||
@@ -212,6 +212,8 @@ services:
|
||||
VOLCENGINE_TOS_ACCESS_KEY: ${PLUGIN_VOLCENGINE_TOS_ACCESS_KEY:-}
|
||||
VOLCENGINE_TOS_SECRET_KEY: ${PLUGIN_VOLCENGINE_TOS_SECRET_KEY:-}
|
||||
VOLCENGINE_TOS_REGION: ${PLUGIN_VOLCENGINE_TOS_REGION:-}
|
||||
SENTRY_ENABLED: ${PLUGIN_SENTRY_ENABLED:-false}
|
||||
SENTRY_DSN: ${PLUGIN_SENTRY_DSN:-}
|
||||
ports:
|
||||
- "${EXPOSE_PLUGIN_DEBUGGING_PORT:-5003}:${PLUGIN_DEBUGGING_PORT:-5003}"
|
||||
volumes:
|
||||
|
||||
@@ -352,6 +352,8 @@ x-shared-env: &shared-api-worker-env
|
||||
API_SENTRY_TRACES_SAMPLE_RATE: ${API_SENTRY_TRACES_SAMPLE_RATE:-1.0}
|
||||
API_SENTRY_PROFILES_SAMPLE_RATE: ${API_SENTRY_PROFILES_SAMPLE_RATE:-1.0}
|
||||
WEB_SENTRY_DSN: ${WEB_SENTRY_DSN:-}
|
||||
PLUGIN_SENTRY_ENABLED: ${PLUGIN_SENTRY_ENABLED:-false}
|
||||
PLUGIN_SENTRY_DSN: ${PLUGIN_SENTRY_DSN:-}
|
||||
NOTION_INTEGRATION_TYPE: ${NOTION_INTEGRATION_TYPE:-public}
|
||||
NOTION_CLIENT_SECRET: ${NOTION_CLIENT_SECRET:-}
|
||||
NOTION_CLIENT_ID: ${NOTION_CLIENT_ID:-}
|
||||
@@ -790,6 +792,8 @@ services:
|
||||
VOLCENGINE_TOS_ACCESS_KEY: ${PLUGIN_VOLCENGINE_TOS_ACCESS_KEY:-}
|
||||
VOLCENGINE_TOS_SECRET_KEY: ${PLUGIN_VOLCENGINE_TOS_SECRET_KEY:-}
|
||||
VOLCENGINE_TOS_REGION: ${PLUGIN_VOLCENGINE_TOS_REGION:-}
|
||||
SENTRY_ENABLED: ${PLUGIN_SENTRY_ENABLED:-false}
|
||||
SENTRY_DSN: ${PLUGIN_SENTRY_DSN:-}
|
||||
ports:
|
||||
- "${EXPOSE_PLUGIN_DEBUGGING_PORT:-5003}:${PLUGIN_DEBUGGING_PORT:-5003}"
|
||||
volumes:
|
||||
|
||||
20
sdks/nodejs-client/index.d.ts
vendored
20
sdks/nodejs-client/index.d.ts
vendored
@@ -14,6 +14,22 @@ interface HeaderParams {
|
||||
interface User {
|
||||
}
|
||||
|
||||
interface DifyFileBase {
|
||||
type: "image"
|
||||
}
|
||||
|
||||
export interface DifyRemoteFile extends DifyFileBase {
|
||||
transfer_method: "remote_url"
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface DifyLocalFile extends DifyFileBase {
|
||||
transfer_method: "local_file"
|
||||
upload_file_id: string
|
||||
}
|
||||
|
||||
export type DifyFile = DifyRemoteFile | DifyLocalFile;
|
||||
|
||||
export declare class DifyClient {
|
||||
constructor(apiKey: string, baseUrl?: string);
|
||||
|
||||
@@ -44,7 +60,7 @@ export declare class CompletionClient extends DifyClient {
|
||||
inputs: any,
|
||||
user: User,
|
||||
stream?: boolean,
|
||||
files?: File[] | null
|
||||
files?: DifyFile[] | null
|
||||
): Promise<any>;
|
||||
}
|
||||
|
||||
@@ -55,7 +71,7 @@ export declare class ChatClient extends DifyClient {
|
||||
user: User,
|
||||
stream?: boolean,
|
||||
conversation_id?: string | null,
|
||||
files?: File[] | null
|
||||
files?: DifyFile[] | null
|
||||
): Promise<any>;
|
||||
|
||||
getSuggested(message_id: string, user: User): Promise<any>;
|
||||
|
||||
@@ -27,10 +27,11 @@ const ChunkDetailModal: FC<Props> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { segment, score, child_chunks } = payload
|
||||
const { position, content, sign_content, keywords, document } = segment
|
||||
const { position, content, sign_content, keywords, document, answer } = segment
|
||||
const isParentChildRetrieval = !!(child_chunks && child_chunks.length > 0)
|
||||
const extension = document.name.split('.').slice(-1)[0] as FileAppearanceTypeEnum
|
||||
const heighClassName = isParentChildRetrieval ? 'h-[min(627px,_80vh)] overflow-y-auto' : 'h-[min(539px,_80vh)] overflow-y-auto'
|
||||
const labelPrefix = isParentChildRetrieval ? t('datasetDocuments.segment.parentChunk') : t('datasetDocuments.segment.chunk')
|
||||
return (
|
||||
<Modal
|
||||
title={t(`${i18nPrefix}.chunkDetail`)}
|
||||
@@ -45,7 +46,7 @@ const ChunkDetailModal: FC<Props> = ({
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex grow items-center space-x-2'>
|
||||
<SegmentIndexTag
|
||||
labelPrefix={`${isParentChildRetrieval ? 'Parent-' : ''}Chunk`}
|
||||
labelPrefix={labelPrefix}
|
||||
positionId={position}
|
||||
className={cn('w-fit group-hover:opacity-100')}
|
||||
/>
|
||||
@@ -57,11 +58,29 @@ const ChunkDetailModal: FC<Props> = ({
|
||||
</div>
|
||||
<Score value={score} />
|
||||
</div>
|
||||
<Markdown
|
||||
className={cn('!mt-2 !text-text-secondary', heighClassName)}
|
||||
content={sign_content || content}
|
||||
customDisallowedElements={['input']}
|
||||
/>
|
||||
{!answer && (
|
||||
<Markdown
|
||||
className={cn('!mt-2 !text-text-secondary', heighClassName)}
|
||||
content={sign_content || content}
|
||||
customDisallowedElements={['input']}
|
||||
/>
|
||||
)}
|
||||
{answer && (
|
||||
<div>
|
||||
<div className='flex gap-x-1'>
|
||||
<div className='w-4 shrink-0 text-[13px] font-medium leading-[20px] text-text-tertiary'>Q</div>
|
||||
<div className={cn('body-md-regular text-text-secondary line-clamp-20')}>
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex gap-x-1'>
|
||||
<div className='w-4 shrink-0 text-[13px] font-medium leading-[20px] text-text-tertiary'>A</div>
|
||||
<div className={cn('body-md-regular text-text-secondary line-clamp-20')}>
|
||||
{answer}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isParentChildRetrieval && keywords && keywords.length > 0 && (
|
||||
<div className='mt-6'>
|
||||
<div className='text-xs font-medium uppercase text-text-tertiary'>{t(`${i18nPrefix}.keyword`)}</div>
|
||||
|
||||
@@ -86,7 +86,6 @@ const ModelModal: FC<ModelModalProps> = ({
|
||||
} = credentialData as any
|
||||
|
||||
const { isCurrentWorkspaceManager } = useAppContext()
|
||||
const isEditMode = !!formSchemasValue && isCurrentWorkspaceManager
|
||||
const { t } = useTranslation()
|
||||
const language = useLanguage()
|
||||
const {
|
||||
@@ -94,6 +93,9 @@ const ModelModal: FC<ModelModalProps> = ({
|
||||
formValues,
|
||||
} = useModelFormSchemas(provider, providerFormSchemaPredefined, formSchemasValue, credential, model)
|
||||
const formRef = useRef<FormRefObject>(null)
|
||||
const isEditMode = !!Object.keys(formValues).filter((key) => {
|
||||
return key !== '__model_name' && key !== '__model_type'
|
||||
}).length && isCurrentWorkspaceManager
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const {
|
||||
|
||||
@@ -190,7 +190,7 @@ const ModelLoadBalancingConfigs = ({
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className='mr-1 text-[13px]'>
|
||||
<div className='mr-1 text-[13px] text-text-secondary'>
|
||||
{isProviderManaged ? t('common.modelProvider.defaultConfig') : config.name}
|
||||
</div>
|
||||
{isProviderManaged && providerFormSchemaPredefined && (
|
||||
|
||||
@@ -32,6 +32,7 @@ export const checkOrSetAccessToken = async (appCode?: string | null) => {
|
||||
[userId || 'DEFAULT']: res.access_token,
|
||||
}
|
||||
localStorage.setItem('token', JSON.stringify(accessTokenJson))
|
||||
localStorage.removeItem(CONVERSATION_ID_INFO)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@ const RunPanel: FC<RunProps> = ({ hideResult, activeTab = 'RESULT', runID, getRe
|
||||
>{t('runLog.tracing')}</div>
|
||||
</div>
|
||||
{/* panel detail */}
|
||||
<div ref={ref} className={cn('relative h-0 grow overflow-y-auto rounded-b-2xl bg-components-panel-bg')}>
|
||||
<div ref={ref} className={cn('relative h-0 grow overflow-y-auto rounded-b-xl bg-components-panel-bg')}>
|
||||
{loading && (
|
||||
<div className='flex h-full items-center justify-center bg-components-panel-bg'>
|
||||
<Loading />
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { FC, PropsWithChildren } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { create } from 'zustand'
|
||||
import { useGlobalPublicStore } from './global-public-context'
|
||||
|
||||
type WebAppStore = {
|
||||
shareCode: string | null
|
||||
@@ -56,6 +57,7 @@ const getShareCodeFromPathname = (pathname: string): string | null => {
|
||||
}
|
||||
|
||||
const WebAppStoreProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
const isGlobalPending = useGlobalPublicStore(s => s.isGlobalPending)
|
||||
const updateWebAppAccessMode = useWebAppStore(state => state.updateWebAppAccessMode)
|
||||
const updateShareCode = useWebAppStore(state => state.updateShareCode)
|
||||
const pathname = usePathname()
|
||||
@@ -69,7 +71,7 @@ const WebAppStoreProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
}, [shareCode, updateShareCode])
|
||||
|
||||
const { isFetching, data: accessModeResult } = useGetWebAppAccessModeByCode(shareCode)
|
||||
const [isFetchingAccessToken, setIsFetchingAccessToken] = useState(false)
|
||||
const [isFetchingAccessToken, setIsFetchingAccessToken] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (accessModeResult?.accessMode) {
|
||||
@@ -86,7 +88,7 @@ const WebAppStoreProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
}
|
||||
}, [accessModeResult, updateWebAppAccessMode, shareCode])
|
||||
|
||||
if (isFetching || isFetchingAccessToken) {
|
||||
if (isGlobalPending || isFetching || isFetchingAccessToken) {
|
||||
return <div className='flex h-full w-full items-center justify-center'>
|
||||
<Loading />
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import tailwind from 'eslint-plugin-tailwindcss'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import sonar from 'eslint-plugin-sonarjs'
|
||||
import oxlint from 'eslint-plugin-oxlint'
|
||||
import next from '@next/eslint-plugin-next'
|
||||
|
||||
// import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
|
||||
@@ -63,12 +64,14 @@ export default combine(
|
||||
}),
|
||||
unicorn(),
|
||||
node(),
|
||||
// use nextjs config will break @eslint/config-inspector
|
||||
// use `ESLINT_CONFIG_INSPECTOR=true pnpx @eslint/config-inspector` to check the config
|
||||
// ...process.env.ESLINT_CONFIG_INSPECTOR
|
||||
// ? []
|
||||
// Next.js configuration
|
||||
{
|
||||
plugins: {
|
||||
'@next/next': next,
|
||||
},
|
||||
rules: {
|
||||
...next.configs.recommended.rules,
|
||||
...next.configs['core-web-vitals'].rules,
|
||||
// performance issue, and not used.
|
||||
'@next/next/no-html-link-for-pages': 'off',
|
||||
},
|
||||
|
||||
@@ -545,6 +545,7 @@ export type Segment = {
|
||||
keywords: string[]
|
||||
hit_count: number
|
||||
index_node_hash: string
|
||||
answer: string
|
||||
}
|
||||
|
||||
export type Document = {
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"start": "cp -r .next/static .next/standalone/.next/static && cp -r public .next/standalone/public && cross-env PORT=$npm_config_port HOSTNAME=$npm_config_host node .next/standalone/server.js",
|
||||
"lint": "pnpx oxlint && pnpm eslint --cache --cache-location node_modules/.cache/eslint/.eslint-cache",
|
||||
"lint-only-show-error": "pnpx oxlint && pnpm eslint --cache --cache-location node_modules/.cache/eslint/.eslint-cache --quiet",
|
||||
"fix": "next lint --fix",
|
||||
"fix": "eslint --fix .",
|
||||
"eslint-fix": "eslint --cache --cache-location node_modules/.cache/eslint/.eslint-cache --fix",
|
||||
"eslint-fix-only-show-error": "eslint --cache --cache-location node_modules/.cache/eslint/.eslint-cache --fix --quiet",
|
||||
"eslint-complexity": "eslint --rule 'complexity: [error, {max: 15}]' --quiet",
|
||||
@@ -103,14 +103,14 @@
|
||||
"mime": "^4.0.4",
|
||||
"mitt": "^3.0.1",
|
||||
"negotiator": "^0.6.3",
|
||||
"next": "~15.3.5",
|
||||
"next": "15.5.0",
|
||||
"next-themes": "^0.4.3",
|
||||
"pinyin-pro": "^3.25.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"qs": "^6.13.0",
|
||||
"react": "~19.1.0",
|
||||
"react": "19.1.1",
|
||||
"react-18-input-autosize": "^3.0.0",
|
||||
"react-dom": "~19.1.0",
|
||||
"react-dom": "19.1.1",
|
||||
"react-easy-crop": "^5.1.0",
|
||||
"react-error-boundary": "^4.1.2",
|
||||
"react-headless-pagination": "^1.1.6",
|
||||
@@ -161,9 +161,9 @@
|
||||
"@happy-dom/jest-environment": "^17.4.4",
|
||||
"@mdx-js/loader": "^3.1.0",
|
||||
"@mdx-js/react": "^3.1.0",
|
||||
"@next/bundle-analyzer": "^15.4.1",
|
||||
"@next/eslint-plugin-next": "~15.4.5",
|
||||
"@next/mdx": "~15.3.5",
|
||||
"@next/bundle-analyzer": "15.5.0",
|
||||
"@next/eslint-plugin-next": "15.5.0",
|
||||
"@next/mdx": "15.5.0",
|
||||
"@rgrove/parse-xml": "^4.1.0",
|
||||
"@storybook/addon-essentials": "8.5.0",
|
||||
"@storybook/addon-interactions": "8.5.0",
|
||||
@@ -185,8 +185,8 @@
|
||||
"@types/negotiator": "^0.6.3",
|
||||
"@types/node": "18.15.0",
|
||||
"@types/qs": "^6.9.16",
|
||||
"@types/react": "~19.1.8",
|
||||
"@types/react-dom": "~19.1.6",
|
||||
"@types/react": "19.1.11",
|
||||
"@types/react-dom": "19.1.7",
|
||||
"@types/react-slider": "^1.3.6",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/react-window": "^1.8.8",
|
||||
@@ -200,7 +200,7 @@
|
||||
"code-inspector-plugin": "^0.18.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^9.32.0",
|
||||
"eslint-config-next": "~15.4.5",
|
||||
"eslint-config-next": "15.5.0",
|
||||
"eslint-plugin-oxlint": "^1.6.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
@@ -223,8 +223,8 @@
|
||||
"uglify-js": "^3.19.3"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/react": "~19.1.8",
|
||||
"@types/react-dom": "~19.1.6",
|
||||
"@types/react": "19.1.11",
|
||||
"@types/react-dom": "19.1.7",
|
||||
"string-width": "4.2.3"
|
||||
},
|
||||
"lint-staged": {
|
||||
|
||||
1121
web/pnpm-lock.yaml
generated
1121
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -398,9 +398,7 @@ export const ssePost = async (
|
||||
.then((res) => {
|
||||
if (!/^[23]\d{2}$/.test(String(res.status))) {
|
||||
if (res.status === 401) {
|
||||
refreshAccessTokenOrRelogin(TIME_OUT).then(() => {
|
||||
ssePost(url, fetchOptions, otherOptions)
|
||||
}).catch(() => {
|
||||
if (isPublicAPI) {
|
||||
res.json().then((data: any) => {
|
||||
if (isPublicAPI) {
|
||||
if (data.code === 'web_app_access_denied')
|
||||
@@ -417,7 +415,14 @@ export const ssePost = async (
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
else {
|
||||
refreshAccessTokenOrRelogin(TIME_OUT).then(() => {
|
||||
ssePost(url, fetchOptions, otherOptions)
|
||||
}).catch((err) => {
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
else {
|
||||
res.json().then((data) => {
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { fetchAppInfo, fetchAppMeta, fetchAppParams, getAppAccessModeByAppCode } from './share'
|
||||
|
||||
const NAME_SPACE = 'webapp'
|
||||
|
||||
export const useGetWebAppAccessModeByCode = (code: string | null) => {
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
return useQuery({
|
||||
queryKey: [NAME_SPACE, 'appAccessMode', code],
|
||||
queryFn: () => {
|
||||
if (systemFeatures.webapp_auth.enabled === false) {
|
||||
return {
|
||||
accessMode: AccessMode.PUBLIC,
|
||||
}
|
||||
}
|
||||
if (!code || code.length === 0)
|
||||
return Promise.reject(new Error('App code is required to get access mode'))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user