### 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)tags/v0.20.0
| @@ -3,7 +3,14 @@ import { MessageType } from '@/constants/chat'; | |||
| import { useSetModalState } from '@/hooks/common-hooks'; | |||
| import { IReference, IReferenceChunk } from '@/interfaces/database/chat'; | |||
| import classNames from 'classnames'; | |||
| import { memo, useCallback, useEffect, useMemo, useState } from 'react'; | |||
| import { | |||
| PropsWithChildren, | |||
| memo, | |||
| useCallback, | |||
| useEffect, | |||
| useMemo, | |||
| useState, | |||
| } from 'react'; | |||
| import { | |||
| useFetchDocumentInfosByIds, | |||
| @@ -23,7 +30,10 @@ import styles from './index.less'; | |||
| const { Text } = Typography; | |||
| interface IProps extends Partial<IRemoveMessageById>, IRegenerateMessage { | |||
| interface IProps | |||
| extends Partial<IRemoveMessageById>, | |||
| IRegenerateMessage, | |||
| PropsWithChildren { | |||
| item: IMessage; | |||
| reference: IReference; | |||
| loading?: boolean; | |||
| @@ -52,6 +62,7 @@ const MessageItem = ({ | |||
| showLikeButton = true, | |||
| showLoudspeaker = true, | |||
| visibleAvatar = true, | |||
| children, | |||
| }: IProps) => { | |||
| const { theme } = useTheme(); | |||
| const isAssistant = item.role === MessageType.Assistant; | |||
| @@ -152,12 +163,16 @@ const MessageItem = ({ | |||
| : styles.messageUserText | |||
| } | |||
| > | |||
| <MarkdownContent | |||
| loading={loading} | |||
| content={item.content} | |||
| reference={reference} | |||
| clickDocumentButton={clickDocumentButton} | |||
| ></MarkdownContent> | |||
| {item.data ? ( | |||
| children | |||
| ) : ( | |||
| <MarkdownContent | |||
| loading={loading} | |||
| content={item.content} | |||
| reference={reference} | |||
| clickDocumentButton={clickDocumentButton} | |||
| ></MarkdownContent> | |||
| )} | |||
| </div> | |||
| {isAssistant && referenceDocumentList.length > 0 && ( | |||
| <List | |||
| @@ -377,7 +377,7 @@ export const useSelectDerivedMessages = () => { | |||
| if (idx !== -1) { | |||
| return pre.map((x) => { | |||
| if (x.id === answer.id) { | |||
| return { ...x, content: answer.answer }; | |||
| return { ...x, ...answer, content: answer.answer }; | |||
| } | |||
| return x; | |||
| }); | |||
| @@ -1,4 +1,5 @@ | |||
| import { Authorization } from '@/constants/authorization'; | |||
| import { BeginQuery } from '@/pages/agent/interface'; | |||
| import api from '@/utils/api'; | |||
| import { getAuthorization } from '@/utils/authorization-util'; | |||
| import { EventSourceParserStream } from 'eventsource-parser/stream'; | |||
| @@ -31,6 +32,12 @@ export interface INodeData { | |||
| created_at: number; | |||
| } | |||
| export interface IInputData { | |||
| content: string; | |||
| inputs: Record<string, BeginQuery>; | |||
| tips: string; | |||
| } | |||
| export interface IMessageData { | |||
| content: string; | |||
| } | |||
| @@ -39,6 +46,8 @@ export type INodeEvent = IAnswerEvent<INodeData>; | |||
| export type IMessageEvent = IAnswerEvent<IMessageData>; | |||
| export type IInputEvent = IAnswerEvent<IInputData>; | |||
| export type IChatEvent = INodeEvent | IMessageEvent; | |||
| export type IEventList = Array<IChatEvent>; | |||
| @@ -73,6 +73,7 @@ export interface Message { | |||
| prompt?: string; | |||
| id?: string; | |||
| audio_binary?: string; | |||
| data?: any; | |||
| } | |||
| export interface IReferenceChunk { | |||
| @@ -102,6 +103,7 @@ export interface IAnswer { | |||
| prompt?: string; | |||
| id?: string; | |||
| audio_binary?: string; | |||
| data?: any; | |||
| } | |||
| export interface Docagg { | |||
| @@ -1,6 +1,6 @@ | |||
| import { IBeginNode } from '@/interfaces/database/flow'; | |||
| import { cn } from '@/lib/utils'; | |||
| import { NodeProps, Position } from '@xyflow/react'; | |||
| import { Flex } from 'antd'; | |||
| import get from 'lodash/get'; | |||
| import { memo } from 'react'; | |||
| 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 | |||
| function InnerBeginNode({ data, id }: NodeProps<IBeginNode>) { | |||
| const { t } = useTranslation(); | |||
| const query: BeginQuery[] = get(data, 'form.query', []); | |||
| const inputs: Record<string, BeginQuery> = get(data, 'form.inputs', {}); | |||
| return ( | |||
| <NodeWrapper> | |||
| @@ -39,24 +39,22 @@ function InnerBeginNode({ data, id }: NodeProps<IBeginNode>) { | |||
| {t(`flow.begin`)} | |||
| </div> | |||
| </section> | |||
| <Flex gap={8} vertical className={styles.generateParameters}> | |||
| {query.map((x, idx) => { | |||
| const Icon = BeginQueryTypeIconMap[x.type as BeginQueryType]; | |||
| <section className={cn(styles.generateParameters, 'flex gap-2 flex-col')}> | |||
| {Object.entries(inputs).map(([key, val], idx) => { | |||
| const Icon = BeginQueryTypeIconMap[val.type as BeginQueryType]; | |||
| return ( | |||
| <Flex | |||
| <div | |||
| key={idx} | |||
| align="center" | |||
| gap={6} | |||
| className={styles.conditionBlock} | |||
| className={cn(styles.conditionBlock, 'flex gap-1.5 items-center')} | |||
| > | |||
| <Icon className="size-4" /> | |||
| <label htmlFor="">{x.key}</label> | |||
| <span className={styles.parameterValue}>{x.name}</span> | |||
| <span className="flex-1">{x.optional ? 'Yes' : 'No'}</span> | |||
| </Flex> | |||
| <label htmlFor="">{key}</label> | |||
| <span className={styles.parameterValue}>{val.name}</span> | |||
| <span className="flex-1">{val.optional ? 'Yes' : 'No'}</span> | |||
| </div> | |||
| ); | |||
| })} | |||
| </Flex> | |||
| </section> | |||
| </NodeWrapper> | |||
| ); | |||
| } | |||
| @@ -12,6 +12,7 @@ import { useClickDrawer } from '@/components/pdf-drawer/hooks'; | |||
| import { useFetchAgent } from '@/hooks/use-agent-request'; | |||
| import { useFetchUserInfo } from '@/hooks/user-setting-hooks'; | |||
| import { buildMessageUuidWithRole } from '@/utils/chat'; | |||
| import { InputForm } from './input-form'; | |||
| const AgentChatBox = () => { | |||
| const { | |||
| @@ -24,6 +25,7 @@ const AgentChatBox = () => { | |||
| derivedMessages, | |||
| reference, | |||
| stopOutputMessage, | |||
| send, | |||
| } = useSendNextMessage(); | |||
| const { visible, hideModal, documentId, selectedChunk, clickDocumentButton } = | |||
| @@ -59,7 +61,9 @@ const AgentChatBox = () => { | |||
| index={i} | |||
| showLikeButton={false} | |||
| sendLoading={sendLoading} | |||
| ></MessageItem> | |||
| > | |||
| <InputForm send={send} message={message}></InputForm> | |||
| </MessageItem> | |||
| ); | |||
| })} | |||
| </Spin> | |||
| @@ -6,6 +6,7 @@ import { | |||
| import { useFetchAgent } from '@/hooks/use-agent-request'; | |||
| import { | |||
| IEventList, | |||
| IInputEvent, | |||
| IMessageEvent, | |||
| MessageEventType, | |||
| 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 getNode = useGraphStore((state) => state.getNode); | |||
| @@ -136,10 +152,12 @@ export const useSendNextMessage = () => { | |||
| useEffect(() => { | |||
| const { content, id } = findMessageFromList(answerList); | |||
| const inputAnswer = findInputFromList(answerList); | |||
| if (answerList.length > 0) { | |||
| addNewestOneAnswer({ | |||
| answer: content, | |||
| id: id, | |||
| ...inputAnswer, | |||
| }); | |||
| } | |||
| }, [answerList, addNewestOneAnswer]); | |||
| @@ -181,5 +199,6 @@ export const useSendNextMessage = () => { | |||
| ref, | |||
| removeMessageById, | |||
| stopOutputMessage, | |||
| send, | |||
| }; | |||
| }; | |||
| @@ -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> | |||
| ); | |||
| } | |||
| @@ -23,10 +23,7 @@ export const useEditQueryRecord = ({ | |||
| const nextQuery: BeginQuery[] = | |||
| index > -1 ? inputs.toSpliced(index, 1, record) : [...inputs, record]; | |||
| form.setValue('inputs', nextQuery, { | |||
| shouldDirty: true, | |||
| shouldTouch: true, | |||
| }); | |||
| form.setValue('inputs', nextQuery); | |||
| hideModal(); | |||
| }, | |||
| @@ -45,11 +42,11 @@ export const useEditQueryRecord = ({ | |||
| const handleDeleteRecord = useCallback( | |||
| (idx: number) => { | |||
| const inputs = form?.getValues('inputs') || []; | |||
| const nextQuery = inputs.filter( | |||
| const nextInputs = inputs.filter( | |||
| (item: BeginQuery, index: number) => index !== idx, | |||
| ); | |||
| form.setValue('inputs', nextQuery, { shouldDirty: true }); | |||
| form.setValue('inputs', nextInputs); | |||
| }, | |||
| [form], | |||
| ); | |||
| @@ -17,8 +17,8 @@ export function useWatchFormChange(id?: string, form?: UseFormReturn) { | |||
| const updateNodeForm = useGraphStore((state) => state.updateNodeForm); | |||
| useEffect(() => { | |||
| if (id && form?.formState.isDirty) { | |||
| values = form?.getValues(); | |||
| if (id) { | |||
| values = form?.getValues() || {}; | |||
| const nextValues = { | |||
| ...values, | |||
| @@ -3,7 +3,7 @@ import { UseFormReturn, useWatch } from 'react-hook-form'; | |||
| import useGraphStore from '../../store'; | |||
| import { OutputArray, OutputObject } from './interface'; | |||
| function transferToObject(list: OutputArray) { | |||
| export function transferToObject(list: OutputArray) { | |||
| return list.reduce<OutputObject>((pre, cur) => { | |||
| pre[cur.name] = { ref: cur.ref, type: cur.type }; | |||
| return pre; | |||
| @@ -17,8 +17,9 @@ export function useWatchFormChange(id?: string, form?: UseFormReturn) { | |||
| const updateNodeForm = useGraphStore((state) => state.updateNodeForm); | |||
| useEffect(() => { | |||
| if (id && form?.formState.isDirty) { | |||
| values = form?.getValues(); | |||
| // TODO: This should only be executed when the form changes | |||
| if (id) { | |||
| values = form?.getValues() || {}; | |||
| const nextValues = { | |||
| ...values, | |||