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,73 @@
import { PREFIX_CLS } from '../../common/constants';
import { MsgDataType } from '../../common/type';
import ChatMsg from '../ChatMsg';
import Tools from '../Tools';
import Text from './Text';
import Typing from './Typing';
type Props = {
question: string;
executeLoading: boolean;
chartIndex: number;
executeTip?: string;
data?: MsgDataType;
isMobileMode?: boolean;
triggerResize?: boolean;
isLastMessage?: boolean;
onSwitchEntity: (entityId: string) => void;
onChangeChart: () => void;
};
const ExecuteItem: React.FC<Props> = ({
question,
executeLoading,
chartIndex,
executeTip,
data,
isMobileMode,
triggerResize,
isLastMessage,
onSwitchEntity,
onChangeChart,
}) => {
const prefixCls = `${PREFIX_CLS}-item`;
if (executeLoading) {
return <Typing />;
}
if (executeTip) {
return <Text data={executeTip} />;
}
if (!data || data.queryMode === 'WEB_PAGE') {
return null;
}
const isMetricCard =
(data.queryMode === 'METRIC_DOMAIN' || data.queryMode === 'METRIC_FILTER') &&
data.queryResults?.length === 1;
return (
<div className={`${prefixCls}-msg-content`}>
<ChatMsg
question={question}
data={data}
chartIndex={chartIndex}
isMobileMode={isMobileMode}
triggerResize={triggerResize}
/>
{!isMetricCard && (
<Tools
data={data}
isLastMessage={isLastMessage}
isMobileMode={isMobileMode}
onSwitchEntity={onSwitchEntity}
onChangeChart={onChangeChart}
/>
)}
</div>
);
};
export default ExecuteItem;

View File

@@ -0,0 +1,205 @@
import React, { ReactNode } from 'react';
import { AGG_TYPE_MAP, PREFIX_CLS } from '../../common/constants';
import { ChatContextType } from '../../common/type';
import Text from './Text';
import Typing from './Typing';
import classNames from 'classnames';
type Props = {
parseLoading: boolean;
parseInfoOptions: ChatContextType[];
parseTip: string;
currentParseInfo?: ChatContextType;
onSelectParseInfo: (parseInfo: ChatContextType) => void;
};
const MAX_OPTION_VALUES_COUNT = 2;
const ParseTip: React.FC<Props> = ({
parseLoading,
parseInfoOptions,
parseTip,
currentParseInfo,
onSelectParseInfo,
}) => {
const prefixCls = `${PREFIX_CLS}-item`;
if (parseLoading) {
return <Typing />;
}
if (parseTip) {
return <Text data={parseTip} />;
}
if (parseInfoOptions.length === 0) {
return null;
}
const getTipNode = (parseInfo: ChatContextType, isOptions?: boolean, index?: number) => {
const {
domainName,
dateInfo,
dimensionFilters,
dimensions,
metrics,
aggType,
queryMode,
properties,
entity,
elementMatches,
} = parseInfo || {};
const { startDate, endDate } = dateInfo || {};
const dimensionItems = dimensions?.filter(item => item.type === 'DIMENSION');
const metric = metrics?.[0];
const tipContentClass = classNames(`${prefixCls}-tip-content`, {
[`${prefixCls}-tip-content-option`]: isOptions,
[`${prefixCls}-tip-content-option-active`]:
isOptions &&
currentParseInfo &&
JSON.stringify(currentParseInfo) === JSON.stringify(parseInfo),
[`${prefixCls}-tip-content-option-disabled`]:
isOptions &&
currentParseInfo !== undefined &&
JSON.stringify(currentParseInfo) !== JSON.stringify(parseInfo),
});
const itemValueClass = classNames({
[`${prefixCls}-tip-item-value`]: !isOptions,
[`${prefixCls}-tip-item-option`]: isOptions,
});
const entityAlias = entity?.alias?.[0]?.split('.')?.[0];
const entityName = elementMatches?.find(item => item.element?.type === 'ID')?.element.name;
const pluginName = properties?.CONTEXT?.plugin?.name;
const modeName = pluginName
? '调插件'
: queryMode.includes('METRIC')
? '算指标'
: queryMode === 'ENTITY_DETAIL'
? '查明细'
: queryMode === 'ENTITY_LIST_FILTER'
? '做圈选'
: '';
const fields =
queryMode === 'ENTITY_DETAIL' ? dimensionItems?.concat(metrics || []) : dimensionItems;
return (
<div
className={tipContentClass}
onClick={() => {
if (isOptions && currentParseInfo === undefined) {
onSelectParseInfo(parseInfo);
}
}}
>
{index !== undefined && <div>{index + 1}.</div>}
{!pluginName && isOptions && <div className={`${prefixCls}-mode-name`}>{modeName}</div>}
{!!pluginName ? (
<div className={`${prefixCls}-tip-item`}>
<span className={itemValueClass}>{pluginName}</span>
</div>
) : (
<>
{queryMode === 'METRIC_ENTITY' || queryMode === 'ENTITY_DETAIL' ? (
<div className={`${prefixCls}-tip-item`}>
<div className={`${prefixCls}-tip-item-name`}>{entityAlias}</div>
<div className={itemValueClass}>{entityName}</div>
</div>
) : (
<div className={`${prefixCls}-tip-item`}>
<div className={`${prefixCls}-tip-item-name`}></div>
<div className={itemValueClass}>{domainName}</div>
</div>
)}
{modeName === '算指标' && metric && (
<div className={`${prefixCls}-tip-item`}>
<div className={`${prefixCls}-tip-item-name`}></div>
<div className={itemValueClass}>{metric.name}</div>
</div>
)}
{!isOptions && (
<div className={`${prefixCls}-tip-item`}>
<div className={`${prefixCls}-tip-item-name`}></div>
<div className={itemValueClass}>
{startDate === endDate ? startDate : `${startDate} ~ ${endDate}`}
</div>
</div>
)}
{['METRIC_GROUPBY', 'METRIC_ORDERBY', 'ENTITY_DETAIL'].includes(queryMode) &&
fields &&
fields.length > 0 && (
<div className={`${prefixCls}-tip-item`}>
<div className={`${prefixCls}-tip-item-name`}>
{queryMode === 'ENTITY_DETAIL' ? '查询字段' : '下钻维度'}
</div>
<div className={itemValueClass}>
{fields
.slice(0, MAX_OPTION_VALUES_COUNT)
.map(field => field.name)
.join('、')}
{fields.length > MAX_OPTION_VALUES_COUNT && '...'}
</div>
</div>
)}
{['METRIC_FILTER', 'METRIC_ENTITY', 'ENTITY_DETAIL', 'ENTITY_LIST_FILTER'].includes(
queryMode
) &&
dimensionFilters &&
dimensionFilters?.length > 0 && (
<div className={`${prefixCls}-tip-item`}>
<div className={`${prefixCls}-tip-item-name`}></div>
{dimensionFilters.slice(0, MAX_OPTION_VALUES_COUNT).map((filter, index) => (
<div className={itemValueClass}>
<span>{filter.name}</span>
<span>
{Array.isArray(filter.value) ? filter.value.join('、') : filter.value}
</span>
{index !== dimensionFilters.length - 1 && <span></span>}
</div>
))}
{dimensionFilters.length > MAX_OPTION_VALUES_COUNT && '...'}
</div>
)}
{queryMode === 'METRIC_ORDERBY' && aggType && aggType !== 'NONE' && (
<div className={`${prefixCls}-tip-item`}>
<div className={`${prefixCls}-tip-item-name`}></div>
<div className={itemValueClass}>{AGG_TYPE_MAP[aggType]}</div>
</div>
)}
</>
)}
</div>
);
};
let tipNode: ReactNode;
if (parseInfoOptions.length > 1) {
tipNode = (
<div className={`${prefixCls}-multi-options`}>
<div></div>
<div className={`${prefixCls}-options`}>
{parseInfoOptions.map((item, index) => getTipNode(item, true, index))}
</div>
</div>
);
} else {
const pluginName = parseInfoOptions[0]?.properties?.CONTEXT?.plugin?.name;
tipNode = (
<div className={`${prefixCls}-tip`}>
<div>{!!pluginName ? '您的问题' : '您的问题解析为:'}</div>
{getTipNode(parseInfoOptions[0])}
</div>
);
}
return <Text data={tipNode} />;
};
export default ParseTip;

View File

@@ -1,71 +1,112 @@
import { MsgDataType } from '../../common/type';
import { ChatContextType, MsgDataType, ParseStateEnum } from '../../common/type';
import { useEffect, useState } from 'react';
import Typing from './Typing';
import ChatMsg from '../ChatMsg';
import { chatQuery } from '../../service';
import { PARSE_ERROR_TIP, PREFIX_CLS } from '../../common/constants';
import Text from './Text';
import Tools from '../Tools';
import { chatExecute, chatParse, switchEntity } from '../../service';
import { PARSE_ERROR_TIP, PREFIX_CLS, SEARCH_EXCEPTION_TIP } from '../../common/constants';
import IconFont from '../IconFont';
import ParseTip from './ParseTip';
import ExecuteItem from './ExecuteItem';
type Props = {
msg: string;
followQuestions?: string[];
conversationId?: number;
domainId?: number;
filter?: any[];
isLastMessage?: boolean;
msgData?: MsgDataType;
isMobileMode?: boolean;
triggerResize?: boolean;
onMsgDataLoaded?: (data: MsgDataType) => void;
onMsgDataLoaded?: (data: MsgDataType, valid: boolean) => void;
onUpdateMessageScroll?: () => void;
};
const ChatItem: React.FC<Props> = ({
msg,
followQuestions,
conversationId,
domainId,
filter,
isLastMessage,
isMobileMode,
triggerResize,
msgData,
onMsgDataLoaded,
onUpdateMessageScroll,
}) => {
const [data, setData] = useState<MsgDataType>();
const [loading, setLoading] = useState(false);
const [tip, setTip] = useState('');
const [parseLoading, setParseLoading] = useState(false);
const [parseInfo, setParseInfo] = useState<ChatContextType>();
const [parseInfoOptions, setParseInfoOptions] = useState<ChatContextType[]>([]);
const [parseTip, setParseTip] = useState('');
const [executeLoading, setExecuteLoading] = useState(false);
const [executeTip, setExecuteTip] = useState('');
const [executeMode, setExecuteMode] = useState(false);
const [entitySwitching, setEntitySwitching] = useState(false);
const [chartIndex, setChartIndex] = useState(0);
const prefixCls = `${PREFIX_CLS}-item`;
const updateData = (res: Result<MsgDataType>) => {
if (res.code === 401 || res.code === 412) {
setTip(res.msg);
setExecuteTip(res.msg);
return false;
}
if (res.code !== 200) {
setTip(PARSE_ERROR_TIP);
setExecuteTip(SEARCH_EXCEPTION_TIP);
return false;
}
const { queryColumns, queryResults, queryState, queryMode } = res.data || {};
const { queryColumns, queryResults, queryState, queryMode, response } = res.data || {};
if (queryState !== 'SUCCESS') {
setTip(PARSE_ERROR_TIP);
setExecuteTip(response && typeof response === 'string' ? response : SEARCH_EXCEPTION_TIP);
return false;
}
if ((queryColumns && queryColumns.length > 0 && queryResults) || queryMode === 'INSTRUCTION') {
if ((queryColumns && queryColumns.length > 0 && queryResults) || queryMode === 'WEB_PAGE') {
setData(res.data);
setTip('');
setExecuteTip('');
return true;
}
setTip(PARSE_ERROR_TIP);
return false;
setExecuteTip(SEARCH_EXCEPTION_TIP);
return true;
};
const onExecute = async (parseInfoValue: ChatContextType, isSwitch?: boolean) => {
setExecuteMode(true);
setExecuteLoading(true);
const { data } = await chatExecute(msg, conversationId!, parseInfoValue);
setExecuteLoading(false);
const valid = updateData(data);
if (onMsgDataLoaded && !isSwitch) {
onMsgDataLoaded({ ...data.data, chatContext: parseInfoValue }, valid);
}
};
const onSendMsg = async () => {
setLoading(true);
const semanticRes = await chatQuery(msg, conversationId, domainId);
updateData(semanticRes.data);
if (onMsgDataLoaded) {
onMsgDataLoaded(semanticRes.data.data);
setParseLoading(true);
const { data: parseData } = await chatParse(msg, conversationId, domainId, filter);
setParseLoading(false);
const { code, data } = parseData || {};
const { state, selectedParses } = data || {};
if (
code !== 200 ||
state === ParseStateEnum.FAILED ||
selectedParses == null ||
selectedParses.length === 0 ||
(selectedParses.length === 1 &&
!selectedParses[0]?.domainName &&
!selectedParses[0]?.properties?.CONTEXT?.plugin?.name &&
selectedParses[0]?.queryMode !== 'WEB_PAGE')
) {
setParseTip(PARSE_ERROR_TIP);
return;
}
if (onUpdateMessageScroll) {
onUpdateMessageScroll();
}
setParseInfoOptions(selectedParses || []);
if (selectedParses.length === 1) {
const parseInfoValue = selectedParses[0];
setParseInfo(parseInfoValue);
onExecute(parseInfoValue);
}
setLoading(false);
};
useEffect(() => {
@@ -73,55 +114,66 @@ const ChatItem: React.FC<Props> = ({
return;
}
if (msgData) {
setParseInfoOptions([msgData.chatContext]);
setExecuteMode(true);
updateData({ code: 200, data: msgData, msg: 'success' });
} else if (msg) {
onSendMsg();
}
}, [msg, msgData]);
const prefixCls = `${PREFIX_CLS}-item`;
const onSwitchEntity = async (entityId: string) => {
setEntitySwitching(true);
const res = await switchEntity(entityId, data?.chatContext?.domainId, conversationId || 0);
setEntitySwitching(false);
setData(res.data.data);
};
if (loading) {
return (
<div className={prefixCls}>
<IconFont type="icon-zhinengsuanfa" className={`${prefixCls}-avatar`} />
<Typing />
</div>
);
}
const onChangeChart = () => {
setChartIndex(chartIndex + 1);
};
if (tip) {
return (
<div className={prefixCls}>
<IconFont type="icon-zhinengsuanfa" className={`${prefixCls}-avatar`} />
<Text data={tip} />
</div>
);
}
if (!data || data.queryMode === 'INSTRUCTION') {
return null;
}
const isMetricCard =
(data.queryMode === 'METRIC_DOMAIN' || data.queryMode === 'METRIC_FILTER') &&
data.queryResults?.length === 1;
const onSelectParseInfo = async (parseInfoValue: ChatContextType) => {
setParseInfo(parseInfoValue);
onExecute(parseInfoValue, parseInfo !== undefined);
if (onUpdateMessageScroll) {
onUpdateMessageScroll();
}
};
return (
<div className={prefixCls}>
<IconFont type="icon-zhinengsuanfa" className={`${prefixCls}-avatar`} />
<div className={`${prefixCls}-content`}>
<ChatMsg
question={msg}
followQuestions={followQuestions}
data={data}
isMobileMode={isMobileMode}
triggerResize={triggerResize}
/>
{!isMetricCard && (
<Tools data={data} isLastMessage={isLastMessage} isMobileMode={isMobileMode} />
)}
<div className={`${prefixCls}-section`}>
<IconFont type="icon-zhinengsuanfa" className={`${prefixCls}-avatar`} />
<div className={`${prefixCls}-content`}>
<ParseTip
parseLoading={parseLoading}
parseInfoOptions={parseInfoOptions}
parseTip={parseTip}
currentParseInfo={parseInfo}
onSelectParseInfo={onSelectParseInfo}
/>
</div>
</div>
{executeMode && data?.queryMode !== 'WEB_PAGE' && (
<div className={`${prefixCls}-section`}>
<IconFont type="icon-zhinengsuanfa" className={`${prefixCls}-avatar`} />
<div className={`${prefixCls}-content`}>
<ExecuteItem
question={msg}
executeLoading={executeLoading}
executeTip={executeTip}
chartIndex={chartIndex}
data={data}
isMobileMode={isMobileMode}
isLastMessage={isLastMessage}
triggerResize={triggerResize}
onSwitchEntity={onSwitchEntity}
onChangeChart={onChangeChart}
/>
</div>
</div>
)}
</div>
);
};

View File

@@ -4,6 +4,101 @@
.@{chat-item-prefix-cls} {
display: flex;
flex-direction: column;
row-gap: 20px;
width: 100%;
&-section {
width: 100%;
display: flex;
}
&-content-text {
margin-top: 12px;
}
&-msg-content {
width: 100%;
display: flex;
flex-direction: column;
}
&-multi-options {
display: flex;
flex-direction: column;
row-gap: 12px;
padding: 4px 0 12px;
}
&-options {
display: flex;
flex-direction: column;
row-gap: 12px;
margin-top: 4px;
}
&-tip {
display: flex;
align-items: center;
row-gap: 6px;
flex-wrap: wrap;
color: var(--text-color);
}
&-tip-content {
display: flex;
align-items: center;
flex-wrap: wrap;
row-gap: 6px;
column-gap: 12px;
color: var(--text-color);
}
&-tip-content-option {
padding: 6px 14px;
border-radius: 16px;
border: 1px solid var(--border-color-base);
cursor: pointer;
&:hover {
border-color: var(--chat-blue);
color: var(--chat-blue);
}
}
&-tip-content-option-disabled {
cursor: auto;
&:hover {
color: var(--text-color-secondary);
border-color: var(--border-color-base);
}
}
&-tip-content-option-active {
border-color: var(--chat-blue);
color: var(--chat-blue);
cursor: auto;
}
&-tip-item {
display: flex;
align-items: center;
}
&-mode-name {
margin-right: -10px;
font-weight: 500;
}
&-tip-item-value {
color: var(--chat-blue);
font-weight: 500;
}
&-tip-item-option {
font-weight: 500;
}
&-avatar {
display: flex;
@@ -41,7 +136,7 @@
&-typing-bubble {
width: fit-content;
padding: 16px !important;
// padding: 16px !important;
}
&-text-bubble {