From df09acb74b67f80841ebd08d851c1efeb1ef45a8 Mon Sep 17 00:00:00 2001 From: yyh Date: Thu, 25 Dec 2025 15:21:44 +0800 Subject: [PATCH] feat: chat messages api support parent message id --- api/controllers/service_api/app/completion.py | 53 ++++++++- .../app/apps/advanced_chat/app_generator.py | 3 +- api/core/app/apps/agent_chat/app_generator.py | 3 +- api/core/app/apps/chat/app_generator.py | 3 +- .../app/apps/message_based_app_generator.py | 11 +- api/core/app/entities/app_invoke_entities.py | 17 +-- .../test_chat_parent_message_validation.py | 110 ++++++++++++++++++ .../app/test_chat_request_payload.py | 21 ++++ .../template/template_advanced_chat.en.mdx | 3 + .../template/template_advanced_chat.ja.mdx | 3 + .../template/template_advanced_chat.zh.mdx | 3 + .../develop/template/template_chat.en.mdx | 3 + .../develop/template/template_chat.ja.mdx | 3 + .../develop/template/template_chat.zh.mdx | 3 + 14 files changed, 213 insertions(+), 26 deletions(-) create mode 100644 api/tests/unit_tests/controllers/service_api/app/test_chat_parent_message_validation.py diff --git a/api/controllers/service_api/app/completion.py b/api/controllers/service_api/app/completion.py index b3836f3a47..bbacf076c7 100644 --- a/api/controllers/service_api/app/completion.py +++ b/api/controllers/service_api/app/completion.py @@ -4,7 +4,7 @@ from uuid import UUID from flask import request from flask_restx import Resource -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, ValidationInfo, field_validator from werkzeug.exceptions import BadRequest, InternalServerError, NotFound import services @@ -33,8 +33,11 @@ from libs import helper from models.model import App, AppMode, EndUser from services.app_generate_service import AppGenerateService from services.app_task_service import AppTaskService +from services.conversation_service import ConversationService from services.errors.app import IsDraftWorkflowError, WorkflowIdFormatError, WorkflowNotFoundError from services.errors.llm import InvokeRateLimitError +from services.errors.message import MessageNotExistsError +from services.message_service import MessageService logger = logging.getLogger(__name__) @@ -53,14 +56,18 @@ class ChatRequestPayload(BaseModel): files: list[dict[str, Any]] | None = None response_mode: Literal["blocking", "streaming"] | None = None conversation_id: str | None = Field(default=None, description="Conversation UUID") + parent_message_id: str | None = Field(default=None, description="Parent message UUID") retriever_from: str = Field(default="dev") auto_generate_name: bool = Field(default=True, description="Auto generate conversation name") workflow_id: str | None = Field(default=None, description="Workflow ID for advanced chat") - @field_validator("conversation_id", mode="before") + @field_validator("conversation_id", "parent_message_id", mode="before") @classmethod - def normalize_conversation_id(cls, value: str | UUID | None) -> str | None: - """Allow missing or blank conversation IDs; enforce UUID format when provided.""" + def normalize_uuid_fields(cls, value: str | UUID | None, info: ValidationInfo) -> str | None: + """Allow missing or blank UUID fields; enforce UUID format when provided.""" + if isinstance(value, UUID): + return str(value) + if isinstance(value, str): value = value.strip() @@ -70,7 +77,36 @@ class ChatRequestPayload(BaseModel): try: return helper.uuid_value(value) except ValueError as exc: - raise ValueError("conversation_id must be a valid UUID") from exc + raise ValueError(f"{info.field_name} must be a valid UUID") from exc + + +def _validate_parent_message_request( + *, + app_model: App, + end_user: EndUser, + conversation_id: str | None, + parent_message_id: str | None, +) -> None: + if not parent_message_id: + return + + if not conversation_id: + raise BadRequest("conversation_id is required when parent_message_id is provided.") + + try: + conversation = ConversationService.get_conversation( + app_model=app_model, conversation_id=conversation_id, user=end_user + ) + except services.errors.conversation.ConversationNotExistsError: + raise NotFound("Conversation Not Exists.") + + try: + parent_message = MessageService.get_message(app_model=app_model, user=end_user, message_id=parent_message_id) + except MessageNotExistsError: + raise NotFound("Message Not Exists.") + + if parent_message.conversation_id != conversation.id: + raise BadRequest("parent_message_id does not belong to the conversation.") register_schema_models(service_api_ns, CompletionRequestPayload, ChatRequestPayload) @@ -205,6 +241,13 @@ class ChatApi(Resource): streaming = payload.response_mode == "streaming" + _validate_parent_message_request( + app_model=app_model, + end_user=end_user, + conversation_id=args.get("conversation_id"), + parent_message_id=args.get("parent_message_id"), + ) + try: response = AppGenerateService.generate( app_model=app_model, user=end_user, args=args, invoke_from=InvokeFrom.SERVICE_API, streaming=streaming diff --git a/api/core/app/apps/advanced_chat/app_generator.py b/api/core/app/apps/advanced_chat/app_generator.py index feb0d3358c..452d0efb67 100644 --- a/api/core/app/apps/advanced_chat/app_generator.py +++ b/api/core/app/apps/advanced_chat/app_generator.py @@ -12,7 +12,6 @@ from sqlalchemy.orm import Session, sessionmaker import contexts from configs import dify_config -from constants import UUID_NIL from core.app.app_config.features.file_upload.manager import FileUploadConfigManager from core.app.apps.advanced_chat.app_config_manager import AdvancedChatAppConfigManager from core.app.apps.advanced_chat.app_runner import AdvancedChatAppRunner @@ -168,7 +167,7 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator): ), query=query, files=list(file_objs), - parent_message_id=args.get("parent_message_id") if invoke_from != InvokeFrom.SERVICE_API else UUID_NIL, + parent_message_id=self._resolve_parent_message_id(args, invoke_from), user_id=user.id, stream=streaming, invoke_from=invoke_from, diff --git a/api/core/app/apps/agent_chat/app_generator.py b/api/core/app/apps/agent_chat/app_generator.py index 7bd3b8a56e..8af412add4 100644 --- a/api/core/app/apps/agent_chat/app_generator.py +++ b/api/core/app/apps/agent_chat/app_generator.py @@ -9,7 +9,6 @@ from flask import Flask, current_app from pydantic import ValidationError from configs import dify_config -from constants import UUID_NIL from core.app.app_config.easy_ui_based_app.model_config.converter import ModelConfigConverter from core.app.app_config.features.file_upload.manager import FileUploadConfigManager from core.app.apps.agent_chat.app_config_manager import AgentChatAppConfigManager @@ -163,7 +162,7 @@ class AgentChatAppGenerator(MessageBasedAppGenerator): ), query=query, files=list(file_objs), - parent_message_id=args.get("parent_message_id") if invoke_from != InvokeFrom.SERVICE_API else UUID_NIL, + parent_message_id=self._resolve_parent_message_id(args, invoke_from), user_id=user.id, stream=streaming, invoke_from=invoke_from, diff --git a/api/core/app/apps/chat/app_generator.py b/api/core/app/apps/chat/app_generator.py index c1251d2feb..a94fe1731f 100644 --- a/api/core/app/apps/chat/app_generator.py +++ b/api/core/app/apps/chat/app_generator.py @@ -8,7 +8,6 @@ from flask import Flask, copy_current_request_context, current_app from pydantic import ValidationError from configs import dify_config -from constants import UUID_NIL from core.app.app_config.easy_ui_based_app.model_config.converter import ModelConfigConverter from core.app.app_config.features.file_upload.manager import FileUploadConfigManager from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom @@ -156,7 +155,7 @@ class ChatAppGenerator(MessageBasedAppGenerator): ), query=query, files=list(file_objs), - parent_message_id=args.get("parent_message_id") if invoke_from != InvokeFrom.SERVICE_API else UUID_NIL, + parent_message_id=self._resolve_parent_message_id(args, invoke_from), user_id=user.id, invoke_from=invoke_from, extras=extras, diff --git a/api/core/app/apps/message_based_app_generator.py b/api/core/app/apps/message_based_app_generator.py index 57617d8863..3a00c08152 100644 --- a/api/core/app/apps/message_based_app_generator.py +++ b/api/core/app/apps/message_based_app_generator.py @@ -1,11 +1,12 @@ import json import logging -from collections.abc import Generator -from typing import Union, cast +from collections.abc import Generator, Mapping +from typing import Any, Union, cast from sqlalchemy import select from sqlalchemy.orm import Session +from constants import UUID_NIL from core.app.app_config.entities import EasyUIBasedAppConfig, EasyUIBasedAppModelConfigFrom from core.app.apps.base_app_generator import BaseAppGenerator from core.app.apps.base_app_queue_manager import AppQueueManager @@ -84,6 +85,12 @@ class MessageBasedAppGenerator(BaseAppGenerator): logger.exception("Failed to handle response, conversation_id: %s", conversation.id) raise e + def _resolve_parent_message_id(self, args: Mapping[str, Any], invoke_from: InvokeFrom) -> str | None: + parent_message_id = args.get("parent_message_id") + if invoke_from == InvokeFrom.SERVICE_API and not parent_message_id: + return UUID_NIL + return parent_message_id + def _get_app_model_config(self, app_model: App, conversation: Conversation | None = None) -> AppModelConfig: if conversation: stmt = select(AppModelConfig).where( diff --git a/api/core/app/entities/app_invoke_entities.py b/api/core/app/entities/app_invoke_entities.py index 0cb573cb86..50f7ec0ee7 100644 --- a/api/core/app/entities/app_invoke_entities.py +++ b/api/core/app/entities/app_invoke_entities.py @@ -2,9 +2,8 @@ from collections.abc import Mapping, Sequence from enum import StrEnum from typing import TYPE_CHECKING, Any, Optional -from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator +from pydantic import BaseModel, ConfigDict, Field -from constants import UUID_NIL from core.app.app_config.entities import EasyUIBasedAppConfig, WorkflowUIBasedAppConfig from core.entities.provider_configuration import ProviderModelBundle from core.file import File, FileUploadConfig @@ -158,20 +157,12 @@ class ConversationAppGenerateEntity(AppGenerateEntity): parent_message_id: str | None = Field( default=None, description=( - "Starting from v0.9.0, parent_message_id is used to support message regeneration for internal chat API." - "For service API, we need to ensure its forward compatibility, " - "so passing in the parent_message_id as request arg is not supported for now. " - "It needs to be set to UUID_NIL so that the subsequent processing will treat it as legacy messages." + "Starting from v0.9.0, parent_message_id is used to support message regeneration " + "and branching in chat APIs." + "For service API, when it is omitted, the system treats it as UUID_NIL to preserve legacy linear history." ), ) - @field_validator("parent_message_id") - @classmethod - def validate_parent_message_id(cls, v, info: ValidationInfo): - if info.data.get("invoke_from") == InvokeFrom.SERVICE_API and v != UUID_NIL: - raise ValueError("parent_message_id should be UUID_NIL for service API") - return v - class ChatAppGenerateEntity(ConversationAppGenerateEntity, EasyUIBasedAppGenerateEntity): """ diff --git a/api/tests/unit_tests/controllers/service_api/app/test_chat_parent_message_validation.py b/api/tests/unit_tests/controllers/service_api/app/test_chat_parent_message_validation.py new file mode 100644 index 0000000000..808d0c6a18 --- /dev/null +++ b/api/tests/unit_tests/controllers/service_api/app/test_chat_parent_message_validation.py @@ -0,0 +1,110 @@ +from types import SimpleNamespace +from unittest.mock import patch + +import pytest +from werkzeug.exceptions import BadRequest, NotFound + +from controllers.service_api.app.completion import _validate_parent_message_request +from services.errors.conversation import ConversationNotExistsError +from services.errors.message import MessageNotExistsError + + +def test_validate_parent_message_skips_when_missing(): + app_model = object() + end_user = object() + + with ( + patch("controllers.service_api.app.completion.ConversationService.get_conversation") as get_conversation, + patch("controllers.service_api.app.completion.MessageService.get_message") as get_message, + ): + _validate_parent_message_request( + app_model=app_model, end_user=end_user, conversation_id=None, parent_message_id=None + ) + + get_conversation.assert_not_called() + get_message.assert_not_called() + + +def test_validate_parent_message_requires_conversation_id(): + app_model = object() + end_user = object() + + with pytest.raises(BadRequest): + _validate_parent_message_request( + app_model=app_model, end_user=end_user, conversation_id=None, parent_message_id="parent-id" + ) + + +def test_validate_parent_message_missing_conversation_raises_not_found(): + app_model = object() + end_user = object() + + with patch( + "controllers.service_api.app.completion.ConversationService.get_conversation", + side_effect=ConversationNotExistsError(), + ): + with pytest.raises(NotFound): + _validate_parent_message_request( + app_model=app_model, + end_user=end_user, + conversation_id="conversation-id", + parent_message_id="parent-id", + ) + + +def test_validate_parent_message_missing_message_raises_not_found(): + app_model = object() + end_user = object() + conversation = SimpleNamespace(id="conversation-id") + + with ( + patch("controllers.service_api.app.completion.ConversationService.get_conversation", return_value=conversation), + patch( + "controllers.service_api.app.completion.MessageService.get_message", + side_effect=MessageNotExistsError(), + ), + ): + with pytest.raises(NotFound): + _validate_parent_message_request( + app_model=app_model, + end_user=end_user, + conversation_id="conversation-id", + parent_message_id="parent-id", + ) + + +def test_validate_parent_message_mismatch_conversation_raises_bad_request(): + app_model = object() + end_user = object() + conversation = SimpleNamespace(id="conversation-id") + message = SimpleNamespace(conversation_id="different-id") + + with ( + patch("controllers.service_api.app.completion.ConversationService.get_conversation", return_value=conversation), + patch("controllers.service_api.app.completion.MessageService.get_message", return_value=message), + ): + with pytest.raises(BadRequest): + _validate_parent_message_request( + app_model=app_model, + end_user=end_user, + conversation_id="conversation-id", + parent_message_id="parent-id", + ) + + +def test_validate_parent_message_matches_conversation(): + app_model = object() + end_user = object() + conversation = SimpleNamespace(id="conversation-id") + message = SimpleNamespace(conversation_id="conversation-id") + + with ( + patch("controllers.service_api.app.completion.ConversationService.get_conversation", return_value=conversation), + patch("controllers.service_api.app.completion.MessageService.get_message", return_value=message), + ): + _validate_parent_message_request( + app_model=app_model, + end_user=end_user, + conversation_id="conversation-id", + parent_message_id="parent-id", + ) diff --git a/api/tests/unit_tests/controllers/service_api/app/test_chat_request_payload.py b/api/tests/unit_tests/controllers/service_api/app/test_chat_request_payload.py index 1fb7e7009d..71e8876a4a 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_chat_request_payload.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_chat_request_payload.py @@ -23,3 +23,24 @@ def test_chat_request_payload_validates_uuid(): def test_chat_request_payload_rejects_invalid_uuid(): with pytest.raises(ValidationError): ChatRequestPayload.model_validate({"inputs": {}, "query": "hello", "conversation_id": "invalid"}) + + +def test_chat_request_payload_accepts_blank_parent_message_id(): + payload = ChatRequestPayload.model_validate({"inputs": {}, "query": "hello", "parent_message_id": ""}) + + assert payload.parent_message_id is None + + +def test_chat_request_payload_validates_parent_message_id_uuid(): + parent_message_id = str(uuid.uuid4()) + + payload = ChatRequestPayload.model_validate( + {"inputs": {}, "query": "hello", "parent_message_id": parent_message_id} + ) + + assert payload.parent_message_id == parent_message_id + + +def test_chat_request_payload_rejects_invalid_parent_message_id(): + with pytest.raises(ValidationError): + ChatRequestPayload.model_validate({"inputs": {}, "query": "hello", "parent_message_id": "invalid"}) diff --git a/web/app/components/develop/template/template_advanced_chat.en.mdx b/web/app/components/develop/template/template_advanced_chat.en.mdx index 42dd57957b..301a85cbb6 100644 --- a/web/app/components/develop/template/template_advanced_chat.en.mdx +++ b/web/app/components/develop/template/template_advanced_chat.en.mdx @@ -56,6 +56,9 @@ Chat applications support session persistence, allowing previous chat history to Conversation ID, to continue the conversation based on previous chat records, it is necessary to pass the previous message's conversation_id. + + Parent message ID to continue from a specific message or regenerate. Requires `conversation_id` and must belong to that conversation. + File list, suitable for inputting files combined with text understanding and answering questions, available only when the model supports Vision/Video capability. - `type` (string) Supported type: diff --git a/web/app/components/develop/template/template_advanced_chat.ja.mdx b/web/app/components/develop/template/template_advanced_chat.ja.mdx index 4ab123f565..b46486ab29 100644 --- a/web/app/components/develop/template/template_advanced_chat.ja.mdx +++ b/web/app/components/develop/template/template_advanced_chat.ja.mdx @@ -56,6 +56,9 @@ import { Row, Col, Properties, Property, Heading, SubProperty, Paragraph } from 会話ID、以前のチャット記録に基づいて会話を続けるには、以前のメッセージのconversation_idを渡す必要があります。 + + 特定のメッセージから続けたり再生成するための親メッセージID。`conversation_id` が必須で、その会話に属している必要があります。 + ファイルリスト、モデルが Vision/Video 機能をサポートしている場合に限り、ファイルをテキスト理解および質問応答に組み合わせて入力するのに適しています。 - `type` (string) サポートされるタイプ: diff --git a/web/app/components/develop/template/template_advanced_chat.zh.mdx b/web/app/components/develop/template/template_advanced_chat.zh.mdx index 009b6e0cc0..98f9d7cec0 100755 --- a/web/app/components/develop/template/template_advanced_chat.zh.mdx +++ b/web/app/components/develop/template/template_advanced_chat.zh.mdx @@ -54,6 +54,9 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx' (选填)会话 ID,需要基于之前的聊天记录继续对话,必须传之前消息的 conversation_id。 + + 用于从特定消息继续或重新生成的父消息 ID。需要提供 `conversation_id`,且必须属于该对话。 + 文件列表,适用于传入文件结合文本理解并回答问题,仅当模型支持 Vision/Video 能力时可用。 - `type` (string) 支持类型: diff --git a/web/app/components/develop/template/template_chat.en.mdx b/web/app/components/develop/template/template_chat.en.mdx index 1e4e767d6d..80aabdb0e8 100644 --- a/web/app/components/develop/template/template_chat.en.mdx +++ b/web/app/components/develop/template/template_chat.en.mdx @@ -55,6 +55,9 @@ Chat applications support session persistence, allowing previous chat history to Conversation ID, to continue the conversation based on previous chat records, it is necessary to pass the previous message's conversation_id. + + Parent message ID to continue from a specific message or regenerate. Requires `conversation_id` and must belong to that conversation. + File list, suitable for inputting files combined with text understanding and answering questions, available only when the model supports Vision/Video capability. - `type` (string) Supported type: diff --git a/web/app/components/develop/template/template_chat.ja.mdx b/web/app/components/develop/template/template_chat.ja.mdx index 6ba80d8890..2af4f4f091 100644 --- a/web/app/components/develop/template/template_chat.ja.mdx +++ b/web/app/components/develop/template/template_chat.ja.mdx @@ -55,6 +55,9 @@ import { Row, Col, Properties, Property, Heading, SubProperty, Paragraph } from 会話ID、以前のチャット記録に基づいて会話を続けるには、前のメッセージのconversation_idを渡す必要があります。 + + 特定のメッセージから続けたり再生成するための親メッセージID。`conversation_id` が必須で、その会話に属している必要があります。 + ファイルリスト、モデルが Vision/Video 機能をサポートしている場合に限り、ファイルをテキスト理解および質問応答に組み合わせて入力するのに適しています。 - `type` (string) サポートされるタイプ: diff --git a/web/app/components/develop/template/template_chat.zh.mdx b/web/app/components/develop/template/template_chat.zh.mdx index bf69be2c28..b8d488c8f4 100644 --- a/web/app/components/develop/template/template_chat.zh.mdx +++ b/web/app/components/develop/template/template_chat.zh.mdx @@ -54,6 +54,9 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx' (选填)会话 ID,需要基于之前的聊天记录继续对话,必须传之前消息的 conversation_id。 + + 用于从特定消息继续或重新生成的父消息 ID。需要提供 `conversation_id`,且必须属于该对话。 + 文件列表,适用于传入文件结合文本理解并回答问题,仅当模型支持 Vision/Video 能力时可用。 - `type` (string) 支持类型: