Browse Source

feat: regenerate in `Chat`, `agent` and `Chatflow` app (#7661)

Hash Brown 6 months ago
parent
commit
8c51d06222
51 changed files with 604 additions and 179 deletions
  1. 1 0
      api/constants/__init__.py
  2. 1 0
      api/controllers/console/app/completion.py
  3. 0 2
      api/controllers/console/app/message.py
  4. 2 0
      api/controllers/console/app/workflow.py
  5. 1 0
      api/controllers/console/explore/completion.py
  6. 1 1
      api/controllers/console/explore/message.py
  7. 1 0
      api/controllers/service_api/app/message.py
  8. 1 0
      api/controllers/web/completion.py
  9. 2 1
      api/controllers/web/message.py
  10. 4 1
      api/core/agent/base_agent_runner.py
  11. 1 0
      api/core/app/apps/advanced_chat/app_generator.py
  12. 1 0
      api/core/app/apps/agent_chat/app_generator.py
  13. 1 0
      api/core/app/apps/chat/app_generator.py
  14. 1 0
      api/core/app/apps/message_based_app_generator.py
  15. 3 0
      api/core/app/entities/app_invoke_entities.py
  16. 18 3
      api/core/memory/token_buffer_memory.py
  17. 22 0
      api/core/prompt/utils/extract_thread_messages.py
  18. 1 0
      api/fields/conversation_fields.py
  19. 1 0
      api/fields/message_fields.py
  20. 36 0
      api/migrations/versions/2024_09_11_1012-d57ba9ebb251_add_parent_message_id_to_messages.py
  21. 1 0
      api/models/model.py
  22. 3 1
      api/services/message_service.py
  23. 91 0
      api/tests/unit_tests/core/prompt/test_extract_thread_messages.py
  24. 3 1
      web/app/components/app/configuration/debug/debug-with-multiple-model/chat-item.tsx
  25. 23 3
      web/app/components/app/configuration/debug/debug-with-single-model/index.tsx
  26. 86 63
      web/app/components/app/log/list.tsx
  27. 24 1
      web/app/components/base/chat/chat-with-history/chat-wrapper.tsx
  28. 7 28
      web/app/components/base/chat/chat-with-history/hooks.tsx
  29. 3 0
      web/app/components/base/chat/chat/answer/index.tsx
  30. 9 4
      web/app/components/base/chat/chat/answer/operation.tsx
  31. 3 0
      web/app/components/base/chat/chat/context.tsx
  32. 2 1
      web/app/components/base/chat/chat/hooks.ts
  33. 5 0
      web/app/components/base/chat/chat/index.tsx
  34. 1 0
      web/app/components/base/chat/chat/type.ts
  35. 1 0
      web/app/components/base/chat/constants.ts
  36. 24 1
      web/app/components/base/chat/embedded-chatbot/chat-wrapper.tsx
  37. 8 30
      web/app/components/base/chat/embedded-chatbot/hooks.tsx
  38. 3 1
      web/app/components/base/chat/types.ts
  39. 56 1
      web/app/components/base/chat/utils.ts
  40. 1 0
      web/app/components/base/icons/assets/vender/line/general/refresh.svg
  41. 23 0
      web/app/components/base/icons/src/vender/line/general/Refresh.json
  42. 16 0
      web/app/components/base/icons/src/vender/line/general/Refresh.tsx
  43. 1 0
      web/app/components/base/icons/src/vender/line/general/index.ts
  44. 31 0
      web/app/components/base/regenerate-btn/index.tsx
  45. 49 30
      web/app/components/workflow/panel/chat-record/index.tsx
  46. 23 3
      web/app/components/workflow/panel/debug-and-preview/chat-wrapper.tsx
  47. 2 0
      web/app/components/workflow/panel/debug-and-preview/hooks.ts
  48. 3 3
      web/hooks/use-app-favicon.ts
  49. 1 0
      web/i18n/en-US/app-api.ts
  50. 1 0
      web/i18n/zh-Hans/app-api.ts
  51. 1 0
      web/models/log.ts

+ 1 - 0
api/constants/__init__.py

@@ -1 +1,2 @@
 HIDDEN_VALUE = "[__HIDDEN__]"
+UUID_NIL = "00000000-0000-0000-0000-000000000000"

+ 1 - 0
api/controllers/console/app/completion.py

@@ -109,6 +109,7 @@ class ChatMessageApi(Resource):
         parser.add_argument("files", type=list, required=False, location="json")
         parser.add_argument("model_config", type=dict, required=True, location="json")
         parser.add_argument("conversation_id", type=uuid_value, location="json")
+        parser.add_argument("parent_message_id", type=uuid_value, required=False, location="json")
         parser.add_argument("response_mode", type=str, choices=["blocking", "streaming"], location="json")
         parser.add_argument("retriever_from", type=str, required=False, default="dev", location="json")
         args = parser.parse_args()

+ 0 - 2
api/controllers/console/app/message.py

@@ -105,8 +105,6 @@ class ChatMessageListApi(Resource):
             if rest_count > 0:
                 has_more = True
 
-        history_messages = list(reversed(history_messages))
-
         return InfiniteScrollPagination(data=history_messages, limit=args["limit"], has_more=has_more)
 
 

+ 2 - 0
api/controllers/console/app/workflow.py

@@ -166,6 +166,8 @@ class AdvancedChatDraftWorkflowRunApi(Resource):
         parser.add_argument("query", type=str, required=True, location="json", default="")
         parser.add_argument("files", type=list, location="json")
         parser.add_argument("conversation_id", type=uuid_value, location="json")
+        parser.add_argument("parent_message_id", type=uuid_value, required=False, location="json")
+
         args = parser.parse_args()
 
         try:

+ 1 - 0
api/controllers/console/explore/completion.py

@@ -100,6 +100,7 @@ class ChatApi(InstalledAppResource):
         parser.add_argument("query", type=str, required=True, location="json")
         parser.add_argument("files", type=list, required=False, location="json")
         parser.add_argument("conversation_id", type=uuid_value, location="json")
+        parser.add_argument("parent_message_id", type=uuid_value, required=False, location="json")
         parser.add_argument("retriever_from", type=str, required=False, default="explore_app", location="json")
         args = parser.parse_args()
 

+ 1 - 1
api/controllers/console/explore/message.py

@@ -51,7 +51,7 @@ class MessageListApi(InstalledAppResource):
 
         try:
             return MessageService.pagination_by_first_id(
-                app_model, current_user, args["conversation_id"], args["first_id"], args["limit"]
+                app_model, current_user, args["conversation_id"], args["first_id"], args["limit"], "desc"
             )
         except services.errors.conversation.ConversationNotExistsError:
             raise NotFound("Conversation Not Exists.")

+ 1 - 0
api/controllers/service_api/app/message.py

@@ -54,6 +54,7 @@ class MessageListApi(Resource):
     message_fields = {
         "id": fields.String,
         "conversation_id": fields.String,
+        "parent_message_id": fields.String,
         "inputs": fields.Raw,
         "query": fields.String,
         "answer": fields.String(attribute="re_sign_file_url_answer"),

+ 1 - 0
api/controllers/web/completion.py

@@ -96,6 +96,7 @@ class ChatApi(WebApiResource):
         parser.add_argument("files", type=list, required=False, location="json")
         parser.add_argument("response_mode", type=str, choices=["blocking", "streaming"], location="json")
         parser.add_argument("conversation_id", type=uuid_value, location="json")
+        parser.add_argument("parent_message_id", type=uuid_value, required=False, location="json")
         parser.add_argument("retriever_from", type=str, required=False, default="web_app", location="json")
 
         args = parser.parse_args()

+ 2 - 1
api/controllers/web/message.py

@@ -57,6 +57,7 @@ class MessageListApi(WebApiResource):
     message_fields = {
         "id": fields.String,
         "conversation_id": fields.String,
+        "parent_message_id": fields.String,
         "inputs": fields.Raw,
         "query": fields.String,
         "answer": fields.String(attribute="re_sign_file_url_answer"),
@@ -89,7 +90,7 @@ class MessageListApi(WebApiResource):
 
         try:
             return MessageService.pagination_by_first_id(
-                app_model, end_user, args["conversation_id"], args["first_id"], args["limit"]
+                app_model, end_user, args["conversation_id"], args["first_id"], args["limit"], "desc"
             )
         except services.errors.conversation.ConversationNotExistsError:
             raise NotFound("Conversation Not Exists.")

+ 4 - 1
api/core/agent/base_agent_runner.py

@@ -32,6 +32,7 @@ from core.model_runtime.entities.message_entities import (
 from core.model_runtime.entities.model_entities import ModelFeature
 from core.model_runtime.model_providers.__base.large_language_model import LargeLanguageModel
 from core.model_runtime.utils.encoders import jsonable_encoder
+from core.prompt.utils.extract_thread_messages import extract_thread_messages
 from core.tools.entities.tool_entities import (
     ToolParameter,
     ToolRuntimeVariablePool,
@@ -441,10 +442,12 @@ class BaseAgentRunner(AppRunner):
             .filter(
                 Message.conversation_id == self.message.conversation_id,
             )
-            .order_by(Message.created_at.asc())
+            .order_by(Message.created_at.desc())
             .all()
         )
 
+        messages = list(reversed(extract_thread_messages(messages)))
+
         for message in messages:
             if message.id == self.message.id:
                 continue

+ 1 - 0
api/core/app/apps/advanced_chat/app_generator.py

@@ -121,6 +121,7 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
             inputs=conversation.inputs if conversation else self._get_cleaned_inputs(inputs, app_config),
             query=query,
             files=file_objs,
+            parent_message_id=args.get("parent_message_id"),
             user_id=user.id,
             stream=stream,
             invoke_from=invoke_from,

+ 1 - 0
api/core/app/apps/agent_chat/app_generator.py

@@ -127,6 +127,7 @@ class AgentChatAppGenerator(MessageBasedAppGenerator):
             inputs=conversation.inputs if conversation else self._get_cleaned_inputs(inputs, app_config),
             query=query,
             files=file_objs,
+            parent_message_id=args.get("parent_message_id"),
             user_id=user.id,
             stream=stream,
             invoke_from=invoke_from,

+ 1 - 0
api/core/app/apps/chat/app_generator.py

@@ -128,6 +128,7 @@ class ChatAppGenerator(MessageBasedAppGenerator):
             inputs=conversation.inputs if conversation else self._get_cleaned_inputs(inputs, app_config),
             query=query,
             files=file_objs,
+            parent_message_id=args.get("parent_message_id"),
             user_id=user.id,
             stream=stream,
             invoke_from=invoke_from,

+ 1 - 0
api/core/app/apps/message_based_app_generator.py

@@ -218,6 +218,7 @@ class MessageBasedAppGenerator(BaseAppGenerator):
             answer_tokens=0,
             answer_unit_price=0,
             answer_price_unit=0,
+            parent_message_id=getattr(application_generate_entity, "parent_message_id", None),
             provider_response_latency=0,
             total_price=0,
             currency="USD",

+ 3 - 0
api/core/app/entities/app_invoke_entities.py

@@ -122,6 +122,7 @@ class ChatAppGenerateEntity(EasyUIBasedAppGenerateEntity):
     """
 
     conversation_id: Optional[str] = None
+    parent_message_id: Optional[str] = None
 
 
 class CompletionAppGenerateEntity(EasyUIBasedAppGenerateEntity):
@@ -138,6 +139,7 @@ class AgentChatAppGenerateEntity(EasyUIBasedAppGenerateEntity):
     """
 
     conversation_id: Optional[str] = None
+    parent_message_id: Optional[str] = None
 
 
 class AdvancedChatAppGenerateEntity(AppGenerateEntity):
@@ -149,6 +151,7 @@ class AdvancedChatAppGenerateEntity(AppGenerateEntity):
     app_config: WorkflowUIBasedAppConfig
 
     conversation_id: Optional[str] = None
+    parent_message_id: Optional[str] = None
     query: str
 
     class SingleIterationRunEntity(BaseModel):

+ 18 - 3
api/core/memory/token_buffer_memory.py

@@ -11,6 +11,7 @@ from core.model_runtime.entities.message_entities import (
     TextPromptMessageContent,
     UserPromptMessage,
 )
+from core.prompt.utils.extract_thread_messages import extract_thread_messages
 from extensions.ext_database import db
 from models.model import AppMode, Conversation, Message, MessageFile
 from models.workflow import WorkflowRun
@@ -33,8 +34,17 @@ class TokenBufferMemory:
 
         # fetch limited messages, and return reversed
         query = (
-            db.session.query(Message.id, Message.query, Message.answer, Message.created_at, Message.workflow_run_id)
-            .filter(Message.conversation_id == self.conversation.id, Message.answer != "")
+            db.session.query(
+                Message.id,
+                Message.query,
+                Message.answer,
+                Message.created_at,
+                Message.workflow_run_id,
+                Message.parent_message_id,
+            )
+            .filter(
+                Message.conversation_id == self.conversation.id,
+            )
             .order_by(Message.created_at.desc())
         )
 
@@ -45,7 +55,12 @@ class TokenBufferMemory:
 
         messages = query.limit(message_limit).all()
 
-        messages = list(reversed(messages))
+        # instead of all messages from the conversation, we only need to extract messages
+        # that belong to the thread of last message
+        thread_messages = extract_thread_messages(messages)
+        thread_messages.pop(0)
+        messages = list(reversed(thread_messages))
+
         message_file_parser = MessageFileParser(tenant_id=app_record.tenant_id, app_id=app_record.id)
         prompt_messages = []
         for message in messages:

+ 22 - 0
api/core/prompt/utils/extract_thread_messages.py

@@ -0,0 +1,22 @@
+from constants import UUID_NIL
+
+
+def extract_thread_messages(messages: list[dict]) -> list[dict]:
+    thread_messages = []
+    next_message = None
+
+    for message in messages:
+        if not message.parent_message_id:
+            # If the message is regenerated and does not have a parent message, it is the start of a new thread
+            thread_messages.append(message)
+            break
+
+        if not next_message:
+            thread_messages.append(message)
+            next_message = message.parent_message_id
+        else:
+            if next_message in {message.id, UUID_NIL}:
+                thread_messages.append(message)
+                next_message = message.parent_message_id
+
+    return thread_messages

+ 1 - 0
api/fields/conversation_fields.py

@@ -75,6 +75,7 @@ message_detail_fields = {
     "metadata": fields.Raw(attribute="message_metadata_dict"),
     "status": fields.String,
     "error": fields.String,
+    "parent_message_id": fields.String,
 }
 
 feedback_stat_fields = {"like": fields.Integer, "dislike": fields.Integer}

+ 1 - 0
api/fields/message_fields.py

@@ -62,6 +62,7 @@ retriever_resource_fields = {
 message_fields = {
     "id": fields.String,
     "conversation_id": fields.String,
+    "parent_message_id": fields.String,
     "inputs": fields.Raw,
     "query": fields.String,
     "answer": fields.String(attribute="re_sign_file_url_answer"),

+ 36 - 0
api/migrations/versions/2024_09_11_1012-d57ba9ebb251_add_parent_message_id_to_messages.py

@@ -0,0 +1,36 @@
+"""add parent_message_id to messages
+
+Revision ID: d57ba9ebb251
+Revises: 675b5321501b
+Create Date: 2024-09-11 10:12:45.826265
+
+"""
+import sqlalchemy as sa
+from alembic import op
+
+import models as models
+
+# revision identifiers, used by Alembic.
+revision = 'd57ba9ebb251'
+down_revision = '675b5321501b'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    with op.batch_alter_table('messages', schema=None) as batch_op:
+        batch_op.add_column(sa.Column('parent_message_id', models.types.StringUUID(), nullable=True))
+
+    # Set parent_message_id for existing messages to uuid_nil() to distinguish them from new messages with actual parent IDs or NULLs
+    op.execute('UPDATE messages SET parent_message_id = uuid_nil() WHERE parent_message_id IS NULL')
+
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    with op.batch_alter_table('messages', schema=None) as batch_op:
+        batch_op.drop_column('parent_message_id')
+
+    # ### end Alembic commands ###

+ 1 - 0
api/models/model.py

@@ -710,6 +710,7 @@ class Message(db.Model):
     answer_tokens = db.Column(db.Integer, nullable=False, server_default=db.text("0"))
     answer_unit_price = db.Column(db.Numeric(10, 4), nullable=False)
     answer_price_unit = db.Column(db.Numeric(10, 7), nullable=False, server_default=db.text("0.001"))
+    parent_message_id = db.Column(StringUUID, nullable=True)
     provider_response_latency = db.Column(db.Float, nullable=False, server_default=db.text("0"))
     total_price = db.Column(db.Numeric(10, 7))
     currency = db.Column(db.String(255), nullable=False)

+ 3 - 1
api/services/message_service.py

@@ -34,6 +34,7 @@ class MessageService:
         conversation_id: str,
         first_id: Optional[str],
         limit: int,
+        order: str = "asc",
     ) -> InfiniteScrollPagination:
         if not user:
             return InfiniteScrollPagination(data=[], limit=limit, has_more=False)
@@ -91,7 +92,8 @@ class MessageService:
             if rest_count > 0:
                 has_more = True
 
-        history_messages = list(reversed(history_messages))
+        if order == "asc":
+            history_messages = list(reversed(history_messages))
 
         return InfiniteScrollPagination(data=history_messages, limit=limit, has_more=has_more)
 

+ 91 - 0
api/tests/unit_tests/core/prompt/test_extract_thread_messages.py

@@ -0,0 +1,91 @@
+from uuid import uuid4
+
+from constants import UUID_NIL
+from core.prompt.utils.extract_thread_messages import extract_thread_messages
+
+
+class TestMessage:
+    def __init__(self, id, parent_message_id):
+        self.id = id
+        self.parent_message_id = parent_message_id
+
+    def __getitem__(self, item):
+        return getattr(self, item)
+
+
+def test_extract_thread_messages_single_message():
+    messages = [TestMessage(str(uuid4()), UUID_NIL)]
+    result = extract_thread_messages(messages)
+    assert len(result) == 1
+    assert result[0] == messages[0]
+
+
+def test_extract_thread_messages_linear_thread():
+    id1, id2, id3, id4, id5 = str(uuid4()), str(uuid4()), str(uuid4()), str(uuid4()), str(uuid4())
+    messages = [
+        TestMessage(id5, id4),
+        TestMessage(id4, id3),
+        TestMessage(id3, id2),
+        TestMessage(id2, id1),
+        TestMessage(id1, UUID_NIL),
+    ]
+    result = extract_thread_messages(messages)
+    assert len(result) == 5
+    assert [msg["id"] for msg in result] == [id5, id4, id3, id2, id1]
+
+
+def test_extract_thread_messages_branched_thread():
+    id1, id2, id3, id4 = str(uuid4()), str(uuid4()), str(uuid4()), str(uuid4())
+    messages = [
+        TestMessage(id4, id2),
+        TestMessage(id3, id2),
+        TestMessage(id2, id1),
+        TestMessage(id1, UUID_NIL),
+    ]
+    result = extract_thread_messages(messages)
+    assert len(result) == 3
+    assert [msg["id"] for msg in result] == [id4, id2, id1]
+
+
+def test_extract_thread_messages_empty_list():
+    messages = []
+    result = extract_thread_messages(messages)
+    assert len(result) == 0
+
+
+def test_extract_thread_messages_partially_loaded():
+    id0, id1, id2, id3 = str(uuid4()), str(uuid4()), str(uuid4()), str(uuid4())
+    messages = [
+        TestMessage(id3, id2),
+        TestMessage(id2, id1),
+        TestMessage(id1, id0),
+    ]
+    result = extract_thread_messages(messages)
+    assert len(result) == 3
+    assert [msg["id"] for msg in result] == [id3, id2, id1]
+
+
+def test_extract_thread_messages_legacy_messages():
+    id1, id2, id3 = str(uuid4()), str(uuid4()), str(uuid4())
+    messages = [
+        TestMessage(id3, UUID_NIL),
+        TestMessage(id2, UUID_NIL),
+        TestMessage(id1, UUID_NIL),
+    ]
+    result = extract_thread_messages(messages)
+    assert len(result) == 3
+    assert [msg["id"] for msg in result] == [id3, id2, id1]
+
+
+def test_extract_thread_messages_mixed_with_legacy_messages():
+    id1, id2, id3, id4, id5 = str(uuid4()), str(uuid4()), str(uuid4()), str(uuid4()), str(uuid4())
+    messages = [
+        TestMessage(id5, id4),
+        TestMessage(id4, id2),
+        TestMessage(id3, id2),
+        TestMessage(id2, UUID_NIL),
+        TestMessage(id1, UUID_NIL),
+    ]
+    result = extract_thread_messages(messages)
+    assert len(result) == 4
+    assert [msg["id"] for msg in result] == [id5, id4, id2, id1]

+ 3 - 1
web/app/components/app/configuration/debug/debug-with-multiple-model/chat-item.tsx

@@ -46,6 +46,7 @@ const ChatItem: FC<ChatItemProps> = ({
   const config = useConfigFromDebugContext()
   const {
     chatList,
+    chatListRef,
     isResponding,
     handleSend,
     suggestedQuestions,
@@ -80,6 +81,7 @@ const ChatItem: FC<ChatItemProps> = ({
       query: message,
       inputs,
       model_config: configData,
+      parent_message_id: chatListRef.current.at(-1)?.id || null,
     }
 
     if (visionConfig.enabled && files?.length && supportVision)
@@ -93,7 +95,7 @@ const ChatItem: FC<ChatItemProps> = ({
         onGetSuggestedQuestions: (responseItemId, getAbortController) => fetchSuggestedQuestions(appId, responseItemId, getAbortController),
       },
     )
-  }, [appId, config, handleSend, inputs, modelAndParameter, textGenerationModelList, visionConfig.enabled])
+  }, [appId, config, handleSend, inputs, modelAndParameter, textGenerationModelList, visionConfig.enabled, chatListRef])
 
   const { eventEmitter } = useEventEmitterContextContext()
   eventEmitter?.useSubscription((v: any) => {

+ 23 - 3
web/app/components/app/configuration/debug/debug-with-single-model/index.tsx

@@ -12,7 +12,7 @@ import {
 import Chat from '@/app/components/base/chat/chat'
 import { useChat } from '@/app/components/base/chat/chat/hooks'
 import { useDebugConfigurationContext } from '@/context/debug-configuration'
-import type { OnSend } from '@/app/components/base/chat/types'
+import type { ChatItem, OnSend } from '@/app/components/base/chat/types'
 import { useProviderContext } from '@/context/provider-context'
 import {
   fetchConversationMessages,
@@ -45,10 +45,12 @@ const DebugWithSingleModel = forwardRef<DebugWithSingleModelRefType, DebugWithSi
   const config = useConfigFromDebugContext()
   const {
     chatList,
+    chatListRef,
     isResponding,
     handleSend,
     suggestedQuestions,
     handleStop,
+    handleUpdateChatList,
     handleRestart,
     handleAnnotationAdded,
     handleAnnotationEdited,
@@ -64,7 +66,7 @@ const DebugWithSingleModel = forwardRef<DebugWithSingleModelRefType, DebugWithSi
   )
   useFormattingChangedSubscription(chatList)
 
-  const doSend: OnSend = useCallback((message, files) => {
+  const doSend: OnSend = useCallback((message, files, last_answer) => {
     if (checkCanSend && !checkCanSend())
       return
     const currentProvider = textGenerationModelList.find(item => item.provider === modelConfig.provider)
@@ -85,6 +87,7 @@ const DebugWithSingleModel = forwardRef<DebugWithSingleModelRefType, DebugWithSi
       query: message,
       inputs,
       model_config: configData,
+      parent_message_id: last_answer?.id || chatListRef.current.at(-1)?.id || null,
     }
 
     if (visionConfig.enabled && files?.length && supportVision)
@@ -98,7 +101,23 @@ const DebugWithSingleModel = forwardRef<DebugWithSingleModelRefType, DebugWithSi
         onGetSuggestedQuestions: (responseItemId, getAbortController) => fetchSuggestedQuestions(appId, responseItemId, getAbortController),
       },
     )
-  }, [appId, checkCanSend, completionParams, config, handleSend, inputs, modelConfig, textGenerationModelList, visionConfig.enabled])
+  }, [chatListRef, appId, checkCanSend, completionParams, config, handleSend, inputs, modelConfig, textGenerationModelList, visionConfig.enabled])
+
+  const doRegenerate = useCallback((chatItem: ChatItem) => {
+    const index = chatList.findIndex(item => item.id === chatItem.id)
+    if (index === -1)
+      return
+
+    const prevMessages = chatList.slice(0, index)
+    const question = prevMessages.pop()
+    const lastAnswer = prevMessages.at(-1)
+
+    if (!question)
+      return
+
+    handleUpdateChatList(prevMessages)
+    doSend(question.content, question.message_files, (!lastAnswer || lastAnswer.isOpeningStatement) ? undefined : lastAnswer)
+  }, [chatList, handleUpdateChatList, doSend])
 
   const allToolIcons = useMemo(() => {
     const icons: Record<string, any> = {}
@@ -123,6 +142,7 @@ const DebugWithSingleModel = forwardRef<DebugWithSingleModelRefType, DebugWithSi
       chatFooterClassName='px-6 pt-10 pb-4'
       suggestedQuestions={suggestedQuestions}
       onSend={doSend}
+      onRegenerate={doRegenerate}
       onStopResponding={handleStop}
       showPromptLog
       questionIcon={<Avatar name={userProfile.name} size={40} />}

+ 86 - 63
web/app/components/app/log/list.tsx

@@ -16,6 +16,7 @@ import timezone from 'dayjs/plugin/timezone'
 import { createContext, useContext } from 'use-context-selector'
 import { useShallow } from 'zustand/react/shallow'
 import { useTranslation } from 'react-i18next'
+import { UUID_NIL } from '../../base/chat/constants'
 import s from './style.module.css'
 import VarPanel from './var-panel'
 import cn from '@/utils/classnames'
@@ -81,72 +82,92 @@ const PARAM_MAP = {
   frequency_penalty: 'Frequency Penalty',
 }
 
-// Format interface data for easy display
-const getFormattedChatList = (messages: ChatMessage[], conversationId: string, timezone: string, format: string) => {
-  const newChatList: IChatItem[] = []
-  messages.forEach((item: ChatMessage) => {
-    newChatList.push({
-      id: `question-${item.id}`,
-      content: item.inputs.query || item.inputs.default_input || item.query, // text generation: item.inputs.query; chat: item.query
-      isAnswer: false,
-      message_files: item.message_files?.filter((file: any) => file.belongs_to === 'user') || [],
-    })
-    newChatList.push({
-      id: item.id,
-      content: item.answer,
-      agent_thoughts: addFileInfos(item.agent_thoughts ? sortAgentSorts(item.agent_thoughts) : item.agent_thoughts, item.message_files),
-      feedback: item.feedbacks.find(item => item.from_source === 'user'), // user feedback
-      adminFeedback: item.feedbacks.find(item => item.from_source === 'admin'), // admin feedback
-      feedbackDisabled: false,
-      isAnswer: true,
-      message_files: item.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [],
-      log: [
-        ...item.message,
-        ...(item.message[item.message.length - 1]?.role !== 'assistant'
-          ? [
-            {
-              role: 'assistant',
-              text: item.answer,
-              files: item.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [],
-            },
-          ]
-          : []),
-      ],
-      workflow_run_id: item.workflow_run_id,
-      conversationId,
-      input: {
-        inputs: item.inputs,
-        query: item.query,
-      },
-      more: {
-        time: dayjs.unix(item.created_at).tz(timezone).format(format),
-        tokens: item.answer_tokens + item.message_tokens,
-        latency: item.provider_response_latency.toFixed(2),
-      },
-      citation: item.metadata?.retriever_resources,
-      annotation: (() => {
-        if (item.annotation_hit_history) {
-          return {
-            id: item.annotation_hit_history.annotation_id,
-            authorName: item.annotation_hit_history.annotation_create_account?.name || 'N/A',
-            created_at: item.annotation_hit_history.created_at,
-          }
+function appendQAToChatList(newChatList: IChatItem[], item: any, conversationId: string, timezone: string, format: string) {
+  newChatList.push({
+    id: item.id,
+    content: item.answer,
+    agent_thoughts: addFileInfos(item.agent_thoughts ? sortAgentSorts(item.agent_thoughts) : item.agent_thoughts, item.message_files),
+    feedback: item.feedbacks.find((item: any) => item.from_source === 'user'), // user feedback
+    adminFeedback: item.feedbacks.find((item: any) => item.from_source === 'admin'), // admin feedback
+    feedbackDisabled: false,
+    isAnswer: true,
+    message_files: item.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [],
+    log: [
+      ...item.message,
+      ...(item.message[item.message.length - 1]?.role !== 'assistant'
+        ? [
+          {
+            role: 'assistant',
+            text: item.answer,
+            files: item.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [],
+          },
+        ]
+        : []),
+    ],
+    workflow_run_id: item.workflow_run_id,
+    conversationId,
+    input: {
+      inputs: item.inputs,
+      query: item.query,
+    },
+    more: {
+      time: dayjs.unix(item.created_at).tz(timezone).format(format),
+      tokens: item.answer_tokens + item.message_tokens,
+      latency: item.provider_response_latency.toFixed(2),
+    },
+    citation: item.metadata?.retriever_resources,
+    annotation: (() => {
+      if (item.annotation_hit_history) {
+        return {
+          id: item.annotation_hit_history.annotation_id,
+          authorName: item.annotation_hit_history.annotation_create_account?.name || 'N/A',
+          created_at: item.annotation_hit_history.created_at,
         }
+      }
 
-        if (item.annotation) {
-          return {
-            id: item.annotation.id,
-            authorName: item.annotation.account.name,
-            logAnnotation: item.annotation,
-            created_at: 0,
-          }
+      if (item.annotation) {
+        return {
+          id: item.annotation.id,
+          authorName: item.annotation.account.name,
+          logAnnotation: item.annotation,
+          created_at: 0,
         }
+      }
 
-        return undefined
-      })(),
-    })
+      return undefined
+    })(),
+    parentMessageId: `question-${item.id}`,
   })
-  return newChatList
+  newChatList.push({
+    id: `question-${item.id}`,
+    content: item.inputs.query || item.inputs.default_input || item.query, // text generation: item.inputs.query; chat: item.query
+    isAnswer: false,
+    message_files: item.message_files?.filter((file: any) => file.belongs_to === 'user') || [],
+    parentMessageId: item.parent_message_id || undefined,
+  })
+}
+
+const getFormattedChatList = (messages: ChatMessage[], conversationId: string, timezone: string, format: string) => {
+  const newChatList: IChatItem[] = []
+  let nextMessageId = null
+  for (const item of messages) {
+    if (!item.parent_message_id) {
+      appendQAToChatList(newChatList, item, conversationId, timezone, format)
+      break
+    }
+
+    if (!nextMessageId) {
+      appendQAToChatList(newChatList, item, conversationId, timezone, format)
+      nextMessageId = item.parent_message_id
+    }
+    else {
+      if (item.id === nextMessageId || nextMessageId === UUID_NIL) {
+        appendQAToChatList(newChatList, item, conversationId, timezone, format)
+        nextMessageId = item.parent_message_id
+      }
+    }
+  }
+  return newChatList.reverse()
 }
 
 // const displayedParams = CompletionParams.slice(0, -2)
@@ -171,6 +192,7 @@ function DetailPanel<T extends ChatConversationFullDetailResponse | CompletionCo
   })))
   const { t } = useTranslation()
   const [items, setItems] = React.useState<IChatItem[]>([])
+  const fetchedMessages = useRef<ChatMessage[]>([])
   const [hasMore, setHasMore] = useState(true)
   const [varValues, setVarValues] = useState<Record<string, string>>({})
   const fetchData = async () => {
@@ -192,7 +214,8 @@ function DetailPanel<T extends ChatConversationFullDetailResponse | CompletionCo
         const varValues = messageRes.data[0].inputs
         setVarValues(varValues)
       }
-      const newItems = [...getFormattedChatList(messageRes.data, detail.id, timezone!, t('appLog.dateTimeFormat') as string), ...items]
+      fetchedMessages.current = [...fetchedMessages.current, ...messageRes.data]
+      const newItems = getFormattedChatList(fetchedMessages.current, detail.id, timezone!, t('appLog.dateTimeFormat') as string)
       if (messageRes.has_more === false && detail?.model_config?.configs?.introduction) {
         newItems.unshift({
           id: 'introduction',
@@ -435,7 +458,7 @@ function DetailPanel<T extends ChatConversationFullDetailResponse | CompletionCo
             siteInfo={null}
           />
         </div>
-        : items.length < 8
+        : (items.length < 8 && !hasMore)
           ? <div className="pt-4 mb-4">
             <Chat
               config={{

+ 24 - 1
web/app/components/base/chat/chat-with-history/chat-wrapper.tsx

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo } from 'react'
 import Chat from '../chat'
 import type {
   ChatConfig,
+  ChatItem,
   OnSend,
 } from '../types'
 import { useChat } from '../chat/hooks'
@@ -44,6 +45,8 @@ const ChatWrapper = () => {
   }, [appParams, currentConversationItem?.introduction, currentConversationId])
   const {
     chatList,
+    chatListRef,
+    handleUpdateChatList,
     handleSend,
     handleStop,
     isResponding,
@@ -63,11 +66,12 @@ const ChatWrapper = () => {
       currentChatInstanceRef.current.handleStop = handleStop
   }, [])
 
-  const doSend: OnSend = useCallback((message, files) => {
+  const doSend: OnSend = useCallback((message, files, last_answer) => {
     const data: any = {
       query: message,
       inputs: currentConversationId ? currentConversationItem?.inputs : newConversationInputs,
       conversation_id: currentConversationId,
+      parent_message_id: last_answer?.id || chatListRef.current.at(-1)?.id || null,
     }
 
     if (appConfig?.file_upload?.image.enabled && files?.length)
@@ -83,6 +87,7 @@ const ChatWrapper = () => {
       },
     )
   }, [
+    chatListRef,
     appConfig,
     currentConversationId,
     currentConversationItem,
@@ -92,6 +97,23 @@ const ChatWrapper = () => {
     isInstalledApp,
     appId,
   ])
+
+  const doRegenerate = useCallback((chatItem: ChatItem) => {
+    const index = chatList.findIndex(item => item.id === chatItem.id)
+    if (index === -1)
+      return
+
+    const prevMessages = chatList.slice(0, index)
+    const question = prevMessages.pop()
+    const lastAnswer = prevMessages.at(-1)
+
+    if (!question)
+      return
+
+    handleUpdateChatList(prevMessages)
+    doSend(question.content, question.message_files, (!lastAnswer || lastAnswer.isOpeningStatement) ? undefined : lastAnswer)
+  }, [chatList, handleUpdateChatList, doSend])
+
   const chatNode = useMemo(() => {
     if (inputsForms.length) {
       return (
@@ -148,6 +170,7 @@ const ChatWrapper = () => {
       chatFooterClassName='pb-4'
       chatFooterInnerClassName={`mx-auto w-full max-w-full ${isMobile && 'px-4'}`}
       onSend={doSend}
+      onRegenerate={doRegenerate}
       onStopResponding={handleStop}
       chatNode={chatNode}
       allToolIcons={appMeta?.tool_icons || {}}

+ 7 - 28
web/app/components/base/chat/chat-with-history/hooks.tsx

@@ -12,10 +12,10 @@ import produce from 'immer'
 import type {
   Callback,
   ChatConfig,
-  ChatItem,
   Feedback,
 } from '../types'
 import { CONVERSATION_ID_INFO } from '../constants'
+import { getPrevChatList } from '../utils'
 import {
   delConversation,
   fetchAppInfo,
@@ -34,7 +34,6 @@ import type {
   AppData,
   ConversationItem,
 } from '@/models/share'
-import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils'
 import { useToastContext } from '@/app/components/base/toast'
 import { changeLanguage } from '@/i18n/i18next-config'
 import { useAppFavicon } from '@/hooks/use-app-favicon'
@@ -108,32 +107,12 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
   const { data: appConversationData, isLoading: appConversationDataLoading, mutate: mutateAppConversationData } = useSWR(['appConversationData', isInstalledApp, appId, false], () => fetchConversations(isInstalledApp, appId, undefined, false, 100))
   const { data: appChatListData, isLoading: appChatListDataLoading } = useSWR(chatShouldReloadKey ? ['appChatList', chatShouldReloadKey, isInstalledApp, appId] : null, () => fetchChatList(chatShouldReloadKey, isInstalledApp, appId))
 
-  const appPrevChatList = useMemo(() => {
-    const data = appChatListData?.data || []
-    const chatList: ChatItem[] = []
-
-    if (currentConversationId && data.length) {
-      data.forEach((item: any) => {
-        chatList.push({
-          id: `question-${item.id}`,
-          content: item.query,
-          isAnswer: false,
-          message_files: item.message_files?.filter((file: any) => file.belongs_to === 'user') || [],
-        })
-        chatList.push({
-          id: item.id,
-          content: item.answer,
-          agent_thoughts: addFileInfos(item.agent_thoughts ? sortAgentSorts(item.agent_thoughts) : item.agent_thoughts, item.message_files),
-          feedback: item.feedback,
-          isAnswer: true,
-          citation: item.retriever_resources,
-          message_files: item.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [],
-        })
-      })
-    }
-
-    return chatList
-  }, [appChatListData, currentConversationId])
+  const appPrevChatList = useMemo(
+    () => (currentConversationId && appChatListData?.data.length)
+      ? getPrevChatList(appChatListData.data)
+      : [],
+    [appChatListData, currentConversationId],
+  )
 
   const [showNewConversationItemInList, setShowNewConversationItemInList] = useState(false)
 

+ 3 - 0
web/app/components/base/chat/chat/answer/index.tsx

@@ -35,6 +35,7 @@ type AnswerProps = {
   chatAnswerContainerInner?: string
   hideProcessDetail?: boolean
   appData?: AppData
+  noChatInput?: boolean
 }
 const Answer: FC<AnswerProps> = ({
   item,
@@ -48,6 +49,7 @@ const Answer: FC<AnswerProps> = ({
   chatAnswerContainerInner,
   hideProcessDetail,
   appData,
+  noChatInput,
 }) => {
   const { t } = useTranslation()
   const {
@@ -110,6 +112,7 @@ const Answer: FC<AnswerProps> = ({
                   question={question}
                   index={index}
                   showPromptLog={showPromptLog}
+                  noChatInput={noChatInput}
                 />
               )
             }

+ 9 - 4
web/app/components/base/chat/chat/answer/operation.tsx

@@ -7,6 +7,7 @@ import {
 import { useTranslation } from 'react-i18next'
 import type { ChatItem } from '../../types'
 import { useChatContext } from '../context'
+import RegenerateBtn from '@/app/components/base/regenerate-btn'
 import cn from '@/utils/classnames'
 import CopyBtn from '@/app/components/base/copy-btn'
 import { MessageFast } from '@/app/components/base/icons/src/vender/solid/communication'
@@ -28,6 +29,7 @@ type OperationProps = {
   maxSize: number
   contentWidth: number
   hasWorkflowProcess: boolean
+  noChatInput?: boolean
 }
 const Operation: FC<OperationProps> = ({
   item,
@@ -37,6 +39,7 @@ const Operation: FC<OperationProps> = ({
   maxSize,
   contentWidth,
   hasWorkflowProcess,
+  noChatInput,
 }) => {
   const { t } = useTranslation()
   const {
@@ -45,6 +48,7 @@ const Operation: FC<OperationProps> = ({
     onAnnotationEdited,
     onAnnotationRemoved,
     onFeedback,
+    onRegenerate,
   } = useChatContext()
   const [isShowReplyModal, setIsShowReplyModal] = useState(false)
   const {
@@ -159,12 +163,13 @@ const Operation: FC<OperationProps> = ({
             </div>
           )
         }
+        {
+          !isOpeningStatement && !noChatInput && <RegenerateBtn className='hidden group-hover:block mr-1' onClick={() => onRegenerate?.(item)} />
+        }
         {
           config?.supportFeedback && !localFeedback?.rating && onFeedback && !isOpeningStatement && (
-            <div className='hidden group-hover:flex ml-1 shrink-0 items-center px-0.5 bg-white border-[0.5px] border-gray-100 shadow-md text-gray-500 rounded-lg'>
-              <Tooltip
-                popupContent={t('appDebug.operation.agree')}
-              >
+            <div className='hidden group-hover:flex shrink-0 items-center px-0.5 bg-white border-[0.5px] border-gray-100 shadow-md text-gray-500 rounded-lg'>
+              <Tooltip popupContent={t('appDebug.operation.agree')}>
                 <div
                   className='flex items-center justify-center mr-0.5 w-6 h-6 rounded-md hover:bg-black/5 hover:text-gray-800 cursor-pointer'
                   onClick={() => handleFeedback('like')}

+ 3 - 0
web/app/components/base/chat/chat/context.tsx

@@ -12,6 +12,7 @@ export type ChatContextValue = Pick<ChatProps, 'config'
   | 'answerIcon'
   | 'allToolIcons'
   | 'onSend'
+  | 'onRegenerate'
   | 'onAnnotationEdited'
   | 'onAnnotationAdded'
   | 'onAnnotationRemoved'
@@ -36,6 +37,7 @@ export const ChatContextProvider = ({
   answerIcon,
   allToolIcons,
   onSend,
+  onRegenerate,
   onAnnotationEdited,
   onAnnotationAdded,
   onAnnotationRemoved,
@@ -51,6 +53,7 @@ export const ChatContextProvider = ({
       answerIcon,
       allToolIcons,
       onSend,
+      onRegenerate,
       onAnnotationEdited,
       onAnnotationAdded,
       onAnnotationRemoved,

+ 2 - 1
web/app/components/base/chat/chat/hooks.ts

@@ -647,7 +647,8 @@ export const useChat = (
 
   return {
     chatList,
-    setChatList,
+    chatListRef,
+    handleUpdateChatList,
     conversationId: conversationId.current,
     isResponding,
     setIsResponding,

+ 5 - 0
web/app/components/base/chat/chat/index.tsx

@@ -16,6 +16,7 @@ import type {
   ChatConfig,
   ChatItem,
   Feedback,
+  OnRegenerate,
   OnSend,
 } from '../types'
 import type { ThemeBuilder } from '../embedded-chatbot/theme/theme-context'
@@ -42,6 +43,7 @@ export type ChatProps = {
   onStopResponding?: () => void
   noChatInput?: boolean
   onSend?: OnSend
+  onRegenerate?: OnRegenerate
   chatContainerClassName?: string
   chatContainerInnerClassName?: string
   chatFooterClassName?: string
@@ -67,6 +69,7 @@ const Chat: FC<ChatProps> = ({
   appData,
   config,
   onSend,
+  onRegenerate,
   chatList,
   isResponding,
   noStopResponding,
@@ -186,6 +189,7 @@ const Chat: FC<ChatProps> = ({
       answerIcon={answerIcon}
       allToolIcons={allToolIcons}
       onSend={onSend}
+      onRegenerate={onRegenerate}
       onAnnotationAdded={onAnnotationAdded}
       onAnnotationEdited={onAnnotationEdited}
       onAnnotationRemoved={onAnnotationRemoved}
@@ -219,6 +223,7 @@ const Chat: FC<ChatProps> = ({
                       showPromptLog={showPromptLog}
                       chatAnswerContainerInner={chatAnswerContainerInner}
                       hideProcessDetail={hideProcessDetail}
+                      noChatInput={noChatInput}
                     />
                   )
                 }

+ 1 - 0
web/app/components/base/chat/chat/type.ts

@@ -95,6 +95,7 @@ export type IChatItem = {
   // for agent log
   conversationId?: string
   input?: any
+  parentMessageId?: string
 }
 
 export type Metadata = {

+ 1 - 0
web/app/components/base/chat/constants.ts

@@ -1 +1,2 @@
 export const CONVERSATION_ID_INFO = 'conversationIdInfo'
+export const UUID_NIL = '00000000-0000-0000-0000-000000000000'

+ 24 - 1
web/app/components/base/chat/embedded-chatbot/chat-wrapper.tsx

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo } from 'react'
 import Chat from '../chat'
 import type {
   ChatConfig,
+  ChatItem,
   OnSend,
 } from '../types'
 import { useChat } from '../chat/hooks'
@@ -45,11 +46,13 @@ const ChatWrapper = () => {
     } as ChatConfig
   }, [appParams, currentConversationItem?.introduction, currentConversationId])
   const {
+    chatListRef,
     chatList,
     handleSend,
     handleStop,
     isResponding,
     suggestedQuestions,
+    handleUpdateChatList,
   } = useChat(
     appConfig,
     {
@@ -65,11 +68,12 @@ const ChatWrapper = () => {
       currentChatInstanceRef.current.handleStop = handleStop
   }, [])
 
-  const doSend: OnSend = useCallback((message, files) => {
+  const doSend: OnSend = useCallback((message, files, last_answer) => {
     const data: any = {
       query: message,
       inputs: currentConversationId ? currentConversationItem?.inputs : newConversationInputs,
       conversation_id: currentConversationId,
+      parent_message_id: last_answer?.id || chatListRef.current.at(-1)?.id || null,
     }
 
     if (appConfig?.file_upload?.image.enabled && files?.length)
@@ -85,6 +89,7 @@ const ChatWrapper = () => {
       },
     )
   }, [
+    chatListRef,
     appConfig,
     currentConversationId,
     currentConversationItem,
@@ -94,6 +99,23 @@ const ChatWrapper = () => {
     isInstalledApp,
     appId,
   ])
+
+  const doRegenerate = useCallback((chatItem: ChatItem) => {
+    const index = chatList.findIndex(item => item.id === chatItem.id)
+    if (index === -1)
+      return
+
+    const prevMessages = chatList.slice(0, index)
+    const question = prevMessages.pop()
+    const lastAnswer = prevMessages.at(-1)
+
+    if (!question)
+      return
+
+    handleUpdateChatList(prevMessages)
+    doSend(question.content, question.message_files, (!lastAnswer || lastAnswer.isOpeningStatement) ? undefined : lastAnswer)
+  }, [chatList, handleUpdateChatList, doSend])
+
   const chatNode = useMemo(() => {
     if (inputsForms.length) {
       return (
@@ -136,6 +158,7 @@ const ChatWrapper = () => {
       chatFooterClassName='pb-4'
       chatFooterInnerClassName={cn('mx-auto w-full max-w-full tablet:px-4', isMobile && 'px-4')}
       onSend={doSend}
+      onRegenerate={doRegenerate}
       onStopResponding={handleStop}
       chatNode={chatNode}
       allToolIcons={appMeta?.tool_icons || {}}

+ 8 - 30
web/app/components/base/chat/embedded-chatbot/hooks.tsx

@@ -11,10 +11,10 @@ import { useLocalStorageState } from 'ahooks'
 import produce from 'immer'
 import type {
   ChatConfig,
-  ChatItem,
   Feedback,
 } from '../types'
 import { CONVERSATION_ID_INFO } from '../constants'
+import { getPrevChatList, getProcessedInputsFromUrlParams } from '../utils'
 import {
   fetchAppInfo,
   fetchAppMeta,
@@ -28,10 +28,8 @@ import type {
   // AppData,
   ConversationItem,
 } from '@/models/share'
-import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils'
 import { useToastContext } from '@/app/components/base/toast'
 import { changeLanguage } from '@/i18n/i18next-config'
-import { getProcessedInputsFromUrlParams } from '@/app/components/base/chat/utils'
 
 export const useEmbeddedChatbot = () => {
   const isInstalledApp = false
@@ -75,32 +73,12 @@ export const useEmbeddedChatbot = () => {
   const { data: appConversationData, isLoading: appConversationDataLoading, mutate: mutateAppConversationData } = useSWR(['appConversationData', isInstalledApp, appId, false], () => fetchConversations(isInstalledApp, appId, undefined, false, 100))
   const { data: appChatListData, isLoading: appChatListDataLoading } = useSWR(chatShouldReloadKey ? ['appChatList', chatShouldReloadKey, isInstalledApp, appId] : null, () => fetchChatList(chatShouldReloadKey, isInstalledApp, appId))
 
-  const appPrevChatList = useMemo(() => {
-    const data = appChatListData?.data || []
-    const chatList: ChatItem[] = []
-
-    if (currentConversationId && data.length) {
-      data.forEach((item: any) => {
-        chatList.push({
-          id: `question-${item.id}`,
-          content: item.query,
-          isAnswer: false,
-          message_files: item.message_files?.filter((file: any) => file.belongs_to === 'user') || [],
-        })
-        chatList.push({
-          id: item.id,
-          content: item.answer,
-          agent_thoughts: addFileInfos(item.agent_thoughts ? sortAgentSorts(item.agent_thoughts) : item.agent_thoughts, item.message_files),
-          feedback: item.feedback,
-          isAnswer: true,
-          citation: item.retriever_resources,
-          message_files: item.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [],
-        })
-      })
-    }
-
-    return chatList
-  }, [appChatListData, currentConversationId])
+  const appPrevChatList = useMemo(
+    () => (currentConversationId && appChatListData?.data.length)
+      ? getPrevChatList(appChatListData.data)
+      : [],
+    [appChatListData, currentConversationId],
+  )
 
   const [showNewConversationItemInList, setShowNewConversationItemInList] = useState(false)
 
@@ -155,7 +133,7 @@ export const useEmbeddedChatbot = () => {
         type: 'text-input',
       }
     })
-  }, [appParams])
+  }, [initInputs, appParams])
 
   useEffect(() => {
     // init inputs from url params

+ 3 - 1
web/app/components/base/chat/types.ts

@@ -63,7 +63,9 @@ export type ChatItem = IChatItem & {
   conversationId?: string
 }
 
-export type OnSend = (message: string, files?: VisionFile[]) => void
+export type OnSend = (message: string, files?: VisionFile[], last_answer?: ChatItem) => void
+
+export type OnRegenerate = (chatItem: ChatItem) => void
 
 export type Callback = {
   onSuccess: () => void

+ 56 - 1
web/app/components/base/chat/utils.ts

@@ -1,7 +1,11 @@
+import { addFileInfos, sortAgentSorts } from '../../tools/utils'
+import { UUID_NIL } from './constants'
+import type { ChatItem } from './types'
+
 async function decodeBase64AndDecompress(base64String: string) {
   const binaryString = atob(base64String)
   const compressedUint8Array = Uint8Array.from(binaryString, char => char.charCodeAt(0))
-  const decompressedStream = new Response(compressedUint8Array).body.pipeThrough(new DecompressionStream('gzip'))
+  const decompressedStream = new Response(compressedUint8Array).body?.pipeThrough(new DecompressionStream('gzip'))
   const decompressedArrayBuffer = await new Response(decompressedStream).arrayBuffer()
   return new TextDecoder().decode(decompressedArrayBuffer)
 }
@@ -15,6 +19,57 @@ function getProcessedInputsFromUrlParams(): Record<string, any> {
   return inputs
 }
 
+function appendQAToChatList(chatList: ChatItem[], item: any) {
+  // we append answer first and then question since will reverse the whole chatList later
+  chatList.push({
+    id: item.id,
+    content: item.answer,
+    agent_thoughts: addFileInfos(item.agent_thoughts ? sortAgentSorts(item.agent_thoughts) : item.agent_thoughts, item.message_files),
+    feedback: item.feedback,
+    isAnswer: true,
+    citation: item.retriever_resources,
+    message_files: item.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [],
+  })
+  chatList.push({
+    id: `question-${item.id}`,
+    content: item.query,
+    isAnswer: false,
+    message_files: item.message_files?.filter((file: any) => file.belongs_to === 'user') || [],
+  })
+}
+
+/**
+ * Computes the latest thread messages from all messages of the conversation.
+ * Same logic as backend codebase `api/core/prompt/utils/extract_thread_messages.py`
+ *
+ * @param fetchedMessages - The history chat list data from the backend, sorted by created_at in descending order. This includes all flattened history messages of the conversation.
+ * @returns An array of ChatItems representing the latest thread.
+ */
+function getPrevChatList(fetchedMessages: any[]) {
+  const ret: ChatItem[] = []
+  let nextMessageId = null
+
+  for (const item of fetchedMessages) {
+    if (!item.parent_message_id) {
+      appendQAToChatList(ret, item)
+      break
+    }
+
+    if (!nextMessageId) {
+      appendQAToChatList(ret, item)
+      nextMessageId = item.parent_message_id
+    }
+    else {
+      if (item.id === nextMessageId || nextMessageId === UUID_NIL) {
+        appendQAToChatList(ret, item)
+        nextMessageId = item.parent_message_id
+      }
+    }
+  }
+  return ret.reverse()
+}
+
 export {
   getProcessedInputsFromUrlParams,
+  getPrevChatList,
 }

+ 1 - 0
web/app/components/base/icons/assets/vender/line/general/refresh.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M5.46257 4.43262C7.21556 2.91688 9.5007 2 12 2C17.5228 2 22 6.47715 22 12C22 14.1361 21.3302 16.1158 20.1892 17.7406L17 12H20C20 7.58172 16.4183 4 12 4C9.84982 4 7.89777 4.84827 6.46023 6.22842L5.46257 4.43262ZM18.5374 19.5674C16.7844 21.0831 14.4993 22 12 22C6.47715 22 2 17.5228 2 12C2 9.86386 2.66979 7.88416 3.8108 6.25944L7 12H4C4 16.4183 7.58172 20 12 20C14.1502 20 16.1022 19.1517 17.5398 17.7716L18.5374 19.5674Z"></path></svg>

+ 23 - 0
web/app/components/base/icons/src/vender/line/general/Refresh.json

@@ -0,0 +1,23 @@
+{
+	"icon": {
+		"type": "element",
+		"isRootNode": true,
+		"name": "svg",
+		"attributes": {
+			"xmlns": "http://www.w3.org/2000/svg",
+			"viewBox": "0 0 24 24",
+			"fill": "currentColor"
+		},
+		"children": [
+			{
+				"type": "element",
+				"name": "path",
+				"attributes": {
+					"d": "M5.46257 4.43262C7.21556 2.91688 9.5007 2 12 2C17.5228 2 22 6.47715 22 12C22 14.1361 21.3302 16.1158 20.1892 17.7406L17 12H20C20 7.58172 16.4183 4 12 4C9.84982 4 7.89777 4.84827 6.46023 6.22842L5.46257 4.43262ZM18.5374 19.5674C16.7844 21.0831 14.4993 22 12 22C6.47715 22 2 17.5228 2 12C2 9.86386 2.66979 7.88416 3.8108 6.25944L7 12H4C4 16.4183 7.58172 20 12 20C14.1502 20 16.1022 19.1517 17.5398 17.7716L18.5374 19.5674Z"
+				},
+				"children": []
+			}
+		]
+	},
+	"name": "Refresh"
+}

+ 16 - 0
web/app/components/base/icons/src/vender/line/general/Refresh.tsx

@@ -0,0 +1,16 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import * as React from 'react'
+import data from './Refresh.json'
+import IconBase from '@/app/components/base/icons/IconBase'
+import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
+
+const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
+  props,
+  ref,
+) => <IconBase {...props} ref={ref} data={data as IconData} />)
+
+Icon.displayName = 'Refresh'
+
+export default Icon

+ 1 - 0
web/app/components/base/icons/src/vender/line/general/index.ts

@@ -18,6 +18,7 @@ export { default as Menu01 } from './Menu01'
 export { default as Pin01 } from './Pin01'
 export { default as Pin02 } from './Pin02'
 export { default as Plus02 } from './Plus02'
+export { default as Refresh } from './Refresh'
 export { default as Settings01 } from './Settings01'
 export { default as Settings04 } from './Settings04'
 export { default as Target04 } from './Target04'

+ 31 - 0
web/app/components/base/regenerate-btn/index.tsx

@@ -0,0 +1,31 @@
+'use client'
+import { t } from 'i18next'
+import { Refresh } from '../icons/src/vender/line/general'
+import Tooltip from '@/app/components/base/tooltip'
+
+type Props = {
+  className?: string
+  onClick?: () => void
+}
+
+const RegenerateBtn = ({ className, onClick }: Props) => {
+  return (
+    <div className={`${className}`}>
+      <Tooltip
+        popupContent={t('appApi.regenerate') as string}
+      >
+        <div
+          className={'box-border p-0.5 flex items-center justify-center rounded-md bg-white cursor-pointer'}
+          onClick={() => onClick?.()}
+          style={{
+            boxShadow: '0px 4px 8px -2px rgba(16, 24, 40, 0.1), 0px 2px 4px -2px rgba(16, 24, 40, 0.06)',
+          }}
+        >
+          <Refresh className="p-[3.5px] w-6 h-6 text-[#667085] hover:bg-gray-50" />
+        </div>
+      </Tooltip>
+    </div>
+  )
+}
+
+export default RegenerateBtn

+ 49 - 30
web/app/components/workflow/panel/chat-record/index.tsx

@@ -2,7 +2,6 @@ import {
   memo,
   useCallback,
   useEffect,
-  useMemo,
   useState,
 } from 'react'
 import { RiCloseLine } from '@remixicon/react'
@@ -17,50 +16,70 @@ import type { ChatItem } from '@/app/components/base/chat/types'
 import { fetchConversationMessages } from '@/service/debug'
 import { useStore as useAppStore } from '@/app/components/app/store'
 import Loading from '@/app/components/base/loading'
+import { UUID_NIL } from '@/app/components/base/chat/constants'
+
+function appendQAToChatList(newChatList: ChatItem[], item: any) {
+  newChatList.push({
+    id: item.id,
+    content: item.answer,
+    feedback: item.feedback,
+    isAnswer: true,
+    citation: item.metadata?.retriever_resources,
+    message_files: item.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [],
+    workflow_run_id: item.workflow_run_id,
+  })
+  newChatList.push({
+    id: `question-${item.id}`,
+    content: item.query,
+    isAnswer: false,
+    message_files: item.message_files?.filter((file: any) => file.belongs_to === 'user') || [],
+  })
+}
+
+function getFormattedChatList(messages: any[]) {
+  const newChatList: ChatItem[] = []
+  let nextMessageId = null
+  for (const item of messages) {
+    if (!item.parent_message_id) {
+      appendQAToChatList(newChatList, item)
+      break
+    }
+
+    if (!nextMessageId) {
+      appendQAToChatList(newChatList, item)
+      nextMessageId = item.parent_message_id
+    }
+    else {
+      if (item.id === nextMessageId || nextMessageId === UUID_NIL) {
+        appendQAToChatList(newChatList, item)
+        nextMessageId = item.parent_message_id
+      }
+    }
+  }
+  return newChatList.reverse()
+}
 
 const ChatRecord = () => {
   const [fetched, setFetched] = useState(false)
-  const [chatList, setChatList] = useState([])
+  const [chatList, setChatList] = useState<ChatItem[]>([])
   const appDetail = useAppStore(s => s.appDetail)
   const workflowStore = useWorkflowStore()
   const { handleLoadBackupDraft } = useWorkflowRun()
   const historyWorkflowData = useStore(s => s.historyWorkflowData)
   const currentConversationID = historyWorkflowData?.conversation_id
 
-  const chatMessageList = useMemo(() => {
-    const res: ChatItem[] = []
-    if (chatList.length) {
-      chatList.forEach((item: any) => {
-        res.push({
-          id: `question-${item.id}`,
-          content: item.query,
-          isAnswer: false,
-          message_files: item.message_files?.filter((file: any) => file.belongs_to === 'user') || [],
-        })
-        res.push({
-          id: item.id,
-          content: item.answer,
-          feedback: item.feedback,
-          isAnswer: true,
-          citation: item.metadata?.retriever_resources,
-          message_files: item.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [],
-          workflow_run_id: item.workflow_run_id,
-        })
-      })
-    }
-    return res
-  }, [chatList])
-
   const handleFetchConversationMessages = useCallback(async () => {
     if (appDetail && currentConversationID) {
       try {
         setFetched(false)
         const res = await fetchConversationMessages(appDetail.id, currentConversationID)
-        setFetched(true)
-        setChatList((res as any).data)
+        setChatList(getFormattedChatList((res as any).data))
       }
       catch (e) {
-
+        console.error(e)
+      }
+      finally {
+        setFetched(true)
       }
     }
   }, [appDetail, currentConversationID])
@@ -101,7 +120,7 @@ const ChatRecord = () => {
               config={{
                 supportCitationHitInfo: true,
               } as any}
-              chatList={chatMessageList}
+              chatList={chatList}
               chatContainerClassName='px-4'
               chatContainerInnerClassName='pt-6 w-full max-w-full mx-auto'
               chatFooterClassName='px-4 rounded-b-2xl'

+ 23 - 3
web/app/components/workflow/panel/debug-and-preview/chat-wrapper.tsx

@@ -18,7 +18,7 @@ import ConversationVariableModal from './conversation-variable-modal'
 import { useChat } from './hooks'
 import type { ChatWrapperRefType } from './index'
 import Chat from '@/app/components/base/chat/chat'
-import type { OnSend } from '@/app/components/base/chat/types'
+import type { ChatItem, OnSend } from '@/app/components/base/chat/types'
 import { useFeaturesStore } from '@/app/components/base/features/hooks'
 import {
   fetchSuggestedQuestions,
@@ -58,6 +58,8 @@ const ChatWrapper = forwardRef<ChatWrapperRefType, ChatWrapperProps>(({ showConv
   const {
     conversationId,
     chatList,
+    chatListRef,
+    handleUpdateChatList,
     handleStop,
     isResponding,
     suggestedQuestions,
@@ -73,19 +75,36 @@ const ChatWrapper = forwardRef<ChatWrapperRefType, ChatWrapperProps>(({ showConv
     taskId => stopChatMessageResponding(appDetail!.id, taskId),
   )
 
-  const doSend = useCallback<OnSend>((query, files) => {
+  const doSend = useCallback<OnSend>((query, files, last_answer) => {
     handleSend(
       {
         query,
         files,
         inputs: workflowStore.getState().inputs,
         conversation_id: conversationId,
+        parent_message_id: last_answer?.id || chatListRef.current.at(-1)?.id || null,
       },
       {
         onGetSuggestedQuestions: (messageId, getAbortController) => fetchSuggestedQuestions(appDetail!.id, messageId, getAbortController),
       },
     )
-  }, [conversationId, handleSend, workflowStore, appDetail])
+  }, [chatListRef, conversationId, handleSend, workflowStore, appDetail])
+
+  const doRegenerate = useCallback((chatItem: ChatItem) => {
+    const index = chatList.findIndex(item => item.id === chatItem.id)
+    if (index === -1)
+      return
+
+    const prevMessages = chatList.slice(0, index)
+    const question = prevMessages.pop()
+    const lastAnswer = prevMessages.at(-1)
+
+    if (!question)
+      return
+
+    handleUpdateChatList(prevMessages)
+    doSend(question.content, question.message_files, (!lastAnswer || lastAnswer.isOpeningStatement) ? undefined : lastAnswer)
+  }, [chatList, handleUpdateChatList, doSend])
 
   useImperativeHandle(ref, () => {
     return {
@@ -107,6 +126,7 @@ const ChatWrapper = forwardRef<ChatWrapperRefType, ChatWrapperProps>(({ showConv
         chatFooterClassName='px-4 rounded-bl-2xl'
         chatFooterInnerClassName='pb-4 w-full max-w-full mx-auto'
         onSend={doSend}
+        onRegenerate={doRegenerate}
         onStopResponding={handleStop}
         chatNode={(
           <>

+ 2 - 0
web/app/components/workflow/panel/debug-and-preview/hooks.ts

@@ -387,6 +387,8 @@ export const useChat = (
   return {
     conversationId: conversationId.current,
     chatList,
+    chatListRef,
+    handleUpdateChatList,
     handleSend,
     handleStop,
     handleRestart,

+ 3 - 3
web/hooks/use-app-favicon.ts

@@ -5,10 +5,10 @@ import type { AppIconType } from '@/types/app'
 
 type UseAppFaviconOptions = {
   enable?: boolean
-  icon_type?: AppIconType
+  icon_type?: AppIconType | null
   icon?: string
-  icon_background?: string
-  icon_url?: string
+  icon_background?: string | null
+  icon_url?: string | null
 }
 
 export function useAppFavicon(options: UseAppFaviconOptions) {

+ 1 - 0
web/i18n/en-US/app-api.ts

@@ -6,6 +6,7 @@ const translation = {
   ok: 'In Service',
   copy: 'Copy',
   copied: 'Copied',
+  regenerate: 'Regenerate',
   play: 'Play',
   pause: 'Pause',
   playing: 'Playing',

+ 1 - 0
web/i18n/zh-Hans/app-api.ts

@@ -6,6 +6,7 @@ const translation = {
   ok: '运行中',
   copy: '复制',
   copied: '已复制',
+  regenerate: '重新生成',
   play: '播放',
   pause: '暂停',
   playing: '播放中',

+ 1 - 0
web/models/log.ts

@@ -106,6 +106,7 @@ export type MessageContent = {
   metadata: Metadata
   agent_thoughts: any[] // TODO
   workflow_run_id: string
+  parent_message_id: string | null
 }
 
 export type CompletionConversationGeneralDetail = {