Просмотр исходного кода

feat: Add RunDrawer #3355 (#3434)

### What problem does this PR solve?

feat: Translation test run form #3355
feat: Wrap QueryTable with Collapse #3355
feat: If the required fields are not filled in, the submit button will
be grayed out. #3355
feat: Add RunDrawer #3355

### Type of change


- [x] New Feature (non-breaking change which adds functionality)
tags/v0.14.0
balibabu 11 месяцев назад
Родитель
Сommit
e0659a4f0e
Аккаунт пользователя с таким Email не найден

+ 26
- 1
web/src/hooks/document-hooks.ts Просмотреть файл

import i18n from '@/locales/config'; import i18n from '@/locales/config';
import chatService from '@/services/chat-service'; import chatService from '@/services/chat-service';
import kbService from '@/services/knowledge-service'; import kbService from '@/services/knowledge-service';
import { api_host } from '@/utils/api';
import api, { api_host } from '@/utils/api';
import { buildChunkHighlights } from '@/utils/document-util'; import { buildChunkHighlights } from '@/utils/document-util';
import { post } from '@/utils/request';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { UploadFile, message } from 'antd'; import { UploadFile, message } from 'antd';
import { get } from 'lodash'; import { get } from 'lodash';


return { data, loading, uploadAndParseDocument: mutateAsync }; return { data, loading, uploadAndParseDocument: mutateAsync };
}; };

export const useParseDocument = () => {
const {
data,
isPending: loading,
mutateAsync,
} = useMutation({
mutationKey: ['parseDocument'],
mutationFn: async (url: string) => {
try {
const data = await post(api.parse, { url });
if (data?.code === 0) {
message.success(i18n.t('message.uploaded'));
}
return data;
} catch (error) {
console.log('🚀 ~ mutationFn: ~ error:', error);
message.error('error');
}
},
});

return { parseDocument: mutateAsync, data, loading };
};

+ 19
- 1
web/src/hooks/login-hooks.ts Просмотреть файл

import userService from '@/services/user-service'; import userService from '@/services/user-service';
import authorizationUtil from '@/utils/authorization-util'; import authorizationUtil from '@/utils/authorization-util';
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { message } from 'antd';
import { Form, message } from 'antd';
import { FormInstance } from 'antd/lib';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { history } from 'umi'; import { history } from 'umi';




return { data, loading, logout: mutateAsync }; return { data, loading, logout: mutateAsync };
}; };

export const useHandleSubmittable = (form: FormInstance) => {
const [submittable, setSubmittable] = useState<boolean>(false);

// Watch all values
const values = Form.useWatch([], form);

useEffect(() => {
form
.validateFields({ validateOnly: true })
.then(() => setSubmittable(true))
.catch(() => setSubmittable(false));
}, [form, values]);

return { submittable };
};

+ 5
- 1
web/src/locales/en.ts Просмотреть файл

s: 'S', s: 'S',
pleaseSelect: 'Please select', pleaseSelect: 'Please select',
pleaseInput: 'Please input', pleaseInput: 'Please input',
submit: 'Submit',
}, },
login: { login: {
login: 'Sign in', login: 'Sign in',
chunkTokenNumber: 'Chunk token number', chunkTokenNumber: 'Chunk token number',
chunkTokenNumberMessage: 'Chunk token number is required', chunkTokenNumberMessage: 'Chunk token number is required',
embeddingModelTip: embeddingModelTip:
"The model that converts chunks into embeddings. It cannot be changed once the knowledge base has chunks. To switch to a different embedding model, You must delete all chunks in the knowledge base.",
'The model that converts chunks into embeddings. It cannot be changed once the knowledge base has chunks. To switch to a different embedding model, You must delete all chunks in the knowledge base.',
permissionsTip: permissionsTip:
"If set to 'Team', all team members will be able to manage the knowledge base.", "If set to 'Team', all team members will be able to manage the knowledge base.",
chunkTokenNumberTip: chunkTokenNumberTip:
content: 'Content', content: 'Content',
operationResults: 'Operation Results', operationResults: 'Operation Results',
autosaved: 'Autosaved', autosaved: 'Autosaved',
optional: 'Optional',
pasteFileLink: 'Paste file link',
testRun: 'Test Run',
}, },
footer: { footer: {
profile: 'All rights reserved @ React', profile: 'All rights reserved @ React',

+ 4
- 0
web/src/locales/zh-traditional.ts Просмотреть файл

s: '秒', s: '秒',
pleaseSelect: '請選擇', pleaseSelect: '請選擇',
pleaseInput: '請輸入', pleaseInput: '請輸入',
submit: '提交',
}, },
login: { login: {
login: '登入', login: '登入',
content: '內容', content: '內容',
operationResults: '運行結果', operationResults: '運行結果',
autosaved: '已自動儲存', autosaved: '已自動儲存',
optional: '可選項',
pasteFileLink: '貼上文件連結',
testRun: '試運行',
}, },
footer: { footer: {
profile: '“保留所有權利 @ react”', profile: '“保留所有權利 @ react”',

+ 4
- 0
web/src/locales/zh.ts Просмотреть файл

s: '秒', s: '秒',
pleaseSelect: '请选择', pleaseSelect: '请选择',
pleaseInput: '请输入', pleaseInput: '请输入',
submit: '提交',
}, },
login: { login: {
login: '登录', login: '登录',
content: '内容', content: '内容',
operationResults: '运行结果', operationResults: '运行结果',
autosaved: '已自动保存', autosaved: '已自动保存',
optional: '可选项',
pasteFileLink: '粘贴文件链接',
testRun: '试运行',
}, },
footer: { footer: {
profile: 'All rights reserved @ React', profile: 'All rights reserved @ React',

+ 76
- 24
web/src/pages/flow/canvas/index.tsx Просмотреть файл

import { useCallback } from 'react';
import { useSetModalState } from '@/hooks/common-hooks';
import { useCallback, useEffect } from 'react';
import ReactFlow, { import ReactFlow, {
Background, Background,
ConnectionMode, ConnectionMode,
import 'reactflow/dist/style.css'; import 'reactflow/dist/style.css';
import ChatDrawer from '../chat/drawer'; import ChatDrawer from '../chat/drawer';
import { Operator } from '../constant'; import { Operator } from '../constant';
import FlowDrawer from '../flow-drawer';
import FormDrawer from '../flow-drawer';
import { import {
useGetBeginNodeDataQuery,
useHandleDrop, useHandleDrop,
useSelectCanvasData, useSelectCanvasData,
useShowDrawer,
useShowFormDrawer,
useValidateConnection, useValidateConnection,
useWatchNodeFormDataChange, useWatchNodeFormDataChange,
} from '../hooks'; } from '../hooks';
import { BeginQuery } from '../interface';
import RunDrawer from '../run-drawer';
import { ButtonEdge } from './edge'; import { ButtonEdge } from './edge';
import styles from './index.less'; import styles from './index.less';
import { RagNode } from './node'; import { RagNode } from './node';
}; };


interface IProps { interface IProps {
chatDrawerVisible: boolean;
hideChatDrawer(): void;
drawerVisible: boolean;
hideDrawer(): void;
} }


function FlowCanvas({ chatDrawerVisible, hideChatDrawer }: IProps) {
function FlowCanvas({ drawerVisible, hideDrawer }: IProps) {
const { const {
nodes, nodes,
edges, edges,
onSelectionChange, onSelectionChange,
} = useSelectCanvasData(); } = useSelectCanvasData();
const isValidConnection = useValidateConnection(); const isValidConnection = useValidateConnection();
const {
visible: runVisible,
showModal: showRunModal,
hideModal: hideRunModal,
} = useSetModalState();
const {
visible: chatVisible,
showModal: showChatModal,
hideModal: hideChatModal,
} = useSetModalState();

const { formDrawerVisible, hideFormDrawer, showFormDrawer, clickedNode } =
useShowFormDrawer();

const onPaneClick = useCallback(() => {
hideFormDrawer();
}, [hideFormDrawer]);


const { drawerVisible, hideDrawer, showDrawer, clickedNode } =
useShowDrawer();
const { onDrop, onDragOver, setReactFlowInstance } = useHandleDrop();

useWatchNodeFormDataChange();

const hideRunOrChatDrawer = useCallback(() => {
hideChatModal();
hideRunModal();
hideDrawer();
}, [hideChatModal, hideDrawer, hideRunModal]);


const onNodeClick: NodeMouseHandler = useCallback( const onNodeClick: NodeMouseHandler = useCallback(
(e, node) => { (e, node) => {
if (node.data.label !== Operator.Note) { if (node.data.label !== Operator.Note) {
showDrawer(node);
hideRunOrChatDrawer();
showFormDrawer(node);
} }
}, },
[showDrawer],
[hideRunOrChatDrawer, showFormDrawer],
); );


const onPaneClick = useCallback(() => {
hideDrawer();
}, [hideDrawer]);
const getBeginNodeDataQuery = useGetBeginNodeDataQuery();


const { onDrop, onDragOver, setReactFlowInstance } = useHandleDrop();

useWatchNodeFormDataChange();
useEffect(() => {
if (drawerVisible) {
const query: BeginQuery[] = getBeginNodeDataQuery();
if (query.length > 0) {
showRunModal();
hideChatModal();
} else {
showChatModal();
hideRunModal();
}
}
}, [
hideChatModal,
hideRunModal,
showChatModal,
showRunModal,
drawerVisible,
getBeginNodeDataQuery,
]);


return ( return (
<div className={styles.canvasWrapper}> <div className={styles.canvasWrapper}>
<Background /> <Background />
<Controls /> <Controls />
</ReactFlow> </ReactFlow>
<FlowDrawer
node={clickedNode}
visible={drawerVisible}
hideModal={hideDrawer}
></FlowDrawer>
{chatDrawerVisible && (
{formDrawerVisible && (
<FormDrawer
node={clickedNode}
visible={formDrawerVisible}
hideModal={hideFormDrawer}
></FormDrawer>
)}
{chatVisible && (
<ChatDrawer <ChatDrawer
visible={chatDrawerVisible}
hideModal={hideChatDrawer}
visible={chatVisible}
hideModal={hideRunOrChatDrawer}
></ChatDrawer> ></ChatDrawer>
)} )}

{runVisible && (
<RunDrawer
hideModal={hideRunOrChatDrawer}
showModal={showChatModal}
></RunDrawer>
)}
</div> </div>
); );
} }

+ 28
- 6
web/src/pages/flow/canvas/node/begin-node.tsx Просмотреть файл

import { Flex } from 'antd'; import { Flex } from 'antd';
import classNames from 'classnames'; import classNames from 'classnames';
import get from 'lodash/get';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Handle, NodeProps, Position } from 'reactflow'; import { Handle, NodeProps, Position } from 'reactflow';
import { Operator, operatorMap } from '../../constant';
import { NodeData } from '../../interface';
import {
BeginQueryType,
BeginQueryTypeIconMap,
Operator,
operatorMap,
} from '../../constant';
import { BeginQuery, NodeData } from '../../interface';
import OperatorIcon from '../../operator-icon'; import OperatorIcon from '../../operator-icon';
import { RightHandleStyle } from './handle-icon'; import { RightHandleStyle } from './handle-icon';
import styles from './index.less'; import styles from './index.less';
// TODO: do not allow other nodes to connect to this node // TODO: do not allow other nodes to connect to this node
export function BeginNode({ selected, data }: NodeProps<NodeData>) { export function BeginNode({ selected, data }: NodeProps<NodeData>) {
const { t } = useTranslation(); const { t } = useTranslation();
const query: BeginQuery[] = get(data, 'form.query', []);


return ( return (
<section <section
className={classNames(styles.ragNode, { className={classNames(styles.ragNode, {
[styles.selectedNode]: selected, [styles.selectedNode]: selected,
})} })}
style={{
width: 100,
}}
> >
<Handle <Handle
type="source" type="source"
style={RightHandleStyle} style={RightHandleStyle}
></Handle> ></Handle>


<Flex align="center" justify={'space-around'}>
<Flex align="center" justify={'center'} gap={10}>
<OperatorIcon <OperatorIcon
name={data.label as Operator} name={data.label as Operator}
fontSize={24} fontSize={24}
></OperatorIcon> ></OperatorIcon>
<div className={styles.nodeTitle}>{t(`flow.begin`)}</div> <div className={styles.nodeTitle}>{t(`flow.begin`)}</div>
</Flex> </Flex>
<Flex gap={8} vertical className={styles.generateParameters}>
{query.map((x, idx) => {
const Icon = BeginQueryTypeIconMap[x.type as BeginQueryType];
return (
<Flex
key={idx}
align="center"
gap={6}
className={styles.conditionBlock}
>
<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>
);
})}
</Flex>
</section> </section>
); );
} }

+ 17
- 8
web/src/pages/flow/constant.tsx Просмотреть файл

SendOutlined, SendOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import upperFirst from 'lodash/upperFirst'; import upperFirst from 'lodash/upperFirst';
import {
CloudUpload,
Link2,
ListOrdered,
OptionIcon,
TextCursorInput,
ToggleLeft,
WrapText,
} from 'lucide-react';


export enum Operator { export enum Operator {
Begin = 'Begin', Begin = 'Begin',
Url = 'url', Url = 'url',
} }


export const BeginQueryTypeMap = {
[BeginQueryType.Line]: 'input',
[BeginQueryType.Paragraph]: 'textarea',
[BeginQueryType.Options]: 'select',
[BeginQueryType.File]: 'file',
[BeginQueryType.Integer]: 'inputnumber',
[BeginQueryType.Boolean]: 'switch',
[BeginQueryType.Url]: 'input',
export const BeginQueryTypeIconMap = {
[BeginQueryType.Line]: TextCursorInput,
[BeginQueryType.Paragraph]: WrapText,
[BeginQueryType.Options]: OptionIcon,
[BeginQueryType.File]: CloudUpload,
[BeginQueryType.Integer]: ListOrdered,
[BeginQueryType.Boolean]: ToggleLeft,
[BeginQueryType.Url]: Link2,
}; };

+ 2
- 2
web/src/pages/flow/flow-drawer/index.tsx Просмотреть файл



const EmptyContent = () => <div></div>; const EmptyContent = () => <div></div>;


const FlowDrawer = ({
const FormDrawer = ({
visible, visible,
hideModal, hideModal,
node, node,
); );
}; };


export default FlowDrawer;
export default FormDrawer;

+ 24
- 0
web/src/pages/flow/form/begin-form/index.less Просмотреть файл

.dynamicInputVariable {
background-color: #ebe9e9;
:global(.ant-collapse-content) {
background-color: #f6f6f6;
}
:global(.ant-collapse-content-box) {
padding: 0 !important;
}
margin-bottom: 20px;
.title {
font-weight: 600;
font-size: 16px;
}

.addButton {
color: rgb(22, 119, 255);
font-weight: 600;
}
}

.addButton {
color: rgb(22, 119, 255);
font-weight: 600;
}

+ 11
- 7
web/src/pages/flow/form/begin-form/index.tsx Просмотреть файл

import { useTranslate } from '@/hooks/common-hooks';
import { PlusOutlined } from '@ant-design/icons';
import { Button, Form, Input } from 'antd'; import { Button, Form, Input } from 'antd';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { BeginQuery, IOperatorForm } from '../../interface'; import { BeginQuery, IOperatorForm } from '../../interface';
import { useEditQueryRecord } from './hooks'; import { useEditQueryRecord } from './hooks';
import { ModalForm } from './paramater-modal'; import { ModalForm } from './paramater-modal';
import QueryTable from './query-table'; import QueryTable from './query-table';


import styles from './index.less';

type FieldType = { type FieldType = {
prologue?: string; prologue?: string;
}; };


const BeginForm = ({ onValuesChange, form }: IOperatorForm) => { const BeginForm = ({ onValuesChange, form }: IOperatorForm) => {
const { t } = useTranslate('chat');
const { t } = useTranslation();
const { const {
ok, ok,
currentRecord, currentRecord,
> >
<Form.Item<FieldType> <Form.Item<FieldType>
name={'prologue'} name={'prologue'}
label={t('setAnOpener')}
tooltip={t('setAnOpenerTip')}
initialValue={t('setAnOpenerInitial')}
label={t('chat.setAnOpener')}
tooltip={t('chat.setAnOpenerTip')}
initialValue={t('chat.setAnOpenerInitial')}
> >
<Input.TextArea autoSize={{ minRows: 5 }} /> <Input.TextArea autoSize={{ minRows: 5 }} />
</Form.Item> </Form.Item>
<Form.Item name="query" noStyle /> <Form.Item name="query" noStyle />


<Form.Item <Form.Item
label="Query List"
shouldUpdate={(prevValues, curValues) => shouldUpdate={(prevValues, curValues) =>
prevValues.query !== curValues.query prevValues.query !== curValues.query
} }
htmlType="button" htmlType="button"
style={{ margin: '0 8px' }} style={{ margin: '0 8px' }}
onClick={() => showModal()} onClick={() => showModal()}
icon={<PlusOutlined />}
block block
className={styles.addButton}
> >
Add +
{t('flow.addItem')}
</Button> </Button>
{visible && ( {visible && (
<ModalForm <ModalForm

+ 11
- 2
web/src/pages/flow/form/begin-form/paramater-modal.tsx Просмотреть файл

import { Form, Input, Modal, Select, Switch } from 'antd'; import { Form, Input, Modal, Select, Switch } from 'antd';
import { DefaultOptionType } from 'antd/es/select'; import { DefaultOptionType } from 'antd/es/select';
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo } from 'react';
import { BeginQueryType } from '../../constant';
import { BeginQueryType, BeginQueryTypeIconMap } from '../../constant';
import { BeginQuery } from '../../interface'; import { BeginQuery } from '../../interface';
import BeginDynamicOptions from './begin-dynamic-options'; import BeginDynamicOptions from './begin-dynamic-options';


const options = useMemo(() => { const options = useMemo(() => {
return Object.values(BeginQueryType).reduce<DefaultOptionType[]>( return Object.values(BeginQueryType).reduce<DefaultOptionType[]>(
(pre, cur) => { (pre, cur) => {
const Icon = BeginQueryTypeIconMap[cur];

return [ return [
...pre, ...pre,
{ {
label: cur,
label: (
<div className="flex items-center gap-2">
<Icon
className={`size-${cur === BeginQueryType.Options ? 4 : 5}`}
></Icon>
{cur}
</div>
),
value: cur, value: cur,
}, },
]; ];

+ 27
- 6
web/src/pages/flow/form/begin-form/query-table.tsx Просмотреть файл

import { DeleteOutlined, EditOutlined } from '@ant-design/icons'; import { DeleteOutlined, EditOutlined } from '@ant-design/icons';
import type { TableProps } from 'antd'; import type { TableProps } from 'antd';
import { Space, Table, Tooltip } from 'antd';
import { Collapse, Space, Table, Tooltip } from 'antd';
import { BeginQuery } from '../../interface'; import { BeginQuery } from '../../interface';


import { useTranslation } from 'react-i18next';
import styles from './index.less';

interface IProps { interface IProps {
data: BeginQuery[]; data: BeginQuery[];
deleteRecord(index: number): void; deleteRecord(index: number): void;
} }


const QueryTable = ({ data, deleteRecord, showModal }: IProps) => { const QueryTable = ({ data, deleteRecord, showModal }: IProps) => {
const { t } = useTranslation();

const columns: TableProps<BeginQuery>['columns'] = [ const columns: TableProps<BeginQuery>['columns'] = [
{ {
title: 'Key', title: 'Key',
), ),
}, },
{ {
title: 'Name',
title: t('flow.name'),
dataIndex: 'name', dataIndex: 'name',
key: 'name', key: 'name',
ellipsis: { ellipsis: {
), ),
}, },
{ {
title: 'Type',
title: t('flow.type'),
dataIndex: 'type', dataIndex: 'type',
key: 'type', key: 'type',
}, },
{ {
title: 'Optional',
title: t('flow.optional'),
dataIndex: 'optional', dataIndex: 'optional',
key: 'optional', key: 'optional',
render: (optional) => (optional ? 'Yes' : 'No'), render: (optional) => (optional ? 'Yes' : 'No'),
}, },
{ {
title: 'Action',
title: t('common.action'),
key: 'action', key: 'action',
render: (_, record, idx) => ( render: (_, record, idx) => (
<Space> <Space>
]; ];


return ( return (
<Table<BeginQuery> columns={columns} dataSource={data} pagination={false} />
<Collapse
defaultActiveKey={['1']}
className={styles.dynamicInputVariable}
items={[
{
key: '1',
label: <span className={styles.title}>{t('flow.input')}</span>,
children: (
<Table<BeginQuery>
columns={columns}
dataSource={data}
pagination={false}
/>
),
},
]}
/>
); );
}; };



+ 16
- 6
web/src/pages/flow/form/components/dynamic-input-variable.tsx Просмотреть файл

import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons'; import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
import { Button, Collapse, Flex, Form, Input, Select } from 'antd'; import { Button, Collapse, Flex, Form, Input, Select } from 'antd';


import { useCallback } from 'react';
import { PropsWithChildren, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useBuildComponentIdSelectOptions } from '../../hooks'; import { useBuildComponentIdSelectOptions } from '../../hooks';
import styles from './index.less'; import styles from './index.less';
); );
}; };


const DynamicInputVariable = ({ nodeId }: IProps) => {
const { t } = useTranslation();

export function FormCollapse({
children,
title,
}: PropsWithChildren<{ title: string }>) {
return ( return (
<Collapse <Collapse
className={styles.dynamicInputVariable} className={styles.dynamicInputVariable}
items={[ items={[
{ {
key: '1', key: '1',
label: <span className={styles.title}>{t('flow.input')}</span>,
children: <DynamicVariableForm nodeId={nodeId}></DynamicVariableForm>,
label: <span className={styles.title}>{title}</span>,
children,
}, },
]} ]}
/> />
); );
}

const DynamicInputVariable = ({ nodeId }: IProps) => {
const { t } = useTranslation();
return (
<FormCollapse title={t('flow.input')}>
<DynamicVariableForm nodeId={nodeId}></DynamicVariableForm>
</FormCollapse>
);
}; };


export default DynamicInputVariable; export default DynamicInputVariable;

+ 16
- 3
web/src/pages/flow/header/index.tsx Просмотреть файл

import { useFetchFlow } from '@/hooks/flow-hooks'; import { useFetchFlow } from '@/hooks/flow-hooks';
import { ArrowLeftOutlined } from '@ant-design/icons'; import { ArrowLeftOutlined } from '@ant-design/icons';
import { Button, Flex, Space } from 'antd'; import { Button, Flex, Space } from 'antd';
import { useCallback } from 'react';
import { Link, useParams } from 'umi'; import { Link, useParams } from 'umi';
import FlowIdModal from '../flow-id-modal'; import FlowIdModal from '../flow-id-modal';
import { import {
useGetBeginNodeDataQuery,
useSaveGraph, useSaveGraph,
useSaveGraphBeforeOpeningDebugDrawer, useSaveGraphBeforeOpeningDebugDrawer,
useWatchAgentChange, useWatchAgentChange,
} from '../hooks'; } from '../hooks';
import { BeginQuery } from '../interface';
import styles from './index.less'; import styles from './index.less';


interface IProps { interface IProps {


const FlowHeader = ({ showChatDrawer, chatDrawerVisible }: IProps) => { const FlowHeader = ({ showChatDrawer, chatDrawerVisible }: IProps) => {
const { saveGraph } = useSaveGraph(); const { saveGraph } = useSaveGraph();
const handleRun = useSaveGraphBeforeOpeningDebugDrawer(showChatDrawer);
const { handleRun } = useSaveGraphBeforeOpeningDebugDrawer(showChatDrawer);
const { data } = useFetchFlow(); const { data } = useFetchFlow();
const { t } = useTranslate('flow'); const { t } = useTranslate('flow');
const { const {
const { visible, hideModal, showModal } = useSetModalState(); const { visible, hideModal, showModal } = useSetModalState();
const { id } = useParams(); const { id } = useParams();
const time = useWatchAgentChange(chatDrawerVisible); const time = useWatchAgentChange(chatDrawerVisible);
const getBeginNodeDataQuery = useGetBeginNodeDataQuery();

const handleRunAgent = useCallback(() => {
const query: BeginQuery[] = getBeginNodeDataQuery();
if (query.length > 0) {
showChatDrawer();
} else {
handleRun();
}
}, [getBeginNodeDataQuery, handleRun, showChatDrawer]);


return ( return (
<> <>
</div> </div>
</Space> </Space>
<Space size={'large'}> <Space size={'large'}>
<Button onClick={handleRun}>
<Button onClick={handleRunAgent}>
<b>{t('run')}</b> <b>{t('run')}</b>
</Button> </Button>
<Button type="primary" onClick={saveGraph}>
<Button type="primary" onClick={() => saveGraph()}>
<b>{t('save')}</b> <b>{t('save')}</b>
</Button> </Button>
{/* <Button type="primary" onClick={showOverviewModal} disabled> {/* <Button type="primary" onClick={showOverviewModal} disabled>

web/src/pages/flow/hooks.ts → web/src/pages/flow/hooks.tsx Просмотреть файл

import api from '@/utils/api'; import api from '@/utils/api';
import { useDebounceEffect } from 'ahooks'; import { useDebounceEffect } from 'ahooks';
import { FormInstance, message } from 'antd'; import { FormInstance, message } from 'antd';
import { DefaultOptionType } from 'antd/es/select';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { humanId } from 'human-id'; import { humanId } from 'human-id';
import { get, lowerFirst } from 'lodash'; import { get, lowerFirst } from 'lodash';
initialWikipediaValues, initialWikipediaValues,
initialYahooFinanceValues, initialYahooFinanceValues,
} from './constant'; } from './constant';
import { ICategorizeForm, IRelevantForm, ISwitchForm } from './interface';
import {
BeginQuery,
ICategorizeForm,
IRelevantForm,
ISwitchForm,
} from './interface';
import useGraphStore, { RFState } from './store'; import useGraphStore, { RFState } from './store';
import { import {
buildDslComponentsByGraph, buildDslComponentsByGraph,
return { onDrop, onDragOver, setReactFlowInstance }; return { onDrop, onDragOver, setReactFlowInstance };
}; };


export const useShowDrawer = () => {
export const useShowFormDrawer = () => {
const { const {
clickedNodeId: clickNodeId, clickedNodeId: clickNodeId,
setClickedNodeId, setClickedNodeId,
getNode, getNode,
} = useGraphStore((state) => state); } = useGraphStore((state) => state);
const { const {
visible: drawerVisible,
hideModal: hideDrawer,
showModal: showDrawer,
visible: formDrawerVisible,
hideModal: hideFormDrawer,
showModal: showFormDrawer,
} = useSetModalState(); } = useSetModalState();


const handleShow = useCallback( const handleShow = useCallback(
(node: Node) => { (node: Node) => {
setClickedNodeId(node.id); setClickedNodeId(node.id);
showDrawer();
showFormDrawer();
}, },
[showDrawer, setClickedNodeId],
[showFormDrawer, setClickedNodeId],
); );


return { return {
drawerVisible,
hideDrawer,
showDrawer: handleShow,
formDrawerVisible,
hideFormDrawer,
showFormDrawer: handleShow,
clickedNode: getNode(clickNodeId), clickedNode: getNode(clickNodeId),
}; };
}; };


export const useSaveGraph = () => { export const useSaveGraph = () => {
const { data } = useFetchFlow(); const { data } = useFetchFlow();
const { setFlow } = useSetFlow();
const { setFlow, loading } = useSetFlow();
const { id } = useParams(); const { id } = useParams();
const { nodes, edges } = useGraphStore((state) => state); const { nodes, edges } = useGraphStore((state) => state);
const saveGraph = useCallback(async () => {
const dslComponents = buildDslComponentsByGraph(nodes, edges);
return setFlow({
id,
title: data.title,
dsl: { ...data.dsl, graph: { nodes, edges }, components: dslComponents },
});
}, [nodes, edges, setFlow, id, data]);
useEffect(() => {}, [nodes]);
const saveGraph = useCallback(
async (currentNodes?: Node[]) => {
const dslComponents = buildDslComponentsByGraph(
currentNodes ?? nodes,
edges,
);
return setFlow({
id,
title: data.title,
dsl: {
...data.dsl,
graph: { nodes: currentNodes ?? nodes, edges },
components: dslComponents,
},
});
},
[nodes, edges, setFlow, id, data],
);


return { saveGraph };
return { saveGraph, loading };
}; };


export const useHandleFormValuesChange = (id?: string) => { export const useHandleFormValuesChange = (id?: string) => {
return { name, handleNameBlur, handleNameChange }; return { name, handleNameBlur, handleNameChange };
}; };


export const useGetBeginNodeDataQuery = () => {
const getNode = useGraphStore((state) => state.getNode);

const getBeginNodeDataQuery = useCallback(() => {
return get(getNode('begin'), 'data.form.query', []);
}, [getNode]);

return getBeginNodeDataQuery;
};

export const useSaveGraphBeforeOpeningDebugDrawer = (show: () => void) => { export const useSaveGraphBeforeOpeningDebugDrawer = (show: () => void) => {
const { id } = useParams(); const { id } = useParams();
const { saveGraph } = useSaveGraph();
const { saveGraph, loading } = useSaveGraph();
const { resetFlow } = useResetFlow(); const { resetFlow } = useResetFlow();
const { refetch } = useFetchFlow(); const { refetch } = useFetchFlow();
const { send } = useSendMessageWithSse(api.runCanvas); const { send } = useSendMessageWithSse(api.runCanvas);
const handleRun = useCallback(async () => {
const saveRet = await saveGraph();
if (saveRet?.code === 0) {
// Call the reset api before opening the run drawer each time
const resetRet = await resetFlow();
// After resetting, all previous messages will be cleared.
if (resetRet?.code === 0) {
// fetch prologue
const sendRet = await send({ id });
if (receiveMessageError(sendRet)) {
message.error(sendRet?.data?.message);
} else {
refetch();
show();

const handleRun = useCallback(
async (nextNodes?: Node[]) => {
const saveRet = await saveGraph(nextNodes);
if (saveRet?.code === 0) {
// Call the reset api before opening the run drawer each time
const resetRet = await resetFlow();
// After resetting, all previous messages will be cleared.
if (resetRet?.code === 0) {
// fetch prologue
const sendRet = await send({ id });
if (receiveMessageError(sendRet)) {
message.error(sendRet?.data?.message);
} else {
refetch();
show();
}
} }
} }
}
}, [saveGraph, resetFlow, id, send, show, refetch]);
},
[saveGraph, resetFlow, send, id, refetch, show],
);


return handleRun;
return { handleRun, loading };
}; };


export const useReplaceIdWithName = () => { export const useReplaceIdWithName = () => {


export const useBuildComponentIdSelectOptions = (nodeId?: string) => { export const useBuildComponentIdSelectOptions = (nodeId?: string) => {
const nodes = useGraphStore((state) => state.nodes); const nodes = useGraphStore((state) => state.nodes);
const getBeginNodeDataQuery = useGetBeginNodeDataQuery();
const query: BeginQuery[] = getBeginNodeDataQuery();


const options = useMemo(() => {
const componentIdOptions = useMemo(() => {
return nodes return nodes
.filter( .filter(
(x) => (x) =>
.map((x) => ({ label: x.data.name, value: x.id })); .map((x) => ({ label: x.data.name, value: x.id }));
}, [nodes, nodeId]); }, [nodes, nodeId]);


return options;
const groupedOptions = [
{
label: <span>Component id</span>,
title: 'Component Id',
options: componentIdOptions,
},
{
label: <span>Begin input</span>,
title: 'Begin input',
options: query.map((x) => ({
label: x.name,
value: `begin@${x.key}`,
})),
},
];

return groupedOptions;
}; };


export const useGetComponentLabelByValue = (nodeId: string) => { export const useGetComponentLabelByValue = (nodeId: string) => {
const options = useBuildComponentIdSelectOptions(nodeId); const options = useBuildComponentIdSelectOptions(nodeId);
const flattenOptions = useMemo(
() =>
options.reduce<DefaultOptionType[]>((pre, cur) => {
return [...pre, ...cur.options];
}, []),
[options],
);


const getLabel = useCallback( const getLabel = useCallback(
(val?: string) => { (val?: string) => {
return options.find((x) => x.value === val)?.label;
return flattenOptions.find((x) => x.value === val)?.label;
}, },
[options],
[flattenOptions],
); );
return getLabel; return getLabel;
}; };

+ 2
- 2
web/src/pages/flow/index.tsx Просмотреть файл

></FlowHeader> ></FlowHeader>
<Content style={{ margin: 0 }}> <Content style={{ margin: 0 }}>
<FlowCanvas <FlowCanvas
chatDrawerVisible={chatDrawerVisible}
hideChatDrawer={hideChatDrawer}
drawerVisible={chatDrawerVisible}
hideDrawer={hideChatDrawer}
></FlowCanvas> ></FlowCanvas>
</Content> </Content>
</Layout> </Layout>

+ 5
- 0
web/src/pages/flow/run-drawer/index.less Просмотреть файл

.formWrapper {
:global(.ant-form-item-label) {
font-weight: 600 !important;
}
}

+ 284
- 0
web/src/pages/flow/run-drawer/index.tsx Просмотреть файл

import { Authorization } from '@/constants/authorization';
import { useSetModalState } from '@/hooks/common-hooks';
import { useSetSelectedRecord } from '@/hooks/logic-hooks';
import { useHandleSubmittable } from '@/hooks/login-hooks';
import { IModalProps } from '@/interfaces/common';
import api from '@/utils/api';
import { getAuthorization } from '@/utils/authorization-util';
import { InboxOutlined } from '@ant-design/icons';
import {
Button,
Drawer,
Flex,
Form,
FormItemProps,
Input,
InputNumber,
Select,
Switch,
Upload,
} from 'antd';
import { pick } from 'lodash';
import { Link2, Trash2 } from 'lucide-react';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { BeginQueryType } from '../constant';
import {
useGetBeginNodeDataQuery,
useSaveGraphBeforeOpeningDebugDrawer,
} from '../hooks';
import { BeginQuery } from '../interface';
import useGraphStore from '../store';
import { getDrawerWidth } from '../utils';
import { PopoverForm } from './popover-form';

import styles from './index.less';

const RunDrawer = ({
hideModal,
showModal: showChatModal,
}: IModalProps<any>) => {
const { t } = useTranslation();
const [form] = Form.useForm();
const updateNodeForm = useGraphStore((state) => state.updateNodeForm);
const {
visible,
hideModal: hidePopover,
switchVisible,
showModal: showPopover,
} = useSetModalState();
const { setRecord, currentRecord } = useSetSelectedRecord<number>();
const { submittable } = useHandleSubmittable(form);

const handleShowPopover = useCallback(
(idx: number) => () => {
setRecord(idx);
showPopover();
},
[setRecord, showPopover],
);

const handleRemoveUrl = useCallback(
(key: number, index: number) => () => {
const list: any[] = form.getFieldValue(key);

form.setFieldValue(
key,
list.filter((_, idx) => idx !== index),
);
},
[form],
);

const getBeginNodeDataQuery = useGetBeginNodeDataQuery();
const query: BeginQuery[] = getBeginNodeDataQuery();

const normFile = (e: any) => {
if (Array.isArray(e)) {
return e;
}
return e?.fileList;
};

const renderWidget = useCallback(
(q: BeginQuery, idx: number) => {
const props: FormItemProps & { key: number } = {
key: idx,
label: q.name,
name: idx,
};
if (q.optional === false) {
props.rules = [{ required: true }];
}

const urlList: { url: string; result: string }[] =
form.getFieldValue(idx) || [];

const BeginQueryTypeMap = {
[BeginQueryType.Line]: (
<Form.Item {...props}>
<Input></Input>
</Form.Item>
),
[BeginQueryType.Paragraph]: (
<Form.Item {...props}>
<Input.TextArea rows={4}></Input.TextArea>
</Form.Item>
),
[BeginQueryType.Options]: (
<Form.Item {...props}>
<Select
allowClear
options={q.options?.map((x) => ({ label: x, value: x })) ?? []}
></Select>
</Form.Item>
),
[BeginQueryType.File]: (
<Form.Item
{...props}
valuePropName="fileList"
getValueFromEvent={normFile}
>
<Upload.Dragger
name="file"
action={api.parse}
multiple
headers={{ [Authorization]: getAuthorization() }}
>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text">{t('fileManager.uploadTitle')}</p>
<p className="ant-upload-hint">
{t('fileManager.uploadDescription')}
</p>
</Upload.Dragger>
</Form.Item>
),
[BeginQueryType.Integer]: (
<Form.Item {...props}>
<InputNumber></InputNumber>
</Form.Item>
),
[BeginQueryType.Boolean]: (
<Form.Item valuePropName={'checked'} {...props}>
<Switch></Switch>
</Form.Item>
),
[BeginQueryType.Url]: (
<>
<Form.Item
{...pick(props, ['key', 'label', 'rules'])}
required={!q.optional}
className={urlList.length > 0 ? 'mb-1' : ''}
>
<PopoverForm visible={visible} switchVisible={switchVisible}>
<Button
onClick={handleShowPopover(idx)}
className="text-buttonBlueText"
>
{t('flow.pasteFileLink')}
</Button>
</PopoverForm>
</Form.Item>
<Form.Item name={idx} noStyle {...pick(props, ['rules'])} />
<Form.Item
noStyle
shouldUpdate={(prevValues, curValues) =>
prevValues[idx] !== curValues[idx]
}
>
{({ getFieldValue }) => {
const urlInfo: { url: string; result: string }[] =
getFieldValue(idx) || [];
return urlInfo.length ? (
<Flex vertical gap={8} className="mb-3">
{urlInfo.map((u, index) => (
<div
key={index}
className="flex items-center justify-between gap-2 hover:bg-slate-100 group"
>
<Link2 className="size-5"></Link2>
<span className="flex-1 truncate"> {u.url}</span>
<Trash2
className="size-4 invisible group-hover:visible cursor-pointer"
onClick={handleRemoveUrl(idx, index)}
/>
</div>
))}
</Flex>
) : null;
}}
</Form.Item>
</>
),
};

return BeginQueryTypeMap[q.type as BeginQueryType];
},
[form, handleRemoveUrl, handleShowPopover, switchVisible, t, visible],
);

const { handleRun } = useSaveGraphBeforeOpeningDebugDrawer(showChatModal!);

const handleRunAgent = useCallback(
(nextValues: Record<string, any>) => {
const currentNodes = updateNodeForm('begin', nextValues, ['query']);
handleRun(currentNodes);
hideModal?.();
},
[handleRun, hideModal, updateNodeForm],
);

const onOk = useCallback(async () => {
const values = await form.validateFields();
const nextValues = Object.entries(values).map(([key, value]) => {
const item = query[Number(key)];
let nextValue = value;
if (Array.isArray(value)) {
nextValue = ``;

value.forEach((x, idx) => {
if (x?.originFileObj instanceof File) {
if (idx === 0) {
nextValue += `${x.name}\n\n${x.response.data}\n\n`;
} else {
nextValue += `${x.response.data}\n\n`;
}
} else {
if (idx === 0) {
nextValue += `${x.url}\n\n${x.result}\n\n`;
} else {
nextValue += `${x.result}\n\n`;
}
}
});
}
return { ...item, value: nextValue };
});
handleRunAgent(nextValues);
}, [form, handleRunAgent, query]);

return (
<Drawer
title={t('flow.testRun')}
placement="right"
onClose={hideModal}
open
getContainer={false}
width={getDrawerWidth()}
mask={false}
>
<section className={styles.formWrapper}>
<Form.Provider
onFormFinish={(name, { values, forms }) => {
if (name === 'urlForm') {
const { basicForm } = forms;
const urlInfo = basicForm.getFieldValue(currentRecord) || [];
basicForm.setFieldsValue({
[currentRecord]: [...urlInfo, values],
});
hidePopover();
}
}}
>
<Form
name="basicForm"
autoComplete="off"
layout={'vertical'}
form={form}
>
{query.map((x, idx) => {
return renderWidget(x, idx);
})}
</Form>
</Form.Provider>
</section>
<Button type={'primary'} block onClick={onOk} disabled={!submittable}>
{t('common.next')}
</Button>
</Drawer>
);
};

export default RunDrawer;

+ 74
- 0
web/src/pages/flow/run-drawer/popover-form.tsx Просмотреть файл

import { useParseDocument } from '@/hooks/document-hooks';
import { useResetFormOnCloseModal } from '@/hooks/logic-hooks';
import { IModalProps } from '@/interfaces/common';
import { Button, Form, Input, Popover } from 'antd';
import { PropsWithChildren } from 'react';
import { useTranslation } from 'react-i18next';

const reg =
/^(((ht|f)tps?):\/\/)?([^!@#$%^&*?.\s-]([^!@#$%^&*?.\s]{0,63}[^!@#$%^&*?.\s])?\.)+[a-z]{2,6}\/?/;

export const PopoverForm = ({
children,
visible,
switchVisible,
}: PropsWithChildren<IModalProps<any>>) => {
const [form] = Form.useForm();
const { parseDocument, loading } = useParseDocument();
const { t } = useTranslation();

useResetFormOnCloseModal({
form,
visible,
});

const onOk = async () => {
const values = await form.validateFields();
const val = values.url;

if (reg.test(val)) {
const ret = await parseDocument(val);
if (ret?.data?.code === 0) {
form.setFieldValue('result', ret?.data?.data);
form.submit();
}
}
};

const content = (
<Form form={form} name="urlForm">
<Form.Item
name="url"
rules={[{ required: true, type: 'url' }]}
className="m-0"
>
<Input
onPressEnter={(e) => e.preventDefault()}
placeholder={t('flow.pasteFileLink')}
suffix={
<Button
type="primary"
onClick={onOk}
size={'small'}
loading={loading}
>
{t('common.submit')}
</Button>
}
/>
</Form.Item>
<Form.Item name={'result'} noStyle />
</Form>
);

return (
<Popover
content={content}
open={visible}
trigger={'click'}
onOpenChange={switchVisible}
>
{children}
</Popover>
);
};

+ 22
- 19
web/src/pages/flow/store.ts Просмотреть файл

nodeId: string, nodeId: string,
values: any, values: any,
path?: (string | number)[], path?: (string | number)[],
) => void;
) => Node[];
onSelectionChange: OnSelectionChangeFunc; onSelectionChange: OnSelectionChangeFunc;
addNode: (nodes: Node) => void; addNode: (nodes: Node) => void;
getNode: (id?: string | null) => Node<NodeData> | undefined; getNode: (id?: string | null) => Node<NodeData> | undefined;
values: any, values: any,
path: (string | number)[] = [], path: (string | number)[] = [],
) => { ) => {
set({
nodes: get().nodes.map((node) => {
if (node.id === nodeId) {
let nextForm: Record<string, unknown> = { ...node.data.form };
if (path.length === 0) {
nextForm = Object.assign(nextForm, values);
} else {
lodashSet(nextForm, path, values);
}
return {
...node,
data: {
...node.data,
form: nextForm,
},
} as any;
const nextNodes = get().nodes.map((node) => {
if (node.id === nodeId) {
let nextForm: Record<string, unknown> = { ...node.data.form };
if (path.length === 0) {
nextForm = Object.assign(nextForm, values);
} else {
lodashSet(nextForm, path, values);
} }
return {
...node,
data: {
...node.data,
form: nextForm,
},
} as any;
}


return node;
}),
return node;
}); });
set({
nodes: nextNodes,
});

return nextNodes;
}, },
updateSwitchFormData: (source, sourceHandle, target) => { updateSwitchFormData: (source, sourceHandle, target) => {
const { updateNodeForm } = get(); const { updateNodeForm } = get();

+ 1
- 0
web/src/utils/api.ts Просмотреть файл

web_crawl: `${api_host}/document/web_crawl`, web_crawl: `${api_host}/document/web_crawl`,
document_infos: `${api_host}/document/infos`, document_infos: `${api_host}/document/infos`,
upload_and_parse: `${api_host}/document/upload_and_parse`, upload_and_parse: `${api_host}/document/upload_and_parse`,
parse: `${api_host}/document/parse`,


// chat // chat
setDialog: `${api_host}/dialog/set`, setDialog: `${api_host}/dialog/set`,

+ 2
- 2
web/src/utils/request.ts Просмотреть файл

}); });


request.interceptors.response.use(async (response: any, options) => { request.interceptors.response.use(async (response: any, options) => {
if (response?.status === 413) {
message.error(RetcodeMessage[413]);
if (response?.status === 413 || response?.status === 504) {
message.error(RetcodeMessage[response?.status as ResultCode]);
} }


if (options.responseType === 'blob') { if (options.responseType === 'blob') {

+ 1
- 0
web/tailwind.config.js Просмотреть файл

ring: 'hsl(var(--ring))', ring: 'hsl(var(--ring))',
background: 'var(--background)', background: 'var(--background)',
foreground: 'hsl(var(--foreground))', foreground: 'hsl(var(--foreground))',
buttonBlueText: 'var(--button-blue-text)',
primary: { primary: {
DEFAULT: 'hsl(var(--primary))', DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))', foreground: 'hsl(var(--primary-foreground))',

+ 2
- 0
web/tailwind.css Просмотреть файл



--background-inverse-standard: rgba(58, 56, 65, 0.15); --background-inverse-standard: rgba(58, 56, 65, 0.15);
--background-inverse-standard-foreground: rgb(92, 81, 81); --background-inverse-standard-foreground: rgb(92, 81, 81);

--button-blue-text: rgb(22, 119, 255);
} }


.dark { .dark {

Загрузка…
Отмена
Сохранить