diff --git a/api/controllers/console/app/message.py b/api/controllers/console/app/message.py index 195a41f2888..f987ecca745 100644 --- a/api/controllers/console/app/message.py +++ b/api/controllers/console/app/message.py @@ -167,12 +167,16 @@ register_schema_models( ChatMessagesQuery, MessageFeedbackPayload, FeedbackExportQuery, +) +register_response_schema_models( + console_ns, AnnotationCountResponse, SuggestedQuestionsResponse, MessageDetailResponse, MessageInfiniteScrollPaginationResponse, + SimpleResultResponse, + TextFileResponse, ) -register_response_schema_models(console_ns, SimpleResultResponse, TextFileResponse) @console_ns.route("/apps//chat-messages") diff --git a/api/controllers/web/audio.py b/api/controllers/web/audio.py index 801c1f5a629..3d47494edf7 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.audio_service import AudioService from services.errors.audio import ( @@ -37,6 +36,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 @@ -47,18 +50,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( @@ -72,7 +70,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"] @@ -80,7 +78,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() @@ -121,7 +119,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: @@ -130,7 +129,7 @@ class TextApi(WebApiResource): message_id = payload.message_id text = payload.text voice = payload.voice - response = AudioService.transcript_tts( + return AudioService.transcript_tts( app_model=app_model, session=db.session, text=text, @@ -138,8 +137,6 @@ class TextApi(WebApiResource): end_user=end_user.external_user_id, message_id=message_id, ) - - 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 7871b411c4b..34fd89dec53 100644 --- a/api/controllers/web/completion.py +++ b/api/controllers/web/completion.py @@ -5,7 +5,7 @@ from pydantic import BaseModel, Field, field_validator from werkzeug.exceptions import BadRequest, InternalServerError, NotFound import services -from controllers.common.fields import GeneratedAppResponse, SimpleResultResponse +from controllers.common.fields import SimpleResultResponse from controllers.common.schema import register_response_schema_models, register_schema_models from controllers.web import web_ns from controllers.web.error import ( @@ -87,7 +87,7 @@ class ChatMessagePayload(BaseModel): register_schema_models(web_ns, CompletionMessagePayload, ChatMessagePayload) -register_response_schema_models(web_ns, GeneratedAppResponse, SimpleResultResponse) +register_response_schema_models(web_ns, SimpleResultResponse) # define completion api for user @@ -106,7 +106,7 @@ class CompletionApi(WebApiResource): 500: "Internal Server Error", } ) - @web_ns.response(200, "Success", web_ns.models[GeneratedAppResponse.__name__]) + @web_ns.response(200, "Success") def post(self, app_model: App, end_user: EndUser): if app_model.mode != AppMode.COMPLETION: raise NotCompletionAppError() @@ -122,6 +122,7 @@ class CompletionApi(WebApiResource): app_model=app_model, user=end_user, args=args, invoke_from=InvokeFrom.WEB_APP, streaming=streaming ) + # response-contract:ignore compact_generate_response return helper.compact_generate_response(response) except services.errors.conversation.ConversationNotExistsError: raise NotFound("Conversation Not Exists.") @@ -172,7 +173,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") @@ -190,7 +191,7 @@ class ChatApi(WebApiResource): 500: "Internal Server Error", } ) - @web_ns.response(200, "Success", web_ns.models[GeneratedAppResponse.__name__]) + @web_ns.response(200, "Success") def post(self, app_model: App, end_user: EndUser): app_mode = AppMode.value_of(app_model.mode) if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}: @@ -213,6 +214,7 @@ class ChatApi(WebApiResource): app_model=app_model, user=end_user, args=args, invoke_from=InvokeFrom.WEB_APP, streaming=streaming ) + # response-contract:ignore compact_generate_response return helper.compact_generate_response(response) except services.errors.conversation.ConversationNotExistsError: raise NotFound("Conversation Not Exists.") @@ -266,4 +268,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 14b982dd23b..ebf63db2458 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 controllers.web.site import WebAppSiteResponse from extensions.ext_database import db -from graphon.nodes.human_input.entities import FormInputConfig -from libs.helper import RateLimiter, extract_remote_ip, to_timestamp +from fields.base import ResponseModel +from graphon.nodes.human_input.entities import FormInputConfig, UserActionConfig +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) - 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, + can_replace_logo=FeatureService.get_features( + app_model.tenant_id, exclude_vector_space=True + ).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 65ef02471a9..0ecf313660e 100644 --- a/api/controllers/web/message.py +++ b/api/controllers/web/message.py @@ -7,7 +7,6 @@ from pydantic import BaseModel, Field, TypeAdapter from werkzeug.exceptions import InternalServerError, NotFound from controllers.common.controller_schemas import MessageFeedbackPayload, MessageListQuery -from controllers.common.fields import GeneratedAppResponse from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.web import web_ns from controllers.web.error import ( @@ -51,7 +50,6 @@ class MessageMoreLikeThisQuery(BaseModel): register_schema_models(web_ns, MessageListQuery, MessageFeedbackPayload, MessageMoreLikeThisQuery) register_response_schema_models( web_ns, - GeneratedAppResponse, ResultResponse, SuggestedQuestionsResponse, WebMessageInfiniteScrollPagination, @@ -161,7 +159,7 @@ class MessageMoreLikeThisApi(WebApiResource): 500: "Internal Server Error", } ) - @web_ns.response(200, "Success", web_ns.models[GeneratedAppResponse.__name__]) + @web_ns.response(200, "Success") def get(self, app_model: App, end_user: EndUser, message_id: UUID): if app_model.mode != "completion": raise NotCompletionAppError() @@ -182,6 +180,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 6e59a85e2b0..256b5377d7f 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 5e0f8326517..e70cc2c2a1d 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 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 @@ -44,64 +32,92 @@ 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, + 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, + ) + + return cls( + app_id=app_model.id, + end_user_id=end_user_id, + enable_site=app_model.enable_site, + site=WebSiteResponse.model_validate(site, from_attributes=True), + 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, - "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( @@ -114,57 +130,25 @@ 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() can_replace_logo = FeatureService.get_features(app_model.tenant_id, exclude_vector_space=True).can_replace_logo - return AppSiteInfo(app_model.tenant, app_model, site, end_user.id, 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_app_site_payload(app_model: App, site: Site, end_user_id: str | None) -> dict[str, Any]: - can_replace_logo = FeatureService.get_features(app_model.tenant_id, exclude_vector_space=True).can_replace_logo - app_site_info = AppSiteInfo(app_model.tenant, app_model, site, end_user_id, 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, + can_replace_logo=can_replace_logo, + ).model_dump(mode="json") diff --git a/api/controllers/web/workflow.py b/api/controllers/web/workflow.py index 06d9c02fedc..b380eccfe5f 100644 --- a/api/controllers/web/workflow.py +++ b/api/controllers/web/workflow.py @@ -3,7 +3,7 @@ import logging from werkzeug.exceptions import InternalServerError from controllers.common.controller_schemas import WorkflowRunPayload -from controllers.common.fields import GeneratedAppResponse, SimpleResultResponse +from controllers.common.fields import SimpleResultResponse from controllers.common.schema import register_response_schema_models, register_schema_models from controllers.web import web_ns from controllers.web.error import ( @@ -33,7 +33,7 @@ from services.errors.llm import InvokeRateLimitError logger = logging.getLogger(__name__) register_schema_models(web_ns, WorkflowRunPayload) -register_response_schema_models(web_ns, GeneratedAppResponse, SimpleResultResponse) +register_response_schema_models(web_ns, SimpleResultResponse) @web_ns.route("/workflows/run") @@ -51,7 +51,7 @@ class WorkflowRunApi(WebApiResource): 500: "Internal Server Error", } ) - @web_ns.response(200, "Success", web_ns.models[GeneratedAppResponse.__name__]) + @web_ns.response(200, "Workflow run stream started") def post(self, app_model: App, end_user: EndUser): """ Run workflow @@ -68,6 +68,7 @@ class WorkflowRunApi(WebApiResource): app_model=app_model, user=end_user, args=args, invoke_from=InvokeFrom.WEB_APP, streaming=True ) + # response-contract:ignore compact_generate_response return helper.compact_generate_response(response) except ProviderTokenNotInitError as ex: raise ProviderNotInitializeError(ex.description) @@ -121,4 +122,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/core/rag/extractor/extract_processor.py b/api/core/rag/extractor/extract_processor.py index 4d11ebe5005..36d879427a5 100644 --- a/api/core/rag/extractor/extract_processor.py +++ b/api/core/rag/extractor/extract_processor.py @@ -1,7 +1,7 @@ import re import tempfile from pathlib import Path -from typing import Union +from typing import Literal, overload from urllib.parse import unquote from configs import dify_config @@ -40,10 +40,22 @@ USER_AGENT = ( class ExtractProcessor: + @overload + @classmethod + def load_from_upload_file( + cls, upload_file: UploadFile, return_text: Literal[True], is_automatic: bool = False + ) -> str: ... + + @overload + @classmethod + def load_from_upload_file( + cls, upload_file: UploadFile, return_text: Literal[False] = False, is_automatic: bool = False + ) -> list[Document]: ... + @classmethod def load_from_upload_file( cls, upload_file: UploadFile, return_text: bool = False, is_automatic: bool = False - ) -> Union[list[Document], str]: + ) -> list[Document] | str: extract_setting = ExtractSetting( datasource_type=DatasourceType.FILE, upload_file=upload_file, document_model="text_model" ) @@ -53,8 +65,16 @@ class ExtractProcessor: else: return cls.extract(extract_setting, is_automatic) + @overload @classmethod - def load_from_url(cls, url: str, return_text: bool = False) -> Union[list[Document], str]: + def load_from_url(cls, url: str, return_text: Literal[True]) -> str: ... + + @overload + @classmethod + def load_from_url(cls, url: str, return_text: Literal[False] = False) -> list[Document]: ... + + @classmethod + def load_from_url(cls, url: str, return_text: bool = False) -> list[Document] | str: response = remote_fetcher.make_request("GET", url, headers={"User-Agent": USER_AGENT}) with tempfile.TemporaryDirectory() as temp_dir: 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 b3a0b8a6a71..f0f680e540a 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -13429,7 +13429,6 @@ Soft lifecycle state for Agent records. | created_at | integer | | No | | files | [ string ] | | Yes | | id | string | | Yes | -| message_chain_id | string | | No | | message_id | string | | Yes | | observation | string | | No | | position | integer | | Yes | @@ -14540,8 +14539,8 @@ Enum class for configurate method of provider model. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | annotation_create_account | [SimpleAccount](#simpleaccount) | | No | +| annotation_id | string | | Yes | | created_at | integer | | No | -| id | string | | Yes | #### ConversationDetail @@ -17079,6 +17078,7 @@ Enum class for large language model mode. | agent_thoughts | [ [AgentThought](#agentthought) ] | | No | | annotation | [ConversationAnnotation](#conversationannotation) | | No | | annotation_hit_history | [ConversationAnnotationHitHistory](#conversationannotationhithistory) | | No | +| answer | string | | Yes | | answer_tokens | integer | | No | | conversation_id | string | | Yes | | created_at | integer | | No | @@ -17092,12 +17092,11 @@ Enum class for large language model mode. | inputs | object | | Yes | | message | [JSONValue](#jsonvalue) | | No | | message_files | [ [MessageFile](#messagefile) ] | | No | -| message_metadata_dict | [JSONValue](#jsonvalue) | | No | | message_tokens | integer | | No | +| metadata | [JSONValue](#jsonvalue) | | No | | parent_message_id | string | | No | | provider_response_latency | number | | No | | query | string | | Yes | -| re_sign_file_url_answer | string | | Yes | | status | string | | Yes | | workflow_run_id | string | | No | @@ -20187,11 +20186,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 0f368895ab6..1b5642123f4 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 | | @@ -40,14 +40,14 @@ Create a chat message for conversational applications. #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| -| 400 | Bad Request | | -| 401 | Unauthorized | | -| 403 | Forbidden | | -| 404 | App Not Found | | -| 500 | Internal Server Error | | +| Code | Description | +| ---- | ----------- | +| 200 | Success | +| 400 | Bad Request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | App Not Found | +| 500 | Internal Server Error | ### [POST] /chat-messages/{task_id}/stop Stop a running chat message task. @@ -80,14 +80,14 @@ Create a completion message for text generation applications. #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| -| 400 | Bad Request | | -| 401 | Unauthorized | | -| 403 | Forbidden | | -| 404 | App Not Found | | -| 500 | Internal Server Error | | +| Code | Description | +| ---- | ----------- | +| 200 | Success | +| 400 | Bad Request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | App Not Found | +| 500 | Internal Server Error | ### [POST] /completion-messages/{task_id}/stop Stop a running completion message task. @@ -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** @@ -526,14 +540,14 @@ Generate a new completion similar to an existing message (completion apps only). #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| -| 400 | Bad Request - Not a completion app or feature disabled | | -| 401 | Unauthorized | | -| 403 | Forbidden | | -| 404 | Message Not Found | | -| 500 | Internal Server Error | | +| Code | Description | +| ---- | ----------- | +| 200 | Success | +| 400 | Bad Request - Not a completion app or feature disabled | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Message Not Found | +| 500 | Internal Server Error | ### [GET] /messages/{message_id}/suggested-questions Get suggested follow-up questions after a message (chat apps only). @@ -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). @@ -856,14 +870,14 @@ Execute a workflow with provided inputs and files. #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| -| 400 | Bad Request | | -| 401 | Unauthorized | | -| 403 | Forbidden | | -| 404 | App Not Found | | -| 500 | Internal Server Error | | +| Code | Description | +| ---- | ----------- | +| 200 | Success | +| 400 | Bad Request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | App Not Found | +| 500 | Internal Server Error | ### [POST] /workflows/tasks/{task_id}/stop **Stop workflow task** @@ -967,58 +981,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 | -| 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 | | ---- | ---- | ----------- | -------- | @@ -1216,12 +1179,6 @@ Button styles for user actions. | ---- | ---- | ----------- | -------- | | FormInputConfig | [ParagraphInputConfig](#paragraphinputconfig)
[SelectInputConfig](#selectinputconfig)
[FileInputConfig](#fileinputconfig)
[FileListInputConfig](#filelistinputconfig) | | | -#### GeneratedAppResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| GeneratedAppResponse | | | | - #### HumanInputContent | Name | Type | Description | Required | @@ -1260,11 +1217,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 @@ -1306,7 +1263,7 @@ Parsed multipart form fields for HITL uploads. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| JSONValue | string
integer
number
boolean
object
[ object ] | | | +| JSONValue | | | | #### JSONValueType @@ -1681,6 +1638,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 | @@ -1709,6 +1686,38 @@ 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 | +| 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 e41d74ad3eb..abd253ec8b2 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 9adb26ff3d2..4fc99cdc74c 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,21 +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 @@ -41,7 +42,23 @@ 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", @@ -51,31 +68,30 @@ def _create_site(db_session: Session, app_id: str) -> Site: default_language="en", chat_color_theme="light", chat_color_theme_inverted=False, + 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 = SimpleNamespace(can_replace_logo=False) + 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 @@ -83,51 +99,139 @@ class TestAppSiteApi: 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 = SimpleNamespace(can_replace_logo=False) + 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", + 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.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, + 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", + 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 0caeae2cee4..1a602813eaa 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 @@ -112,7 +111,7 @@ def test_get_form_includes_site(monkeypatch: pytest.MonkeyPatch, app: Flask): chat_color_theme_inverted=False, copyright=None, privacy_policy=None, - custom_disclaimer=None, + custom_disclaimer="", prompt_public=False, show_workflow_steps=True, use_icon_as_answer_icon=False, @@ -138,7 +137,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", @@ -167,7 +166,7 @@ def test_get_form_includes_site(monkeypatch: pytest.MonkeyPatch, app: Flask): "description": "desc", "copyright": None, "privacy_policy": None, - "custom_disclaimer": None, + "custom_disclaimer": "", "default_language": "en", "prompt_public": False, "show_workflow_steps": True, @@ -256,7 +255,7 @@ def test_get_form_uses_runtime_select_options(monkeypatch: pytest.MonkeyPatch, a chat_color_theme_inverted=False, copyright=None, privacy_policy=None, - custom_disclaimer=None, + custom_disclaimer="", prompt_public=False, show_workflow_steps=True, use_icon_as_answer_icon=False, @@ -277,7 +276,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) @@ -380,7 +379,7 @@ def test_get_form_allows_backstage_token(monkeypatch: pytest.MonkeyPatch, app: F chat_color_theme_inverted=False, copyright=None, privacy_policy=None, - custom_disclaimer=None, + custom_disclaimer="", prompt_public=False, show_workflow_steps=True, use_icon_as_answer_icon=False, @@ -403,7 +402,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", @@ -432,7 +431,7 @@ def test_get_form_allows_backstage_token(monkeypatch: pytest.MonkeyPatch, app: F "description": "desc", "copyright": None, "privacy_policy": None, - "custom_disclaimer": None, + "custom_disclaimer": "", "default_language": "en", "prompt_public": False, "show_workflow_steps": True, diff --git a/packages/contracts/generated/api/console/agent/types.gen.ts b/packages/contracts/generated/api/console/agent/types.gen.ts index 43119c4f1f4..4026c0d35eb 100644 --- a/packages/contracts/generated/api/console/agent/types.gen.ts +++ b/packages/contracts/generated/api/console/agent/types.gen.ts @@ -269,6 +269,7 @@ export type MessageDetailResponse = { agent_thoughts?: Array annotation?: ConversationAnnotation | null annotation_hit_history?: ConversationAnnotationHitHistory | null + answer: string answer_tokens?: number | null conversation_id: string created_at?: number | null @@ -284,12 +285,11 @@ export type MessageDetailResponse = { } message?: JsonValue | null message_files?: Array - message_metadata_dict?: JsonValue | null message_tokens?: number | null + metadata?: JsonValue | null parent_message_id?: string | null provider_response_latency?: number | null query: string - re_sign_file_url_answer: string status: string workflow_run_id?: string | null } @@ -723,7 +723,6 @@ export type AgentThought = { created_at?: number | null files: Array id: string - message_chain_id?: string | null message_id: string observation?: string | null position: number @@ -743,8 +742,8 @@ export type ConversationAnnotation = { export type ConversationAnnotationHitHistory = { annotation_create_account?: SimpleAccount | null + annotation_id: string created_at?: number | null - id: string } export type HumanInputContent = { diff --git a/packages/contracts/generated/api/console/agent/zod.gen.ts b/packages/contracts/generated/api/console/agent/zod.gen.ts index d7f5681ffc4..45a68beb8a6 100644 --- a/packages/contracts/generated/api/console/agent/zod.gen.ts +++ b/packages/contracts/generated/api/console/agent/zod.gen.ts @@ -570,7 +570,6 @@ export const zAgentThought = z.object({ created_at: z.int().nullish(), files: z.array(z.string()), id: z.string(), - message_chain_id: z.string().nullish(), message_id: z.string(), observation: z.string().nullish(), position: z.int(), @@ -1056,8 +1055,8 @@ export const zConversationAnnotation = z.object({ */ export const zConversationAnnotationHitHistory = z.object({ annotation_create_account: zSimpleAccount.nullish(), + annotation_id: z.string(), created_at: z.int().nullish(), - id: z.string(), }) /** @@ -2035,6 +2034,7 @@ export const zMessageDetailResponse = z.object({ agent_thoughts: z.array(zAgentThought).optional(), annotation: zConversationAnnotation.nullish(), annotation_hit_history: zConversationAnnotationHitHistory.nullish(), + answer: z.string(), answer_tokens: z.int().nullish(), conversation_id: z.string(), created_at: z.int().nullish(), @@ -2048,12 +2048,11 @@ export const zMessageDetailResponse = z.object({ inputs: z.record(z.string(), zJsonValue), message: zJsonValue.nullish(), message_files: z.array(zMessageFile).optional(), - message_metadata_dict: zJsonValue.nullish(), message_tokens: z.int().nullish(), + metadata: zJsonValue.nullish(), parent_message_id: z.string().nullish(), provider_response_latency: z.number().nullish(), query: z.string(), - re_sign_file_url_answer: z.string(), status: z.string(), workflow_run_id: z.string().nullish(), }) diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index 9e79518f3cd..3a046dc4727 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -472,6 +472,7 @@ export type MessageDetailResponse = { agent_thoughts?: Array annotation?: ConversationAnnotation | null annotation_hit_history?: ConversationAnnotationHitHistory | null + answer: string answer_tokens?: number | null conversation_id: string created_at?: number | null @@ -487,12 +488,11 @@ export type MessageDetailResponse = { } message?: JsonValue | null message_files?: Array - message_metadata_dict?: JsonValue | null message_tokens?: number | null + metadata?: JsonValue | null parent_message_id?: string | null provider_response_latency?: number | null query: string - re_sign_file_url_answer: string status: string workflow_run_id?: string | null } @@ -1498,7 +1498,6 @@ export type AgentThought = { created_at?: number | null files: Array id: string - message_chain_id?: string | null message_id: string observation?: string | null position: number @@ -1518,8 +1517,8 @@ export type ConversationAnnotation = { export type ConversationAnnotationHitHistory = { annotation_create_account?: SimpleAccount | null + annotation_id: string created_at?: number | null - id: string } export type HumanInputContent = { diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index 9b86fda0a62..c881edbc9f1 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -1150,7 +1150,6 @@ export const zAgentThought = z.object({ created_at: z.int().nullish(), files: z.array(z.string()), id: z.string(), - message_chain_id: z.string().nullish(), message_id: z.string(), observation: z.string().nullish(), position: z.int(), @@ -1371,8 +1370,8 @@ export const zConversationAnnotation = z.object({ */ export const zConversationAnnotationHitHistory = z.object({ annotation_create_account: zSimpleAccount.nullish(), + annotation_id: z.string(), created_at: z.int().nullish(), - id: z.string(), }) /** @@ -3455,6 +3454,7 @@ export const zMessageDetailResponse = z.object({ agent_thoughts: z.array(zAgentThought).optional(), annotation: zConversationAnnotation.nullish(), annotation_hit_history: zConversationAnnotationHitHistory.nullish(), + answer: z.string(), answer_tokens: z.int().nullish(), conversation_id: z.string(), created_at: z.int().nullish(), @@ -3468,12 +3468,11 @@ export const zMessageDetailResponse = z.object({ inputs: z.record(z.string(), zJsonValue), message: zJsonValue.nullish(), message_files: z.array(zMessageFile).optional(), - message_metadata_dict: zJsonValue.nullish(), message_tokens: z.int().nullish(), + metadata: zJsonValue.nullish(), parent_message_id: z.string().nullish(), provider_response_latency: z.number().nullish(), query: z.string(), - re_sign_file_url_answer: z.string(), status: z.string(), workflow_run_id: z.string().nullish(), }) diff --git a/packages/contracts/generated/api/console/files/types.gen.ts b/packages/contracts/generated/api/console/files/types.gen.ts index 277300ce7a3..536de5a085c 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 4454afcdc86..322f2b3c6c6 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/console/installed-apps/types.gen.ts b/packages/contracts/generated/api/console/installed-apps/types.gen.ts index f9a5eb01edc..61b1303d5b5 100644 --- a/packages/contracts/generated/api/console/installed-apps/types.gen.ts +++ b/packages/contracts/generated/api/console/installed-apps/types.gen.ts @@ -246,7 +246,6 @@ export type AgentThought = { created_at?: number | null files: Array id: string - message_chain_id?: string | null message_id: string observation?: string | null position: number diff --git a/packages/contracts/generated/api/console/installed-apps/zod.gen.ts b/packages/contracts/generated/api/console/installed-apps/zod.gen.ts index a4556058506..53439d132c8 100644 --- a/packages/contracts/generated/api/console/installed-apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/installed-apps/zod.gen.ts @@ -266,7 +266,6 @@ export const zAgentThought = z.object({ created_at: z.int().nullish(), files: z.array(z.string()), id: z.string(), - message_chain_id: z.string().nullish(), message_id: z.string(), observation: z.string().nullish(), position: z.int(), diff --git a/packages/contracts/generated/api/web/orpc.gen.ts b/packages/contracts/generated/api/web/orpc.gen.ts index f0ef39fd976..94274722d4d 100644 --- a/packages/contracts/generated/api/web/orpc.gen.ts +++ b/packages/contracts/generated/api/web/orpc.gen.ts @@ -14,9 +14,6 @@ import { zGetFormHumanInputByFormTokenResponse, zGetLoginStatusQuery, zGetLoginStatusResponse, - zGetMessagesByMessageIdMoreLikeThisPath, - zGetMessagesByMessageIdMoreLikeThisQuery, - zGetMessagesByMessageIdMoreLikeThisResponse, zGetMessagesByMessageIdSuggestedQuestionsPath, zGetMessagesByMessageIdSuggestedQuestionsResponse, zGetMessagesQuery, @@ -42,14 +39,10 @@ import { zPatchConversationsByCIdUnpinPath, zPatchConversationsByCIdUnpinResponse, zPostAudioToTextResponse, - zPostChatMessagesBody, zPostChatMessagesByTaskIdStopPath, zPostChatMessagesByTaskIdStopResponse, - zPostChatMessagesResponse, - zPostCompletionMessagesBody, zPostCompletionMessagesByTaskIdStopPath, zPostCompletionMessagesByTaskIdStopResponse, - zPostCompletionMessagesResponse, zPostConversationsByCIdNameBody, zPostConversationsByCIdNamePath, zPostConversationsByCIdNameQuery, @@ -83,10 +76,6 @@ import { zPostSavedMessagesBody, zPostSavedMessagesQuery, zPostSavedMessagesResponse, - zPostTextToAudioBody, - zPostTextToAudioResponse, - zPostWorkflowsRunBody, - zPostWorkflowsRunResponse, zPostWorkflowsTasksByTaskIdStopPath, zPostWorkflowsTasksByTaskIdStopResponse, } from './zod.gen' @@ -135,30 +124,14 @@ export const byTaskId = { stop, } -/** - * Create a chat message for conversational applications. - */ -export const post3 = oc - .route({ - description: 'Create a chat message for conversational applications.', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postChatMessages', - path: '/chat-messages', - tags: ['web'], - }) - .input(z.object({ body: zPostChatMessagesBody })) - .output(zPostChatMessagesResponse) - export const chatMessages = { - post: post3, byTaskId, } /** * Stop a running completion message task. */ -export const post4 = oc +export const post3 = oc .route({ description: 'Stop a running completion message task.', inputStructure: 'detailed', @@ -171,37 +144,21 @@ export const post4 = oc .output(zPostCompletionMessagesByTaskIdStopResponse) export const stop2 = { - post: post4, + post: post3, } export const byTaskId2 = { stop: stop2, } -/** - * Create a completion message for text generation applications. - */ -export const post5 = oc - .route({ - description: 'Create a completion message for text generation applications.', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postCompletionMessages', - path: '/completion-messages', - tags: ['web'], - }) - .input(z.object({ body: zPostCompletionMessagesBody })) - .output(zPostCompletionMessagesResponse) - export const completionMessages = { - post: post5, byTaskId: byTaskId2, } /** * Rename a specific conversation with a custom name or auto-generate one. */ -export const post6 = oc +export const post4 = oc .route({ description: 'Rename a specific conversation with a custom name or auto-generate one.', inputStructure: 'detailed', @@ -220,7 +177,7 @@ export const post6 = oc .output(zPostConversationsByCIdNameResponse) export const name = { - post: post6, + post: post4, } /** @@ -307,7 +264,7 @@ export const conversations = { /** * Verify email code and complete login */ -export const post7 = oc +export const post5 = oc .route({ description: 'Verify email code and complete login', inputStructure: 'detailed', @@ -320,13 +277,13 @@ export const post7 = oc .output(zPostEmailCodeLoginValidityResponse) export const validity = { - post: post7, + post: post5, } /** * Send email verification code for login */ -export const post8 = oc +export const post6 = oc .route({ description: 'Send email verification code for login', inputStructure: 'detailed', @@ -339,7 +296,7 @@ export const post8 = oc .output(zPostEmailCodeLoginResponse) export const emailCodeLogin = { - post: post8, + post: post6, validity, } @@ -369,7 +326,7 @@ export const emailCodeLogin = { * FileTooLargeError: File exceeds size limit * UnsupportedFileTypeError: File type not supported */ -export const post9 = oc +export const post7 = oc .route({ description: 'Upload a file for use in web applications\nAccepts file uploads for use within web applications, supporting\nmultiple file types with automatic validation and storage.\n\nArgs:\n app_model: The associated application model\n end_user: The end user uploading the file\n\nForm Parameters:\n file: The file to upload (required)\n source: Optional source type (datasets or None)\n\nReturns:\n dict: File information including ID, URL, and metadata\n int: HTTP status code 201 for success\n\nRaises:\n NoFileUploadedError: No file provided in request\n TooManyFilesError: Multiple files provided (only one allowed)\n FilenameNotExistsError: File has no filename\n FileTooLargeError: File exceeds size limit\n UnsupportedFileTypeError: File type not supported', @@ -384,7 +341,7 @@ export const post9 = oc .output(zPostFilesUploadResponse) export const upload = { - post: post9, + post: post7, } export const files = { @@ -394,7 +351,7 @@ export const files = { /** * Reset user password with verification token */ -export const post10 = oc +export const post8 = oc .route({ description: 'Reset user password with verification token', inputStructure: 'detailed', @@ -407,13 +364,13 @@ export const post10 = oc .output(zPostForgotPasswordResetsResponse) export const resets = { - post: post10, + post: post8, } /** * Verify password reset token validity */ -export const post11 = oc +export const post9 = oc .route({ description: 'Verify password reset token validity', inputStructure: 'detailed', @@ -426,13 +383,13 @@ export const post11 = oc .output(zPostForgotPasswordValidityResponse) export const validity2 = { - post: post11, + post: post9, } /** * Send password reset email */ -export const post12 = oc +export const post10 = oc .route({ description: 'Send password reset email', inputStructure: 'detailed', @@ -445,7 +402,7 @@ export const post12 = oc .output(zPostForgotPasswordResponse) export const forgotPassword = { - post: post12, + post: post10, resets, validity: validity2, } @@ -453,11 +410,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 +export const post11 = 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', @@ -469,17 +428,19 @@ export const post13 = oc .output(zPostFormHumanInputByFormTokenUploadTokenResponse) export const uploadToken = { - post: post13, + post: post11, } /** * 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 +454,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: @@ -503,10 +465,10 @@ export const get2 = oc * "action": "Approve" * } */ -export const post14 = oc +export const post12 = 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', @@ -524,7 +486,7 @@ export const post14 = oc export const byFormToken = { get: get2, - post: post14, + post: post12, uploadToken, } @@ -539,7 +501,7 @@ export const form = { /** * Upload one local file or remote URL file for a HITL human input form */ -export const post15 = oc +export const post13 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -552,7 +514,7 @@ export const post15 = oc .output(zPostHumanInputFormsFilesResponse) export const files2 = { - post: post15, + post: post13, } export const humanInputForms = { @@ -583,7 +545,7 @@ export const status = { * * Authenticate user for web application access */ -export const post16 = oc +export const post14 = oc .route({ description: 'Authenticate user for web application access', inputStructure: 'detailed', @@ -597,14 +559,14 @@ export const post16 = oc .output(zPostLoginResponse) export const login = { - post: post16, + post: post14, status, } /** * Logout user from web application */ -export const post17 = oc +export const post15 = oc .route({ description: 'Logout user from web application', inputStructure: 'detailed', @@ -616,13 +578,13 @@ export const post17 = oc .output(zPostLogoutResponse) export const logout = { - post: post17, + post: post15, } /** * Submit feedback (like/dislike) for a specific message. */ -export const post18 = oc +export const post16 = oc .route({ description: 'Submit feedback (like/dislike) for a specific message.', inputStructure: 'detailed', @@ -641,37 +603,13 @@ export const post18 = oc .output(zPostMessagesByMessageIdFeedbacksResponse) export const feedbacks = { - post: post18, -} - -/** - * Generate a new completion similar to an existing message (completion apps only). - */ -export const get4 = oc - .route({ - description: 'Generate a new completion similar to an existing message (completion apps only).', - inputStructure: 'detailed', - method: 'GET', - operationId: 'getMessagesByMessageIdMoreLikeThis', - path: '/messages/{message_id}/more-like-this', - tags: ['web'], - }) - .input( - z.object({ - params: zGetMessagesByMessageIdMoreLikeThisPath, - query: zGetMessagesByMessageIdMoreLikeThisQuery, - }), - ) - .output(zGetMessagesByMessageIdMoreLikeThisResponse) - -export const moreLikeThis = { - get: get4, + post: post16, } /** * Get suggested follow-up questions after a message (chat apps only). */ -export const get5 = oc +export const get4 = oc .route({ description: 'Get suggested follow-up questions after a message (chat apps only).', inputStructure: 'detailed', @@ -684,19 +622,18 @@ export const get5 = oc .output(zGetMessagesByMessageIdSuggestedQuestionsResponse) export const suggestedQuestions = { - get: get5, + get: get4, } export const byMessageId = { feedbacks, - moreLikeThis, suggestedQuestions, } /** * Retrieve paginated list of messages from a conversation in a chat application. */ -export const get6 = oc +export const get5 = oc .route({ description: 'Retrieve paginated list of messages from a conversation in a chat application.', inputStructure: 'detailed', @@ -709,7 +646,7 @@ export const get6 = oc .output(zGetMessagesResponse) export const messages = { - get: get6, + get: get5, byMessageId, } @@ -718,7 +655,7 @@ export const messages = { * * Retrieve the metadata for a specific app. */ -export const get7 = oc +export const get6 = oc .route({ description: 'Retrieve the metadata for a specific app.', inputStructure: 'detailed', @@ -731,7 +668,7 @@ export const get7 = oc .output(zGetMetaResponse) export const meta = { - get: get7, + get: get6, } /** @@ -739,7 +676,7 @@ export const meta = { * * Retrieve the parameters for a specific app. */ -export const get8 = oc +export const get7 = oc .route({ description: 'Retrieve the parameters for a specific app.', inputStructure: 'detailed', @@ -752,13 +689,13 @@ export const get8 = oc .output(zGetParametersResponse) export const parameters = { - get: get8, + get: get7, } /** * Get authentication passport for web application access */ -export const get9 = oc +export const get8 = oc .route({ description: 'Get authentication passport for web application access', inputStructure: 'detailed', @@ -771,7 +708,7 @@ export const get9 = oc .output(zGetPassportResponse) export const passport = { - get: get9, + get: get8, } /** @@ -797,7 +734,7 @@ export const passport = { * FileTooLargeError: File exceeds size limit * UnsupportedFileTypeError: File type not supported */ -export const post19 = oc +export const post17 = oc .route({ description: 'Upload a file from a remote URL\nDownloads a file from the provided remote URL and uploads it\nto the platform storage for use in web applications.\n\nArgs:\n app_model: The associated application model\n end_user: The end user making the request\n\nJSON Parameters:\n url: The remote URL to download the file from (required)\n\nReturns:\n dict: File information including ID, signed URL, and metadata\n int: HTTP status code 201 for success\n\nRaises:\n RemoteFileUploadError: Failed to fetch file from remote URL\n FileTooLargeError: File exceeds size limit\n UnsupportedFileTypeError: File type not supported', @@ -813,7 +750,7 @@ export const post19 = oc .output(zPostRemoteFilesUploadResponse) export const upload2 = { - post: post19, + post: post17, } /** @@ -834,7 +771,7 @@ export const upload2 = { * Raises: * HTTPException: If the remote file cannot be accessed */ -export const get10 = oc +export const get9 = oc .route({ description: 'Get information about a remote file\nRetrieves basic information about a file located at a remote URL,\nincluding content type and content length.\n\nArgs:\n app_model: The associated application model\n end_user: The end user making the request\n url: URL-encoded path to the remote file\n\nReturns:\n dict: Remote file information including type and length\n\nRaises:\n HTTPException: If the remote file cannot be accessed', @@ -849,7 +786,7 @@ export const get10 = oc .output(zGetRemoteFilesByUrlResponse) export const byUrl = { - get: get10, + get: get9, } export const remoteFiles = { @@ -880,7 +817,7 @@ export const byMessageId2 = { /** * Retrieve paginated list of saved messages for a completion application. */ -export const get11 = oc +export const get10 = oc .route({ description: 'Retrieve paginated list of saved messages for a completion application.', inputStructure: 'detailed', @@ -895,7 +832,7 @@ export const get11 = oc /** * Save a specific message for later reference. */ -export const post20 = oc +export const post18 = oc .route({ description: 'Save a specific message for later reference.', inputStructure: 'detailed', @@ -908,8 +845,8 @@ export const post20 = oc .output(zPostSavedMessagesResponse) export const savedMessages = { - get: get11, - post: post20, + get: get10, + post: post18, byMessageId: byMessageId2, } @@ -918,7 +855,7 @@ export const savedMessages = { * * Retrieve app site information and configuration. */ -export const get12 = oc +export const get11 = oc .route({ description: 'Retrieve app site information and configuration.', inputStructure: 'detailed', @@ -931,7 +868,7 @@ export const get12 = oc .output(zGetSiteResponse) export const site = { - get: get12, + get: get11, } /** @@ -954,7 +891,7 @@ export const site = { * * Only non-sensitive configuration data should be returned by this endpoint. */ -export const get13 = oc +export const get12 = oc .route({ description: 'Get system feature flags and configuration\nReturns the current system feature flags and configuration\nthat control various functionalities across the platform.\n\nReturns:\n dict: System feature configuration object\n\nThis endpoint is akin to the `SystemFeatureApi` endpoint in api/controllers/console/feature.py,\nexcept it is intended for use by the web app, instead of the console dashboard.\n\nNOTE: This endpoint is unauthenticated by design, as it provides system features\ndata required for webapp initialization.\n\nAuthentication would create circular dependency (can\'t authenticate without webapp loading).\n\nOnly non-sensitive configuration data should be returned by this endpoint.', @@ -968,35 +905,13 @@ export const get13 = oc .output(zGetSystemFeaturesResponse) export const systemFeatures = { - get: get13, -} - -/** - * Convert text to audio - * - * Convert text to audio using text-to-speech service. - */ -export const post21 = oc - .route({ - description: 'Convert text to audio using text-to-speech service.', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postTextToAudio', - path: '/text-to-audio', - summary: 'Convert text to audio', - tags: ['web'], - }) - .input(z.object({ body: zPostTextToAudioBody })) - .output(zPostTextToAudioResponse) - -export const textToAudio = { - post: post21, + get: get12, } /** * Retrieve the access mode for a web application (public or restricted). */ -export const get14 = oc +export const get13 = oc .route({ description: 'Retrieve the access mode for a web application (public or restricted).', inputStructure: 'detailed', @@ -1009,13 +924,13 @@ export const get14 = oc .output(zGetWebappAccessModeResponse) export const accessMode = { - get: get14, + get: get13, } /** * Check if user has permission to access a web application. */ -export const get15 = oc +export const get14 = oc .route({ description: 'Check if user has permission to access a web application.', inputStructure: 'detailed', @@ -1028,7 +943,7 @@ export const get15 = oc .output(zGetWebappPermissionResponse) export const permission = { - get: get15, + get: get14, } export const webapp = { @@ -1043,7 +958,7 @@ export const webapp = { * * Returns Server-Sent Events stream. */ -export const get16 = oc +export const get15 = oc .route({ description: 'GET /api/workflow//events\n\nReturns Server-Sent Events stream.', inputStructure: 'detailed', @@ -1057,7 +972,7 @@ export const get16 = oc .output(zGetWorkflowByTaskIdEventsResponse) export const events = { - get: get16, + get: get15, } export const byTaskId3 = { @@ -1068,34 +983,12 @@ export const workflow = { byTaskId: byTaskId3, } -/** - * Run workflow - * - * Execute a workflow with provided inputs and files. - */ -export const post22 = oc - .route({ - description: 'Execute a workflow with provided inputs and files.', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postWorkflowsRun', - path: '/workflows/run', - summary: 'Run workflow', - tags: ['web'], - }) - .input(z.object({ body: zPostWorkflowsRunBody })) - .output(zPostWorkflowsRunResponse) - -export const run = { - post: post22, -} - /** * Stop workflow task * * Stop a running workflow task. */ -export const post23 = oc +export const post19 = oc .route({ description: 'Stop a running workflow task.', inputStructure: 'detailed', @@ -1109,7 +1002,7 @@ export const post23 = oc .output(zPostWorkflowsTasksByTaskIdStopResponse) export const stop3 = { - post: post23, + post: post19, } export const byTaskId4 = { @@ -1121,7 +1014,6 @@ export const tasks = { } export const workflows = { - run, tasks, } @@ -1145,7 +1037,6 @@ export const contract = { savedMessages, site, systemFeatures, - textToAudio, webapp, workflow, workflows, diff --git a/packages/contracts/generated/api/web/types.gen.ts b/packages/contracts/generated/api/web/types.gen.ts index 722b3042841..bae9ac7b56c 100644 --- a/packages/contracts/generated/api/web/types.gen.ts +++ b/packages/contracts/generated/api/web/types.gen.ts @@ -46,50 +46,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 - 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 } @@ -256,8 +213,6 @@ export type FormInputConfig type: 'file-list' } & FileListInputConfig) -export type GeneratedAppResponse = JsonValue - export type HumanInputContent = { form_definition?: HumanInputFormDefinition | null form_submission_data?: HumanInputFormSubmissionData | null @@ -287,15 +242,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 = { @@ -317,7 +270,7 @@ export type HumanInputFormSubmitPayload = { } export type HumanInputFormSubmitResponse = { - [key: string]: never + [key: string]: unknown } export type HumanInputUploadTokenResponse = { @@ -329,16 +282,7 @@ export type JsonObject = { [key: string]: unknown } -export type JsonValue - = | string - | number - | number - | boolean - | { - [key: string]: unknown - } - | Array - | null +export type JsonValue = unknown export type JsonValueType = unknown @@ -614,6 +558,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 @@ -640,6 +600,34 @@ 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 + 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' @@ -652,6 +640,49 @@ export type WorkflowRunPayload = { } } +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 + 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 @@ -669,32 +700,11 @@ export type PostAudioToTextErrors = { } export type PostAudioToTextResponses = { - 200: AudioTranscriptResponse + 200: AudioToTextResponse } export type PostAudioToTextResponse = PostAudioToTextResponses[keyof PostAudioToTextResponses] -export type PostChatMessagesData = { - body: ChatMessagePayload - path?: never - query?: never - url: '/chat-messages' -} - -export type PostChatMessagesErrors = { - 400: unknown - 401: unknown - 403: unknown - 404: unknown - 500: unknown -} - -export type PostChatMessagesResponses = { - 200: GeneratedAppResponse -} - -export type PostChatMessagesResponse = PostChatMessagesResponses[keyof PostChatMessagesResponses] - export type PostChatMessagesByTaskIdStopData = { body?: never path: { @@ -719,28 +729,6 @@ export type PostChatMessagesByTaskIdStopResponses = { export type PostChatMessagesByTaskIdStopResponse = PostChatMessagesByTaskIdStopResponses[keyof PostChatMessagesByTaskIdStopResponses] -export type PostCompletionMessagesData = { - body: CompletionMessagePayload - path?: never - query?: never - url: '/completion-messages' -} - -export type PostCompletionMessagesErrors = { - 400: unknown - 401: unknown - 403: unknown - 404: unknown - 500: unknown -} - -export type PostCompletionMessagesResponses = { - 200: GeneratedAppResponse -} - -export type PostCompletionMessagesResponse - = PostCompletionMessagesResponses[keyof PostCompletionMessagesResponses] - export type PostCompletionMessagesByTaskIdStopData = { body?: never path: { @@ -1016,6 +1004,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 } @@ -1032,6 +1027,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 } @@ -1048,6 +1050,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 } @@ -1174,32 +1182,6 @@ export type PostMessagesByMessageIdFeedbacksResponses = { export type PostMessagesByMessageIdFeedbacksResponse = PostMessagesByMessageIdFeedbacksResponses[keyof PostMessagesByMessageIdFeedbacksResponses] -export type GetMessagesByMessageIdMoreLikeThisData = { - body?: never - path: { - message_id: string - } - query: { - response_mode: 'blocking' | 'streaming' - } - url: '/messages/{message_id}/more-like-this' -} - -export type GetMessagesByMessageIdMoreLikeThisErrors = { - 400: unknown - 401: unknown - 403: unknown - 404: unknown - 500: unknown -} - -export type GetMessagesByMessageIdMoreLikeThisResponses = { - 200: GeneratedAppResponse -} - -export type GetMessagesByMessageIdMoreLikeThisResponse - = GetMessagesByMessageIdMoreLikeThisResponses[keyof GetMessagesByMessageIdMoreLikeThisResponses] - export type GetMessagesByMessageIdSuggestedQuestionsData = { body?: never path: { @@ -1416,7 +1398,7 @@ export type GetSiteErrors = { } export type GetSiteResponses = { - 200: AppSiteInfoResponse + 200: WebAppSiteResponse } export type GetSiteResponse = GetSiteResponses[keyof GetSiteResponses] @@ -1438,26 +1420,6 @@ export type GetSystemFeaturesResponses = { export type GetSystemFeaturesResponse = GetSystemFeaturesResponses[keyof GetSystemFeaturesResponses] -export type PostTextToAudioData = { - body: TextToAudioPayload - path?: never - query?: never - url: '/text-to-audio' -} - -export type PostTextToAudioErrors = { - 400: unknown - 401: unknown - 403: unknown - 500: unknown -} - -export type PostTextToAudioResponses = { - 200: AudioBinaryResponse -} - -export type PostTextToAudioResponse = PostTextToAudioResponses[keyof PostTextToAudioResponses] - export type GetWebappAccessModeData = { body?: never path?: never @@ -1518,27 +1480,6 @@ export type GetWorkflowByTaskIdEventsResponses = { export type GetWorkflowByTaskIdEventsResponse = GetWorkflowByTaskIdEventsResponses[keyof GetWorkflowByTaskIdEventsResponses] -export type PostWorkflowsRunData = { - body: WorkflowRunPayload - path?: never - query?: never - url: '/workflows/run' -} - -export type PostWorkflowsRunErrors = { - 400: unknown - 401: unknown - 403: unknown - 404: unknown - 500: unknown -} - -export type PostWorkflowsRunResponses = { - 200: GeneratedAppResponse -} - -export type PostWorkflowsRunResponse = PostWorkflowsRunResponses[keyof PostWorkflowsRunResponses] - export type PostWorkflowsTasksByTaskIdStopData = { body?: never path: { diff --git a/packages/contracts/generated/api/web/zod.gen.ts b/packages/contracts/generated/api/web/zod.gen.ts index d555ad5f85c..b44351b9378 100644 --- a/packages/contracts/generated/api/web/zod.gen.ts +++ b/packages/contracts/generated/api/web/zod.gen.ts @@ -47,62 +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(), - 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(), }) @@ -320,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 @@ -347,16 +282,7 @@ export const zHumanInputUploadTokenResponse = z.object({ export const zJsonObject = z.record(z.string(), z.unknown()) -export const zJsonValue = z - .union([ - z.string(), - z.int(), - z.number(), - z.boolean(), - z.record(z.string(), z.unknown()), - z.array(z.unknown()), - ]) - .nullable() +export const zJsonValue = z.unknown() /** * AgentThought @@ -375,11 +301,6 @@ export const zAgentThought = z.object({ tool_labels: zJsonValue, }) -/** - * GeneratedAppResponse - */ -export const zGeneratedAppResponse = zJsonValue - export const zJsonValueType = z.unknown() export const zJsonValue2 = z.unknown() @@ -874,6 +795,14 @@ export const zSystemFeatureModel = z.object({ }), }) +/** + * WebAppCustomConfigResponse + */ +export const zWebAppCustomConfigResponse = z.object({ + remove_webapp_brand: z.boolean(), + replace_webapp_logo: z.string().nullish(), +}) + /** * WebMessageListItem */ @@ -904,6 +833,66 @@ 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(), + 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 */ @@ -922,16 +911,60 @@ export const zWorkflowRunPayload = z.object({ }) /** - * Success + * HumanInputFormSubmitResponse */ -export const zPostAudioToTextResponse = zAudioTranscriptResponse +export const zHumanInputFormSubmitResponseWritable = z.record(z.string(), z.unknown()) -export const zPostChatMessagesBody = zChatMessagePayload +/** + * 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(), + 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 zPostChatMessagesResponse = zGeneratedAppResponse +export const zPostAudioToTextResponse = zAudioToTextResponse export const zPostChatMessagesByTaskIdStopPath = z.object({ task_id: z.string(), @@ -942,13 +975,6 @@ export const zPostChatMessagesByTaskIdStopPath = z.object({ */ export const zPostChatMessagesByTaskIdStopResponse = zSimpleResultResponse -export const zPostCompletionMessagesBody = zCompletionMessagePayload - -/** - * Success - */ -export const zPostCompletionMessagesResponse = zGeneratedAppResponse - export const zPostCompletionMessagesByTaskIdStopPath = z.object({ task_id: z.string(), }) @@ -1061,7 +1087,7 @@ export const zGetFormHumanInputByFormTokenPath = z.object({ }) /** - * Success + * Form retrieved successfully */ export const zGetFormHumanInputByFormTokenResponse = zHumanInputFormDefinitionResponse @@ -1072,7 +1098,7 @@ export const zPostFormHumanInputByFormTokenPath = z.object({ }) /** - * Success + * Form submitted successfully */ export const zPostFormHumanInputByFormTokenResponse = zHumanInputFormSubmitResponse @@ -1081,7 +1107,7 @@ export const zPostFormHumanInputByFormTokenUploadTokenPath = z.object({ }) /** - * Success + * Upload token issued successfully */ export const zPostFormHumanInputByFormTokenUploadTokenResponse = zHumanInputUploadTokenResponse @@ -1139,19 +1165,6 @@ export const zPostMessagesByMessageIdFeedbacksQuery = z.object({ */ export const zPostMessagesByMessageIdFeedbacksResponse = zResultResponse -export const zGetMessagesByMessageIdMoreLikeThisPath = z.object({ - message_id: z.uuid(), -}) - -export const zGetMessagesByMessageIdMoreLikeThisQuery = z.object({ - response_mode: z.enum(['blocking', 'streaming']), -}) - -/** - * Success - */ -export const zGetMessagesByMessageIdMoreLikeThisResponse = zGeneratedAppResponse - export const zGetMessagesByMessageIdSuggestedQuestionsPath = z.object({ message_id: z.uuid(), }) @@ -1229,20 +1242,13 @@ export const zDeleteSavedMessagesByMessageIdResponse = z.void() /** * Success */ -export const zGetSiteResponse = zAppSiteInfoResponse +export const zGetSiteResponse = zWebAppSiteResponse /** * System features retrieved successfully */ export const zGetSystemFeaturesResponse = zSystemFeatureModel -export const zPostTextToAudioBody = zTextToAudioPayload - -/** - * Success - */ -export const zPostTextToAudioResponse = zAudioBinaryResponse - export const zGetWebappAccessModeQuery = z.object({ appCode: z.string().optional(), appId: z.string().optional(), @@ -1271,13 +1277,6 @@ export const zGetWorkflowByTaskIdEventsPath = z.object({ */ export const zGetWorkflowByTaskIdEventsResponse = zEventStreamResponse -export const zPostWorkflowsRunBody = zWorkflowRunPayload - -/** - * Success - */ -export const zPostWorkflowsRunResponse = zGeneratedAppResponse - export const zPostWorkflowsTasksByTaskIdStopPath = z.object({ task_id: z.string(), }) diff --git a/packages/contracts/openapi-ts.api.config.ts b/packages/contracts/openapi-ts.api.config.ts index 8fce8a25bd3..1adbf4fda8e 100644 --- a/packages/contracts/openapi-ts.api.config.ts +++ b/packages/contracts/openapi-ts.api.config.ts @@ -10,13 +10,21 @@ type SwaggerSchema = JsonObject & { $ref?: string } +type OpenApiMediaType = JsonObject & { + schema?: SwaggerSchema +} + +type OpenApiResponse = JsonObject & { + content?: Record +} + type OpenApiComponents = JsonObject & { schemas?: Record } type SwaggerOperation = JsonObject & { operationId?: string - responses?: Record + responses?: Record } type SwaggerDocument = JsonObject & { @@ -52,6 +60,17 @@ const currentDir = path.dirname(fileURLToPath(import.meta.url)) const apiOpenApiDir = path.resolve(currentDir, 'openapi') const operationMethods = new Set(['delete', 'get', 'patch', 'post', 'put']) +const pydanticDecimalStringPattern = '^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$' +const codegenSafeDecimalStringPattern = '^(?![-+.]*$)[+-]?0*\\d*\\.?\\d*$' + +const opaqueJsonContent = (): Record => ({ + 'application/json': { + schema: { + additionalProperties: true, + type: 'object', + }, + }, +}) const apiSpecs: ApiSpec[] = [ { filename: 'console-openapi.json', name: 'console' }, @@ -182,6 +201,46 @@ const addOperationIds = (document: SwaggerDocument) => { } } +const isOpaqueContractResponse = (response: OpenApiResponse) => { + const content = response.content + if (!isObject(content)) + return false + + return Object.entries(content).some(([mediaType, media]) => { + if (!isObject(media)) + return false + + return (mediaType === 'application/json' || mediaType === 'text/event-stream') && !('schema' in media) + }) +} + +const hasOpaqueContractSuccessResponse = (operation: SwaggerOperation) => { + return Object.entries(operation.responses ?? {}).some(([status, response]) => { + return /^2\d\d$/.test(status) && isObject(response) && isOpaqueContractResponse(response) + }) +} + +const normalizeOpaqueContractResponses = (document: SwaggerDocument) => { + // Some backend endpoints has no schema (e.g. external) and will trap heyapi here + // So we forge an opaque schema here + for (const pathItem of Object.values(document.paths ?? {})) { + for (const [method, operation] of Object.entries(pathItem)) { + if (!operationMethods.has(method) || !isObject(operation)) + continue + + const swaggerOperation = operation as SwaggerOperation + if (!hasOpaqueContractSuccessResponse(swaggerOperation)) + continue + + Object.values(swaggerOperation.responses ?? {}) + .filter(response => isObject(response) && isOpaqueContractResponse(response)) + .forEach((response) => { + response.content = opaqueJsonContent() + }) + } + } +} + const hasSuccessResponse = (operation: SwaggerOperation) => { return Object.entries(operation.responses ?? {}).some(([status, response]) => { if (!/^2\d\d$/.test(status)) @@ -215,6 +274,7 @@ const filterContractOperations = (document: SwaggerDocument) => { } const normalizeApiSwagger = (document: SwaggerDocument) => { + normalizeOpaqueContractResponses(document) filterContractOperations(document) addOperationIds(document) @@ -380,10 +440,20 @@ const createApiConfig = (job: ApiJob): UserConfig => ({ 'name': 'zod', '~resolvers': { string: (ctx) => { - if (ctx.schema.format !== 'binary') - return undefined + if (ctx.schema.format === 'binary') + return $(ctx.symbols.z).attr('custom').call().generic($.type.or($.type('Blob'), $.type('File'))) - return $(ctx.symbols.z).attr('custom').call().generic($.type.or($.type('Blob'), $.type('File'))) + if (ctx.schema.pattern === pydanticDecimalStringPattern) { + // the pydantic generated regex will emit error like + // regexp/no-useless-assertions, so patch the regex here + return $(ctx.symbols.z) + .attr('string') + .call() + .attr('regex') + .call($.regexp(codegenSafeDecimalStringPattern)) + } + + return undefined }, }, }, 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/contract/console/trigger.ts b/web/contract/console/trigger.ts index 41a326ccf56..bb9fed3881a 100644 --- a/web/contract/console/trigger.ts +++ b/web/contract/console/trigger.ts @@ -88,7 +88,7 @@ export const triggerSubscriptionUpdateContract = base credentials?: Record } }>()) - .output(type<{ result: string, id: string }>()) + .output(type<{ result: string }>()) export const triggerSubscriptionBuilderLogsContract = base .route({ path: '/workspaces/current/trigger-provider/{provider}/subscriptions/builder/logs/{subscriptionBuilderId}', method: 'GET' }) diff --git a/web/features/agent-v2/agent-detail/configure/components/preview/chat.tsx b/web/features/agent-v2/agent-detail/configure/components/preview/chat.tsx index a3127b4bd49..9ad0555d196 100644 --- a/web/features/agent-v2/agent-detail/configure/components/preview/chat.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/preview/chat.tsx @@ -217,14 +217,8 @@ const toFeedback = (feedback: NonNullable[nu } } -type AgentDebugMessageWithLegacyAnswer = MessageDetailResponse & { - answer?: string | null -} - const getAgentDebugMessageAnswer = (message: MessageDetailResponse) => { - const legacyAnswer = (message as AgentDebugMessageWithLegacyAnswer).answer - - return message.re_sign_file_url_answer ?? legacyAnswer ?? '' + return message.answer ?? '' } function getFormattedAgentDebugChatTree(messages: MessageDetailResponse[]): ChatItemInTree[] { 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 = {