mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
Merge branch 'main' into feat/dify-builder
This commit is contained in:
266 files changed
+9891
-3597
No files matched your search
@@ -4,6 +4,7 @@ from collections.abc import Sequence
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from flask import send_file
|
||||
from flask_restx import Resource
|
||||
from pydantic import AliasChoices, BaseModel, Field, ValidationInfo, computed_field, field_validator, model_validator
|
||||
from sqlalchemy import select
|
||||
@@ -47,10 +48,12 @@ from extensions.ext_application_services import application_services
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from graphon.enums import WorkflowExecutionStatus
|
||||
from libs.flask_restx_compat import BINARY_RESPONSE_MEDIA_TYPES_VENDOR_KEY
|
||||
from libs.helper import build_icon_url, dump_response, to_timestamp
|
||||
from libs.login import login_required
|
||||
from models import Account, App, DatasetPermissionEnum, Workflow
|
||||
from models.model import IconType
|
||||
from models.model import AppMode, IconType
|
||||
from services.agent.roster_package_exporter import RosterAgentPackageExporter
|
||||
from services.app_dsl_service import AppDslService
|
||||
from services.app_service import (
|
||||
AppListParams,
|
||||
@@ -180,6 +183,9 @@ class CopyAppPayload(BaseModel):
|
||||
|
||||
|
||||
class AppExportQuery(BaseModel):
|
||||
format: Literal["yaml", "ifpkg"] | None = Field(
|
||||
default=None, description="Export format; defaults to ifpkg for Agent Apps and yaml for other Apps"
|
||||
)
|
||||
include_secret: bool = Field(default=False, description="Include secrets in export")
|
||||
workflow_id: str | None = Field(default=None, description="Specific workflow ID to export")
|
||||
|
||||
@@ -1027,6 +1033,10 @@ class AppExportApi(Resource):
|
||||
@console_ns.doc(description="Export application configuration as DSL")
|
||||
@console_ns.doc(params={"app_id": "Application ID to export"})
|
||||
@console_ns.doc(params=query_params_from_model(AppExportQuery))
|
||||
@console_ns.doc(
|
||||
produces=["application/json", "application/zip"],
|
||||
vendor={BINARY_RESPONSE_MEDIA_TYPES_VENDOR_KEY: ["application/zip"]},
|
||||
)
|
||||
@console_ns.response(200, "App exported successfully", console_ns.models[AppExportResponse.__name__])
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@setup_required
|
||||
@@ -1042,6 +1052,23 @@ class AppExportApi(Resource):
|
||||
def get(self, req_data: AppExportQuery, app_model: App):
|
||||
"""Export app"""
|
||||
|
||||
if req_data.format == "ifpkg" or (req_data.format is None and app_model.mode == AppMode.AGENT):
|
||||
if app_model.mode != AppMode.AGENT:
|
||||
raise BadRequest("The ifpkg format is only available for Agent Apps")
|
||||
agent_id = app_model.bound_agent_id_with_session(session=db.session())
|
||||
if agent_id is None:
|
||||
raise NotFound("Agent not found")
|
||||
exported = RosterAgentPackageExporter().export(tenant_id=app_model.tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
archive_response = send_file(
|
||||
exported.archive, mimetype="application/zip", as_attachment=True, download_name=exported.filename
|
||||
)
|
||||
except Exception:
|
||||
exported.close()
|
||||
raise
|
||||
archive_response.call_on_close(exported.close)
|
||||
return archive_response
|
||||
|
||||
response = AppExportResponse(
|
||||
data=AppDslService.export_dsl(
|
||||
app_model=app_model,
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
from typing import BinaryIO, Literal, cast
|
||||
from uuid import uuid4
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -5,23 +9,27 @@ from werkzeug.exceptions import Forbidden
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.common.rbac import PlainApp, RBACCheck, Workspace
|
||||
from controllers.common.schema import register_enum_models, register_schema_models
|
||||
from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
account_initialization_required,
|
||||
cloud_edition_billing_resource_check,
|
||||
edit_permission_required,
|
||||
model_validate,
|
||||
rbac_permission_required,
|
||||
setup_required,
|
||||
validate_request,
|
||||
with_current_user,
|
||||
)
|
||||
from core.plugin.entities.plugin import PluginDependency
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_redis import redis_client
|
||||
from fields.base import ResponseModel
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from models.account import Account
|
||||
from models.model import App
|
||||
from models.model import App, AppMode
|
||||
from services.agent.errors import InvalidRosterAgentPackageError
|
||||
from services.agent.roster_package_importer import RosterAgentPackageImporter
|
||||
from services.app_dsl_service import (
|
||||
IMPORT_INFO_REDIS_KEY_PREFIX,
|
||||
AppDslService,
|
||||
@@ -49,8 +57,16 @@ class AppImportPayload(BaseModel):
|
||||
app_id: str | None = Field(None)
|
||||
|
||||
|
||||
class RosterAgentPackageConflictResponse(ResponseModel):
|
||||
code: str
|
||||
message: str
|
||||
status: Literal[409] = 409
|
||||
leaked_dependencies: list[PluginDependency] = Field(default_factory=list)
|
||||
|
||||
|
||||
register_enum_models(console_ns, ImportStatus)
|
||||
register_schema_models(console_ns, AppImportPayload, Import, CheckDependenciesResult)
|
||||
register_response_schema_models(console_ns, RosterAgentPackageConflictResponse)
|
||||
|
||||
|
||||
def _current_user_and_tenant_id(current_user: Account | None) -> tuple[Account, str | None]:
|
||||
@@ -73,19 +89,74 @@ def _current_user_and_tenant_id(current_user: Account | None) -> tuple[Account,
|
||||
|
||||
@console_ns.route("/apps/imports")
|
||||
class AppImportApi(Resource):
|
||||
@console_ns.expect(console_ns.models[AppImportPayload.__name__])
|
||||
@console_ns.doc(
|
||||
params={
|
||||
"payload": {
|
||||
"required": True,
|
||||
"content": {
|
||||
"application/json": {"schema": {"$ref": "#/definitions/AppImportPayload"}},
|
||||
"multipart/form-data": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file": {
|
||||
"type": "string",
|
||||
"format": "binary",
|
||||
"description": "Roster Agent .ifpkg archive",
|
||||
}
|
||||
},
|
||||
"required": ["file"],
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
@console_ns.response(200, "Import completed", console_ns.models[Import.__name__])
|
||||
@console_ns.response(202, "Import pending confirmation", console_ns.models[Import.__name__])
|
||||
@console_ns.response(400, "Import failed", console_ns.models[Import.__name__])
|
||||
@console_ns.response(403, "Insufficient import or plugin installation permissions")
|
||||
@console_ns.response(
|
||||
409, "Agent name conflict or missing plugins", console_ns.models[RosterAgentPackageConflictResponse.__name__]
|
||||
)
|
||||
@console_ns.response(413, "Roster Agent package exceeds the size limit")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@cloud_edition_billing_resource_check("apps")
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.APP_IMPORT_EXPORT_DSL, Workspace()))
|
||||
@with_current_user
|
||||
@model_validate(AppImportPayload)
|
||||
def post(self, req_data: AppImportPayload, current_user: Account | None = None):
|
||||
def post(self, current_user: Account):
|
||||
if request.mimetype == "multipart/form-data":
|
||||
return self._import_package(current_user)
|
||||
return self._import_dsl(validate_request(AppImportPayload), current_user)
|
||||
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.AGENT_CREATE, Workspace()))
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.AGENT_IMPORT_EXPORT_DSL, Workspace()))
|
||||
def _import_package(self, current_user: Account):
|
||||
uploaded = request.files.get("file")
|
||||
if uploaded is None or not uploaded.filename:
|
||||
raise InvalidRosterAgentPackageError("Roster Agent package file is required")
|
||||
if not uploaded.filename.lower().endswith(".ifpkg"):
|
||||
raise InvalidRosterAgentPackageError("Roster Agent package file must use the .ifpkg extension")
|
||||
if request.form.get("app_id"):
|
||||
raise InvalidRosterAgentPackageError("Roster Agent package import does not support overwriting an App")
|
||||
account, tenant_id = _current_user_and_tenant_id(current_user)
|
||||
if tenant_id is None:
|
||||
raise Forbidden("Current workspace is required")
|
||||
result = RosterAgentPackageImporter().import_package(
|
||||
source=cast(BinaryIO, uploaded.stream), tenant_id=tenant_id, account=account
|
||||
)
|
||||
return Import(
|
||||
id=str(uuid4()),
|
||||
status=ImportStatus.COMPLETED_WITH_WARNINGS if result.warnings else ImportStatus.COMPLETED,
|
||||
app_id=result.app_id,
|
||||
app_mode=AppMode.AGENT,
|
||||
warnings=result.warnings,
|
||||
).model_dump(mode="json"), 200
|
||||
|
||||
@cloud_edition_billing_resource_check("apps")
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.APP_IMPORT_EXPORT_DSL, Workspace()))
|
||||
def _import_dsl(self, req_data: AppImportPayload, current_user: Account | None = None):
|
||||
current_user = current_user if current_user is not None else _current_user_and_tenant_id(None)[0]
|
||||
|
||||
# AppDslService performs internal commits for some creation paths, so use a plain
|
||||
|
||||
@@ -123,7 +123,7 @@ class AppMCPServerController(Resource):
|
||||
status=AppMCPServerStatus.ACTIVE,
|
||||
app_id=app_model.id,
|
||||
tenant_id=current_tenant_id,
|
||||
server_code=AppMCPServer.generate_server_code(16),
|
||||
server_code=AppMCPServer.generate_server_code(16, session=db.session()),
|
||||
)
|
||||
db.session.add(server)
|
||||
db.session.commit()
|
||||
@@ -201,6 +201,6 @@ class AppMCPServerRefreshController(Resource):
|
||||
)
|
||||
if not server:
|
||||
raise NotFound()
|
||||
server.server_code = AppMCPServer.generate_server_code(16)
|
||||
server.server_code = AppMCPServer.generate_server_code(16, session=db.session())
|
||||
db.session.commit()
|
||||
return dump_response(AppMCPServerResponse, server)
|
||||
@@ -7,6 +7,7 @@ from controllers.common.schema import query_params_from_model, register_schema_m
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.auth.error import InvitationAccountMismatchError as InvitationAccountMismatchHTTPError
|
||||
from controllers.console.error import AccountInFreezeError, AlreadyActivateError, EmailDomainSuspendedError
|
||||
from controllers.console.wraps import model_validate
|
||||
from extensions.ext_application_services import application_services
|
||||
from libs.helper import EmailStr, dump_response, timezone
|
||||
from libs.login import current_account_with_tenant
|
||||
@@ -88,8 +89,8 @@ class ActivateCheckApi(Resource):
|
||||
"Success",
|
||||
console_ns.models[ActivationCheckResponse.__name__],
|
||||
)
|
||||
def get(self):
|
||||
args = ActivateCheckQuery.model_validate(request.args.to_dict(flat=True))
|
||||
@model_validate(ActivateCheckQuery)
|
||||
def get(self, args: ActivateCheckQuery):
|
||||
result = application_services().account_activation.check(
|
||||
InvitationLookup(
|
||||
workspace_id=args.workspace_id,
|
||||
|
||||
@@ -14,7 +14,7 @@ from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from typing import Any, Concatenate
|
||||
|
||||
from flask import Response, request
|
||||
from flask import Response
|
||||
from flask_restx import Resource
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
@@ -106,9 +106,10 @@ class SnippetWorkflowVariableCollectionApi(Resource):
|
||||
console_ns.models[WorkflowDraftVariableListWithoutValueResponse.__name__],
|
||||
)
|
||||
@_snippet_draft_var_prerequisite
|
||||
def get(self, current_user: Account, snippet: CustomizedSnippet) -> dict[str, Any]:
|
||||
args = WorkflowDraftVariableListQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
|
||||
|
||||
@model_validate(WorkflowDraftVariableListQuery)
|
||||
def get(
|
||||
self, args: WorkflowDraftVariableListQuery, current_user: Account, snippet: CustomizedSnippet
|
||||
) -> dict[str, Any]:
|
||||
snippet_service = _snippet_service()
|
||||
if snippet_service.get_draft_workflow(snippet=snippet) is None:
|
||||
raise DraftWorkflowNotExist()
|
||||
|
||||
@@ -2,7 +2,7 @@ import os
|
||||
from urllib.parse import quote
|
||||
from uuid import UUID
|
||||
|
||||
from flask import Response, request
|
||||
from flask import Response
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
from werkzeug.exceptions import NotFound
|
||||
@@ -10,6 +10,7 @@ from werkzeug.exceptions import NotFound
|
||||
from controllers.common.errors import UnsupportedFileTypeError
|
||||
from controllers.common.file_response import enforce_download_for_html
|
||||
from controllers.common.schema import query_params_from_model, register_schema_models
|
||||
from controllers.console.wraps import model_validate
|
||||
from controllers.files import files_ns
|
||||
from extensions.ext_application_services import application_services
|
||||
from services.errors.file import UnsupportedFileTypeError as UnsupportedFileTypeServiceError
|
||||
@@ -74,13 +75,13 @@ class ImagePreviewApi(Resource):
|
||||
@files_ns.doc(
|
||||
responses={
|
||||
200: "Image preview returned successfully",
|
||||
400: "Missing or invalid query parameters",
|
||||
404: "File not found or signature is invalid",
|
||||
415: "Unsupported file type",
|
||||
422: "Missing or invalid query parameters",
|
||||
}
|
||||
)
|
||||
def get(self, file_id: UUID) -> Response:
|
||||
args = FileSignatureQuery.model_validate(request.args.to_dict(flat=True))
|
||||
@model_validate(FileSignatureQuery)
|
||||
def get(self, args: FileSignatureQuery, file_id: UUID) -> Response:
|
||||
try:
|
||||
delivery = application_services().upload_file_delivery.get_signed_image_preview(
|
||||
file_id=str(file_id),
|
||||
@@ -109,13 +110,12 @@ class FilePreviewApi(Resource):
|
||||
@files_ns.doc(
|
||||
responses={
|
||||
200: "File stream returned successfully",
|
||||
400: "Missing or invalid query parameters",
|
||||
404: "File not found or signature is invalid",
|
||||
422: "Missing or invalid query parameters",
|
||||
}
|
||||
)
|
||||
def get(self, file_id: UUID) -> Response:
|
||||
args = FilePreviewQuery.model_validate(request.args.to_dict(flat=True))
|
||||
|
||||
@model_validate(FilePreviewQuery)
|
||||
def get(self, args: FilePreviewQuery, file_id: UUID) -> Response:
|
||||
try:
|
||||
delivery = application_services().upload_file_delivery.get_signed_file_preview(
|
||||
file_id=str(file_id),
|
||||
|
||||
@@ -88,7 +88,7 @@ class MCPAppApi(Resource):
|
||||
self._validate_server_status(mcp_server)
|
||||
|
||||
# Get user input form
|
||||
user_input_form = self._get_user_input_form(app)
|
||||
user_input_form = self._get_user_input_form(app, session)
|
||||
|
||||
# Handle notification vs request differently
|
||||
return self._process_mcp_message(
|
||||
@@ -180,17 +180,19 @@ class MCPAppApi(Resource):
|
||||
|
||||
return helper.compact_generate_response(result.model_dump(by_alias=True, mode="json", exclude_none=True))
|
||||
|
||||
def _get_user_input_form(self, app: App) -> list[VariableEntity]:
|
||||
def _get_user_input_form(self, app: App, session: Session) -> list[VariableEntity]:
|
||||
"""Get and convert user input form"""
|
||||
# Get raw user input form based on app mode
|
||||
if app.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
|
||||
if not app.workflow:
|
||||
workflow = app.workflow_with_session(session=session)
|
||||
if not workflow:
|
||||
raise MCPRequestError(mcp_types.INVALID_REQUEST, "App is unavailable")
|
||||
raw_user_input_form = app.workflow.user_input_form(to_old_structure=True)
|
||||
raw_user_input_form = workflow.user_input_form(to_old_structure=True)
|
||||
else:
|
||||
if not app.app_model_config:
|
||||
app_model_config = app.app_model_config_with_session(session=session)
|
||||
if not app_model_config:
|
||||
raise MCPRequestError(mcp_types.INVALID_REQUEST, "App is unavailable")
|
||||
features_dict = app.app_model_config.to_dict()
|
||||
features_dict = app_model_config.to_dict()
|
||||
raw_user_input_form = features_dict.get("user_input_form", [])
|
||||
|
||||
# Convert to VariableEntity objects
|
||||
|
||||
Reference in new issue
Block a user