refactor(api): migrate web chat endpoints to BaseModel (#37962)

This commit is contained in:
chariri 2026-07-09 13:23:13 +09:00 committed by GitHub
parent 89d5f74a40
commit 458aa4892d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 770 additions and 568 deletions

View File

@ -1,13 +1,11 @@
import logging
from flask import request
from flask_restx import fields, marshal_with
from pydantic import field_validator
from werkzeug.exceptions import InternalServerError
import services
from controllers.common.controller_schemas import TextToAudioPayload as TextToAudioPayloadBase
from controllers.common.fields import AudioBinaryResponse, AudioTranscriptResponse
from controllers.web import web_ns
from controllers.web.error import (
AppUnavailableError,
@ -23,8 +21,9 @@ from controllers.web.error import (
from controllers.web.wraps import WebApiResource
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from extensions.ext_database import db
from fields.base import ResponseModel
from graphon.model_runtime.errors.invoke import InvokeError
from libs.helper import uuid_value
from libs.helper import dump_response, uuid_value
from models.model import App, EndUser
from services.app_ref_service import AppRefService
from services.audio_service import AudioService
@ -38,6 +37,10 @@ from services.errors.audio import (
from ..common.schema import register_response_schema_models, register_schema_models
class AudioToTextResponse(ResponseModel):
text: str
class TextToAudioPayload(TextToAudioPayloadBase):
@field_validator("message_id")
@classmethod
@ -48,18 +51,13 @@ class TextToAudioPayload(TextToAudioPayloadBase):
register_schema_models(web_ns, TextToAudioPayload)
register_response_schema_models(web_ns, AudioBinaryResponse, AudioTranscriptResponse)
register_response_schema_models(web_ns, AudioToTextResponse)
logger = logging.getLogger(__name__)
@web_ns.route("/audio-to-text")
class AudioApi(WebApiResource):
audio_to_text_response_fields = {
"text": fields.String,
}
@marshal_with(audio_to_text_response_fields)
@web_ns.doc("Audio to Text")
@web_ns.doc(description="Convert audio file to text using speech-to-text service.")
@web_ns.doc(
@ -73,7 +71,7 @@ class AudioApi(WebApiResource):
500: "Internal Server Error",
}
)
@web_ns.response(200, "Success", web_ns.models[AudioTranscriptResponse.__name__])
@web_ns.response(200, "Success", web_ns.models[AudioToTextResponse.__name__])
def post(self, app_model: App, end_user: EndUser):
"""Convert audio to text"""
file = request.files["file"]
@ -81,7 +79,7 @@ class AudioApi(WebApiResource):
try:
response = AudioService.transcript_asr(app_model=app_model, file=file, end_user=end_user.external_user_id)
return response
return dump_response(AudioToTextResponse, response)
except services.errors.app_model_config.AppModelConfigBrokenError:
logger.exception("App model config broken.")
raise AppUnavailableError()
@ -122,7 +120,8 @@ class TextApi(WebApiResource):
500: "Internal Server Error",
}
)
@web_ns.response(200, "Success", web_ns.models[AudioBinaryResponse.__name__])
# response-contract:ignore provider audio bytes; TODO: model binary audio response if shape is standardized.
@web_ns.response(200, "Success")
def post(self, app_model: App, end_user: EndUser):
"""Convert text to audio"""
try:
@ -139,7 +138,7 @@ class TextApi(WebApiResource):
message_id,
end_user_id=end_user.id,
)
response = AudioService.transcript_tts(
return AudioService.transcript_tts(
app_model=app_model,
session=db.session(),
text=text,
@ -147,8 +146,6 @@ class TextApi(WebApiResource):
end_user=end_user.external_user_id,
message_ref=message_ref,
)
return response
except services.errors.app_model_config.AppModelConfigBrokenError:
logger.exception("App model config broken.")
raise AppUnavailableError()

View File

@ -133,6 +133,7 @@ class CompletionApi(WebApiResource):
streaming=streaming,
)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(response)
except services.errors.conversation.ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
@ -185,7 +186,7 @@ class CompletionStopApi(WebApiResource):
app_mode=AppMode.value_of(app_model.mode),
)
return {"result": "success"}, 200
return SimpleResultResponse(result="success").model_dump(mode="json"), 200
@web_ns.route("/chat-messages")
@ -235,6 +236,7 @@ class ChatApi(WebApiResource):
streaming=streaming,
)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(response)
except services.errors.conversation.ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
@ -290,4 +292,4 @@ class ChatStopApi(WebApiResource):
app_mode=app_mode,
)
return {"result": "success"}, 200
return SimpleResultResponse(result="success").model_dump(mode="json"), 200

View File

@ -13,6 +13,7 @@ from controllers.web import web_ns
from controllers.web.wraps import WebApiResource
from extensions.ext_database import db
from fields.file_fields import FileResponse
from libs.helper import dump_response
from models.model import App, EndUser
from services.file_service import FileService
@ -84,5 +85,4 @@ class FileApi(WebApiResource):
except services.errors.file.UnsupportedFileTypeError:
raise UnsupportedFileTypeError()
response = FileResponse.model_validate(upload_file, from_attributes=True)
return response.model_dump(mode="json"), 201
return dump_response(FileResponse, upload_file), 201

View File

@ -29,6 +29,7 @@ from extensions.ext_database import db
from fields.file_fields import FileResponse, FileWithSignedUrl
from graphon.file import helpers as file_helpers
from libs.exception import BaseHTTPException
from libs.helper import dump_response
from repositories.factory import DifyAPIRepositoryFactory
from services.file_service import FileService
from services.human_input_file_upload_service import (
@ -141,8 +142,7 @@ def _upload_local_file(context):
except services.errors.file.BlockedFileExtensionError as exc:
raise BlockedFileExtensionError() from exc
response = FileResponse.model_validate(upload_file, from_attributes=True)
return upload_file.id, response
return upload_file.id, dump_response(FileResponse, upload_file)
def _upload_remote_file(context, url: str):
@ -186,7 +186,7 @@ def _upload_remote_file(context, url: str):
created_by=upload_file.created_by,
created_at=int(upload_file.created_at.timestamp()),
)
return upload_file.id, response
return upload_file.id, response.model_dump(mode="json")
@web_ns.route("/human-input-forms/files")
@ -209,4 +209,5 @@ class HumanInputFileUploadApi(Resource):
file_id, response = _upload_local_file(context=context)
upload_service.record_upload_file(context=context, file_id=file_id)
return response.model_dump(mode="json"), 201
# response-contract:ignore pre-dumped response. See above
return response, 201

View File

@ -2,14 +2,12 @@
Web App Human Input Form APIs.
"""
import json
import logging
from collections.abc import Sequence
from typing import Any, NotRequired, TypedDict
from typing import Self
from flask import Response, request
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import select
from sqlalchemy.orm import sessionmaker
from werkzeug.exceptions import Forbidden
@ -20,35 +18,58 @@ from controllers.common.human_input import HumanInputFormSubmitPayload, stringif
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.web import web_ns
from controllers.web.error import WebFormRateLimitExceededError
from controllers.web.site import serialize_app_site_payload
from core.workflow.nodes.human_input.entities import FormInputConfig
from controllers.web.site import WebAppSiteResponse
from core.workflow.nodes.human_input.entities import FormInputConfig, UserActionConfig
from extensions.ext_database import db
from libs.helper import RateLimiter, extract_remote_ip, to_timestamp
from fields.base import ResponseModel
from libs.helper import RateLimiter, dump_response, extract_remote_ip, to_timestamp
from models.account import TenantStatus
from models.model import App, Site
from repositories.factory import DifyAPIRepositoryFactory
from services.feature_service import FeatureService
from services.human_input_file_upload_service import HumanInputFileUploadService
from services.human_input_service import Form, FormNotFoundError, HumanInputService
logger = logging.getLogger(__name__)
class HumanInputUploadTokenResponse(BaseModel):
class HumanInputUploadTokenResponse(ResponseModel):
upload_token: str
expires_at: int
class HumanInputFormDefinitionResponse(BaseModel):
form_content: Any
inputs: Any
class HumanInputFormDefinitionResponse(ResponseModel):
form_content: str
inputs: list[FormInputConfig]
resolved_default_values: dict[str, str]
user_actions: Any
user_actions: list[UserActionConfig]
expiration_time: int
site: dict[str, Any] | None = Field(default=None)
site: WebAppSiteResponse | None = None
@classmethod
def from_form(
cls,
form: Form,
*,
inputs: Sequence[FormInputConfig] = (),
site: WebAppSiteResponse | None = None,
) -> Self:
definition_payload = form.get_definition().model_dump(mode="json")
expiration_time = to_timestamp(form.expiration_time)
if expiration_time is None:
raise ValueError("Human input form expiration_time is required")
return cls(
form_content=definition_payload["rendered_content"],
inputs=list(inputs),
resolved_default_values=stringify_form_default_values(definition_payload["default_values"]),
user_actions=definition_payload["user_actions"],
expiration_time=expiration_time,
site=site,
)
class HumanInputFormSubmitResponse(BaseModel):
model_config = ConfigDict(extra="forbid")
class HumanInputFormSubmitResponse(ResponseModel):
pass
register_schema_models(web_ns, HumanInputFormSubmitPayload)
@ -86,40 +107,26 @@ def _create_upload_service() -> HumanInputFileUploadService:
)
class FormDefinitionPayload(TypedDict):
form_content: Any
inputs: Any
resolved_default_values: dict[str, str]
user_actions: Any
expiration_time: int
site: NotRequired[dict]
def _jsonify_form_definition(
form: Form,
*,
inputs: Sequence[FormInputConfig] = (),
site_payload: dict | None = None,
) -> Response:
"""Return the form payload (optionally with site) as a JSON response."""
definition_payload = form.get_definition().model_dump(mode="json")
payload: FormDefinitionPayload = {
"form_content": definition_payload["rendered_content"],
"inputs": [i.model_dump(mode="json") for i in inputs],
"resolved_default_values": stringify_form_default_values(definition_payload["default_values"]),
"user_actions": definition_payload["user_actions"],
"expiration_time": to_timestamp(form.expiration_time),
}
if site_payload is not None:
payload["site"] = site_payload
return Response(json.dumps(payload, ensure_ascii=False), mimetype="application/json")
@web_ns.route("/form/human_input/<string:form_token>/upload-token")
class HumanInputFormUploadTokenApi(Resource):
"""API for issuing HITL upload tokens for active human input forms."""
@web_ns.response(200, "Success", web_ns.models[HumanInputUploadTokenResponse.__name__])
@web_ns.doc("create_human_input_form_upload_token")
@web_ns.doc(description="Issue an upload token for an active human input form")
@web_ns.doc(params={"form_token": "Human input form token"})
@web_ns.doc(
responses={
200: "Upload token issued successfully",
404: "Form not found",
412: "Form already submitted or expired",
429: "Too many requests",
}
)
@web_ns.response(
200,
"Upload token issued successfully",
web_ns.models[HumanInputUploadTokenResponse.__name__],
)
def post(self, form_token: str):
"""
Issue an upload token for a human input form.
@ -136,11 +143,9 @@ class HumanInputFormUploadTokenApi(Resource):
except FormNotFoundError:
raise NotFoundError("Form not found")
response = HumanInputUploadTokenResponse(
upload_token=token.upload_token,
expires_at=to_timestamp(token.expires_at),
)
return response.model_dump(mode="json"), 200
return HumanInputUploadTokenResponse(
upload_token=token.upload_token, expires_at=to_timestamp(token.expires_at)
).model_dump(mode="json"), 200
@web_ns.route("/form/human_input/<string:form_token>")
@ -150,7 +155,23 @@ class HumanInputFormApi(Resource):
# NOTE(QuantumGhost): this endpoint is unauthenticated on purpose for now.
# def get(self, _app_model: App, _end_user: EndUser, form_token: str):
@web_ns.response(200, "Success", web_ns.models[HumanInputFormDefinitionResponse.__name__])
@web_ns.doc("get_human_input_form")
@web_ns.doc(description="Get a human input form definition by token")
@web_ns.doc(params={"form_token": "Human input form token"})
@web_ns.doc(
responses={
200: "Form retrieved successfully",
403: "Forbidden",
404: "Form not found",
412: "Form already submitted or expired",
429: "Too many requests",
}
)
@web_ns.response(
200,
"Form retrieved successfully",
web_ns.models[HumanInputFormDefinitionResponse.__name__],
)
def get(self, form_token: str):
"""
Get human input form definition by token.
@ -172,17 +193,47 @@ class HumanInputFormApi(Resource):
service.ensure_form_active(form)
app_model, site = _get_app_site_from_form(form)
tenant = app_model.tenant
if tenant is None:
raise Forbidden()
inputs = service.resolve_form_inputs(form)
features = FeatureService.get_features(app_model.tenant_id, exclude_vector_space=True)
return _jsonify_form_definition(
form,
inputs=inputs,
site_payload=serialize_app_site_payload(app_model, site, None),
return dump_response(
HumanInputFormDefinitionResponse,
HumanInputFormDefinitionResponse.from_form(
form,
inputs=inputs,
site=WebAppSiteResponse.from_app_site(
tenant=tenant,
app_model=app_model,
site=site,
end_user_id=None,
features=features,
can_replace_logo=features.can_replace_logo,
),
),
)
# def post(self, _app_model: App, _end_user: EndUser, form_token: str):
@web_ns.response(200, "Success", web_ns.models[HumanInputFormSubmitResponse.__name__])
@web_ns.expect(web_ns.models[HumanInputFormSubmitPayload.__name__])
@web_ns.doc("submit_human_input_form")
@web_ns.doc(description="Submit a human input form by token")
@web_ns.doc(params={"form_token": "Human input form token"})
@web_ns.doc(
responses={
200: "Form submitted successfully",
400: "Bad request - invalid submission data",
404: "Form not found",
412: "Form already submitted or expired",
429: "Too many requests",
}
)
@web_ns.response(
200,
"Form submitted successfully",
web_ns.models[HumanInputFormSubmitResponse.__name__],
)
def post(self, form_token: str):
"""
Submit human input form by token.
@ -225,7 +276,7 @@ class HumanInputFormApi(Resource):
except FormNotFoundError:
raise NotFoundError("Form not found")
return {}, 200
return HumanInputFormSubmitResponse().model_dump(mode="json"), 200
def _get_app_site_from_form(form: Form) -> tuple[App, Site]:
@ -238,7 +289,7 @@ def _get_app_site_from_form(form: Form) -> tuple[App, Site]:
if site is None:
raise Forbidden()
if app_model.tenant and app_model.tenant.status == TenantStatus.ARCHIVE:
if app_model.tenant is None or app_model.tenant.status == TenantStatus.ARCHIVE:
raise Forbidden()
return app_model, site

View File

@ -188,6 +188,7 @@ class MessageMoreLikeThisApi(WebApiResource):
streaming=streaming,
)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(response)
except MessageNotExistsError:
raise NotFound("Message Not Exists.")

View File

@ -65,11 +65,10 @@ class RemoteFileInfoApi(WebApiResource):
# failed back to get method
resp = remote_fetcher.make_request("GET", decoded_url, timeout=3)
resp.raise_for_status()
info = RemoteFileInfo(
return RemoteFileInfo(
file_type=resp.headers.get("Content-Type", "application/octet-stream"),
file_length=int(resp.headers.get("Content-Length", -1)),
)
return info.model_dump(mode="json")
).model_dump(mode="json")
@web_ns.route("/remote-files/upload")
@ -141,7 +140,7 @@ class RemoteFileUploadApi(WebApiResource):
except services.errors.file.UnsupportedFileTypeError:
raise UnsupportedFileTypeError
payload1 = FileWithSignedUrl(
return FileWithSignedUrl(
id=upload_file.id,
name=upload_file.name,
size=upload_file.size,
@ -150,5 +149,4 @@ class RemoteFileUploadApi(WebApiResource):
mime_type=upload_file.mime_type,
created_by=upload_file.created_by,
created_at=int(upload_file.created_at.timestamp()),
)
return payload1.model_dump(mode="json"), 201
).model_dump(mode="json"), 201

View File

@ -49,9 +49,7 @@ class SavedMessageListApi(WebApiResource):
adapter = TypeAdapter(SavedMessageItem)
items = [adapter.validate_python(message, from_attributes=True) for message in pagination.data]
return SavedMessageInfiniteScrollPagination(
limit=pagination.limit,
has_more=pagination.has_more,
data=items,
limit=pagination.limit, has_more=pagination.has_more, data=items
).model_dump(mode="json")
@web_ns.doc("Save Message")
@ -102,6 +100,7 @@ class SavedMessageApi(WebApiResource):
500: "Internal Server Error",
}
)
@web_ns.response(204, "Message removed successfully")
def delete(self, app_model: App, end_user: EndUser, message_id: UUID):
message_id_str = str(message_id)

View File

@ -1,7 +1,6 @@
from typing import Any, cast
from typing import Any, Self
from flask_restx import fields, marshal, marshal_with
from pydantic import Field
from pydantic import AliasChoices, Field, computed_field
from sqlalchemy import select
from werkzeug.exceptions import Forbidden
@ -11,30 +10,19 @@ from controllers.web import web_ns
from controllers.web.wraps import WebApiResource
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.helper import AppIconUrlField
from models.account import TenantStatus
from libs.helper import build_icon_url
from models.account import Tenant, TenantStatus
from models.model import App, EndUser, Site
from services.feature_service import FeatureModel, FeatureService
class AppSiteModelConfigResponse(ResponseModel):
opening_statement: str | None = None
suggested_questions: Any
suggested_questions_after_answer: Any
more_like_this: Any
model: Any
user_input_form: Any
pre_prompt: str | None = None
class AppSiteResponse(ResponseModel):
title: str | None = None
class WebSiteResponse(ResponseModel):
title: str
chat_color_theme: str | None = None
chat_color_theme_inverted: bool | None = None
chat_color_theme_inverted: bool
icon_type: str | None = None
icon: str | None = None
icon_background: str | None = None
icon_url: str | None = None
description: str | None = None
copyright: str | None = None
privacy_policy: str | None = None
@ -45,65 +33,98 @@ class AppSiteResponse(ResponseModel):
show_workflow_steps: bool | None = None
use_icon_as_answer_icon: bool | None = None
@computed_field(return_type=str | None) # type: ignore[prop-decorator]
@property
def icon_url(self) -> str | None:
return build_icon_url(self.icon_type, self.icon)
class AppSiteInfoResponse(ResponseModel):
class WebModelConfigResponse(ResponseModel):
opening_statement: str | None = None
suggested_questions: Any = Field(
default=None,
validation_alias=AliasChoices("suggested_questions_list", "suggested_questions"),
)
suggested_questions_after_answer: Any = Field(
default=None,
validation_alias=AliasChoices("suggested_questions_after_answer_dict", "suggested_questions_after_answer"),
)
more_like_this: Any = Field(
default=None,
validation_alias=AliasChoices("more_like_this_dict", "more_like_this"),
)
model: Any = Field(default=None, validation_alias=AliasChoices("model_dict", "model"))
user_input_form: Any = Field(
default=None,
validation_alias=AliasChoices("user_input_form_list", "user_input_form"),
)
pre_prompt: str | None = None
class WebAppCustomConfigResponse(ResponseModel):
remove_webapp_brand: bool
replace_webapp_logo: str | None = None
class WebAppSiteResponse(ResponseModel):
app_id: str
end_user_id: str | None = None
enable_site: bool
site: AppSiteResponse
model_config_: AppSiteModelConfigResponse | None = Field(default=None, alias="model_config")
plan: str | None = None
site: WebSiteResponse
model_config_: WebModelConfigResponse | None = Field(
default=None, validation_alias="model_config", serialization_alias="model_config"
)
plan: str
can_replace_logo: bool
custom_config: dict[str, Any] | None = Field(default=None)
custom_config: WebAppCustomConfigResponse | None = None
@classmethod
def from_app_site(
cls,
*,
tenant: Tenant,
app_model: App,
site: Site,
end_user_id: str | None,
features: FeatureModel,
can_replace_logo: bool,
) -> Self:
custom_config = None
if can_replace_logo:
replace_webapp_logo = (
f"{dify_config.FILES_URL}/files/workspaces/{tenant.id}/webapp-logo"
if tenant.custom_config_dict.get("replace_webapp_logo")
else None
)
custom_config = WebAppCustomConfigResponse(
remove_webapp_brand=tenant.custom_config_dict.get("remove_webapp_brand", False),
replace_webapp_logo=replace_webapp_logo,
)
site_response = WebSiteResponse.model_validate(site, from_attributes=True)
if features.billing.enabled and not features.webapp_copyright_enabled:
site_response.copyright = None
site_response.input_placeholder = None
return cls(
app_id=app_model.id,
end_user_id=end_user_id,
enable_site=app_model.enable_site,
site=site_response,
model_config_=None,
plan=tenant.plan,
can_replace_logo=can_replace_logo,
custom_config=custom_config,
)
register_response_schema_models(web_ns, AppSiteInfoResponse)
register_response_schema_models(
web_ns, WebSiteResponse, WebModelConfigResponse, WebAppCustomConfigResponse, WebAppSiteResponse
)
@web_ns.route("/site")
class AppSiteApi(WebApiResource):
"""Resource for app sites."""
model_config_fields = {
"opening_statement": fields.String,
"suggested_questions": fields.Raw(attribute="suggested_questions_list"),
"suggested_questions_after_answer": fields.Raw(attribute="suggested_questions_after_answer_dict"),
"more_like_this": fields.Raw(attribute="more_like_this_dict"),
"model": fields.Raw(attribute="model_dict"),
"user_input_form": fields.Raw(attribute="user_input_form_list"),
"pre_prompt": fields.String,
}
site_fields = {
"title": fields.String,
"chat_color_theme": fields.String,
"chat_color_theme_inverted": fields.Boolean,
"icon_type": fields.String,
"icon": fields.String,
"icon_background": fields.String,
"icon_url": AppIconUrlField,
"description": fields.String,
"copyright": fields.String,
"privacy_policy": fields.String,
"input_placeholder": fields.String,
"custom_disclaimer": fields.String,
"default_language": fields.String,
"prompt_public": fields.Boolean,
"show_workflow_steps": fields.Boolean,
"use_icon_as_answer_icon": fields.Boolean,
}
app_fields = {
"app_id": fields.String,
"end_user_id": fields.String,
"enable_site": fields.Boolean,
"site": fields.Nested(site_fields),
"model_config": fields.Nested(model_config_fields, allow_null=True),
"plan": fields.String,
"can_replace_logo": fields.Boolean,
"custom_config": fields.Raw(attribute="custom_config"),
}
@web_ns.doc("Get App Site Info")
@web_ns.doc(description="Retrieve app site information and configuration.")
@web_ns.doc(
@ -116,79 +137,26 @@ class AppSiteApi(WebApiResource):
500: "Internal Server Error",
}
)
@web_ns.response(200, "Success", web_ns.models[AppSiteInfoResponse.__name__])
@marshal_with(app_fields)
@web_ns.response(200, "Success", web_ns.models[WebAppSiteResponse.__name__])
def get(self, app_model: App, end_user: EndUser):
"""Retrieve app site info."""
# get site
site = db.session.scalar(select(Site).where(Site.app_id == app_model.id).limit(1))
if not site:
if site is None:
raise Forbidden()
if app_model.tenant and app_model.tenant.status == TenantStatus.ARCHIVE:
tenant = app_model.tenant
if tenant is None or tenant.status == TenantStatus.ARCHIVE:
raise Forbidden()
features = FeatureService.get_features(app_model.tenant_id, exclude_vector_space=True)
return AppSiteInfo(
app_model.tenant,
app_model,
serialize_runtime_site(site, features),
end_user.id,
features.can_replace_logo,
)
class AppSiteInfo:
"""Class to store site information."""
def __init__(self, tenant, app, site, end_user, can_replace_logo):
"""Initialize AppSiteInfo instance."""
self.app_id = app.id
self.end_user_id = end_user
self.enable_site = app.enable_site
self.site = site
self.model_config = None
self.plan = tenant.plan
self.can_replace_logo = can_replace_logo
if can_replace_logo:
base_url = dify_config.FILES_URL
remove_webapp_brand = tenant.custom_config_dict.get("remove_webapp_brand", False)
replace_webapp_logo = (
f"{base_url}/files/workspaces/{tenant.id}/webapp-logo"
if tenant.custom_config_dict.get("replace_webapp_logo")
else None
)
self.custom_config = {
"remove_webapp_brand": remove_webapp_brand,
"replace_webapp_logo": replace_webapp_logo,
}
def serialize_site(site: Site) -> dict[str, Any]:
"""Serialize Site model using the same schema as AppSiteApi."""
return cast(dict[str, Any], marshal(site, AppSiteApi.site_fields))
def serialize_runtime_site(site: Site, features: FeatureModel) -> dict[str, Any]:
site_payload = serialize_site(site)
if not features.billing.enabled or features.webapp_copyright_enabled:
return site_payload
site_payload["copyright"] = None
site_payload["input_placeholder"] = None
return site_payload
def serialize_app_site_payload(app_model: App, site: Site, end_user_id: str | None) -> dict[str, Any]:
features = FeatureService.get_features(app_model.tenant_id, exclude_vector_space=True)
app_site_info = AppSiteInfo(
app_model.tenant,
app_model,
serialize_runtime_site(site, features),
end_user_id,
features.can_replace_logo,
)
return cast(dict[str, Any], marshal(app_site_info, AppSiteApi.app_fields))
return WebAppSiteResponse.from_app_site(
tenant=tenant,
app_model=app_model,
site=site,
end_user_id=end_user.id,
features=features,
can_replace_logo=features.can_replace_logo,
).model_dump(mode="json")

View File

@ -76,6 +76,7 @@ class WorkflowRunApi(WebApiResource):
streaming=True,
)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(response)
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
@ -129,4 +130,4 @@ class WorkflowTaskStopApi(WebApiResource):
# New graph engine command channel mechanism
GraphEngineManager(redis_client).send_stop_command(task_id)
return {"result": "success"}
return SimpleResultResponse(result="success").model_dump(mode="json")

View File

@ -11,14 +11,14 @@ from libs.helper import to_timestamp
class UploadConfig(ResponseModel):
file_size_limit: int
batch_count_limit: int
file_upload_limit: int | None = None
file_upload_limit: int
image_file_size_limit: int
video_file_size_limit: int
audio_file_size_limit: int
workflow_file_upload_limit: int
image_file_batch_limit: int
single_chunk_attachment_limit: int
attachment_image_file_size_limit: int | None = None
attachment_image_file_size_limit: int
class FileResponse(ResponseModel):

View File

@ -22489,11 +22489,11 @@ Payload for updating a snippet.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| attachment_image_file_size_limit | integer | | No |
| attachment_image_file_size_limit | integer | | Yes |
| audio_file_size_limit | integer | | Yes |
| batch_count_limit | integer | | Yes |
| file_size_limit | integer | | Yes |
| file_upload_limit | integer | | No |
| file_upload_limit | integer | | Yes |
| image_file_batch_limit | integer | | Yes |
| image_file_size_limit | integer | | Yes |
| single_chunk_attachment_limit | integer | | Yes |

View File

@ -21,7 +21,7 @@ Convert audio file to text using speech-to-text service.
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [AudioTranscriptResponse](#audiotranscriptresponse)<br> |
| 200 | Success | **application/json**: [AudioToTextResponse](#audiototextresponse)<br> |
| 400 | Bad Request | |
| 401 | Unauthorized | |
| 403 | Forbidden | |
@ -346,23 +346,29 @@ Verify password reset token validity
### [GET] /form/human_input/{form_token}
**Get human input form definition by token**
Get a human input form definition by token
GET /api/form/human_input/<form_token>
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| form_token | path | | Yes | string |
| form_token | path | Human input form token | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [HumanInputFormDefinitionResponse](#humaninputformdefinitionresponse)<br> |
| 200 | Form retrieved successfully | **application/json**: [HumanInputFormDefinitionResponse](#humaninputformdefinitionresponse)<br> |
| 403 | Forbidden | |
| 404 | Form not found | |
| 412 | Form already submitted or expired | |
| 429 | Too many requests | |
### [POST] /form/human_input/{form_token}
**Submit human input form by token**
Submit a human input form by token
POST /api/form/human_input/<form_token>
Request body:
@ -377,7 +383,7 @@ Request body:
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| form_token | path | | Yes | string |
| form_token | path | Human input form token | Yes | string |
#### Request Body
@ -389,24 +395,32 @@ Request body:
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [HumanInputFormSubmitResponse](#humaninputformsubmitresponse)<br> |
| 200 | Form submitted successfully | **application/json**: [HumanInputFormSubmitResponse](#humaninputformsubmitresponse)<br> |
| 400 | Bad request - invalid submission data | |
| 404 | Form not found | |
| 412 | Form already submitted or expired | |
| 429 | Too many requests | |
### [POST] /form/human_input/{form_token}/upload-token
**Issue an upload token for a human input form**
Issue an upload token for an active human input form
POST /api/form/human_input/<form_token>/upload-token
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| form_token | path | | Yes | string |
| form_token | path | Human input form token | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [HumanInputUploadTokenResponse](#humaninputuploadtokenresponse)<br> |
| 200 | Upload token issued successfully | **application/json**: [HumanInputUploadTokenResponse](#humaninputuploadtokenresponse)<br> |
| 404 | Form not found | |
| 412 | Form already submitted or expired | |
| 429 | Too many requests | |
### [POST] /human-input-forms/files
**Upload one local file or remote URL file for a HITL human input form**
@ -752,7 +766,7 @@ Retrieve app site information and configuration.
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [AppSiteInfoResponse](#appsiteinforesponse)<br> |
| 200 | Success | **application/json**: [WebAppSiteResponse](#webappsiteresponse)<br> |
| 400 | Bad Request | |
| 401 | Unauthorized | |
| 403 | Forbidden | |
@ -799,13 +813,13 @@ Convert text to audio using text-to-speech service.
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [AudioBinaryResponse](#audiobinaryresponse)<br> |
| 400 | Bad Request | |
| 401 | Unauthorized | |
| 403 | Forbidden | |
| 500 | Internal Server Error | |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
| 400 | Bad Request |
| 401 | Unauthorized |
| 403 | Forbidden |
| 500 | Internal Server Error |
### [GET] /webapp/access-mode
Retrieve the access mode for a web application (public or restricted).
@ -968,59 +982,7 @@ Returns Server-Sent Events stream.
| ---- | ---- | ----------- | -------- |
| appId | string | Application ID | Yes |
#### AppSiteInfoResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| app_id | string | | Yes |
| can_replace_logo | boolean | | Yes |
| custom_config | object | | No |
| enable_site | boolean | | Yes |
| end_user_id | string | | No |
| model_config | [AppSiteModelConfigResponse](#appsitemodelconfigresponse) | | No |
| plan | string | | No |
| site | [AppSiteResponse](#appsiteresponse) | | Yes |
#### AppSiteModelConfigResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| model | | | Yes |
| more_like_this | | | Yes |
| opening_statement | string | | No |
| pre_prompt | string | | No |
| suggested_questions | | | Yes |
| suggested_questions_after_answer | | | Yes |
| user_input_form | | | Yes |
#### AppSiteResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| chat_color_theme | string | | No |
| chat_color_theme_inverted | boolean | | No |
| copyright | string | | No |
| custom_disclaimer | string | | No |
| default_language | string | | No |
| description | string | | No |
| icon | string | | No |
| icon_background | string | | No |
| icon_type | string | | No |
| icon_url | string | | No |
| input_placeholder | string | | No |
| privacy_policy | string | | No |
| prompt_public | boolean | | No |
| show_workflow_steps | boolean | | No |
| title | string | | No |
| use_icon_as_answer_icon | boolean | | No |
#### AudioBinaryResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| AudioBinaryResponse | string | | |
#### AudioTranscriptResponse
#### AudioToTextResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
@ -1262,11 +1224,11 @@ Parsed multipart form fields for HITL uploads.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| expiration_time | integer | | Yes |
| form_content | | | Yes |
| inputs | | | Yes |
| form_content | string | | Yes |
| inputs | [ [FormInputConfig](#forminputconfig) ] | | Yes |
| resolved_default_values | object | | Yes |
| site | object | | No |
| user_actions | | | Yes |
| site | [WebAppSiteResponse](#webappsiteresponse) | | No |
| user_actions | [ [UserActionConfig](#useractionconfig) ] | | Yes |
#### HumanInputFormSubmissionData
@ -1689,6 +1651,26 @@ in form definiton, or a variable while the workflow is running.
| ---- | ---- | ----------- | -------- |
| protocol | string | | Yes |
#### WebAppCustomConfigResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| remove_webapp_brand | boolean | | Yes |
| replace_webapp_logo | string | | No |
#### WebAppSiteResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| app_id | string | | Yes |
| can_replace_logo | boolean | | Yes |
| custom_config | [WebAppCustomConfigResponse](#webappcustomconfigresponse) | | No |
| enable_site | boolean | | Yes |
| end_user_id | string | | No |
| model_config | [WebModelConfigResponse](#webmodelconfigresponse) | | No |
| plan | string | | Yes |
| site | [WebSiteResponse](#websiteresponse) | | Yes |
#### WebMessageInfiniteScrollPagination
| Name | Type | Description | Required |
@ -1717,6 +1699,39 @@ in form definiton, or a variable while the workflow is running.
| retriever_resources | [ [RetrieverResource](#retrieverresource) ] | | Yes |
| status | string | | Yes |
#### WebModelConfigResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| model | | | No |
| more_like_this | | | No |
| opening_statement | string | | No |
| pre_prompt | string | | No |
| suggested_questions | | | No |
| suggested_questions_after_answer | | | No |
| user_input_form | | | No |
#### WebSiteResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| chat_color_theme | string | | No |
| chat_color_theme_inverted | boolean | | Yes |
| copyright | string | | No |
| custom_disclaimer | string | | No |
| default_language | string | | No |
| description | string | | No |
| icon | string | | No |
| icon_background | string | | No |
| icon_type | string | | No |
| icon_url | string | | Yes |
| input_placeholder | string | | No |
| privacy_policy | string | | No |
| prompt_public | boolean | | No |
| show_workflow_steps | boolean | | No |
| title | string | | Yes |
| use_icon_as_answer_icon | boolean | | No |
#### WorkflowRunPayload
| Name | Type | Description | Required |

View File

@ -173,7 +173,7 @@ class FileService:
return upload_file
def get_file_preview(self, file_id: str, tenant_id: str):
def get_file_preview(self, file_id: str, tenant_id: str) -> str:
"""
Return a short text preview extracted from a document file.
"""
@ -191,9 +191,7 @@ class FileService:
raise UnsupportedFileTypeError()
text = ExtractProcessor.load_from_upload_file(upload_file, return_text=True)
text = text[0:PREVIEW_WORDS_LIMIT] if text else ""
return text
return text[0:PREVIEW_WORDS_LIMIT] if text else ""
def get_image_preview(self, file_id: str, timestamp: str, nonce: str, sign: str):
result = file_helpers.verify_image_signature(

View File

@ -2,22 +2,22 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import patch
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden
from controllers.web.site import AppSiteApi, AppSiteInfo
from controllers.web.site import AppSiteApi, WebAppSiteResponse, WebModelConfigResponse
from models import Tenant, TenantStatus
from models.model import App, AppMode, CustomizeTokenStrategy, Site
from models.account import TenantCustomConfigDict
from models.model import App, AppMode, AppModelConfig, CustomizeTokenStrategy, EndUser, Site
from services.feature_service import FeatureModel
@pytest.fixture
def app(flask_app_with_containers) -> Flask:
def app(flask_app_with_containers: Flask) -> Flask:
return flask_app_with_containers
@ -42,95 +42,201 @@ def _create_app(db_session: Session, tenant_id: str, *, enable_site: bool = True
def _create_site(db_session: Session, app_id: str) -> Site:
site = Site(
site = _site_model(app_id=app_id)
db_session.add(site)
db_session.commit()
return site
def _end_user(tenant_id: str, app_id: str) -> EndUser:
return EndUser(
tenant_id=tenant_id,
app_id=app_id,
type="browser",
session_id=f"session-{app_id}",
)
def _site_model(*, app_id: str) -> Site:
return Site(
app_id=app_id,
title="Site",
icon_type="emoji",
icon="robot",
icon_background="#fff",
description="desc",
input_placeholder="Ask the app",
default_language="en",
chat_color_theme="light",
chat_color_theme_inverted=False,
input_placeholder="Ask the app",
custom_disclaimer="",
customize_token_strategy=CustomizeTokenStrategy.NOT_ALLOW,
code=f"code-{app_id[-6:]}",
prompt_public=False,
show_workflow_steps=True,
use_icon_as_answer_icon=False,
)
db_session.add(site)
db_session.commit()
return site
class TestAppSiteApi:
@patch("controllers.web.site.FeatureService.get_features")
def test_happy_path(self, mock_features, app: Flask, db_session_with_containers: Session) -> None:
def test_happy_path(self, mock_features: MagicMock, app: Flask, db_session_with_containers: Session) -> None:
app.config["RESTX_MASK_HEADER"] = "X-Fields"
tenant = _create_tenant(db_session_with_containers)
app_model = _create_app(db_session_with_containers, tenant.id)
_create_site(db_session_with_containers, app_model.id)
end_user = SimpleNamespace(id="eu-1")
mock_features.return_value = FeatureModel(can_replace_logo=False, webapp_copyright_enabled=True)
end_user = _end_user(tenant.id, app_model.id)
mock_features.return_value = FeatureModel(can_replace_logo=False)
with app.test_request_context("/site"):
result = AppSiteApi().get(app_model, end_user)
assert result["app_id"] == app_model.id
assert result["end_user_id"] == end_user.id
assert result["plan"] == "basic"
assert result["enable_site"] is True
assert result["site"]["input_placeholder"] == "Ask the app"
def test_missing_site_raises_forbidden(self, app: Flask, db_session_with_containers: Session) -> None:
app.config["RESTX_MASK_HEADER"] = "X-Fields"
tenant = _create_tenant(db_session_with_containers)
app_model = _create_app(db_session_with_containers, tenant.id)
end_user = SimpleNamespace(id="eu-1")
end_user = _end_user(tenant.id, app_model.id)
with app.test_request_context("/site"):
with pytest.raises(Forbidden):
AppSiteApi().get(app_model, end_user)
@patch("controllers.web.site.FeatureService.get_features")
def test_archived_tenant_raises_forbidden(
self, mock_features, app: Flask, db_session_with_containers: Session
) -> None:
def test_archived_tenant_raises_forbidden(self, app: Flask, db_session_with_containers: Session) -> None:
app.config["RESTX_MASK_HEADER"] = "X-Fields"
tenant = _create_tenant(db_session_with_containers, status=TenantStatus.ARCHIVE)
app_model = _create_app(db_session_with_containers, tenant.id)
_create_site(db_session_with_containers, app_model.id)
end_user = SimpleNamespace(id="eu-1")
mock_features.return_value = FeatureModel(can_replace_logo=False, webapp_copyright_enabled=True)
end_user = _end_user(tenant.id, app_model.id)
with app.test_request_context("/site"):
with pytest.raises(Forbidden):
AppSiteApi().get(app_model, end_user)
class TestAppSiteInfo:
def _tenant_model(*, plan: str = "basic", custom_config: TenantCustomConfigDict | None = None) -> Tenant:
tenant = Tenant(name="test-tenant", plan=plan)
tenant.custom_config_dict = custom_config or {}
return tenant
def _app_model(*, tenant: Tenant, enable_site: bool = True) -> App:
app_model = App(
tenant_id=tenant.id,
mode=AppMode.CHAT,
name="test-app",
enable_site=enable_site,
enable_api=True,
)
app_model.id = "app-test"
return app_model
class TestWebAppSiteResponse:
def test_basic_fields(self) -> None:
tenant = SimpleNamespace(id="tenant-1", plan="basic", custom_config_dict={})
site_obj = SimpleNamespace()
info = AppSiteInfo(tenant, SimpleNamespace(id="app-1", enable_site=True), site_obj, "eu-1", False)
assert info.app_id == "app-1"
assert info.end_user_id == "eu-1"
assert info.enable_site is True
assert info.plan == "basic"
assert info.can_replace_logo is False
assert info.model_config is None
@patch("controllers.web.site.dify_config", SimpleNamespace(FILES_URL="https://files.example.com"))
def test_can_replace_logo_sets_custom_config(self) -> None:
tenant = SimpleNamespace(
id="tenant-1",
plan="pro",
custom_config_dict={"remove_webapp_brand": True, "replace_webapp_logo": True},
tenant = _tenant_model()
app_model = _app_model(tenant=tenant)
response = WebAppSiteResponse.from_app_site(
tenant=tenant,
app_model=app_model,
site=_site_model(app_id=app_model.id),
end_user_id="eu-1",
features=FeatureModel(can_replace_logo=False, webapp_copyright_enabled=True),
can_replace_logo=False,
)
site_obj = SimpleNamespace()
info = AppSiteInfo(tenant, SimpleNamespace(id="app-1", enable_site=True), site_obj, "eu-1", True)
assert info.can_replace_logo is True
assert info.custom_config["remove_webapp_brand"] is True
assert "webapp-logo" in info.custom_config["replace_webapp_logo"]
assert response.app_id == app_model.id
assert response.end_user_id == "eu-1"
assert response.enable_site is True
assert response.plan == "basic"
assert response.can_replace_logo is False
assert response.model_config_ is None
assert response.custom_config is None
assert response.site.input_placeholder == "Ask the app"
assert response.site.custom_disclaimer == ""
def test_nullable_site_fields_preserve_none(self) -> None:
tenant = _tenant_model()
app_model = _app_model(tenant=tenant)
site = _site_model(app_id=app_model.id)
site.chat_color_theme = None
site.icon_type = None
site.icon = None
site.icon_background = None
site.description = None
site.copyright = None
site.privacy_policy = None
response = WebAppSiteResponse.from_app_site(
tenant=tenant,
app_model=app_model,
site=site,
end_user_id=None,
features=FeatureModel(can_replace_logo=False, webapp_copyright_enabled=True),
can_replace_logo=False,
)
dumped = response.model_dump(mode="json")
assert dumped["end_user_id"] is None
assert dumped["site"]["chat_color_theme"] is None
assert dumped["site"]["icon_type"] is None
assert dumped["site"]["icon"] is None
assert dumped["site"]["icon_background"] is None
assert dumped["site"]["description"] is None
assert dumped["site"]["copyright"] is None
assert dumped["site"]["privacy_policy"] is None
assert dumped["site"]["custom_disclaimer"] == ""
@patch("controllers.web.site.dify_config.FILES_URL", "https://files.example.com")
def test_can_replace_logo_sets_custom_config(self) -> None:
tenant = _tenant_model(
plan="pro",
custom_config={"remove_webapp_brand": True, "replace_webapp_logo": "enabled"},
)
app_model = _app_model(tenant=tenant)
response = WebAppSiteResponse.from_app_site(
tenant=tenant,
app_model=app_model,
site=_site_model(app_id=app_model.id),
end_user_id="eu-1",
features=FeatureModel(can_replace_logo=True, webapp_copyright_enabled=True),
can_replace_logo=True,
)
assert response.can_replace_logo is True
assert response.custom_config is not None
assert response.custom_config.remove_webapp_brand is True
assert response.custom_config.replace_webapp_logo is not None
assert "webapp-logo" in response.custom_config.replace_webapp_logo
class TestWebModelConfigResponse:
def test_serializes_internal_model_config_properties_to_public_keys(self) -> None:
model_config = AppModelConfig(
app_id="app-test",
opening_statement="Hello",
suggested_questions='["Question?"]',
suggested_questions_after_answer='{"enabled": true}',
more_like_this='{"enabled": false}',
model='{"provider": "openai", "name": "gpt-4o", "mode": "chat"}',
user_input_form='[{"text-input": {"label": "Name", "variable": "name", "required": true}}]',
pre_prompt="System prompt",
created_by="account-1",
updated_by="account-1",
)
dumped = WebModelConfigResponse.model_validate(model_config, from_attributes=True).model_dump(mode="json")
assert dumped == {
"opening_statement": "Hello",
"suggested_questions": ["Question?"],
"suggested_questions_after_answer": {"enabled": True},
"more_like_this": {"enabled": False},
"model": {"provider": "openai", "name": "gpt-4o", "mode": "chat"},
"user_input_form": [{"text-input": {"label": "Name", "variable": "name", "required": True}}],
"pre_prompt": "System prompt",
}

View File

@ -2,7 +2,6 @@
from __future__ import annotations
import json
from datetime import UTC, datetime
from types import SimpleNamespace
from typing import Any
@ -107,13 +106,13 @@ def test_get_form_includes_site(monkeypatch: pytest.MonkeyPatch, app: Flask):
icon="robot",
icon_background="#fff",
description="desc",
input_placeholder="Ask the app",
default_language="en",
chat_color_theme="light",
chat_color_theme_inverted=False,
copyright=None,
privacy_policy=None,
input_placeholder="Ask me anything",
custom_disclaimer=None,
custom_disclaimer="",
prompt_public=False,
show_workflow_steps=True,
use_icon_as_answer_icon=False,
@ -139,7 +138,7 @@ def test_get_form_includes_site(monkeypatch: pytest.MonkeyPatch, app: Flask):
with app.test_request_context("/api/form/human_input/token-1", method="GET"):
response = HumanInputFormApi().get("token-1")
body = json.loads(response.get_data(as_text=True))
body = response
assert set(body.keys()) == {
"site",
"form_content",
@ -168,8 +167,8 @@ def test_get_form_includes_site(monkeypatch: pytest.MonkeyPatch, app: Flask):
"description": "desc",
"copyright": None,
"privacy_policy": None,
"input_placeholder": "Ask me anything",
"custom_disclaimer": None,
"input_placeholder": "Ask the app",
"custom_disclaimer": "",
"default_language": "en",
"prompt_public": False,
"show_workflow_steps": True,
@ -188,72 +187,6 @@ def test_get_form_includes_site(monkeypatch: pytest.MonkeyPatch, app: Flask):
limiter_mock.increment_rate_limit.assert_called_once_with("203.0.113.10")
def test_serialize_app_site_payload_masks_paid_webapp_fields_when_feature_disabled(monkeypatch: pytest.MonkeyPatch):
"""Runtime site payload hides paid-only fields for plans that cannot use them."""
tenant = SimpleNamespace(id="tenant-1", plan="sandbox", custom_config_dict={})
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", tenant=tenant, enable_site=True)
site_model = SimpleNamespace(
title="My Site",
icon_type="emoji",
icon="robot",
icon_background="#fff",
description="desc",
default_language="en",
chat_color_theme="light",
chat_color_theme_inverted=False,
copyright="Dify",
privacy_policy=None,
input_placeholder="Ask me anything",
custom_disclaimer=None,
prompt_public=False,
show_workflow_steps=True,
use_icon_as_answer_icon=False,
)
features = FeatureModel(can_replace_logo=False, webapp_copyright_enabled=False)
features.billing.enabled = True
monkeypatch.setattr(site_module.FeatureService, "get_features", lambda tenant_id, **_kwargs: features)
payload = site_module.serialize_app_site_payload(app_model, site_model, end_user_id=None)
assert payload["site"]["copyright"] is None
assert payload["site"]["input_placeholder"] is None
def test_serialize_app_site_payload_keeps_paid_webapp_fields_when_billing_disabled(monkeypatch: pytest.MonkeyPatch):
"""Self-hosted community runtimes keep site fields because billing is not enforcing the paid gate."""
tenant = SimpleNamespace(id="tenant-1", plan="basic", custom_config_dict={})
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", tenant=tenant, enable_site=True)
site_model = SimpleNamespace(
title="My Site",
icon_type="emoji",
icon="robot",
icon_background="#fff",
description="desc",
default_language="en",
chat_color_theme="light",
chat_color_theme_inverted=False,
copyright="Dify",
privacy_policy=None,
input_placeholder="Ask me anything",
custom_disclaimer=None,
prompt_public=False,
show_workflow_steps=True,
use_icon_as_answer_icon=False,
)
monkeypatch.setattr(
site_module.FeatureService,
"get_features",
lambda tenant_id, **_kwargs: FeatureModel(can_replace_logo=False, webapp_copyright_enabled=False),
)
payload = site_module.serialize_app_site_payload(app_model, site_model, end_user_id=None)
assert payload["site"]["copyright"] == "Dify"
assert payload["site"]["input_placeholder"] == "Ask me anything"
def test_get_form_uses_runtime_select_options(monkeypatch: pytest.MonkeyPatch, app: Flask):
"""GET returns variable-backed select options resolved from runtime state."""
@ -319,13 +252,13 @@ def test_get_form_uses_runtime_select_options(monkeypatch: pytest.MonkeyPatch, a
icon="robot",
icon_background="#fff",
description="desc",
input_placeholder="Ask the app",
default_language="en",
chat_color_theme="light",
chat_color_theme_inverted=False,
copyright=None,
privacy_policy=None,
input_placeholder="Ask me anything",
custom_disclaimer=None,
custom_disclaimer="",
prompt_public=False,
show_workflow_steps=True,
use_icon_as_answer_icon=False,
@ -346,7 +279,7 @@ def test_get_form_uses_runtime_select_options(monkeypatch: pytest.MonkeyPatch, a
with app.test_request_context("/api/form/human_input/token-1", method="GET"):
response = HumanInputFormApi().get("token-1")
body = json.loads(response.get_data(as_text=True))
body = response
assert body["inputs"] == [input_config.model_dump(mode="json") for input_config in runtime_inputs]
service_mock.resolve_form_inputs.assert_called_once_with(form)
@ -444,13 +377,13 @@ def test_get_form_allows_backstage_token(monkeypatch: pytest.MonkeyPatch, app: F
icon="robot",
icon_background="#fff",
description="desc",
input_placeholder="Ask the app",
default_language="en",
chat_color_theme="light",
chat_color_theme_inverted=False,
copyright=None,
privacy_policy=None,
input_placeholder="Ask me anything",
custom_disclaimer=None,
custom_disclaimer="",
prompt_public=False,
show_workflow_steps=True,
use_icon_as_answer_icon=False,
@ -473,7 +406,7 @@ def test_get_form_allows_backstage_token(monkeypatch: pytest.MonkeyPatch, app: F
with app.test_request_context("/api/form/human_input/token-1", method="GET"):
response = HumanInputFormApi().get("token-1")
body = json.loads(response.get_data(as_text=True))
body = response
assert set(body.keys()) == {
"site",
"form_content",
@ -502,8 +435,8 @@ def test_get_form_allows_backstage_token(monkeypatch: pytest.MonkeyPatch, app: F
"description": "desc",
"copyright": None,
"privacy_policy": None,
"input_placeholder": "Ask me anything",
"custom_disclaimer": None,
"input_placeholder": "Ask the app",
"custom_disclaimer": "",
"default_language": "en",
"prompt_public": False,
"show_workflow_steps": True,

View File

@ -9,11 +9,11 @@ export type AllowedExtensionsResponse = {
}
export type UploadConfig = {
attachment_image_file_size_limit?: number | null
attachment_image_file_size_limit: number
audio_file_size_limit: number
batch_count_limit: number
file_size_limit: number
file_upload_limit?: number | null
file_upload_limit: number
image_file_batch_limit: number
image_file_size_limit: number
single_chunk_attachment_limit: number

View File

@ -13,11 +13,11 @@ export const zAllowedExtensionsResponse = z.object({
* UploadConfig
*/
export const zUploadConfig = z.object({
attachment_image_file_size_limit: z.int().nullish(),
attachment_image_file_size_limit: z.int(),
audio_file_size_limit: z.int(),
batch_count_limit: z.int(),
file_size_limit: z.int(),
file_upload_limit: z.int().nullish(),
file_upload_limit: z.int(),
image_file_batch_limit: z.int(),
image_file_size_limit: z.int(),
single_chunk_attachment_limit: z.int(),

View File

@ -453,11 +453,13 @@ export const forgotPassword = {
/**
* Issue an upload token for a human input form
*
* Issue an upload token for an active human input form
* POST /api/form/human_input/<form_token>/upload-token
*/
export const post13 = oc
.route({
description: 'POST /api/form/human_input/<form_token>/upload-token',
description:
'Issue an upload token for an active human input form\nPOST /api/form/human_input/<form_token>/upload-token',
inputStructure: 'detailed',
method: 'POST',
operationId: 'postFormHumanInputByFormTokenUploadToken',
@ -475,11 +477,13 @@ export const uploadToken = {
/**
* Get human input form definition by token
*
* Get a human input form definition by token
* GET /api/form/human_input/<form_token>
*/
export const get2 = oc
.route({
description: 'GET /api/form/human_input/<form_token>',
description:
'Get a human input form definition by token\nGET /api/form/human_input/<form_token>',
inputStructure: 'detailed',
method: 'GET',
operationId: 'getFormHumanInputByFormToken',
@ -493,6 +497,7 @@ export const get2 = oc
/**
* Submit human input form by token
*
* Submit a human input form by token
* POST /api/form/human_input/<form_token>
*
* Request body:
@ -506,7 +511,7 @@ export const get2 = oc
export const post14 = oc
.route({
description:
'POST /api/form/human_input/<form_token>\n\nRequest body:\n{\n "inputs": {\n "content": "User input content"\n },\n "action": "Approve"\n}',
'Submit a human input form by token\nPOST /api/form/human_input/<form_token>\n\nRequest body:\n{\n "inputs": {\n "content": "User input content"\n },\n "action": "Approve"\n}',
inputStructure: 'detailed',
method: 'POST',
operationId: 'postFormHumanInputByFormToken',

View File

@ -47,51 +47,7 @@ export type AppPermissionQuery = {
appId: string
}
export type AppSiteInfoResponse = {
app_id: string
can_replace_logo: boolean
custom_config?: {
[key: string]: unknown
} | null
enable_site: boolean
end_user_id?: string | null
model_config?: AppSiteModelConfigResponse | null
plan?: string | null
site: AppSiteResponse
}
export type AppSiteModelConfigResponse = {
model: unknown
more_like_this: unknown
opening_statement?: string | null
pre_prompt?: string | null
suggested_questions: unknown
suggested_questions_after_answer: unknown
user_input_form: unknown
}
export type AppSiteResponse = {
chat_color_theme?: string | null
chat_color_theme_inverted?: boolean | null
copyright?: string | null
custom_disclaimer?: string | null
default_language?: string | null
description?: string | null
icon?: string | null
icon_background?: string | null
icon_type?: string | null
icon_url?: string | null
input_placeholder?: string | null
privacy_policy?: string | null
prompt_public?: boolean | null
show_workflow_steps?: boolean | null
title?: string | null
use_icon_as_answer_icon?: boolean | null
}
export type AudioBinaryResponse = Blob | File
export type AudioTranscriptResponse = {
export type AudioToTextResponse = {
text: string
}
@ -289,15 +245,13 @@ export type HumanInputFormDefinition = {
export type HumanInputFormDefinitionResponse = {
expiration_time: number
form_content: unknown
inputs: unknown
form_content: string
inputs: Array<FormInputConfig>
resolved_default_values: {
[key: string]: string
}
site?: {
[key: string]: unknown
} | null
user_actions: unknown
site?: WebAppSiteResponse | null
user_actions: Array<UserActionConfig>
}
export type HumanInputFormSubmissionData = {
@ -319,7 +273,7 @@ export type HumanInputFormSubmitPayload = {
}
export type HumanInputFormSubmitResponse = {
[key: string]: never
[key: string]: unknown
}
export type HumanInputUploadTokenResponse = {
@ -620,6 +574,22 @@ export type WebAppAuthSsoModel = {
protocol: string
}
export type WebAppCustomConfigResponse = {
remove_webapp_brand: boolean
replace_webapp_logo?: string | null
}
export type WebAppSiteResponse = {
app_id: string
can_replace_logo: boolean
custom_config?: WebAppCustomConfigResponse | null
enable_site: boolean
end_user_id?: string | null
model_config?: WebModelConfigResponse | null
plan: string
site: WebSiteResponse
}
export type WebMessageInfiniteScrollPagination = {
data: Array<WebMessageListItem>
has_more: boolean
@ -646,6 +616,35 @@ export type WebMessageListItem = {
status: string
}
export type WebModelConfigResponse = {
model?: unknown
more_like_this?: unknown
opening_statement?: string | null
pre_prompt?: string | null
suggested_questions?: unknown
suggested_questions_after_answer?: unknown
user_input_form?: unknown
}
export type WebSiteResponse = {
chat_color_theme?: string | null
chat_color_theme_inverted: boolean
copyright?: string | null
custom_disclaimer?: string | null
default_language?: string | null
description?: string | null
icon?: string | null
icon_background?: string | null
icon_type?: string | null
readonly icon_url: string | null
input_placeholder?: string | null
privacy_policy?: string | null
prompt_public?: boolean | null
show_workflow_steps?: boolean | null
title: string
use_icon_as_answer_icon?: boolean | null
}
export type WorkflowRunPayload = {
files?: Array<{
transfer_method: 'local_file' | 'remote_url'
@ -658,6 +657,52 @@ export type WorkflowRunPayload = {
}
}
export type GeneratedAppResponseWritable = JsonValue
export type HumanInputFormDefinitionResponseWritable = {
expiration_time: number
form_content: string
inputs: Array<FormInputConfig>
resolved_default_values: {
[key: string]: string
}
site?: WebAppSiteResponseWritable | null
user_actions: Array<UserActionConfig>
}
export type HumanInputFormSubmitResponseWritable = {
[key: string]: unknown
}
export type WebAppSiteResponseWritable = {
app_id: string
can_replace_logo: boolean
custom_config?: WebAppCustomConfigResponse | null
enable_site: boolean
end_user_id?: string | null
model_config?: WebModelConfigResponse | null
plan: string
site: WebSiteResponseWritable
}
export type WebSiteResponseWritable = {
chat_color_theme?: string | null
chat_color_theme_inverted: boolean
copyright?: string | null
custom_disclaimer?: string | null
default_language?: string | null
description?: string | null
icon?: string | null
icon_background?: string | null
icon_type?: string | null
input_placeholder?: string | null
privacy_policy?: string | null
prompt_public?: boolean | null
show_workflow_steps?: boolean | null
title: string
use_icon_as_answer_icon?: boolean | null
}
export type PostAudioToTextData = {
body?: never
path?: never
@ -675,7 +720,7 @@ export type PostAudioToTextErrors = {
}
export type PostAudioToTextResponses = {
200: AudioTranscriptResponse
200: AudioToTextResponse
}
export type PostAudioToTextResponse = PostAudioToTextResponses[keyof PostAudioToTextResponses]
@ -1022,6 +1067,13 @@ export type GetFormHumanInputByFormTokenData = {
url: '/form/human_input/{form_token}'
}
export type GetFormHumanInputByFormTokenErrors = {
403: unknown
404: unknown
412: unknown
429: unknown
}
export type GetFormHumanInputByFormTokenResponses = {
200: HumanInputFormDefinitionResponse
}
@ -1038,6 +1090,13 @@ export type PostFormHumanInputByFormTokenData = {
url: '/form/human_input/{form_token}'
}
export type PostFormHumanInputByFormTokenErrors = {
400: unknown
404: unknown
412: unknown
429: unknown
}
export type PostFormHumanInputByFormTokenResponses = {
200: HumanInputFormSubmitResponse
}
@ -1054,6 +1113,12 @@ export type PostFormHumanInputByFormTokenUploadTokenData = {
url: '/form/human_input/{form_token}/upload-token'
}
export type PostFormHumanInputByFormTokenUploadTokenErrors = {
404: unknown
412: unknown
429: unknown
}
export type PostFormHumanInputByFormTokenUploadTokenResponses = {
200: HumanInputUploadTokenResponse
}
@ -1422,7 +1487,7 @@ export type GetSiteErrors = {
}
export type GetSiteResponses = {
200: AppSiteInfoResponse
200: WebAppSiteResponse
}
export type GetSiteResponse = GetSiteResponses[keyof GetSiteResponses]
@ -1459,7 +1524,9 @@ export type PostTextToAudioErrors = {
}
export type PostTextToAudioResponses = {
200: AudioBinaryResponse
200: {
[key: string]: unknown
}
}
export type PostTextToAudioResponse = PostTextToAudioResponses[keyof PostTextToAudioResponses]

View File

@ -47,63 +47,9 @@ export const zAppPermissionQuery = z.object({
})
/**
* AppSiteModelConfigResponse
* AudioToTextResponse
*/
export const zAppSiteModelConfigResponse = z.object({
model: z.unknown(),
more_like_this: z.unknown(),
opening_statement: z.string().nullish(),
pre_prompt: z.string().nullish(),
suggested_questions: z.unknown(),
suggested_questions_after_answer: z.unknown(),
user_input_form: z.unknown(),
})
/**
* AppSiteResponse
*/
export const zAppSiteResponse = z.object({
chat_color_theme: z.string().nullish(),
chat_color_theme_inverted: z.boolean().nullish(),
copyright: z.string().nullish(),
custom_disclaimer: z.string().nullish(),
default_language: z.string().nullish(),
description: z.string().nullish(),
icon: z.string().nullish(),
icon_background: z.string().nullish(),
icon_type: z.string().nullish(),
icon_url: z.string().nullish(),
input_placeholder: z.string().nullish(),
privacy_policy: z.string().nullish(),
prompt_public: z.boolean().nullish(),
show_workflow_steps: z.boolean().nullish(),
title: z.string().nullish(),
use_icon_as_answer_icon: z.boolean().nullish(),
})
/**
* AppSiteInfoResponse
*/
export const zAppSiteInfoResponse = z.object({
app_id: z.string(),
can_replace_logo: z.boolean(),
custom_config: z.record(z.string(), z.unknown()).nullish(),
enable_site: z.boolean(),
end_user_id: z.string().nullish(),
model_config: zAppSiteModelConfigResponse.nullish(),
plan: z.string().nullish(),
site: zAppSiteResponse,
})
/**
* AudioBinaryResponse
*/
export const zAudioBinaryResponse = z.custom<Blob | File>()
/**
* AudioTranscriptResponse
*/
export const zAudioTranscriptResponse = z.object({
export const zAudioToTextResponse = z.object({
text: z.string(),
})
@ -321,22 +267,10 @@ export const zHumanInputFileUploadFormPayload = z.object({
url: z.url().min(1).max(2083).nullish(),
})
/**
* HumanInputFormDefinitionResponse
*/
export const zHumanInputFormDefinitionResponse = z.object({
expiration_time: z.int(),
form_content: z.unknown(),
inputs: z.unknown(),
resolved_default_values: z.record(z.string(), z.string()),
site: z.record(z.string(), z.unknown()).nullish(),
user_actions: z.unknown(),
})
/**
* HumanInputFormSubmitResponse
*/
export const zHumanInputFormSubmitResponse = z.record(z.string(), z.never())
export const zHumanInputFormSubmitResponse = z.record(z.string(), z.unknown())
/**
* HumanInputUploadTokenResponse
@ -883,6 +817,14 @@ export const zSystemFeatureModel = z.object({
}),
})
/**
* WebAppCustomConfigResponse
*/
export const zWebAppCustomConfigResponse = z.object({
remove_webapp_brand: z.boolean(),
replace_webapp_logo: z.string().nullish(),
})
/**
* WebMessageListItem
*/
@ -913,6 +855,67 @@ export const zWebMessageInfiniteScrollPagination = z.object({
limit: z.int(),
})
/**
* WebModelConfigResponse
*/
export const zWebModelConfigResponse = z.object({
model: z.unknown().optional(),
more_like_this: z.unknown().optional(),
opening_statement: z.string().nullish(),
pre_prompt: z.string().nullish(),
suggested_questions: z.unknown().optional(),
suggested_questions_after_answer: z.unknown().optional(),
user_input_form: z.unknown().optional(),
})
/**
* WebSiteResponse
*/
export const zWebSiteResponse = z.object({
chat_color_theme: z.string().nullish(),
chat_color_theme_inverted: z.boolean(),
copyright: z.string().nullish(),
custom_disclaimer: z.string().nullish(),
default_language: z.string().nullish(),
description: z.string().nullish(),
icon: z.string().nullish(),
icon_background: z.string().nullish(),
icon_type: z.string().nullish(),
icon_url: z.string().nullable(),
input_placeholder: z.string().nullish(),
privacy_policy: z.string().nullish(),
prompt_public: z.boolean().nullish(),
show_workflow_steps: z.boolean().nullish(),
title: z.string(),
use_icon_as_answer_icon: z.boolean().nullish(),
})
/**
* WebAppSiteResponse
*/
export const zWebAppSiteResponse = z.object({
app_id: z.string(),
can_replace_logo: z.boolean(),
custom_config: zWebAppCustomConfigResponse.nullish(),
enable_site: z.boolean(),
end_user_id: z.string().nullish(),
model_config: zWebModelConfigResponse.nullish(),
plan: z.string(),
site: zWebSiteResponse,
})
/**
* HumanInputFormDefinitionResponse
*/
export const zHumanInputFormDefinitionResponse = z.object({
expiration_time: z.int(),
form_content: z.string(),
inputs: z.array(zFormInputConfig),
resolved_default_values: z.record(z.string(), z.string()),
site: zWebAppSiteResponse.nullish(),
user_actions: z.array(zUserActionConfig),
})
/**
* WorkflowRunPayload
*/
@ -930,10 +933,67 @@ export const zWorkflowRunPayload = z.object({
inputs: z.record(z.string(), z.unknown()),
})
/**
* GeneratedAppResponse
*/
export const zGeneratedAppResponseWritable = zJsonValue
/**
* HumanInputFormSubmitResponse
*/
export const zHumanInputFormSubmitResponseWritable = z.record(z.string(), z.unknown())
/**
* WebSiteResponse
*/
export const zWebSiteResponseWritable = z.object({
chat_color_theme: z.string().nullish(),
chat_color_theme_inverted: z.boolean(),
copyright: z.string().nullish(),
custom_disclaimer: z.string().nullish(),
default_language: z.string().nullish(),
description: z.string().nullish(),
icon: z.string().nullish(),
icon_background: z.string().nullish(),
icon_type: z.string().nullish(),
input_placeholder: z.string().nullish(),
privacy_policy: z.string().nullish(),
prompt_public: z.boolean().nullish(),
show_workflow_steps: z.boolean().nullish(),
title: z.string(),
use_icon_as_answer_icon: z.boolean().nullish(),
})
/**
* WebAppSiteResponse
*/
export const zWebAppSiteResponseWritable = z.object({
app_id: z.string(),
can_replace_logo: z.boolean(),
custom_config: zWebAppCustomConfigResponse.nullish(),
enable_site: z.boolean(),
end_user_id: z.string().nullish(),
model_config: zWebModelConfigResponse.nullish(),
plan: z.string(),
site: zWebSiteResponseWritable,
})
/**
* HumanInputFormDefinitionResponse
*/
export const zHumanInputFormDefinitionResponseWritable = z.object({
expiration_time: z.int(),
form_content: z.string(),
inputs: z.array(zFormInputConfig),
resolved_default_values: z.record(z.string(), z.string()),
site: zWebAppSiteResponseWritable.nullish(),
user_actions: z.array(zUserActionConfig),
})
/**
* Success
*/
export const zPostAudioToTextResponse = zAudioTranscriptResponse
export const zPostAudioToTextResponse = zAudioToTextResponse
export const zPostChatMessagesBody = zChatMessagePayload
@ -1070,7 +1130,7 @@ export const zGetFormHumanInputByFormTokenPath = z.object({
})
/**
* Success
* Form retrieved successfully
*/
export const zGetFormHumanInputByFormTokenResponse = zHumanInputFormDefinitionResponse
@ -1081,7 +1141,7 @@ export const zPostFormHumanInputByFormTokenPath = z.object({
})
/**
* Success
* Form submitted successfully
*/
export const zPostFormHumanInputByFormTokenResponse = zHumanInputFormSubmitResponse
@ -1090,7 +1150,7 @@ export const zPostFormHumanInputByFormTokenUploadTokenPath = z.object({
})
/**
* Success
* Upload token issued successfully
*/
export const zPostFormHumanInputByFormTokenUploadTokenResponse = zHumanInputUploadTokenResponse
@ -1238,7 +1298,7 @@ export const zDeleteSavedMessagesByMessageIdResponse = z.void()
/**
* Success
*/
export const zGetSiteResponse = zAppSiteInfoResponse
export const zGetSiteResponse = zWebAppSiteResponse
/**
* System features retrieved successfully
@ -1250,7 +1310,7 @@ export const zPostTextToAudioBody = zTextToAudioPayload
/**
* Success
*/
export const zPostTextToAudioResponse = zAudioBinaryResponse
export const zPostTextToAudioResponse = z.record(z.string(), z.unknown())
export const zGetWebappAccessModeQuery = z.object({
appCode: z.string().optional(),

View File

@ -36,7 +36,7 @@ const HumanInputForm = ({
setIsSubmitting(false)
}
const isActionDisabled = isSubmitting || hasInvalidSelectOrFileInput(renderedFormInputs, inputs)
const isActionDisabled = isSubmitting || !formToken || hasInvalidSelectOrFileInput(renderedFormInputs, inputs)
return (
<>
@ -55,7 +55,7 @@ const HumanInputForm = ({
key={action.id}
disabled={isActionDisabled}
variant={getButtonStyle(action.button_style) as ButtonProps['variant']}
onClick={() => submit(formToken, action.id, inputs)}
onClick={() => formToken && submit(formToken, action.id, inputs)}
>
{action.title}
</Button>

View File

@ -342,10 +342,10 @@ export type HumanInputFormData = {
form_content: string
inputs: FormInputItem[]
actions: UserAction[]
form_token: string
form_token: string | null
resolved_default_values: Record<string, HumanInputResolvedValue>
display_in_ui: boolean
expiration_time: number
expiration_time: number | null
}
export type HumanInputRequiredResponse = {