### What problem does this PR solve? feat: Automatically save agent page data #3301 ### Type of change - [x] New Feature (non-breaking change which adds functionality)tags/v0.14.0
| howUseId: 'How to use agent ID?', | howUseId: 'How to use agent ID?', | ||||
| content: 'Content', | content: 'Content', | ||||
| operationResults: 'Operation Results', | operationResults: 'Operation Results', | ||||
| autosave: 'Automatically saved', | |||||
| }, | }, | ||||
| footer: { | footer: { | ||||
| profile: 'All rights reserved @ React', | profile: 'All rights reserved @ React', |
| howUseId: '如何使用Agent ID?', | howUseId: '如何使用Agent ID?', | ||||
| content: '內容', | content: '內容', | ||||
| operationResults: '運行結果', | operationResults: '運行結果', | ||||
| autosave: '已自動儲存', | |||||
| }, | }, | ||||
| footer: { | footer: { | ||||
| profile: '“保留所有權利 @ react”', | profile: '“保留所有權利 @ react”', |
| howUseId: '如何使用Agent ID?', | howUseId: '如何使用Agent ID?', | ||||
| content: '内容', | content: '内容', | ||||
| operationResults: '运行结果', | operationResults: '运行结果', | ||||
| autosave: '已自动保存', | |||||
| }, | }, | ||||
| footer: { | footer: { | ||||
| profile: 'All rights reserved @ React', | profile: 'All rights reserved @ React', |
| onSelectionChange={onSelectionChange} | onSelectionChange={onSelectionChange} | ||||
| nodeOrigin={[0.5, 0]} | nodeOrigin={[0.5, 0]} | ||||
| isValidConnection={isValidConnection} | isValidConnection={isValidConnection} | ||||
| onChangeCapture={(...params) => { | |||||
| console.info('onChangeCapture:', ...params); | |||||
| }} | |||||
| onChange={(...params) => { | onChange={(...params) => { | ||||
| console.info('params:', ...params); | console.info('params:', ...params); | ||||
| }} | }} | ||||
| }, | }, | ||||
| }} | }} | ||||
| deleteKeyCode={['Delete', 'Backspace']} | deleteKeyCode={['Delete', 'Backspace']} | ||||
| onPaste={(...params) => { | |||||
| console.info('onPaste:', ...params); | |||||
| }} | |||||
| onPasteCapture={(...params) => { | |||||
| console.info('onPasteCapture:', ...params); | |||||
| }} | |||||
| onCopy={(...params) => { | |||||
| console.info('onCopy:', ...params); | |||||
| }} | |||||
| onCopyCapture={(...params) => { | |||||
| console.info('onCopyCapture:', ...params); | |||||
| }} | |||||
| > | > | ||||
| <Background /> | <Background /> | ||||
| <Controls /> | <Controls /> |
| import { RightHandleStyle } from './handle-icon'; | import { RightHandleStyle } from './handle-icon'; | ||||
| import { get } from 'lodash'; | import { get } from 'lodash'; | ||||
| import { useReplaceIdWithName } from '../../hooks'; | |||||
| import styles from './index.less'; | import styles from './index.less'; | ||||
| import NodeHeader from './node-header'; | import NodeHeader from './node-header'; | ||||
| export function RelevantNode({ id, data, selected }: NodeProps<NodeData>) { | export function RelevantNode({ id, data, selected }: NodeProps<NodeData>) { | ||||
| const yes = get(data, 'form.yes'); | const yes = get(data, 'form.yes'); | ||||
| const no = get(data, 'form.no'); | const no = get(data, 'form.no'); | ||||
| const replaceIdWithName = useReplaceIdWithName(); | |||||
| return ( | return ( | ||||
| <section | <section | ||||
| className={classNames(styles.logicNode, { | className={classNames(styles.logicNode, { | ||||
| <Flex vertical gap={10}> | <Flex vertical gap={10}> | ||||
| <Flex vertical> | <Flex vertical> | ||||
| <div className={styles.relevantLabel}>Yes</div> | <div className={styles.relevantLabel}>Yes</div> | ||||
| <div className={styles.nodeText}>{yes}</div> | |||||
| <div className={styles.nodeText}>{replaceIdWithName(yes)}</div> | |||||
| </Flex> | </Flex> | ||||
| <Flex vertical> | <Flex vertical> | ||||
| <div className={styles.relevantLabel}>No</div> | <div className={styles.relevantLabel}>No</div> | ||||
| <div className={styles.nodeText}>{no}</div> | |||||
| <div className={styles.nodeText}>{replaceIdWithName(no)}</div> | |||||
| </Flex> | </Flex> | ||||
| </Flex> | </Flex> | ||||
| </section> | </section> |
| .flowHeader { | .flowHeader { | ||||
| padding: 0 20px; | |||||
| padding: 10px 20px; | |||||
| } | } |
| import { Button, Flex, Space } from 'antd'; | import { Button, Flex, Space } from 'antd'; | ||||
| import { Link, useParams } from 'umi'; | import { Link, useParams } from 'umi'; | ||||
| import FlowIdModal from '../flow-id-modal'; | import FlowIdModal from '../flow-id-modal'; | ||||
| import { useSaveGraph, useSaveGraphBeforeOpeningDebugDrawer } from '../hooks'; | |||||
| import { | |||||
| useSaveGraph, | |||||
| useSaveGraphBeforeOpeningDebugDrawer, | |||||
| useWatchAgentChange, | |||||
| } from '../hooks'; | |||||
| import styles from './index.less'; | import styles from './index.less'; | ||||
| interface IProps { | interface IProps { | ||||
| const { | const { | ||||
| visible: overviewVisible, | visible: overviewVisible, | ||||
| hideModal: hideOverviewModal, | hideModal: hideOverviewModal, | ||||
| showModal: showOverviewModal, | |||||
| // showModal: showOverviewModal, | |||||
| } = useSetModalState(); | } = useSetModalState(); | ||||
| const { visible, hideModal, showModal } = useSetModalState(); | const { visible, hideModal, showModal } = useSetModalState(); | ||||
| const { id } = useParams(); | const { id } = useParams(); | ||||
| const time = useWatchAgentChange(); | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| <Link to={`/flow`}> | <Link to={`/flow`}> | ||||
| <ArrowLeftOutlined /> | <ArrowLeftOutlined /> | ||||
| </Link> | </Link> | ||||
| <h3>{data.title}</h3> | |||||
| <div className="flex flex-col"> | |||||
| <span className="font-semibold text-[18px]">{data.title}</span> | |||||
| <span className="font-normal text-sm">已自动保存 {time}</span> | |||||
| </div> | |||||
| </Space> | </Space> | ||||
| <Space size={'large'}> | <Space size={'large'}> | ||||
| <Button onClick={handleRun}> | <Button onClick={handleRun}> |
| import { useFetchModelId, useSendMessageWithSse } from '@/hooks/logic-hooks'; | import { useFetchModelId, useSendMessageWithSse } from '@/hooks/logic-hooks'; | ||||
| import { Variable } from '@/interfaces/database/chat'; | import { Variable } from '@/interfaces/database/chat'; | ||||
| import api from '@/utils/api'; | import api from '@/utils/api'; | ||||
| import { useDebounceEffect } from 'ahooks'; | |||||
| import { FormInstance, message } from 'antd'; | import { FormInstance, message } from 'antd'; | ||||
| import dayjs from 'dayjs'; | |||||
| import { humanId } from 'human-id'; | import { humanId } from 'human-id'; | ||||
| import { lowerFirst } from 'lodash'; | import { lowerFirst } from 'lodash'; | ||||
| import trim from 'lodash/trim'; | import trim from 'lodash/trim'; | ||||
| return handleRun; | return handleRun; | ||||
| }; | }; | ||||
| export const useReplaceIdWithText = (output: unknown) => { | |||||
| export const useReplaceIdWithName = () => { | |||||
| const getNode = useGraphStore((state) => state.getNode); | const getNode = useGraphStore((state) => state.getNode); | ||||
| const getNameById = (id?: string) => { | |||||
| return getNode(id)?.data.name; | |||||
| }; | |||||
| const replaceIdWithName = useCallback( | |||||
| (id?: string) => { | |||||
| return getNode(id)?.data.name; | |||||
| }, | |||||
| [getNode], | |||||
| ); | |||||
| return replaceIdWithName; | |||||
| }; | |||||
| export const useReplaceIdWithText = (output: unknown) => { | |||||
| const getNameById = useReplaceIdWithName(); | |||||
| return { | return { | ||||
| replacedOutput: replaceIdWithText(output, getNameById), | replacedOutput: replaceIdWithText(output, getNameById), | ||||
| ); | ); | ||||
| useEffect(() => { | useEffect(() => { | ||||
| console.info('xxx'); | |||||
| nodes.forEach((node) => { | nodes.forEach((node) => { | ||||
| const currentNode = getNode(node.id); | const currentNode = getNode(node.id); | ||||
| const form = currentNode?.data.form ?? {}; | const form = currentNode?.data.form ?? {}; | ||||
| }; | }; | ||||
| }, [onPasteCapture]); | }, [onPasteCapture]); | ||||
| }; | }; | ||||
| export const useWatchAgentChange = () => { | |||||
| const [time, setTime] = useState<string>(); | |||||
| const nodes = useGraphStore((state) => state.nodes); | |||||
| const edges = useGraphStore((state) => state.edges); | |||||
| const { saveGraph } = useSaveGraph(); | |||||
| const { data: flowDetail } = useFetchFlow(); | |||||
| const setSaveTime = useCallback((updateTime: number) => { | |||||
| setTime(dayjs(updateTime).format('YYYY-MM-DD HH:mm:ss')); | |||||
| }, []); | |||||
| useEffect(() => { | |||||
| setSaveTime(flowDetail?.update_time); | |||||
| }, [flowDetail, setSaveTime]); | |||||
| const saveAgent = useCallback(async () => { | |||||
| const ret = await saveGraph(); | |||||
| setSaveTime(ret.data.update_time); | |||||
| }, [saveGraph, setSaveTime]); | |||||
| useDebounceEffect( | |||||
| () => { | |||||
| saveAgent(); | |||||
| }, | |||||
| [nodes, edges], | |||||
| { | |||||
| wait: 1000 * 20, | |||||
| }, | |||||
| ); | |||||
| return time; | |||||
| }; |
| import { Operator, SwitchElseTo } from './constant'; | import { Operator, SwitchElseTo } from './constant'; | ||||
| import { NodeData } from './interface'; | import { NodeData } from './interface'; | ||||
| import { | import { | ||||
| duplicateNodeForm, | |||||
| generateNodeNamesWithIncreasingIndex, | generateNodeNamesWithIncreasingIndex, | ||||
| getNodeDragHandle, | getNodeDragHandle, | ||||
| getOperatorIndex, | getOperatorIndex, | ||||
| addNode({ | addNode({ | ||||
| ...(node || {}), | ...(node || {}), | ||||
| data: { ...(node?.data ?? {}), name: generateNodeName(name) }, | |||||
| data: { | |||||
| ...duplicateNodeForm(node?.data), | |||||
| name: generateNodeName(name), | |||||
| }, | |||||
| selected: false, | selected: false, | ||||
| dragging: false, | dragging: false, | ||||
| id: `${node?.data?.label}:${humanId()}`, | id: `${node?.data?.label}:${humanId()}`, |
| return `${name}_${index}`; | return `${name}_${index}`; | ||||
| }; | }; | ||||
| export const duplicateNodeForm = (nodeData?: NodeData) => { | |||||
| const form: Record<string, any> = { ...(nodeData?.form ?? {}) }; | |||||
| // Delete the downstream node corresponding to the to field of the Categorize operator | |||||
| if (nodeData?.label === Operator.Categorize) { | |||||
| form.category_description = Object.keys(form.category_description).reduce< | |||||
| Record<string, Record<string, any>> | |||||
| >((pre, cur) => { | |||||
| pre[cur] = { | |||||
| ...form.category_description[cur], | |||||
| to: undefined, | |||||
| }; | |||||
| return pre; | |||||
| }, {}); | |||||
| } | |||||
| // Delete the downstream nodes corresponding to the yes and no fields of the Relevant operator | |||||
| if (nodeData?.label === Operator.Relevant) { | |||||
| form.yes = undefined; | |||||
| form.no = undefined; | |||||
| } | |||||
| return { | |||||
| ...(nodeData ?? {}), | |||||
| form, | |||||
| }; | |||||
| }; |