diff --git a/api/controllers/web/audio.py b/api/controllers/web/audio.py index b7856f7dd90..52e9492dbf8 100644 --- a/api/controllers/web/audio.py +++ b/api/controllers/web/audio.py @@ -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() diff --git a/api/controllers/web/completion.py b/api/controllers/web/completion.py index c1a7d1f8d10..baae7e0b181 100644 --- a/api/controllers/web/completion.py +++ b/api/controllers/web/completion.py @@ -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 diff --git a/api/controllers/web/files.py b/api/controllers/web/files.py index e08a3373643..b87438f77b8 100644 --- a/api/controllers/web/files.py +++ b/api/controllers/web/files.py @@ -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 diff --git a/api/controllers/web/human_input_file_upload.py b/api/controllers/web/human_input_file_upload.py index cbb4a785294..5203951a993 100644 --- a/api/controllers/web/human_input_file_upload.py +++ b/api/controllers/web/human_input_file_upload.py @@ -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 diff --git a/api/controllers/web/human_input_form.py b/api/controllers/web/human_input_form.py index f99a6fa3f83..775e802a70c 100644 --- a/api/controllers/web/human_input_form.py +++ b/api/controllers/web/human_input_form.py @@ -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//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/") @@ -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 diff --git a/api/controllers/web/message.py b/api/controllers/web/message.py index 45fea9a328e..3edb8436691 100644 --- a/api/controllers/web/message.py +++ b/api/controllers/web/message.py @@ -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.") diff --git a/api/controllers/web/remote_files.py b/api/controllers/web/remote_files.py index 1772300b5cd..b12661dc5cf 100644 --- a/api/controllers/web/remote_files.py +++ b/api/controllers/web/remote_files.py @@ -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 diff --git a/api/controllers/web/saved_message.py b/api/controllers/web/saved_message.py index d61ffd545c3..cc453961c43 100644 --- a/api/controllers/web/saved_message.py +++ b/api/controllers/web/saved_message.py @@ -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) diff --git a/api/controllers/web/site.py b/api/controllers/web/site.py index 0f0d8694f51..c2c56f9e6de 100644 --- a/api/controllers/web/site.py +++ b/api/controllers/web/site.py @@ -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") diff --git a/api/controllers/web/workflow.py b/api/controllers/web/workflow.py index 6aa5b495946..1e6d6e24d92 100644 --- a/api/controllers/web/workflow.py +++ b/api/controllers/web/workflow.py @@ -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") diff --git a/api/fields/file_fields.py b/api/fields/file_fields.py index 1cd6e8af6a2..681dc3db2d2 100644 --- a/api/fields/file_fields.py +++ b/api/fields/file_fields.py @@ -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): diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 74cbe9f20f2..679fc812cab 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -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 | diff --git a/api/openapi/markdown/web-openapi.md b/api/openapi/markdown/web-openapi.md index 10cc7a831ad..007a6d15c24 100644 --- a/api/openapi/markdown/web-openapi.md +++ b/api/openapi/markdown/web-openapi.md @@ -21,7 +21,7 @@ Convert audio file to text using speech-to-text service. | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [AudioTranscriptResponse](#audiotranscriptresponse)
| +| 200 | Success | **application/json**: [AudioToTextResponse](#audiototextresponse)
| | 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/ #### 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)
| +| 200 | Form retrieved successfully | **application/json**: [HumanInputFormDefinitionResponse](#humaninputformdefinitionresponse)
| +| 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/ 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)
| +| 200 | Form submitted successfully | **application/json**: [HumanInputFormSubmitResponse](#humaninputformsubmitresponse)
| +| 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//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)
| +| 200 | Upload token issued successfully | **application/json**: [HumanInputUploadTokenResponse](#humaninputuploadtokenresponse)
| +| 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)
| +| 200 | Success | **application/json**: [WebAppSiteResponse](#webappsiteresponse)
| | 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)
| -| 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 | diff --git a/api/services/file_service.py b/api/services/file_service.py index ec69af4e80c..92dbe0dc826 100644 --- a/api/services/file_service.py +++ b/api/services/file_service.py @@ -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( diff --git a/api/tests/test_containers_integration_tests/controllers/web/test_site.py b/api/tests/test_containers_integration_tests/controllers/web/test_site.py index bdda2d7a056..fa85f68bc4e 100644 --- a/api/tests/test_containers_integration_tests/controllers/web/test_site.py +++ b/api/tests/test_containers_integration_tests/controllers/web/test_site.py @@ -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", + } diff --git a/api/tests/unit_tests/controllers/web/test_human_input_form.py b/api/tests/unit_tests/controllers/web/test_human_input_form.py index 50ce07cbe7e..bcafd269dfd 100644 --- a/api/tests/unit_tests/controllers/web/test_human_input_form.py +++ b/api/tests/unit_tests/controllers/web/test_human_input_form.py @@ -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, diff --git a/packages/contracts/generated/api/console/files/types.gen.ts b/packages/contracts/generated/api/console/files/types.gen.ts index 9591924ce62..3d22ea66236 100644 --- a/packages/contracts/generated/api/console/files/types.gen.ts +++ b/packages/contracts/generated/api/console/files/types.gen.ts @@ -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 diff --git a/packages/contracts/generated/api/console/files/zod.gen.ts b/packages/contracts/generated/api/console/files/zod.gen.ts index c1827572c9d..34fe6d2aa3d 100644 --- a/packages/contracts/generated/api/console/files/zod.gen.ts +++ b/packages/contracts/generated/api/console/files/zod.gen.ts @@ -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(), diff --git a/packages/contracts/generated/api/web/orpc.gen.ts b/packages/contracts/generated/api/web/orpc.gen.ts index f0ef39fd976..95b8275af4d 100644 --- a/packages/contracts/generated/api/web/orpc.gen.ts +++ b/packages/contracts/generated/api/web/orpc.gen.ts @@ -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//upload-token */ export const post13 = oc .route({ - description: 'POST /api/form/human_input//upload-token', + description: + 'Issue an upload token for an active human input form\nPOST /api/form/human_input//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/ */ export const get2 = oc .route({ - description: 'GET /api/form/human_input/', + description: + 'Get a human input form definition by token\nGET /api/form/human_input/', 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/ * * Request body: @@ -506,7 +511,7 @@ export const get2 = oc export const post14 = oc .route({ description: - 'POST /api/form/human_input/\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/\n\nRequest body:\n{\n "inputs": {\n "content": "User input content"\n },\n "action": "Approve"\n}', inputStructure: 'detailed', method: 'POST', operationId: 'postFormHumanInputByFormToken', diff --git a/packages/contracts/generated/api/web/types.gen.ts b/packages/contracts/generated/api/web/types.gen.ts index 69e4ebdecd0..9a88c36f2b3 100644 --- a/packages/contracts/generated/api/web/types.gen.ts +++ b/packages/contracts/generated/api/web/types.gen.ts @@ -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 resolved_default_values: { [key: string]: string } - site?: { - [key: string]: unknown - } | null - user_actions: unknown + site?: WebAppSiteResponse | null + user_actions: Array } 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 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 + resolved_default_values: { + [key: string]: string + } + site?: WebAppSiteResponseWritable | null + user_actions: Array +} + +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] diff --git a/packages/contracts/generated/api/web/zod.gen.ts b/packages/contracts/generated/api/web/zod.gen.ts index c663be89564..2a3ea96b0ac 100644 --- a/packages/contracts/generated/api/web/zod.gen.ts +++ b/packages/contracts/generated/api/web/zod.gen.ts @@ -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() - -/** - * 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(), diff --git a/web/app/components/base/chat/chat/answer/human-input-content/human-input-form.tsx b/web/app/components/base/chat/chat/answer/human-input-content/human-input-form.tsx index 31b0328a9e2..7d43894889d 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/human-input-form.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/human-input-form.tsx @@ -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} diff --git a/web/types/workflow.ts b/web/types/workflow.ts index cfc886158eb..ca0883b25aa 100644 --- a/web/types/workflow.ts +++ b/web/types/workflow.ts @@ -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 display_in_ui: boolean - expiration_time: number + expiration_time: number | null } export type HumanInputRequiredResponse = {