| @@ -1 +1,2 @@ | |||
| HIDDEN_VALUE = "[__HIDDEN__]" | |||
| UUID_NIL = "00000000-0000-0000-0000-000000000000" | |||
| @@ -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() | |||
| @@ -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) | |||
| @@ -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: | |||
| @@ -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() | |||
| @@ -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.") | |||
| @@ -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"), | |||
| @@ -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() | |||
| @@ -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.") | |||
| @@ -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 | |||
| @@ -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, | |||
| @@ -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, | |||
| @@ -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, | |||
| @@ -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", | |||
| @@ -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): | |||
| @@ -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: | |||
| @@ -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 | |||
| @@ -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} | |||
| @@ -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"), | |||
| @@ -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 ### | |||
| @@ -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) | |||
| @@ -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) | |||
| @@ -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] | |||
| @@ -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) => { | |||
| @@ -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} />} | |||
| @@ -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={{ | |||
| @@ -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 || {}} | |||
| @@ -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) | |||
| @@ -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} | |||
| /> | |||
| ) | |||
| } | |||
| @@ -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')} | |||
| @@ -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, | |||
| @@ -647,7 +647,8 @@ export const useChat = ( | |||
| return { | |||
| chatList, | |||
| setChatList, | |||
| chatListRef, | |||
| handleUpdateChatList, | |||
| conversationId: conversationId.current, | |||
| isResponding, | |||
| setIsResponding, | |||
| @@ -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} | |||
| /> | |||
| ) | |||
| } | |||
| @@ -95,6 +95,7 @@ export type IChatItem = { | |||
| // for agent log | |||
| conversationId?: string | |||
| input?: any | |||
| parentMessageId?: string | |||
| } | |||
| export type Metadata = { | |||
| @@ -1 +1,2 @@ | |||
| export const CONVERSATION_ID_INFO = 'conversationIdInfo' | |||
| export const UUID_NIL = '00000000-0000-0000-0000-000000000000' | |||
| @@ -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 || {}} | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -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, | |||
| } | |||
| @@ -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> | |||
| @@ -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" | |||
| } | |||
| @@ -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 | |||
| @@ -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' | |||
| @@ -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 | |||
| @@ -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' | |||
| @@ -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={( | |||
| <> | |||
| @@ -387,6 +387,8 @@ export const useChat = ( | |||
| return { | |||
| conversationId: conversationId.current, | |||
| chatList, | |||
| chatListRef, | |||
| handleUpdateChatList, | |||
| handleSend, | |||
| handleStop, | |||
| handleRestart, | |||
| @@ -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) { | |||
| @@ -6,6 +6,7 @@ const translation = { | |||
| ok: 'In Service', | |||
| copy: 'Copy', | |||
| copied: 'Copied', | |||
| regenerate: 'Regenerate', | |||
| play: 'Play', | |||
| pause: 'Pause', | |||
| playing: 'Playing', | |||
| @@ -6,6 +6,7 @@ const translation = { | |||
| ok: '运行中', | |||
| copy: '复制', | |||
| copied: '已复制', | |||
| regenerate: '重新生成', | |||
| play: '播放', | |||
| pause: '暂停', | |||
| playing: '播放中', | |||
| @@ -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 = { | |||