add chat plugin and split query to parse and execute (#25)

* [feature](webapp) add drill down dimensions and metric period compare and modify layout

* [feature](webapp) add drill down dimensions and metric period compare and modify layout

* [feature](webapp) gitignore add supersonic-webapp

* [feature](webapp) gitignore add supersonic-webapp

* [feature](webapp) add chat plugin and split query to parse and execute

* [feature](webapp) add chat plugin and split query to parse and execute

* [feature](webapp) add chat plugin and split query to parse and execute

---------

Co-authored-by: williamhliu <williamhliu@tencent.com>
This commit is contained in:
williamhliu
2023-08-05 22:17:42 +08:00
committed by GitHub
parent c9baed6c4e
commit 6951eada9d
86 changed files with 3193 additions and 1595 deletions

View File

@@ -0,0 +1,465 @@
import React, { useEffect, useState } from 'react';
import { Modal, Select, Form, Input, InputNumber, message, Button, Radio } from 'antd';
import { getDimensionList, getDomainList, savePlugin } from './service';
import {
DimensionType,
DomainType,
ParamTypeEnum,
ParseModeEnum,
PluginType,
FunctionParamFormItemType,
PluginTypeEnum,
} from './type';
import { getLeafList, uuid } from '@/utils/utils';
import styles from './style.less';
import { PARSE_MODE_MAP, PLUGIN_TYPE_MAP } from './constants';
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import { isArray, set } from 'lodash';
const FormItem = Form.Item;
const { TextArea } = Input;
type Props = {
detail?: PluginType;
onSubmit: (values: any) => void;
onCancel: () => void;
};
const DetailModal: React.FC<Props> = ({ detail, onSubmit, onCancel }) => {
const [domainList, setDomainList] = useState<DomainType[]>([]);
const [domainDimensionList, setDomainDimensionList] = useState<Record<number, DimensionType[]>>(
{},
);
const [confirmLoading, setConfirmLoading] = useState(false);
const [pluginType, setPluginType] = useState<PluginTypeEnum>();
const [functionName, setFunctionName] = useState<string>();
const [functionParams, setFunctionParams] = useState<FunctionParamFormItemType[]>([]);
const [examples, setExamples] = useState<{ id: string; question?: string }[]>([]);
const [filters, setFilters] = useState<any[]>([]);
const [form] = Form.useForm();
const initDomainList = async () => {
const res = await getDomainList();
setDomainList([{ id: -1, name: '全部' }, ...getLeafList(res.data)]);
};
useEffect(() => {
initDomainList();
}, []);
const initDomainDimensions = async (params: any) => {
const domainIds = params
.filter((param: any) => !!param.domainId)
.map((param: any) => param.domainId);
const res = await Promise.all(domainIds.map((domainId: number) => getDimensionList(domainId)));
setDomainDimensionList(
domainIds.reduce(
(result: Record<number, DimensionType[]>, domainId: number, index: number) => {
result[domainId] = res[index].data.list;
return result;
},
{},
),
);
};
useEffect(() => {
if (detail) {
const { paramOptions } = detail.config || {};
const height = paramOptions?.find(
(option: any) => option.paramType === 'FORWARD' && option.key === 'height',
)?.value;
form.setFieldsValue({
...detail,
url: detail.config?.url,
height,
});
if (paramOptions?.length > 0) {
const params = paramOptions.filter(
(option: any) => option.paramType !== ParamTypeEnum.FORWARD,
);
setFilters(params);
initDomainDimensions(params);
}
setPluginType(detail.type);
const parseModeObj = JSON.parse(detail.parseModeConfig || '{}');
setFunctionName(parseModeObj.name);
const { properties } = parseModeObj.parameters || {};
setFunctionParams(
properties
? Object.keys(properties).map((key: string, index: number) => {
return {
id: `${index}`,
name: key,
type: properties[key].type,
description: properties[key].description,
};
})
: [],
);
setExamples(
parseModeObj.examples
? parseModeObj.examples.map((item: string, index: number) => ({
id: index,
question: item,
}))
: [],
);
}
}, [detail]);
const layout = {
labelCol: { span: 4 },
wrapperCol: { span: 20 },
};
const getFunctionParam = (description: string) => {
return {
name: functionName,
description,
parameters: {
type: 'object',
properties: functionParams
.filter((param) => !!param.name?.trim())
.reduce((acc, cur) => {
acc[cur.name || ''] = {
type: cur.type,
description: cur.description,
};
return acc;
}, {}),
required: functionParams.filter((param) => !!param.name?.trim()).map((param) => param.name),
},
examples: examples
.filter((example) => !!example.question?.trim())
.map((example) => example.question),
};
};
const onOk = async () => {
const values = await form.validateFields();
setConfirmLoading(true);
let paramOptions = isArray(filters)
? filters?.filter(
(filter) =>
typeof filter === 'object' && (filter.paramType !== null || filter.value != null),
)
: [];
paramOptions = paramOptions.concat([
{
paramType: ParamTypeEnum.FORWARD,
key: 'height',
value: values.height || undefined,
},
]);
const config = {
url: values.url,
paramOptions,
};
await savePlugin({
...values,
id: detail?.id,
domainList: isArray(values.domainList) ? values.domainList : [values.domainList],
config: JSON.stringify(config),
parseModeConfig: JSON.stringify(getFunctionParam(values.pattern)),
});
setConfirmLoading(false);
onSubmit(values);
message.success(detail?.id ? '编辑成功' : '新建成功');
};
const updateDimensionList = async (value: number) => {
if (domainDimensionList[value]) {
return;
}
const res = await getDimensionList(value);
setDomainDimensionList({ ...domainDimensionList, [value]: res.data.list });
};
return (
<Modal
open
title={detail ? '编辑插件' : '新建插件'}
width={900}
confirmLoading={confirmLoading}
onOk={onOk}
onCancel={onCancel}
>
<Form {...layout} form={form} style={{ maxWidth: 820 }}>
<FormItem name="domainList" label="主题域">
<Select
placeholder="请选择主题域"
options={domainList.map((domain) => ({
label: domain.name,
value: domain.id,
}))}
showSearch
filterOption={(input, option) =>
((option?.label ?? '') as string).toLowerCase().includes(input.toLowerCase())
}
mode="multiple"
allowClear
/>
</FormItem>
<FormItem
name="name"
label="插件名称"
rules={[{ required: true, message: '请输入插件名称' }]}
>
<Input placeholder="请输入插件名称" allowClear />
</FormItem>
<FormItem
name="type"
label="插件类型"
rules={[{ required: true, message: '请选择插件类型' }]}
>
<Select
placeholder="请选择插件类型"
options={Object.keys(PLUGIN_TYPE_MAP).map((key) => ({
label: PLUGIN_TYPE_MAP[key],
value: key,
}))}
onChange={(value) => {
setPluginType(value);
if (value === PluginTypeEnum.DSL) {
form.setFieldsValue({ parseMode: ParseModeEnum.FUNCTION_CALL });
// setFunctionName('DSL');
setFunctionParams([
{
id: uuid(),
name: 'query_text',
type: 'string',
description: '用户的原始自然语言查询',
},
]);
}
}}
/>
</FormItem>
<FormItem
name="pattern"
label="插件描述"
rules={[{ required: true, message: '请输入插件描述' }]}
>
<TextArea placeholder="请输入插件描述,多个描述换行分隔" allowClear />
</FormItem>
<FormItem name="exampleQuestions" label="示例问题">
<div className={styles.paramsSection}>
{examples.map((example) => {
const { id, question } = example;
return (
<div className={styles.filterRow} key={id}>
<Input
placeholder="示例问题"
value={question}
className={styles.questionExample}
onChange={(e) => {
example.question = e.target.value;
setExamples([...examples]);
}}
allowClear
/>
<DeleteOutlined
onClick={() => {
setExamples(examples.filter((item) => item.id !== id));
}}
/>
</div>
);
})}
<Button
onClick={() => {
setExamples([...examples, { id: uuid() }]);
}}
>
<PlusOutlined />
</Button>
</div>
</FormItem>
<FormItem label="函数名称">
<Input
value={functionName}
onChange={(e) => {
setFunctionName(e.target.value);
}}
placeholder="请输入函数名称,只能包含因为字母和下划线"
allowClear
/>
</FormItem>
<FormItem name="params" label="函数参数" hidden={pluginType === PluginTypeEnum.DSL}>
<div className={styles.paramsSection}>
{functionParams.map((functionParam: FunctionParamFormItemType) => {
const { id, name, type, description } = functionParam;
return (
<div className={styles.filterRow} key={id}>
<Input
placeholder="参数名称"
value={name}
className={styles.filterParamName}
onChange={(e) => {
functionParam.name = e.target.value;
setFunctionParams([...functionParams]);
}}
allowClear
/>
<Select
placeholder="参数类型"
options={[
{ label: '字符串', value: 'string' },
{ label: '整型', value: 'int' },
]}
className={styles.filterParamValueField}
allowClear
value={type}
onChange={(value) => {
functionParam.type = value;
setFunctionParams([...functionParams]);
}}
/>
<Input
placeholder="参数描述"
value={description}
className={styles.filterParamValueField}
onChange={(e) => {
functionParam.description = e.target.value;
setFunctionParams([...functionParams]);
}}
allowClear
/>
<DeleteOutlined
onClick={() => {
setFunctionParams(functionParams.filter((item) => item.id !== id));
}}
/>
</div>
);
})}
<Button
onClick={() => {
setFunctionParams([...functionParams, { id: uuid() }]);
}}
>
<PlusOutlined />
</Button>
</div>
</FormItem>
{(pluginType === PluginTypeEnum.WEB_PAGE || pluginType === PluginTypeEnum.WEB_SERVICE) && (
<>
<FormItem name="url" label="地址" rules={[{ required: true, message: '请输入地址' }]}>
<Input placeholder="请输入地址" allowClear />
</FormItem>
<FormItem name="params" label="参数">
<div className={styles.paramsSection}>
{filters.map((filter: any) => {
return (
<div className={styles.filterRow} key={filter.id}>
<Input
placeholder="参数名称"
value={filter.key}
className={styles.filterParamName}
onChange={(e) => {
filter.key = e.target.value;
setFilters([...filters]);
}}
allowClear
/>
<Radio.Group
onChange={(e) => {
filter.paramType = e.target.value;
setFilters([...filters]);
}}
value={filter.paramType}
>
<Radio value={ParamTypeEnum.SEMANTIC}></Radio>
<Radio value={ParamTypeEnum.CUSTOM}></Radio>
</Radio.Group>
{filter.paramType === ParamTypeEnum.CUSTOM && (
<Input
placeholder="请输入"
value={filter.value}
className={styles.filterParamValueField}
onChange={(e) => {
filter.value = e.target.value;
setFilters([...filters]);
}}
allowClear
/>
)}
{filter.paramType === ParamTypeEnum.SEMANTIC && (
<>
<Select
placeholder="主题域"
options={domainList.map((domain) => ({
label: domain.name,
value: domain.id,
}))}
showSearch
filterOption={(input, option) =>
((option?.label ?? '') as string)
.toLowerCase()
.includes(input.toLowerCase())
}
className={styles.filterParamName}
allowClear
value={filter.domainId}
onChange={(value) => {
filter.domainId = value;
setFilters([...filters]);
updateDimensionList(value);
}}
/>
<Select
placeholder="请选择维度,需先选择主题域"
options={(domainDimensionList[filter.domainId] || []).map(
(dimension) => ({
label: dimension.name,
value: `${dimension.id}`,
}),
)}
showSearch
className={styles.filterParamValueField}
filterOption={(input, option) =>
((option?.label ?? '') as string)
.toLowerCase()
.includes(input.toLowerCase())
}
allowClear
value={filter.elementId}
onChange={(value) => {
filter.elementId = value;
setFilters([...filters]);
}}
/>
</>
)}
<DeleteOutlined
onClick={() => {
setFilters(filters.filter((item) => item.id !== filter.id));
}}
/>
</div>
);
})}
<Button
onClick={() => {
setFilters([...filters, { id: uuid(), key: undefined, value: undefined }]);
}}
>
<PlusOutlined />
</Button>
</div>
</FormItem>
</>
)}
<FormItem name="height" label="高度">
<InputNumber placeholder="单位px" />
</FormItem>
</Form>
</Modal>
);
};
export default DetailModal;

View File

@@ -0,0 +1,17 @@
export const PLUGIN_TYPE_MAP = {
WEB_PAGE: '外链页面',
WEB_SERVICE: 'Web服务',
DSL: 'LLM语义解析',
}
export const PARSE_MODE_MAP = {
EMBEDDING_RECALL: '向量召回',
FUNCTION_CALL: '函数调用'
}
export const PLUGIN_COLOR_MAP = {
WIDGET: 'blue',
DASHBOARD: 'volcano',
URL: 'purple',
TAG: 'cyan',
}

View File

@@ -0,0 +1,248 @@
import { getLeafList } from '@/utils/utils';
import { PlusOutlined } from '@ant-design/icons';
import { Button, Input, message, Popconfirm, Select, Table, Tag } from 'antd';
import moment from 'moment';
import { useEffect, useState } from 'react';
import { PARSE_MODE_MAP, PLUGIN_TYPE_MAP } from './constants';
import DetailModal from './DetailModal';
import { deletePlugin, getDomainList, getPluginList } from './service';
import styles from './style.less';
import { DomainType, ParseModeEnum, PluginType, PluginTypeEnum } from './type';
const { Search } = Input;
const PluginManage = () => {
const [name, setName] = useState<string>();
const [type, setType] = useState<PluginTypeEnum>();
const [pattern, setPattern] = useState<string>();
const [domain, setDomain] = useState<string>();
const [data, setData] = useState<PluginType[]>([]);
const [domainList, setDomainList] = useState<DomainType[]>([]);
const [loading, setLoading] = useState(false);
const [currentPluginDetail, setCurrentPluginDetail] = useState<PluginType>();
const [detailModalVisible, setDetailModalVisible] = useState(false);
const initDomainList = async () => {
const res = await getDomainList();
setDomainList(getLeafList(res.data));
};
const updateData = async (filters?: any) => {
setLoading(true);
const res = await getPluginList({ name, type, pattern, domain, ...(filters || {}) });
setLoading(false);
setData(res.data.map((item) => ({ ...item, config: JSON.parse(item.config || '{}') })));
};
useEffect(() => {
initDomainList();
updateData();
}, []);
const onCheckPluginDetail = (record: PluginType) => {
setCurrentPluginDetail(record);
setDetailModalVisible(true);
};
const onDeletePlugin = async (record: PluginType) => {
await deletePlugin(record.id);
message.success('插件删除成功');
updateData();
};
const columns: any[] = [
{
title: '插件名称',
dataIndex: 'name',
key: 'name',
},
{
title: '主题域',
dataIndex: 'domainList',
key: 'domainList',
width: 200,
render: (value: number[]) => {
if (value?.includes(-1)) {
return '全部';
}
return value ? (
<div className={styles.domainColumn}>
{value.map((id, index) => {
const name = domainList.find((domain) => domain.id === +id)?.name;
return name ? <Tag key={id}>{name}</Tag> : null;
})}
</div>
) : (
'-'
);
},
},
{
title: '插件类型',
dataIndex: 'type',
key: 'type',
render: (value: string) => {
return (
<Tag color={value === PluginTypeEnum.WEB_PAGE ? 'blue' : 'cyan'}>
{PLUGIN_TYPE_MAP[value]}
</Tag>
);
},
},
{
title: '插件描述',
dataIndex: 'pattern',
key: 'pattern',
width: 450,
},
{
title: '更新人',
dataIndex: 'updatedBy',
key: 'updatedBy',
render: (value: string) => {
return value || '-';
},
},
{
title: '更新时间',
dataIndex: 'updatedAt',
key: 'updatedAt',
render: (value: string) => {
return value ? moment(value).format('YYYY-MM-DD HH:mm') : '-';
},
},
{
title: '操作',
dataIndex: 'x',
key: 'x',
render: (_: any, record: any) => {
return (
<div className={styles.operator}>
<a
onClick={() => {
onCheckPluginDetail(record);
}}
>
</a>
<Popconfirm
title="确定删除吗?"
onConfirm={() => {
onDeletePlugin(record);
}}
>
<a></a>
</Popconfirm>
</div>
);
},
},
];
const onDomainChange = (value: string) => {
setDomain(value);
updateData({ domain: value });
};
const onTypeChange = (value: PluginTypeEnum) => {
setType(value);
updateData({ type: value });
};
const onSearch = () => {
updateData();
};
const onCreatePlugin = () => {
setCurrentPluginDetail(undefined);
setDetailModalVisible(true);
};
const onSavePlugin = () => {
setDetailModalVisible(false);
updateData();
};
return (
<div className={styles.pluginManage}>
<div className={styles.filterSection}>
<div className={styles.filterItem}>
<div className={styles.filterItemTitle}></div>
<Select
className={styles.filterItemControl}
placeholder="请选择主题域"
options={domainList.map((domain) => ({ label: domain.name, value: domain.id }))}
value={domain}
allowClear
onChange={onDomainChange}
/>
</div>
<div className={styles.filterItem}>
<div className={styles.filterItemTitle}></div>
<Search
className={styles.filterItemControl}
placeholder="请输入插件名称"
value={name}
onChange={(e) => {
setName(e.target.value);
}}
onSearch={onSearch}
/>
</div>
<div className={styles.filterItem}>
<div className={styles.filterItemTitle}></div>
<Search
className={styles.filterItemControl}
placeholder="请输入插件描述"
value={pattern}
onChange={(e) => {
setPattern(e.target.value);
}}
onSearch={onSearch}
/>
</div>
<div className={styles.filterItem}>
<div className={styles.filterItemTitle}></div>
<Select
className={styles.filterItemControl}
placeholder="请选择插件类型"
options={Object.keys(PLUGIN_TYPE_MAP).map((key) => ({
label: PLUGIN_TYPE_MAP[key],
value: key,
}))}
value={type}
allowClear
onChange={onTypeChange}
/>
</div>
</div>
<div className={styles.pluginList}>
<div className={styles.titleBar}>
<div className={styles.title}></div>
<Button type="primary" onClick={onCreatePlugin}>
<PlusOutlined />
</Button>
</div>
<Table
columns={columns}
dataSource={data}
size="small"
pagination={{ defaultPageSize: 20 }}
loading={loading}
/>
</div>
{detailModalVisible && (
<DetailModal
detail={currentPluginDetail}
onSubmit={onSavePlugin}
onCancel={() => {
setDetailModalVisible(false);
}}
/>
)}
</div>
);
};
export default PluginManage;

View File

@@ -0,0 +1,39 @@
import { request } from "umi";
import { DimensionType, DomainType, PluginType } from "./type";
export function savePlugin(params: Partial<PluginType>) {
return request<Result<any>>('/api/chat/plugin', {
method: params.id ? 'PUT' : 'POST',
data: params,
});
}
export function getPluginList(filters?: any) {
return request<Result<any[]>>('/api/chat/plugin/query', {
method: 'POST',
data: filters
});
}
export function deletePlugin(id: number) {
return request<Result<any>>(`/api/chat/plugin/${id}`, {
method: 'DELETE',
});
}
export function getDomainList() {
return request<Result<DomainType[]>>('/api/chat/conf/domainList', {
method: 'GET',
});
}
export function getDimensionList(domainId: number) {
return request<Result<{list: DimensionType[]}>>('/api/semantic/dimension/queryDimension', {
method: 'POST',
data: {
domainIds: [domainId],
current: 1,
pageSize: 2000
}
});
}

View File

@@ -0,0 +1,85 @@
.pluginManage {
.filterSection {
display: flex;
flex-wrap: wrap;
row-gap: 12px;
column-gap: 30px;
margin: 12px 24px;
padding: 20px;
background: #fff;
border-radius: 12px;
.filterItem {
display: flex;
align-items: center;
column-gap: 12px;
width: 22vw;
.filterItemTitle {
width: 60px;
margin-right: 6px;
text-align: right;
}
.filterItemControl {
// width: 20vw;
flex: 1;
}
}
}
.pluginList {
margin: 12px 24px;
padding: 12px 20px;
background: #fff;
border-radius: 12px;
.titleBar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
.title {
font-size: 16px;
font-weight: 500;
color: var(--text-color);
}
}
.domainColumn {
display: flex;
align-items: center;
column-gap: 2px;
}
}
}
.operator {
display: flex;
align-items: center;
column-gap: 12px;
}
.paramsSection {
display: flex;
flex-direction: column;
row-gap: 12px;
.filterRow {
display: flex;
align-items: center;
column-gap: 12px;
.filterParamName {
width: 120px;
}
.filterParamValueField {
width: 230px;
}
.questionExample {
width: 100%;
}
}
}

View File

@@ -0,0 +1,68 @@
export type PluginConfigType = {
url: string;
params: any;
paramOptions: any;
valueParams: any;
forwardParam: any;
}
export enum PluginTypeEnum {
WEB_PAGE = 'WEB_PAGE',
WEB_SERVICE = 'WEB_SERVICE',
DSL = 'DSL'
}
export enum ParseModeEnum {
EMBEDDING_RECALL = 'EMBEDDING_RECALL',
FUNCTION_CALL = 'FUNCTION_CALL'
}
export enum ParamTypeEnum {
CUSTOM = 'CUSTOM',
SEMANTIC = 'SEMANTIC',
FORWARD = 'FORWARD'
}
export type PluginType = {
id: number;
type: PluginTypeEnum;
domainList: number[];
pattern: string;
parseMode: ParseModeEnum;
parseModeConfig: string;
name: string;
config: PluginConfigType;
}
export type DomainType = {
id: number | string;
parentId: number;
name: string;
bizName: string;
};
export type DimensionType = {
id: number;
name: string;
bizName: string;
};
export type FunctionParamType = {
type: string;
properties: Record<string, { type: string, description: string }>;
required: string[];
}
export type FunctionType = {
name: string;
description: string;
parameters: FunctionParamType;
examples: string[];
}
export type FunctionParamFormItemType = {
id: string;
name?: string;
type?: string;
description?: string;
}