Feat: Allow users to enter text in the middle of a chat #3221 (#8569)

### What problem does this PR solve?

Feat: Allow users to enter text in the middle of a chat #3221
### Type of change


- [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
balibabu
2025-06-30 10:36:52 +08:00
committed by GitHub
parent aafeffa292
commit 356d1f3485
12 changed files with 166 additions and 35 deletions

View File

@ -3,7 +3,14 @@ import { MessageType } from '@/constants/chat';
import { useSetModalState } from '@/hooks/common-hooks'; import { useSetModalState } from '@/hooks/common-hooks';
import { IReference, IReferenceChunk } from '@/interfaces/database/chat'; import { IReference, IReferenceChunk } from '@/interfaces/database/chat';
import classNames from 'classnames'; import classNames from 'classnames';
import { memo, useCallback, useEffect, useMemo, useState } from 'react'; import {
PropsWithChildren,
memo,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { import {
useFetchDocumentInfosByIds, useFetchDocumentInfosByIds,
@ -23,7 +30,10 @@ import styles from './index.less';
const { Text } = Typography; const { Text } = Typography;
interface IProps extends Partial<IRemoveMessageById>, IRegenerateMessage { interface IProps
extends Partial<IRemoveMessageById>,
IRegenerateMessage,
PropsWithChildren {
item: IMessage; item: IMessage;
reference: IReference; reference: IReference;
loading?: boolean; loading?: boolean;
@ -52,6 +62,7 @@ const MessageItem = ({
showLikeButton = true, showLikeButton = true,
showLoudspeaker = true, showLoudspeaker = true,
visibleAvatar = true, visibleAvatar = true,
children,
}: IProps) => { }: IProps) => {
const { theme } = useTheme(); const { theme } = useTheme();
const isAssistant = item.role === MessageType.Assistant; const isAssistant = item.role === MessageType.Assistant;
@ -152,12 +163,16 @@ const MessageItem = ({
: styles.messageUserText : styles.messageUserText
} }
> >
<MarkdownContent {item.data ? (
loading={loading} children
content={item.content} ) : (
reference={reference} <MarkdownContent
clickDocumentButton={clickDocumentButton} loading={loading}
></MarkdownContent> content={item.content}
reference={reference}
clickDocumentButton={clickDocumentButton}
></MarkdownContent>
)}
</div> </div>
{isAssistant && referenceDocumentList.length > 0 && ( {isAssistant && referenceDocumentList.length > 0 && (
<List <List

View File

@ -377,7 +377,7 @@ export const useSelectDerivedMessages = () => {
if (idx !== -1) { if (idx !== -1) {
return pre.map((x) => { return pre.map((x) => {
if (x.id === answer.id) { if (x.id === answer.id) {
return { ...x, content: answer.answer }; return { ...x, ...answer, content: answer.answer };
} }
return x; return x;
}); });

View File

@ -1,4 +1,5 @@
import { Authorization } from '@/constants/authorization'; import { Authorization } from '@/constants/authorization';
import { BeginQuery } from '@/pages/agent/interface';
import api from '@/utils/api'; import api from '@/utils/api';
import { getAuthorization } from '@/utils/authorization-util'; import { getAuthorization } from '@/utils/authorization-util';
import { EventSourceParserStream } from 'eventsource-parser/stream'; import { EventSourceParserStream } from 'eventsource-parser/stream';
@ -31,6 +32,12 @@ export interface INodeData {
created_at: number; created_at: number;
} }
export interface IInputData {
content: string;
inputs: Record<string, BeginQuery>;
tips: string;
}
export interface IMessageData { export interface IMessageData {
content: string; content: string;
} }
@ -39,6 +46,8 @@ export type INodeEvent = IAnswerEvent<INodeData>;
export type IMessageEvent = IAnswerEvent<IMessageData>; export type IMessageEvent = IAnswerEvent<IMessageData>;
export type IInputEvent = IAnswerEvent<IInputData>;
export type IChatEvent = INodeEvent | IMessageEvent; export type IChatEvent = INodeEvent | IMessageEvent;
export type IEventList = Array<IChatEvent>; export type IEventList = Array<IChatEvent>;

View File

@ -73,6 +73,7 @@ export interface Message {
prompt?: string; prompt?: string;
id?: string; id?: string;
audio_binary?: string; audio_binary?: string;
data?: any;
} }
export interface IReferenceChunk { export interface IReferenceChunk {
@ -102,6 +103,7 @@ export interface IAnswer {
prompt?: string; prompt?: string;
id?: string; id?: string;
audio_binary?: string; audio_binary?: string;
data?: any;
} }
export interface Docagg { export interface Docagg {

View File

@ -1,6 +1,6 @@
import { IBeginNode } from '@/interfaces/database/flow'; import { IBeginNode } from '@/interfaces/database/flow';
import { cn } from '@/lib/utils';
import { NodeProps, Position } from '@xyflow/react'; import { NodeProps, Position } from '@xyflow/react';
import { Flex } from 'antd';
import get from 'lodash/get'; import get from 'lodash/get';
import { memo } from 'react'; import { memo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -20,7 +20,7 @@ import { NodeWrapper } from './node-wrapper';
// TODO: do not allow other nodes to connect to this node // TODO: do not allow other nodes to connect to this node
function InnerBeginNode({ data, id }: NodeProps<IBeginNode>) { function InnerBeginNode({ data, id }: NodeProps<IBeginNode>) {
const { t } = useTranslation(); const { t } = useTranslation();
const query: BeginQuery[] = get(data, 'form.query', []); const inputs: Record<string, BeginQuery> = get(data, 'form.inputs', {});
return ( return (
<NodeWrapper> <NodeWrapper>
@ -39,24 +39,22 @@ function InnerBeginNode({ data, id }: NodeProps<IBeginNode>) {
{t(`flow.begin`)} {t(`flow.begin`)}
</div> </div>
</section> </section>
<Flex gap={8} vertical className={styles.generateParameters}> <section className={cn(styles.generateParameters, 'flex gap-2 flex-col')}>
{query.map((x, idx) => { {Object.entries(inputs).map(([key, val], idx) => {
const Icon = BeginQueryTypeIconMap[x.type as BeginQueryType]; const Icon = BeginQueryTypeIconMap[val.type as BeginQueryType];
return ( return (
<Flex <div
key={idx} key={idx}
align="center" className={cn(styles.conditionBlock, 'flex gap-1.5 items-center')}
gap={6}
className={styles.conditionBlock}
> >
<Icon className="size-4" /> <Icon className="size-4" />
<label htmlFor="">{x.key}</label> <label htmlFor="">{key}</label>
<span className={styles.parameterValue}>{x.name}</span> <span className={styles.parameterValue}>{val.name}</span>
<span className="flex-1">{x.optional ? 'Yes' : 'No'}</span> <span className="flex-1">{val.optional ? 'Yes' : 'No'}</span>
</Flex> </div>
); );
})} })}
</Flex> </section>
</NodeWrapper> </NodeWrapper>
); );
} }

View File

@ -12,6 +12,7 @@ import { useClickDrawer } from '@/components/pdf-drawer/hooks';
import { useFetchAgent } from '@/hooks/use-agent-request'; import { useFetchAgent } from '@/hooks/use-agent-request';
import { useFetchUserInfo } from '@/hooks/user-setting-hooks'; import { useFetchUserInfo } from '@/hooks/user-setting-hooks';
import { buildMessageUuidWithRole } from '@/utils/chat'; import { buildMessageUuidWithRole } from '@/utils/chat';
import { InputForm } from './input-form';
const AgentChatBox = () => { const AgentChatBox = () => {
const { const {
@ -24,6 +25,7 @@ const AgentChatBox = () => {
derivedMessages, derivedMessages,
reference, reference,
stopOutputMessage, stopOutputMessage,
send,
} = useSendNextMessage(); } = useSendNextMessage();
const { visible, hideModal, documentId, selectedChunk, clickDocumentButton } = const { visible, hideModal, documentId, selectedChunk, clickDocumentButton } =
@ -59,7 +61,9 @@ const AgentChatBox = () => {
index={i} index={i}
showLikeButton={false} showLikeButton={false}
sendLoading={sendLoading} sendLoading={sendLoading}
></MessageItem> >
<InputForm send={send} message={message}></InputForm>
</MessageItem>
); );
})} })}
</Spin> </Spin>

View File

@ -6,6 +6,7 @@ import {
import { useFetchAgent } from '@/hooks/use-agent-request'; import { useFetchAgent } from '@/hooks/use-agent-request';
import { import {
IEventList, IEventList,
IInputEvent,
IMessageEvent, IMessageEvent,
MessageEventType, MessageEventType,
useSendMessageBySSE, useSendMessageBySSE,
@ -66,6 +67,21 @@ function findMessageFromList(eventList: IEventList) {
}; };
} }
function findInputFromList(eventList: IEventList) {
const inputEvent = eventList.find(
(x) => x.event === MessageEventType.UserInputs,
) as IInputEvent;
if (!inputEvent) {
return {};
}
return {
id: inputEvent?.message_id,
data: inputEvent?.data,
};
}
const useGetBeginNodePrologue = () => { const useGetBeginNodePrologue = () => {
const getNode = useGraphStore((state) => state.getNode); const getNode = useGraphStore((state) => state.getNode);
@ -136,10 +152,12 @@ export const useSendNextMessage = () => {
useEffect(() => { useEffect(() => {
const { content, id } = findMessageFromList(answerList); const { content, id } = findMessageFromList(answerList);
const inputAnswer = findInputFromList(answerList);
if (answerList.length > 0) { if (answerList.length > 0) {
addNewestOneAnswer({ addNewestOneAnswer({
answer: content, answer: content,
id: id, id: id,
...inputAnswer,
}); });
} }
}, [answerList, addNewestOneAnswer]); }, [answerList, addNewestOneAnswer]);
@ -181,5 +199,6 @@ export const useSendNextMessage = () => {
ref, ref,
removeMessageById, removeMessageById,
stopOutputMessage, stopOutputMessage,
send,
}; };
}; };

View File

@ -0,0 +1,86 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Message } from '@/interfaces/database/chat';
import { get } from 'lodash';
import { useParams } from 'umi';
import { useSendNextMessage } from './hooks';
const FormSchema = z.object({
username: z.string().min(2, {
message: 'Username must be at least 2 characters.',
}),
});
type InputFormProps = Pick<ReturnType<typeof useSendNextMessage>, 'send'> & {
message: Message;
};
export function InputForm({ send, message }: InputFormProps) {
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
username: '',
},
});
const { id: canvasId } = useParams();
function onSubmit(data: z.infer<typeof FormSchema>) {
const inputs = get(message, 'data.inputs', {});
const nextInputs = Object.entries(inputs).reduce((pre, [key, val]) => {
pre[key] = { ...val, value: data.username };
return pre;
}, {});
send({
inputs: nextInputs,
id: canvasId,
});
toast('You submitted the following values', {
description: (
<pre className="mt-2 w-[320px] rounded-md bg-neutral-950 p-4">
<code className="text-white">{JSON.stringify(data, null, 2)}</code>
</pre>
),
});
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="w-2/3 space-y-6">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="shadcn" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
);
}

View File

@ -23,10 +23,7 @@ export const useEditQueryRecord = ({
const nextQuery: BeginQuery[] = const nextQuery: BeginQuery[] =
index > -1 ? inputs.toSpliced(index, 1, record) : [...inputs, record]; index > -1 ? inputs.toSpliced(index, 1, record) : [...inputs, record];
form.setValue('inputs', nextQuery, { form.setValue('inputs', nextQuery);
shouldDirty: true,
shouldTouch: true,
});
hideModal(); hideModal();
}, },
@ -45,11 +42,11 @@ export const useEditQueryRecord = ({
const handleDeleteRecord = useCallback( const handleDeleteRecord = useCallback(
(idx: number) => { (idx: number) => {
const inputs = form?.getValues('inputs') || []; const inputs = form?.getValues('inputs') || [];
const nextQuery = inputs.filter( const nextInputs = inputs.filter(
(item: BeginQuery, index: number) => index !== idx, (item: BeginQuery, index: number) => index !== idx,
); );
form.setValue('inputs', nextQuery, { shouldDirty: true }); form.setValue('inputs', nextInputs);
}, },
[form], [form],
); );

View File

@ -17,8 +17,8 @@ export function useWatchFormChange(id?: string, form?: UseFormReturn) {
const updateNodeForm = useGraphStore((state) => state.updateNodeForm); const updateNodeForm = useGraphStore((state) => state.updateNodeForm);
useEffect(() => { useEffect(() => {
if (id && form?.formState.isDirty) { if (id) {
values = form?.getValues(); values = form?.getValues() || {};
const nextValues = { const nextValues = {
...values, ...values,

View File

@ -3,7 +3,7 @@ import { UseFormReturn, useWatch } from 'react-hook-form';
import useGraphStore from '../../store'; import useGraphStore from '../../store';
import { OutputArray, OutputObject } from './interface'; import { OutputArray, OutputObject } from './interface';
function transferToObject(list: OutputArray) { export function transferToObject(list: OutputArray) {
return list.reduce<OutputObject>((pre, cur) => { return list.reduce<OutputObject>((pre, cur) => {
pre[cur.name] = { ref: cur.ref, type: cur.type }; pre[cur.name] = { ref: cur.ref, type: cur.type };
return pre; return pre;

View File

@ -17,8 +17,9 @@ export function useWatchFormChange(id?: string, form?: UseFormReturn) {
const updateNodeForm = useGraphStore((state) => state.updateNodeForm); const updateNodeForm = useGraphStore((state) => state.updateNodeForm);
useEffect(() => { useEffect(() => {
if (id && form?.formState.isDirty) { // TODO: This should only be executed when the form changes
values = form?.getValues(); if (id) {
values = form?.getValues() || {};
const nextValues = { const nextValues = {
...values, ...values,