Merge branch 'main' into feat/dify-builder

This commit is contained in:
twwu committed 2026-09-17 17:01:34 +08:00
commit 782688563d
266 files changed
+9891 -3597

No files matched your search

+28 -1
View File
@@ -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,
+79 -8
View File
@@ -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
+2 -2
View File
@@ -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)
+3 -2
View File
@@ -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),
+8 -6
View File
@@ -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