### 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
| 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, | ||||
| 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; | ||||
| 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; | ||||
| : styles.messageUserText | : 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> | </div> | ||||
| {isAssistant && referenceDocumentList.length > 0 && ( | {isAssistant && referenceDocumentList.length > 0 && ( | ||||
| <List | <List |
| 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; | ||||
| }); | }); |
| 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'; | ||||
| 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; | ||||
| } | } | ||||
| 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>; |
| prompt?: string; | prompt?: string; | ||||
| id?: string; | id?: string; | ||||
| audio_binary?: string; | audio_binary?: string; | ||||
| data?: any; | |||||
| } | } | ||||
| export interface IReferenceChunk { | export interface IReferenceChunk { | ||||
| prompt?: string; | prompt?: string; | ||||
| id?: string; | id?: string; | ||||
| audio_binary?: string; | audio_binary?: string; | ||||
| data?: any; | |||||
| } | } | ||||
| export interface Docagg { | export interface Docagg { |
| 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'; | ||||
| // 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> | ||||
| {t(`flow.begin`)} | {t(`flow.begin`)} | ||||
| </div> | </div> | ||||
| </section> | </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 ( | return ( | ||||
| <Flex | |||||
| <div | |||||
| key={idx} | key={idx} | ||||
| align="center" | |||||
| gap={6} | |||||
| className={styles.conditionBlock} | |||||
| className={cn(styles.conditionBlock, 'flex gap-1.5 items-center')} | |||||
| > | > | ||||
| <Icon className="size-4" /> | <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> | </NodeWrapper> | ||||
| ); | ); | ||||
| } | } |
| 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 { | ||||
| derivedMessages, | derivedMessages, | ||||
| reference, | reference, | ||||
| stopOutputMessage, | stopOutputMessage, | ||||
| send, | |||||
| } = useSendNextMessage(); | } = useSendNextMessage(); | ||||
| const { visible, hideModal, documentId, selectedChunk, clickDocumentButton } = | const { visible, hideModal, documentId, selectedChunk, clickDocumentButton } = | ||||
| index={i} | index={i} | ||||
| showLikeButton={false} | showLikeButton={false} | ||||
| sendLoading={sendLoading} | sendLoading={sendLoading} | ||||
| ></MessageItem> | |||||
| > | |||||
| <InputForm send={send} message={message}></InputForm> | |||||
| </MessageItem> | |||||
| ); | ); | ||||
| })} | })} | ||||
| </Spin> | </Spin> |
| 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, | ||||
| }; | }; | ||||
| } | } | ||||
| 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); | ||||
| 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]); | ||||
| ref, | ref, | ||||
| removeMessageById, | removeMessageById, | ||||
| stopOutputMessage, | stopOutputMessage, | ||||
| send, | |||||
| }; | }; | ||||
| }; | }; |
| '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> | |||||
| ); | |||||
| } |
| 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, { | |||||
| shouldDirty: true, | |||||
| shouldTouch: true, | |||||
| }); | |||||
| form.setValue('inputs', nextQuery); | |||||
| hideModal(); | hideModal(); | ||||
| }, | }, | ||||
| 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], | ||||
| ); | ); |
| const updateNodeForm = useGraphStore((state) => state.updateNodeForm); | const updateNodeForm = useGraphStore((state) => state.updateNodeForm); | ||||
| useEffect(() => { | useEffect(() => { | ||||
| if (id && form?.formState.isDirty) { | |||||
| values = form?.getValues(); | |||||
| if (id) { | |||||
| values = form?.getValues() || {}; | |||||
| const nextValues = { | const nextValues = { | ||||
| ...values, | ...values, |
| 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; |
| const updateNodeForm = useGraphStore((state) => state.updateNodeForm); | const updateNodeForm = useGraphStore((state) => state.updateNodeForm); | ||||
| useEffect(() => { | 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 = { | const nextValues = { | ||||
| ...values, | ...values, |