### What problem does this PR solve? Feat: Add note node #3221 ### Type of change - [x] New Feature (non-breaking change which adds functionality)tags/v0.20.0
| @@ -70,6 +70,7 @@ | |||
| "mammoth": "^1.7.2", | |||
| "next-themes": "^0.4.6", | |||
| "openai-speech-stream-player": "^1.0.8", | |||
| "pptx-preview": "^1.0.5", | |||
| "rc-tween-one": "^3.0.6", | |||
| "react-copy-to-clipboard": "^5.1.0", | |||
| "react-dropzone": "^14.3.5", | |||
| @@ -15819,6 +15820,22 @@ | |||
| "node": ">=4" | |||
| } | |||
| }, | |||
| "node_modules/echarts": { | |||
| "version": "5.6.0", | |||
| "resolved": "https://registry.npmmirror.com/echarts/-/echarts-5.6.0.tgz", | |||
| "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", | |||
| "license": "Apache-2.0", | |||
| "dependencies": { | |||
| "tslib": "2.3.0", | |||
| "zrender": "5.6.1" | |||
| } | |||
| }, | |||
| "node_modules/echarts/node_modules/tslib": { | |||
| "version": "2.3.0", | |||
| "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz", | |||
| "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", | |||
| "license": "0BSD" | |||
| }, | |||
| "node_modules/ee-first": { | |||
| "version": "1.1.1", | |||
| "resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz", | |||
| @@ -26241,6 +26258,32 @@ | |||
| "resolved": "https://registry.npmmirror.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", | |||
| "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" | |||
| }, | |||
| "node_modules/pptx-preview": { | |||
| "version": "1.0.5", | |||
| "resolved": "https://registry.npmmirror.com/pptx-preview/-/pptx-preview-1.0.5.tgz", | |||
| "integrity": "sha512-4SafvnLUpwpAY9pHHTHzzU77DANTnxZQgnLK51g3qqv0CMSOAV6f9SVc9ANYXJ0+vyTwakt780xY4s/mbRO/KQ==", | |||
| "license": "ISC", | |||
| "dependencies": { | |||
| "echarts": "^5.5.1", | |||
| "jszip": "^3.10.1", | |||
| "lodash": "^4.17.21", | |||
| "tslib": "^2.7.0", | |||
| "uuid": "^10.0.0" | |||
| } | |||
| }, | |||
| "node_modules/pptx-preview/node_modules/uuid": { | |||
| "version": "10.0.0", | |||
| "resolved": "https://registry.npmmirror.com/uuid/-/uuid-10.0.0.tgz", | |||
| "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", | |||
| "funding": [ | |||
| "https://github.com/sponsors/broofa", | |||
| "https://github.com/sponsors/ctavan" | |||
| ], | |||
| "license": "MIT", | |||
| "bin": { | |||
| "uuid": "dist/bin/uuid" | |||
| } | |||
| }, | |||
| "node_modules/prelude-ls": { | |||
| "version": "1.2.1", | |||
| "resolved": "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", | |||
| @@ -33925,6 +33968,21 @@ | |||
| "url": "https://github.com/sponsors/colinhacks" | |||
| } | |||
| }, | |||
| "node_modules/zrender": { | |||
| "version": "5.6.1", | |||
| "resolved": "https://registry.npmmirror.com/zrender/-/zrender-5.6.1.tgz", | |||
| "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==", | |||
| "license": "BSD-3-Clause", | |||
| "dependencies": { | |||
| "tslib": "2.3.0" | |||
| } | |||
| }, | |||
| "node_modules/zrender/node_modules/tslib": { | |||
| "version": "2.3.0", | |||
| "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz", | |||
| "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", | |||
| "license": "0BSD" | |||
| }, | |||
| "node_modules/zustand": { | |||
| "version": "4.5.2", | |||
| "resolved": "https://registry.npmmirror.com/zustand/-/zustand-4.5.2.tgz", | |||
| @@ -318,7 +318,7 @@ export const useFetchMessageTrace = () => { | |||
| refetchOnWindowFocus: false, | |||
| gcTime: 0, | |||
| enabled: !!id && !!messageId, | |||
| refetchInterval: 2000, | |||
| refetchInterval: 3000, | |||
| queryFn: async () => { | |||
| const { data } = await flowService.trace({ | |||
| canvas_id: id, | |||
| @@ -1,11 +1,21 @@ | |||
| import { | |||
| Tooltip, | |||
| TooltipContent, | |||
| TooltipTrigger, | |||
| } from '@/components/ui/tooltip'; | |||
| import { cn } from '@/lib/utils'; | |||
| import { | |||
| Background, | |||
| ConnectionMode, | |||
| ControlButton, | |||
| Controls, | |||
| NodeTypes, | |||
| ReactFlow, | |||
| } from '@xyflow/react'; | |||
| import '@xyflow/react/dist/style.css'; | |||
| import { useEffect } from 'react'; | |||
| import { NotebookPen } from 'lucide-react'; | |||
| import { useCallback, useEffect } from 'react'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| import { ChatSheet } from '../chat/chat-sheet'; | |||
| import { | |||
| AgentChatContext, | |||
| @@ -21,6 +31,7 @@ import { | |||
| import { useAddNode } from '../hooks/use-add-node'; | |||
| import { useBeforeDelete } from '../hooks/use-before-delete'; | |||
| import { useCacheChatLog } from '../hooks/use-cache-chat-log'; | |||
| import { useMoveNote } from '../hooks/use-move-note'; | |||
| import { useShowDrawer, useShowLogSheet } from '../hooks/use-show-drawer'; | |||
| import { LogSheet } from '../log-sheet'; | |||
| import RunSheet from '../run-sheet'; | |||
| @@ -77,6 +88,7 @@ interface IProps { | |||
| } | |||
| function AgentCanvas({ drawerVisible, hideDrawer }: IProps) { | |||
| const { t } = useTranslation(); | |||
| const { | |||
| nodes, | |||
| edges, | |||
| @@ -94,7 +106,6 @@ function AgentCanvas({ drawerVisible, hideDrawer }: IProps) { | |||
| const { | |||
| onNodeClick, | |||
| onPaneClick, | |||
| clickedNode, | |||
| formDrawerVisible, | |||
| hideFormDrawer, | |||
| @@ -124,7 +135,17 @@ function AgentCanvas({ drawerVisible, hideDrawer }: IProps) { | |||
| const { handleBeforeDelete } = useBeforeDelete(); | |||
| const { addCanvasNode } = useAddNode(reactFlowInstance); | |||
| const { addCanvasNode, addNoteNode } = useAddNode(reactFlowInstance); | |||
| const { ref, showImage, hideImage, imgVisible, mouse } = useMoveNote(); | |||
| const onPaneClick = useCallback(() => { | |||
| hideFormDrawer(); | |||
| if (imgVisible) { | |||
| addNoteNode(mouse); | |||
| hideImage(); | |||
| } | |||
| }, [addNoteNode, hideFormDrawer, hideImage, imgVisible, mouse]); | |||
| useEffect(() => { | |||
| if (!chatVisible) { | |||
| @@ -176,6 +197,7 @@ function AgentCanvas({ drawerVisible, hideDrawer }: IProps) { | |||
| onEdgeMouseEnter={onEdgeMouseEnter} | |||
| onEdgeMouseLeave={onEdgeMouseLeave} | |||
| className="h-full" | |||
| colorMode="dark" | |||
| defaultEdgeOptions={{ | |||
| type: 'buttonEdge', | |||
| markerEnd: 'logo', | |||
| @@ -189,8 +211,22 @@ function AgentCanvas({ drawerVisible, hideDrawer }: IProps) { | |||
| onBeforeDelete={handleBeforeDelete} | |||
| > | |||
| <Background /> | |||
| <Controls position={'bottom-center'} orientation="horizontal"> | |||
| <ControlButton> | |||
| <Tooltip> | |||
| <TooltipTrigger asChild> | |||
| <NotebookPen className="!fill-none" onClick={showImage} /> | |||
| </TooltipTrigger> | |||
| <TooltipContent>{t('flow.note')}</TooltipContent> | |||
| </Tooltip> | |||
| </ControlButton> | |||
| </Controls> | |||
| </ReactFlow> | |||
| </AgentInstanceContext.Provider> | |||
| <NotebookPen | |||
| className={cn('hidden absolute size-6', { block: imgVisible })} | |||
| ref={ref} | |||
| ></NotebookPen> | |||
| {formDrawerVisible && ( | |||
| <AgentInstanceContext.Provider value={{ addCanvasNode }}> | |||
| <FormSheet | |||
| @@ -12,41 +12,9 @@ import { RightHandleStyle } from './handle-icon'; | |||
| import styles from './index.less'; | |||
| import NodeHeader from './node-header'; | |||
| import { NodeWrapper } from './node-wrapper'; | |||
| import { ResizeIcon, controlStyle } from './resize-icon'; | |||
| import { ToolBar } from './toolbar'; | |||
| function ResizeIcon() { | |||
| return ( | |||
| <svg | |||
| xmlns="http://www.w3.org/2000/svg" | |||
| width="20" | |||
| height="20" | |||
| viewBox="0 0 24 24" | |||
| strokeWidth="2" | |||
| stroke="#5025f9" | |||
| fill="none" | |||
| strokeLinecap="round" | |||
| strokeLinejoin="round" | |||
| style={{ | |||
| position: 'absolute', | |||
| right: 5, | |||
| bottom: 5, | |||
| }} | |||
| > | |||
| <path stroke="none" d="M0 0h24v24H0z" fill="none" /> | |||
| <polyline points="16 20 20 20 20 16" /> | |||
| <line x1="14" y1="14" x2="20" y2="20" /> | |||
| <polyline points="8 4 4 4 4 8" /> | |||
| <line x1="4" y1="4" x2="10" y2="10" /> | |||
| </svg> | |||
| ); | |||
| } | |||
| const controlStyle = { | |||
| background: 'transparent', | |||
| border: 'none', | |||
| cursor: 'nwse-resize', | |||
| }; | |||
| export function InnerIterationNode({ | |||
| id, | |||
| data, | |||
| @@ -1,90 +0,0 @@ | |||
| import { NodeProps, NodeResizeControl } from '@xyflow/react'; | |||
| import { Flex, Form, Input } from 'antd'; | |||
| import classNames from 'classnames'; | |||
| import NodeDropdown from './dropdown'; | |||
| import SvgIcon from '@/components/svg-icon'; | |||
| import { useTheme } from '@/components/theme-provider'; | |||
| import { INoteNode } from '@/interfaces/database/flow'; | |||
| import { memo, useEffect } from 'react'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| import { useHandleNodeNameChange } from '../../hooks'; | |||
| import { useHandleFormValuesChange } from '../../hooks/use-watch-form-change'; | |||
| import styles from './index.less'; | |||
| const { TextArea } = Input; | |||
| const controlStyle = { | |||
| background: 'transparent', | |||
| border: 'none', | |||
| }; | |||
| function NoteNode({ data, id }: NodeProps<INoteNode>) { | |||
| const { t } = useTranslation(); | |||
| const [form] = Form.useForm(); | |||
| const { theme } = useTheme(); | |||
| const { name, handleNameBlur, handleNameChange } = useHandleNodeNameChange({ | |||
| id, | |||
| data, | |||
| }); | |||
| const { handleValuesChange } = useHandleFormValuesChange(id); | |||
| useEffect(() => { | |||
| form.setFieldsValue(data?.form); | |||
| }, [form, data?.form]); | |||
| return ( | |||
| <> | |||
| <NodeResizeControl style={controlStyle} minWidth={190} minHeight={128}> | |||
| <SvgIcon | |||
| name="resize" | |||
| width={12} | |||
| style={{ | |||
| position: 'absolute', | |||
| right: 5, | |||
| bottom: 5, | |||
| cursor: 'nwse-resize', | |||
| }} | |||
| ></SvgIcon> | |||
| </NodeResizeControl> | |||
| <section | |||
| className={classNames( | |||
| styles.noteNode, | |||
| theme === 'dark' ? styles.dark : '', | |||
| )} | |||
| > | |||
| <Flex | |||
| justify={'space-between'} | |||
| className={classNames('note-drag-handle')} | |||
| align="center" | |||
| gap={6} | |||
| > | |||
| <SvgIcon name="note" width={14}></SvgIcon> | |||
| <Input | |||
| value={name ?? t('flow.note')} | |||
| onBlur={handleNameBlur} | |||
| onChange={handleNameChange} | |||
| className={styles.noteName} | |||
| ></Input> | |||
| <NodeDropdown id={id} label={data.label}></NodeDropdown> | |||
| </Flex> | |||
| <Form | |||
| onValuesChange={handleValuesChange} | |||
| form={form} | |||
| className={styles.noteForm} | |||
| > | |||
| <Form.Item name="text" noStyle> | |||
| <TextArea | |||
| rows={3} | |||
| placeholder={t('flow.notePlaceholder')} | |||
| className={styles.noteTextarea} | |||
| /> | |||
| </Form.Item> | |||
| </Form> | |||
| </section> | |||
| </> | |||
| ); | |||
| } | |||
| export default memo(NoteNode); | |||
| @@ -0,0 +1,76 @@ | |||
| import { NodeProps, NodeResizeControl } from '@xyflow/react'; | |||
| import { | |||
| Form, | |||
| FormControl, | |||
| FormField, | |||
| FormItem, | |||
| FormMessage, | |||
| } from '@/components/ui/form'; | |||
| import { Input } from '@/components/ui/input'; | |||
| import { Textarea } from '@/components/ui/textarea'; | |||
| import { INoteNode } from '@/interfaces/database/flow'; | |||
| import { zodResolver } from '@hookform/resolvers/zod'; | |||
| import { NotebookPen } from 'lucide-react'; | |||
| import { memo } from 'react'; | |||
| import { useForm } from 'react-hook-form'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| import { z } from 'zod'; | |||
| import { NodeWrapper } from '../node-wrapper'; | |||
| import { ResizeIcon, controlStyle } from '../resize-icon'; | |||
| import { useChangeName, useWatchFormChange } from './use-watch-change'; | |||
| const FormSchema = z.object({ | |||
| text: z.string(), | |||
| }); | |||
| function NoteNode({ data, id }: NodeProps<INoteNode>) { | |||
| const { t } = useTranslation(); | |||
| const form = useForm<z.infer<typeof FormSchema>>({ | |||
| resolver: zodResolver(FormSchema), | |||
| defaultValues: data.form, | |||
| }); | |||
| const { handleChangeName } = useChangeName(id); | |||
| useWatchFormChange(id, form); | |||
| return ( | |||
| <NodeWrapper className="p-0 w-full h-full flex flex-col rounded-md "> | |||
| <NodeResizeControl minWidth={190} minHeight={128} style={controlStyle}> | |||
| <ResizeIcon /> | |||
| </NodeResizeControl> | |||
| <section className="px-1 py-2 flex gap-2 bg-background-highlight items-center note-drag-handle rounded-s-md"> | |||
| <NotebookPen className="size-4" /> | |||
| <Input | |||
| type="text" | |||
| defaultValue={data.name} | |||
| onChange={handleChangeName} | |||
| ></Input> | |||
| </section> | |||
| <Form {...form}> | |||
| <form className="flex-1"> | |||
| <FormField | |||
| control={form.control} | |||
| name="text" | |||
| render={({ field }) => ( | |||
| <FormItem className="h-full"> | |||
| <FormControl> | |||
| <Textarea | |||
| placeholder={t('flow.notePlaceholder')} | |||
| className="resize-none rounded-none p-1 h-full overflow-auto bg-background-header-bar" | |||
| {...field} | |||
| /> | |||
| </FormControl> | |||
| <FormMessage /> | |||
| </FormItem> | |||
| )} | |||
| /> | |||
| </form> | |||
| </Form> | |||
| </NodeWrapper> | |||
| ); | |||
| } | |||
| export default memo(NoteNode); | |||
| @@ -0,0 +1,31 @@ | |||
| import useGraphStore from '@/pages/agent/store'; | |||
| import { useCallback, useEffect } from 'react'; | |||
| import { UseFormReturn, useWatch } from 'react-hook-form'; | |||
| export function useWatchFormChange(id?: string, form?: UseFormReturn<any>) { | |||
| let values = useWatch({ control: form?.control }); | |||
| const updateNodeForm = useGraphStore((state) => state.updateNodeForm); | |||
| useEffect(() => { | |||
| // Manually triggered form updates are synchronized to the canvas | |||
| if (id) { | |||
| values = form?.getValues() || {}; | |||
| let nextValues: any = values; | |||
| updateNodeForm(id, nextValues); | |||
| } | |||
| }, [id, updateNodeForm, values]); | |||
| } | |||
| export function useChangeName(id: string) { | |||
| const updateNodeName = useGraphStore((state) => state.updateNodeName); | |||
| const handleChangeName = useCallback( | |||
| (e: React.ChangeEvent<HTMLInputElement>) => { | |||
| updateNodeName(id, e.target.value.trim()); | |||
| }, | |||
| [id, updateNodeName], | |||
| ); | |||
| return { handleChangeName }; | |||
| } | |||
| @@ -0,0 +1,32 @@ | |||
| export function ResizeIcon() { | |||
| return ( | |||
| <svg | |||
| xmlns="http://www.w3.org/2000/svg" | |||
| width="20" | |||
| height="20" | |||
| viewBox="0 0 24 24" | |||
| strokeWidth="2" | |||
| stroke="#5025f9" | |||
| fill="none" | |||
| strokeLinecap="round" | |||
| strokeLinejoin="round" | |||
| style={{ | |||
| position: 'absolute', | |||
| right: 5, | |||
| bottom: 5, | |||
| }} | |||
| > | |||
| <path stroke="none" d="M0 0h24v24H0z" fill="none" /> | |||
| <polyline points="16 20 20 20 20 16" /> | |||
| <line x1="14" y1="14" x2="20" y2="20" /> | |||
| <polyline points="8 4 4 4 4 8" /> | |||
| <line x1="4" y1="4" x2="10" y2="10" /> | |||
| </svg> | |||
| ); | |||
| } | |||
| export const controlStyle = { | |||
| background: 'transparent', | |||
| border: 'none', | |||
| cursor: 'nwse-resize', | |||
| }; | |||
| @@ -85,6 +85,10 @@ function findInputFromList(eventList: IEventList) { | |||
| }; | |||
| } | |||
| export function getLatestError(eventList: IEventList) { | |||
| return get(eventList.at(-1), 'data.outputs._ERROR'); | |||
| } | |||
| const useGetBeginNodePrologue = () => { | |||
| const getNode = useGraphStore((state) => state.getNode); | |||
| @@ -159,7 +163,7 @@ export const useSendNextMessage = () => { | |||
| const inputAnswer = findInputFromList(answerList); | |||
| if (answerList.length > 0) { | |||
| addNewestOneAnswer({ | |||
| answer: content, | |||
| answer: content || getLatestError(answerList), | |||
| id: id, | |||
| ...inputAnswer, | |||
| }); | |||
| @@ -76,7 +76,7 @@ export function FileUploadDirectUpload({ | |||
| onUpload={onUpload} | |||
| onFileReject={onFileReject} | |||
| maxFiles={1} | |||
| className="w-full max-w-md" | |||
| className="w-full" | |||
| multiple={false} | |||
| > | |||
| <FileUploadDropzone> | |||
| @@ -269,9 +269,13 @@ function useResizeIterationNode() { | |||
| return { resizeIterationNode }; | |||
| } | |||
| type CanvasMouseEvent = Pick< | |||
| React.MouseEvent<HTMLElement>, | |||
| 'clientX' | 'clientY' | |||
| >; | |||
| export function useAddNode(reactFlowInstance?: ReactFlowInstance<any, any>) { | |||
| const { edges, nodes, addEdge, addNode, getNode, updateNode } = useGraphStore( | |||
| const { edges, nodes, addEdge, addNode, getNode } = useGraphStore( | |||
| (state) => state, | |||
| ); | |||
| const getNodeName = useGetNodeName(); | |||
| @@ -290,7 +294,7 @@ export function useAddNode(reactFlowInstance?: ReactFlowInstance<any, any>) { | |||
| position: Position.Right, | |||
| }, | |||
| ) => | |||
| (event?: React.MouseEvent<HTMLElement>) => { | |||
| (event?: CanvasMouseEvent) => { | |||
| const nodeId = params.nodeId; | |||
| const node = getNode(nodeId); | |||
| @@ -303,7 +307,7 @@ export function useAddNode(reactFlowInstance?: ReactFlowInstance<any, any>) { | |||
| y: event?.clientY || 0, | |||
| }); | |||
| if (params.position === Position.Right) { | |||
| if (params.position === Position.Right && type !== Operator.Note) { | |||
| position = calculateNewlyBackChildPosition(nodeId, params.id); | |||
| } | |||
| @@ -420,9 +424,16 @@ export function useAddNode(reactFlowInstance?: ReactFlowInstance<any, any>) { | |||
| initializeOperatorParams, | |||
| nodes, | |||
| reactFlowInstance, | |||
| updateNode, | |||
| resizeIterationNode, | |||
| ], | |||
| ); | |||
| return { addCanvasNode }; | |||
| const addNoteNode = useCallback( | |||
| (e: CanvasMouseEvent) => { | |||
| addCanvasNode(Operator.Note)(e); | |||
| }, | |||
| [addCanvasNode], | |||
| ); | |||
| return { addCanvasNode, addNoteNode }; | |||
| } | |||
| @@ -1,4 +1,8 @@ | |||
| import { IEventList, MessageEventType } from '@/hooks/use-send-message'; | |||
| import { | |||
| IEventList, | |||
| INodeEvent, | |||
| MessageEventType, | |||
| } from '@/hooks/use-send-message'; | |||
| import { useCallback, useMemo, useState } from 'react'; | |||
| export const ExcludeTypes = [ | |||
| @@ -41,11 +45,13 @@ export function useCacheChatLog() { | |||
| }, []); | |||
| const currentEventListWithoutMessage = useMemo(() => { | |||
| return eventList.filter( | |||
| const list = eventList.filter( | |||
| (x) => | |||
| x.message_id === currentMessageId && | |||
| ExcludeTypes.every((y) => y !== x.event), | |||
| ); | |||
| return list as INodeEvent[]; | |||
| }, [currentMessageId, eventList]); | |||
| return { | |||
| @@ -0,0 +1,35 @@ | |||
| import { useMouse } from 'ahooks'; | |||
| import { useCallback, useEffect, useRef, useState } from 'react'; | |||
| export function useMoveNote() { | |||
| const ref = useRef<SVGSVGElement>(null); | |||
| const mouse = useMouse(); | |||
| const [imgVisible, setImgVisible] = useState(false); | |||
| const toggleVisible = useCallback((visible: boolean) => { | |||
| setImgVisible(visible); | |||
| }, []); | |||
| const showImage = useCallback(() => { | |||
| toggleVisible(true); | |||
| }, [toggleVisible]); | |||
| const hideImage = useCallback(() => { | |||
| toggleVisible(false); | |||
| }, [toggleVisible]); | |||
| useEffect(() => { | |||
| if (ref.current) { | |||
| ref.current.style.top = `${mouse.clientY - 70}px`; | |||
| ref.current.style.left = `${mouse.clientX + 10}px`; | |||
| } | |||
| }, [mouse.clientX, mouse.clientY]); | |||
| return { | |||
| ref, | |||
| showImage, | |||
| hideImage, | |||
| mouse, | |||
| imgVisible, | |||
| }; | |||
| } | |||
| @@ -1,154 +1,6 @@ | |||
| import { CodeTemplateStrMap, ProgrammingLanguage } from '@/constants/agent'; | |||
| import { settledModelVariableMap } from '@/constants/knowledge'; | |||
| import { omit } from 'lodash'; | |||
| import { useCallback, useEffect } from 'react'; | |||
| import { useEffect } from 'react'; | |||
| import { UseFormReturn, useWatch } from 'react-hook-form'; | |||
| import { Operator } from '../constant'; | |||
| import useGraphStore from '../store'; | |||
| import { buildCategorizeObjectFromList, convertToStringArray } from '../utils'; | |||
| export const useHandleFormValuesChange = ( | |||
| operatorName: Operator, | |||
| id?: string, | |||
| form?: UseFormReturn, | |||
| ) => { | |||
| const updateNodeForm = useGraphStore((state) => state.updateNodeForm); | |||
| const handleValuesChange = useCallback( | |||
| (changedValues: any, values: any) => { | |||
| let nextValues: any = values; | |||
| // Fixed the issue that the related form value does not change after selecting the freedom field of the model | |||
| if ( | |||
| Object.keys(changedValues).length === 1 && | |||
| 'parameter' in changedValues && | |||
| changedValues['parameter'] in settledModelVariableMap | |||
| ) { | |||
| nextValues = { | |||
| ...values, | |||
| ...settledModelVariableMap[ | |||
| changedValues['parameter'] as keyof typeof settledModelVariableMap | |||
| ], | |||
| }; | |||
| } | |||
| if (id) { | |||
| updateNodeForm(id, nextValues); | |||
| } | |||
| }, | |||
| [updateNodeForm, id], | |||
| ); | |||
| let values = useWatch({ control: form?.control }); | |||
| // console.log('🚀 ~ x:', values); | |||
| useEffect(() => { | |||
| // Manually triggered form updates are synchronized to the canvas | |||
| if (id && form?.formState.isDirty) { | |||
| values = form?.getValues(); | |||
| let nextValues: any = values; | |||
| // run(id, nextValues); | |||
| const categoryDescriptionRegex = /items\.\d+\.name/g; | |||
| if (operatorName === Operator.Categorize) { | |||
| console.log('🚀 ~ useEffect ~ values:', values); | |||
| const categoryDescription = Array.isArray(values.items) | |||
| ? buildCategorizeObjectFromList(values.items) | |||
| : {}; | |||
| if (categoryDescription) { | |||
| nextValues = { | |||
| ...omit(values, 'items'), | |||
| category_description: categoryDescription, | |||
| }; | |||
| } | |||
| } else if (operatorName === Operator.Message) { | |||
| nextValues = { | |||
| ...values, | |||
| content: convertToStringArray(values.content), | |||
| }; | |||
| } | |||
| updateNodeForm(id, nextValues); | |||
| } | |||
| }, [form?.formState.isDirty, id, operatorName, updateNodeForm, values]); | |||
| // useEffect(() => { | |||
| // form?.subscribe({ | |||
| // formState: { values: true }, | |||
| // callback: ({ values }) => { | |||
| // // console.info('subscribe', values); | |||
| // }, | |||
| // }); | |||
| // }, [form]); | |||
| return { handleValuesChange }; | |||
| useEffect(() => { | |||
| const subscription = form?.watch((value, { name, type, values }) => { | |||
| if (id && name) { | |||
| let nextValues: any = value; | |||
| // Fixed the issue that the related form value does not change after selecting the freedom field of the model | |||
| if ( | |||
| name === 'parameter' && | |||
| value['parameter'] in settledModelVariableMap | |||
| ) { | |||
| nextValues = { | |||
| ...value, | |||
| ...settledModelVariableMap[ | |||
| value['parameter'] as keyof typeof settledModelVariableMap | |||
| ], | |||
| }; | |||
| } | |||
| const categoryDescriptionRegex = /items\.\d+\.name/g; | |||
| if ( | |||
| operatorName === Operator.Categorize && | |||
| categoryDescriptionRegex.test(name) | |||
| ) { | |||
| nextValues = { | |||
| ...omit(value, 'items'), | |||
| category_description: buildCategorizeObjectFromList(value.items), | |||
| }; | |||
| } | |||
| if ( | |||
| operatorName === Operator.Code && | |||
| type === 'change' && | |||
| name === 'lang' | |||
| ) { | |||
| nextValues = { | |||
| ...value, | |||
| script: CodeTemplateStrMap[value.lang as ProgrammingLanguage], | |||
| }; | |||
| } | |||
| if (operatorName === Operator.Message) { | |||
| nextValues = { | |||
| ...value, | |||
| content: convertToStringArray(value.content), | |||
| }; | |||
| } | |||
| // Manually triggered form updates are synchronized to the canvas | |||
| if (form.formState.isDirty) { | |||
| console.log( | |||
| '🚀 ~ useEffect ~ value:', | |||
| name, | |||
| type, | |||
| values, | |||
| operatorName, | |||
| ); | |||
| // run(id, nextValues); | |||
| updateNodeForm(id, nextValues); | |||
| } | |||
| } | |||
| }); | |||
| return () => subscription?.unsubscribe(); | |||
| }, [form, form?.watch, id, operatorName, updateNodeForm]); | |||
| return { handleValuesChange }; | |||
| }; | |||
| export function useWatchFormChange(id?: string, form?: UseFormReturn) { | |||
| let values = useWatch({ control: form?.control }); | |||
| @@ -19,11 +19,15 @@ import { | |||
| SheetTitle, | |||
| } from '@/components/ui/sheet'; | |||
| import { useFetchMessageTrace } from '@/hooks/use-agent-request'; | |||
| import { ILogEvent, MessageEventType } from '@/hooks/use-send-message'; | |||
| import { | |||
| INodeData, | |||
| INodeEvent, | |||
| MessageEventType, | |||
| } from '@/hooks/use-send-message'; | |||
| import { IModalProps } from '@/interfaces/common'; | |||
| import { ITraceData } from '@/interfaces/database/agent'; | |||
| import { cn } from '@/lib/utils'; | |||
| import { isEmpty } from 'lodash'; | |||
| import { get } from 'lodash'; | |||
| import { BellElectric, NotebookText } from 'lucide-react'; | |||
| import { useCallback, useEffect, useMemo } from 'react'; | |||
| import JsonView from 'react18-json-view'; | |||
| @@ -57,25 +61,19 @@ function JsonViewer({ | |||
| ); | |||
| } | |||
| function concatData( | |||
| firstRecord: Record<string, any> | Array<Record<string, any>>, | |||
| nextRecord: Record<string, any> | Array<Record<string, any>>, | |||
| function getInputsOrOutputs( | |||
| nodeEventList: INodeData[], | |||
| field: 'inputs' | 'outputs', | |||
| ) { | |||
| let result: Array<Record<string, any>> = []; | |||
| if (!isEmpty(firstRecord)) { | |||
| result = result.concat(firstRecord); | |||
| } | |||
| const inputsOrOutputs = nodeEventList.map((x) => get(x, field, {})); | |||
| if (!isEmpty(nextRecord)) { | |||
| result = result.concat(nextRecord); | |||
| if (inputsOrOutputs.length < 2) { | |||
| return inputsOrOutputs[0] || {}; | |||
| } | |||
| return isEmpty(result) ? {} : result; | |||
| return inputsOrOutputs; | |||
| } | |||
| type EventWithIndex = { startNodeIdx: number } & ILogEvent; | |||
| export function LogSheet({ | |||
| hideModal, | |||
| currentEventListWithoutMessage, | |||
| @@ -96,68 +94,58 @@ export function LogSheet({ | |||
| [getNode], | |||
| ); | |||
| const startedNodeList = useMemo(() => { | |||
| const duplicateList = currentEventListWithoutMessage.filter( | |||
| (x) => x.event === MessageEventType.NodeStarted, | |||
| ) as INodeEvent[]; | |||
| // Remove duplicate nodes | |||
| return duplicateList.reduce<Array<INodeEvent>>((pre, cur) => { | |||
| if (pre.every((x) => x.data.component_id !== cur.data.component_id)) { | |||
| pre.push(cur); | |||
| } | |||
| return pre; | |||
| }, []); | |||
| }, [currentEventListWithoutMessage]); | |||
| const hasTrace = useCallback( | |||
| (componentId: string) => { | |||
| if (Array.isArray(traceData)) { | |||
| return traceData?.some((x) => x.component_id === componentId); | |||
| } | |||
| return false; | |||
| }, | |||
| [traceData], | |||
| ); | |||
| const filterTrace = useCallback( | |||
| (componentId: string) => { | |||
| return traceData | |||
| const trace = traceData | |||
| ?.filter((x) => x.component_id === componentId) | |||
| .reduce<ITraceData['trace']>((pre, cur) => { | |||
| pre.push(...cur.trace); | |||
| return pre; | |||
| }, []); | |||
| return Array.isArray(trace) ? trace : {}; | |||
| }, | |||
| [traceData], | |||
| ); | |||
| // Look up to find the nearest start component id and concatenate the finish and log data into one | |||
| const finishedNodeList = useMemo(() => { | |||
| return currentEventListWithoutMessage.filter( | |||
| (x) => | |||
| x.event === MessageEventType.NodeFinished || | |||
| x.event === MessageEventType.NodeLogs, | |||
| ) as ILogEvent[]; | |||
| }, [currentEventListWithoutMessage]); | |||
| const nextList = useMemo(() => { | |||
| return finishedNodeList.reduce<Array<EventWithIndex>>((pre, cur) => { | |||
| const startNodeIdx = ( | |||
| currentEventListWithoutMessage as Array<ILogEvent> | |||
| ).findLastIndex( | |||
| (x) => | |||
| x.data.component_id === cur.data.component_id && | |||
| x.event === MessageEventType.NodeStarted, | |||
| ); | |||
| const item = pre.find((x) => x.startNodeIdx === startNodeIdx); | |||
| const { inputs = {}, outputs = {} } = cur.data; | |||
| if (item) { | |||
| const { inputs: inputList, outputs: outputList } = item.data; | |||
| item.data = { | |||
| ...item.data, | |||
| inputs: concatData(inputList, inputs), | |||
| outputs: concatData(outputList, outputs), | |||
| }; | |||
| } else { | |||
| pre.push({ | |||
| ...cur, | |||
| startNodeIdx, | |||
| }); | |||
| } | |||
| return pre; | |||
| }, []); | |||
| }, [currentEventListWithoutMessage, finishedNodeList]); | |||
| const filterFinishedNodeList = useCallback( | |||
| (componentId: string) => { | |||
| const nodeEventList = currentEventListWithoutMessage | |||
| .filter( | |||
| (x) => | |||
| x.event === MessageEventType.NodeFinished && | |||
| (x.data as INodeData)?.component_id === componentId, | |||
| ) | |||
| .map((x) => x.data); | |||
| return nodeEventList; | |||
| }, | |||
| [currentEventListWithoutMessage], | |||
| ); | |||
| return ( | |||
| <Sheet open onOpenChange={hideModal} modal={false}> | |||
| @@ -170,76 +158,75 @@ export function LogSheet({ | |||
| </SheetHeader> | |||
| <section className="max-h-[82vh] overflow-auto mt-6"> | |||
| <Timeline> | |||
| {nextList.map((x, idx) => ( | |||
| <TimelineItem | |||
| key={idx} | |||
| step={idx} | |||
| className="group-data-[orientation=vertical]/timeline:ms-10 group-data-[orientation=vertical]/timeline:not-last:pb-8" | |||
| > | |||
| <TimelineHeader> | |||
| <TimelineSeparator className="group-data-[orientation=vertical]/timeline:-left-7 group-data-[orientation=vertical]/timeline:h-[calc(100%-1.5rem-0.25rem)] group-data-[orientation=vertical]/timeline:translate-y-6.5 top-6 bg-background-checked" /> | |||
| <TimelineIndicator className="bg-primary/10 group-data-completed/timeline-item:bg-primary group-data-completed/timeline-item:text-primary-foreground flex size-6 items-center justify-center border-none group-data-[orientation=vertical]/timeline:-left-7"> | |||
| <BellElectric className="size-5" /> | |||
| {/* <img | |||
| src={item.image} | |||
| alt={item.title} | |||
| className="size-6 rounded-full" | |||
| /> */} | |||
| </TimelineIndicator> | |||
| </TimelineHeader> | |||
| <TimelineContent className="text-foreground rounded-lg border mb-5"> | |||
| <section key={idx}> | |||
| <Accordion | |||
| type="single" | |||
| collapsible | |||
| className="bg-background-card px-3" | |||
| > | |||
| <AccordionItem value={idx.toString()}> | |||
| <AccordionTrigger> | |||
| <div className="flex gap-2 items-center"> | |||
| <span>{getNodeName(x.data?.component_id)}</span> | |||
| <span className="text-text-sub-title text-xs"> | |||
| {x.data.elapsed_time?.toString().slice(0, 6)} | |||
| </span> | |||
| <span | |||
| className={cn( | |||
| 'border-background -end-1 -top-1 size-2 rounded-full border-2 bg-dot-green', | |||
| { 'text-dot-green': x.data.error === null }, | |||
| { 'text-dot-red': x.data.error !== null }, | |||
| {startedNodeList.map((x, idx) => { | |||
| const nodeDataList = filterFinishedNodeList(x.data.component_id); | |||
| const inputs = getInputsOrOutputs(nodeDataList, 'inputs'); | |||
| const outputs = getInputsOrOutputs(nodeDataList, 'outputs'); | |||
| return ( | |||
| <TimelineItem | |||
| key={idx} | |||
| step={idx} | |||
| className="group-data-[orientation=vertical]/timeline:ms-10 group-data-[orientation=vertical]/timeline:not-last:pb-8" | |||
| > | |||
| <TimelineHeader> | |||
| <TimelineSeparator className="group-data-[orientation=vertical]/timeline:-left-7 group-data-[orientation=vertical]/timeline:h-[calc(100%-1.5rem-0.25rem)] group-data-[orientation=vertical]/timeline:translate-y-6.5 top-6 bg-background-checked" /> | |||
| <TimelineIndicator className="bg-primary/10 group-data-completed/timeline-item:bg-primary group-data-completed/timeline-item:text-primary-foreground flex size-6 items-center justify-center border-none group-data-[orientation=vertical]/timeline:-left-7"> | |||
| <BellElectric className="size-5" /> | |||
| </TimelineIndicator> | |||
| </TimelineHeader> | |||
| <TimelineContent className="text-foreground rounded-lg border mb-5"> | |||
| <section key={idx}> | |||
| <Accordion | |||
| type="single" | |||
| collapsible | |||
| className="bg-background-card px-3" | |||
| > | |||
| <AccordionItem value={idx.toString()}> | |||
| <AccordionTrigger> | |||
| <div className="flex gap-2 items-center"> | |||
| <span>{getNodeName(x.data?.component_id)}</span> | |||
| <span className="text-text-sub-title text-xs"> | |||
| {x.data.elapsed_time?.toString().slice(0, 6)} | |||
| </span> | |||
| <span | |||
| className={cn( | |||
| 'border-background -end-1 -top-1 size-2 rounded-full border-2 bg-dot-green', | |||
| { 'text-dot-green': x.data.error === null }, | |||
| { 'text-dot-red': x.data.error !== null }, | |||
| )} | |||
| > | |||
| <span className="sr-only">Online</span> | |||
| </span> | |||
| </div> | |||
| </AccordionTrigger> | |||
| <AccordionContent> | |||
| <div className="space-y-2"> | |||
| <JsonViewer | |||
| data={inputs} | |||
| title="Input" | |||
| ></JsonViewer> | |||
| {hasTrace(x.data.component_id) && ( | |||
| <JsonViewer | |||
| data={filterTrace(x.data.component_id)} | |||
| title={'Trace'} | |||
| ></JsonViewer> | |||
| )} | |||
| > | |||
| <span className="sr-only">Online</span> | |||
| </span> | |||
| </div> | |||
| </AccordionTrigger> | |||
| <AccordionContent> | |||
| <div className="space-y-2"> | |||
| <JsonViewer | |||
| data={x.data.inputs} | |||
| title="Input" | |||
| ></JsonViewer> | |||
| {hasTrace(x.data.component_id) && ( | |||
| <JsonViewer | |||
| data={filterTrace(x.data.component_id) ?? {}} | |||
| title={'Trace'} | |||
| data={outputs} | |||
| title={'Output'} | |||
| ></JsonViewer> | |||
| )} | |||
| <JsonViewer | |||
| data={x.data.outputs} | |||
| title={'Output'} | |||
| ></JsonViewer> | |||
| </div> | |||
| </AccordionContent> | |||
| </AccordionItem> | |||
| </Accordion> | |||
| </section> | |||
| {/* <TimelineDate className="mt-1 mb-0">{item.date}</TimelineDate> */} | |||
| </TimelineContent> | |||
| </TimelineItem> | |||
| ))} | |||
| </div> | |||
| </AccordionContent> | |||
| </AccordionItem> | |||
| </Accordion> | |||
| </section> | |||
| </TimelineContent> | |||
| </TimelineItem> | |||
| ); | |||
| })} | |||
| </Timeline> | |||
| </section> | |||
| </SheetContent> | |||
| @@ -21,6 +21,7 @@ export const OperatorIconMap = { | |||
| [Operator.Agent]: 'agent-ai', | |||
| [Operator.UserFillUp]: 'await', | |||
| [Operator.StringTransform]: 'a-textprocessing', | |||
| [Operator.Note]: 'notebook-pen', | |||
| // [Operator.Relevant]: BranchesOutlined, | |||
| // [Operator.RewriteQuestion]: FormOutlined, | |||
| // [Operator.KeywordExtract]: KeywordIcon, | |||
| @@ -47,7 +47,7 @@ export const currentReg = /\[ID:(\d+)\]/g; | |||
| // To be compatible with the old index matching mode | |||
| export const replaceTextByOldReg = (text: string) => { | |||
| return text.replace(oldReg, (substring: string) => { | |||
| return text?.replace(oldReg, (substring: string) => { | |||
| return `[ID:${substring.slice(2, -2)}]`; | |||
| }); | |||
| }; | |||