Feat: When a Wait Node precedes a Message Node within a Loop Node, the outgoing message is split into two separate messages. (#14839)

### What problem does this PR solve?

Feat: When a Wait Node precedes a Message Node within a Loop Node, the
outgoing message is split into two separate messages.

### Type of change


- [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
balibabu
2026-05-12 18:48:44 +08:00
committed by GitHub
parent 127aeac4aa
commit 3f41f8cfae
7 changed files with 105 additions and 46 deletions

View File

@@ -15,10 +15,9 @@ import {
import { useFetchUserInfo } from '@/hooks/use-user-setting-request';
import { buildMessageUuidWithRole } from '@/utils/chat';
import { memo, useCallback, useContext } from 'react';
import { useParams } from 'react-router';
import { AgentChatContext } from '../context';
import DebugContent from '../debug-content';
import { useAwaitCompentData } from '../hooks/use-chat-logic';
import { useAwaitComponentData } from '../hooks/use-chat-logic';
import { useIsTaskMode } from '../hooks/use-get-begin-query';
import { useGetFileIcon } from './use-get-file-icon';
@@ -43,13 +42,11 @@ function AgentChatBox() {
useClickDrawer();
useGetFileIcon();
const { data: userInfo } = useFetchUserInfo();
const { id: canvasId } = useParams();
const { uploadAgentFile, loading } = useUploadAgentFileWithProgress();
const { buildInputList, handleOk, isWaitting } = useAwaitCompentData({
const { buildInputList, handleOk, isWaiting } = useAwaitComponentData({
derivedMessages,
sendFormMessage,
canvasId: canvasId as string,
});
const { setDerivedMessages } = useContext(AgentChatContext);
@@ -125,9 +122,9 @@ function AgentChatBox() {
<NextMessageInput
value={value}
sendLoading={sendLoading}
disabled={isWaitting}
sendDisabled={sendLoading || isWaitting}
isUploading={loading || isWaitting}
disabled={isWaiting}
sendDisabled={sendLoading || isWaiting}
isUploading={loading || isWaiting}
resize="vertical"
onPressEnter={handlePressEnter}
onInputChange={handleInputChange}

View File

@@ -29,6 +29,7 @@ import {
import { useParams, useSearchParams } from 'react-router';
import { v4 as uuid } from 'uuid';
import { BeginId } from '../constant';
import { MessageWaitSuffix } from '../constant/chat';
import { AgentChatLogContext } from '../context';
import { transferInputsArrayToObject } from '../form/begin-form/use-watch-change';
import {
@@ -39,6 +40,7 @@ import { useStopMessage } from '../hooks/use-stop-message';
import { BeginQuery } from '../interface';
import useGraphStore from '../store';
import { receiveMessageError } from '../utils';
import { shouldSplitMessage } from '../utils/chat';
export function findMessageFromList(eventList: IEventList) {
const messageEventList = eventList.filter(
@@ -363,7 +365,7 @@ export const useSendAgentMessage = ({
);
const sendFormMessage = useCallback(
async (body: { agent_id?: string; inputs: Record<string, BeginQuery> }) => {
async (body: { inputs: Record<string, BeginQuery> }) => {
addNewestOneQuestion({
content: Object.entries(body.inputs)
.map(([, val]) => `${val.name}: ${val.value}`)
@@ -372,12 +374,21 @@ export const useSendAgentMessage = ({
});
await send({
...body,
...(isShared ? {} : { agent_id: agentId }),
session_id: sessionId,
...(releaseMode ? { release: releaseMode } : {}),
});
refetch?.();
},
[addNewestOneQuestion, refetch, releaseMode, send, sessionId],
[
addNewestOneQuestion,
agentId,
isShared,
refetch,
releaseMode,
send,
sessionId,
],
);
// reset session
@@ -450,14 +461,31 @@ export const useSendAgentMessage = ({
const answer = content || getLatestError(answerList);
if (answerList.length > 0) {
addNewestOneAnswer({
answer: answer ?? '',
audio_binary: audio_binary,
attachment: attachment as IAttachment,
downloads,
id: id,
...inputAnswer,
});
const shouldSplit = shouldSplitMessage(answerList, content);
if (shouldSplit) {
addNewestOneAnswer({
answer: answer ?? '',
audio_binary: audio_binary,
attachment: attachment as IAttachment,
downloads,
id,
});
addNewestOneAnswer({
answer: '',
...inputAnswer,
id: `${id}${MessageWaitSuffix}`,
});
} else {
addNewestOneAnswer({
answer: answer ?? '',
audio_binary: audio_binary,
attachment: attachment as IAttachment,
downloads,
id,
...inputAnswer,
});
}
}
}, [answerList, addNewestOneAnswer]);

View File

@@ -0,0 +1 @@
export const MessageWaitSuffix = '-wait';

View File

@@ -5,12 +5,16 @@ import {
} from '@/hooks/use-send-message';
import { get, isEmpty } from 'lodash';
import { useCallback, useMemo, useState } from 'react';
import { MessageWaitSuffix } from '../constant/chat';
export const ExcludeTypes = [
MessageEventType.Message,
MessageEventType.MessageEnd,
];
const resolveMessageId = (messageId: string) =>
messageId?.replace(new RegExp(`${MessageWaitSuffix}$`), '');
export function useCacheChatLog() {
const [messageIdPool, setMessageIdPool] = useState<
Record<string, IEventList>
@@ -22,8 +26,9 @@ export function useCacheChatLog() {
const filterEventListByMessageId = useCallback(
(messageId: string) => {
return messageIdPool[messageId]?.filter(
(x) => x.message_id === messageId,
const resolvedId = resolveMessageId(messageId);
return messageIdPool[resolvedId]?.filter(
(x) => x.message_id === resolvedId,
);
},
[messageIdPool],
@@ -31,9 +36,8 @@ export function useCacheChatLog() {
const filterEventListByEventType = useCallback(
(eventType: string) => {
return messageIdPool[currentMessageId]?.filter(
(x) => x.event === eventType,
);
const resolvedId = resolveMessageId(currentMessageId);
return messageIdPool[resolvedId]?.filter((x) => x.event === eventType);
},
[messageIdPool, currentMessageId],
);
@@ -62,19 +66,20 @@ export function useCacheChatLog() {
}, []);
const currentEventListWithoutMessage = useMemo(() => {
const list = messageIdPool[currentMessageId]?.filter(
const resolvedId = resolveMessageId(currentMessageId);
const list = messageIdPool[resolvedId]?.filter(
(x) =>
x.message_id === currentMessageId &&
ExcludeTypes.every((y) => y !== x.event),
x.message_id === resolvedId && ExcludeTypes.every((y) => y !== x.event),
);
return list as INodeEvent[];
}, [currentMessageId, messageIdPool]);
const currentEventListWithoutMessageById = useCallback(
(messageId: string) => {
const list = messageIdPool[messageId]?.filter(
const resolvedId = resolveMessageId(messageId);
const list = messageIdPool[resolvedId]?.filter(
(x) =>
x.message_id === messageId &&
x.message_id === resolvedId &&
ExcludeTypes.every((y) => y !== x.event),
);
return list as INodeEvent[];

View File

@@ -6,14 +6,10 @@ import { BeginQuery } from '../interface';
import { buildBeginQueryWithObject } from '../utils';
type IAwaitCompentData = {
derivedMessages: IMessage[];
sendFormMessage: (params: {
inputs: Record<string, BeginQuery>;
agent_id: string;
}) => void;
canvasId: string;
sendFormMessage: (params: { inputs: Record<string, BeginQuery> }) => void;
};
const useAwaitCompentData = (props: IAwaitCompentData) => {
const { derivedMessages, sendFormMessage, canvasId } = props;
const useAwaitComponentData = (props: IAwaitCompentData) => {
const { derivedMessages, sendFormMessage } = props;
const getInputs = useCallback((message: Message) => {
return get(message, 'data.inputs', {}) as Record<string, BeginQuery>;
@@ -37,13 +33,12 @@ const useAwaitCompentData = (props: IAwaitCompentData) => {
const nextInputs = buildBeginQueryWithObject(inputs, values);
sendFormMessage({
inputs: nextInputs,
agent_id: canvasId,
});
},
[getInputs, sendFormMessage, canvasId],
[getInputs, sendFormMessage],
);
const isWaitting = useMemo(() => {
const isWaiting = useMemo(() => {
const temp = derivedMessages?.some((message, i) => {
const flag =
message.role === MessageType.Assistant &&
@@ -53,7 +48,7 @@ const useAwaitCompentData = (props: IAwaitCompentData) => {
});
return temp;
}, [derivedMessages]);
return { getInputs, buildInputList, handleOk, isWaitting };
return { getInputs, buildInputList, handleOk, isWaiting };
};
export { useAwaitCompentData };
export { useAwaitComponentData };

View File

@@ -11,7 +11,7 @@ import { cn } from '@/lib/utils';
import i18n, { changeLanguageAsync } from '@/locales/config';
import DebugContent from '@/pages/agent/debug-content';
import { useCacheChatLog } from '@/pages/agent/hooks/use-cache-chat-log';
import { useAwaitCompentData } from '@/pages/agent/hooks/use-chat-logic';
import { useAwaitComponentData } from '@/pages/agent/hooks/use-chat-logic';
import { buildMessageUuidWithRole } from '@/utils/chat';
import { isEmpty } from 'lodash';
import React, { forwardRef, useCallback } from 'react';
@@ -64,10 +64,9 @@ const ChatContainer = () => {
resetSession,
} = useSendNextSharedMessage(addEventList);
const { buildInputList, handleOk, isWaitting } = useAwaitCompentData({
const { buildInputList, handleOk, isWaiting } = useAwaitComponentData({
derivedMessages,
sendFormMessage,
canvasId: conversationId as string,
});
const sendDisabled = useSendButtonDisabled(value);
@@ -191,8 +190,8 @@ const ChatContainer = () => {
<NextMessageInput
isShared
value={value}
disabled={hasError || isWaitting}
sendDisabled={sendDisabled || isWaitting}
disabled={hasError || isWaiting}
sendDisabled={sendDisabled || isWaiting}
resize="vertical"
conversationId={conversationId}
onInputChange={handleInputChange}
@@ -200,7 +199,7 @@ const ChatContainer = () => {
sendLoading={sendLoading}
stopOutputMessage={stopOutputMessage}
onUpload={handleUploadFile}
isUploading={loading || isWaitting}
isUploading={loading || isWaiting}
></NextMessageInput>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import { MessageType } from '@/constants/chat';
import { IEventList, MessageEventType } from '@/hooks/use-send-message';
import { IMessage, IReference } from '@/interfaces/database/chat';
import { isEmpty } from 'lodash';
@@ -18,3 +19,36 @@ export const buildAgentMessageItemReference = (
return reference ?? { doc_aggs: [], chunks: [], total: 0 };
};
/**
* Determines whether the message should be split into two separate entries:
* one for the assistant's answer and one for the user input prompt.
*
* A split is needed when all of the following are true:
* 1. The event list contains a `MessageEnd` event.
* 2. The event list contains a `UserInputs` event.
* 3. The `MessageEnd` event occurs before the `UserInputs` event.
* 4. There is actual message content (`content` is truthy).
*
* @param eventList - The list of SSE events received from the server.
* @param content - The assistant's message content extracted from the events.
* @returns `true` if the message should be split, otherwise `false`.
*/
export function shouldSplitMessage(
eventList: IEventList,
content?: string,
): boolean {
const messageEndIndex = eventList.findIndex(
(x) => x.event === MessageEventType.MessageEnd,
);
const userInputsIndex = eventList.findIndex(
(x) => x.event === MessageEventType.UserInputs,
);
return (
messageEndIndex !== -1 &&
userInputsIndex !== -1 &&
messageEndIndex < userInputsIndex &&
!!content
);
}