feat(chat-sdk/chatitem): 消息支持导出图表图片 (#1937)

This commit is contained in:
pisces
2024-12-23 09:05:56 +08:00
committed by GitHub
parent 5de5b0a5e2
commit 642d6a02e1
9 changed files with 274 additions and 124 deletions

View File

@@ -9,10 +9,10 @@ import {
RangeValue, RangeValue,
SimilarQuestionType, SimilarQuestionType,
} from '../../common/type'; } from '../../common/type';
import { useEffect, useState } from 'react'; import { createContext, useEffect, useRef, useState } from 'react';
import { chatExecute, chatParse, queryData, deleteQuery, switchEntity } from '../../service'; import { chatExecute, chatParse, queryData, deleteQuery, switchEntity } from '../../service';
import { PARSE_ERROR_TIP, PREFIX_CLS, SEARCH_EXCEPTION_TIP } from '../../common/constants'; import { PARSE_ERROR_TIP, PREFIX_CLS, SEARCH_EXCEPTION_TIP } from '../../common/constants';
import { Spin } from 'antd'; import { message, Spin } from 'antd';
import IconFont from '../IconFont'; import IconFont from '../IconFont';
import ExpandParseTip from './ExpandParseTip'; import ExpandParseTip from './ExpandParseTip';
import ParseTip from './ParseTip'; import ParseTip from './ParseTip';
@@ -25,6 +25,7 @@ import SimilarQuestionItem from './SimilarQuestionItem';
import { AgentType } from '../../Chat/type'; import { AgentType } from '../../Chat/type';
import dayjs, { Dayjs } from 'dayjs'; import dayjs, { Dayjs } from 'dayjs';
import { exportCsvFile } from '../../utils/utils'; import { exportCsvFile } from '../../utils/utils';
import { useMethodRegister } from '../../hooks';
type Props = { type Props = {
msg: string; msg: string;
@@ -51,6 +52,11 @@ type Props = {
onSendMsg?: (msg: string) => void; onSendMsg?: (msg: string) => void;
}; };
export const ChartItemContext = createContext({
register: (...args: any[]) => {},
call: (...args: any[]) => {},
});
const ChatItem: React.FC<Props> = ({ const ChatItem: React.FC<Props> = ({
msg, msg,
conversationId, conversationId,
@@ -433,126 +439,135 @@ const ChatItem: React.FC<Props> = ({
const { llmReq, llmResp } = parseInfo?.properties?.CONTEXT || {}; const { llmReq, llmResp } = parseInfo?.properties?.CONTEXT || {};
const { register, call } = useMethodRegister(() => message.error('该条消息暂不支持该操作'));
return ( return (
<div className={prefixCls}> <ChartItemContext.Provider value={{ register, call }}>
{!isMobile && <IconFont type="icon-zhinengsuanfa" className={`${prefixCls}-avatar`} />} <div className={prefixCls}>
<div className={isMobile ? `${prefixCls}-mobile-msg-card` : ''}> {!isMobile && <IconFont type="icon-zhinengsuanfa" className={`${prefixCls}-avatar`} />}
<div className={`${prefixCls}-time`}> <div className={isMobile ? `${prefixCls}-mobile-msg-card` : ''}>
{parseTimeCost?.parseStartTime <div className={`${prefixCls}-time`}>
? dayjs(parseTimeCost.parseStartTime).format('M月D日 HH:mm') {parseTimeCost?.parseStartTime
: ''} ? dayjs(parseTimeCost.parseStartTime).format('M月D日 HH:mm')
</div> : ''}
<div className={contentClass}> </div>
<> <div className={contentClass}>
{currentAgent?.enableFeedback === 1 && !questionId && showExpandParseTip && ( <>
<div style={{ marginBottom: 10 }}> {currentAgent?.enableFeedback === 1 && !questionId && showExpandParseTip && (
<ExpandParseTip <div style={{ marginBottom: 10 }}>
<ExpandParseTip
isSimpleMode={isSimpleMode}
parseInfoOptions={preParseInfoOptions}
agentId={agentId}
integrateSystem={integrateSystem}
parseTimeCost={parseTimeCost?.parseTime}
isDeveloper={isDeveloper}
onSelectParseInfo={onExpandSelectParseInfo}
onSwitchEntity={onSwitchEntity}
onFiltersChange={onFiltersChange}
onDateInfoChange={onDateInfoChange}
onRefresh={onRefresh}
handlePresetClick={handlePresetClick}
/>
</div>
)}
{!preParseMode && (
<ParseTip
isSimpleMode={isSimpleMode} isSimpleMode={isSimpleMode}
parseInfoOptions={preParseInfoOptions} parseLoading={parseLoading}
parseInfoOptions={parseInfoOptions}
parseTip={parseTip}
currentParseInfo={parseInfo}
agentId={agentId} agentId={agentId}
dimensionFilters={dimensionFilters}
dateInfo={dateInfo}
entityInfo={entityInfo}
integrateSystem={integrateSystem} integrateSystem={integrateSystem}
parseTimeCost={parseTimeCost?.parseTime} parseTimeCost={parseTimeCost?.parseTime}
isDeveloper={isDeveloper} isDeveloper={isDeveloper}
onSelectParseInfo={onExpandSelectParseInfo} onSelectParseInfo={onSelectParseInfo}
onSwitchEntity={onSwitchEntity} onSwitchEntity={onSwitchEntity}
onFiltersChange={onFiltersChange} onFiltersChange={onFiltersChange}
onDateInfoChange={onDateInfoChange} onDateInfoChange={onDateInfoChange}
onRefresh={onRefresh} onRefresh={() => {
onRefresh();
}}
handlePresetClick={handlePresetClick} handlePresetClick={handlePresetClick}
/> />
</div> )}
)} </>
{!preParseMode && ( {executeMode && (
<ParseTip <Spin spinning={entitySwitchLoading}>
isSimpleMode={isSimpleMode} <div style={{ minHeight: 50 }}>
parseLoading={parseLoading} {!isMobile &&
parseInfoOptions={parseInfoOptions} parseInfo?.sqlInfo &&
parseTip={parseTip} isDeveloper &&
currentParseInfo={parseInfo} isDebugMode &&
agentId={agentId} !isSimpleMode && (
dimensionFilters={dimensionFilters} <SqlItem
dateInfo={dateInfo} agentId={agentId}
entityInfo={entityInfo} queryId={parseInfo.queryId}
integrateSystem={integrateSystem} question={msg}
parseTimeCost={parseTimeCost?.parseTime} llmReq={llmReq}
isDeveloper={isDeveloper} llmResp={llmResp}
onSelectParseInfo={onSelectParseInfo} integrateSystem={integrateSystem}
onSwitchEntity={onSwitchEntity} queryMode={parseInfo.queryMode}
onFiltersChange={onFiltersChange} sqlInfo={parseInfo.sqlInfo}
onDateInfoChange={onDateInfoChange} sqlTimeCost={parseTimeCost?.sqlTime}
onRefresh={() => { executeErrorMsg={executeErrorMsg}
onRefresh(); />
}} )}
handlePresetClick={handlePresetClick} <ExecuteItem
/> isSimpleMode={isSimpleMode}
)} queryId={parseInfo?.queryId}
</>
{executeMode && (
<Spin spinning={entitySwitchLoading}>
<div style={{ minHeight: 50 }}>
{!isMobile && parseInfo?.sqlInfo && isDeveloper && isDebugMode && !isSimpleMode && (
<SqlItem
agentId={agentId}
queryId={parseInfo.queryId}
question={msg} question={msg}
llmReq={llmReq} queryMode={parseInfo?.queryMode}
llmResp={llmResp} executeLoading={executeLoading}
integrateSystem={integrateSystem} executeTip={executeTip}
queryMode={parseInfo.queryMode}
sqlInfo={parseInfo.sqlInfo}
sqlTimeCost={parseTimeCost?.sqlTime}
executeErrorMsg={executeErrorMsg} executeErrorMsg={executeErrorMsg}
chartIndex={0}
data={data}
triggerResize={triggerResize}
executeItemNode={executeItemNode}
isDeveloper={isDeveloper}
renderCustomExecuteNode={renderCustomExecuteNode}
/> />
)} </div>
<ExecuteItem </Spin>
isSimpleMode={isSimpleMode} )}
{executeMode &&
!executeLoading &&
!isSimpleMode &&
parseInfo?.queryMode !== 'PLAIN_TEXT' && (
<SimilarQuestionItem
queryId={parseInfo?.queryId} queryId={parseInfo?.queryId}
question={msg} defaultExpanded={parseTip !== '' || executeTip !== ''}
queryMode={parseInfo?.queryMode} similarQueries={data?.similarQueries}
executeLoading={executeLoading} onSelectQuestion={onSelectQuestion}
executeTip={executeTip}
executeErrorMsg={executeErrorMsg}
chartIndex={0}
data={data}
triggerResize={triggerResize}
executeItemNode={executeItemNode}
isDeveloper={isDeveloper}
renderCustomExecuteNode={renderCustomExecuteNode}
/> />
</div> )}
</Spin> </div>
)} {(parseTip !== '' || (executeMode && !executeLoading)) &&
{executeMode &&
!executeLoading &&
!isSimpleMode &&
parseInfo?.queryMode !== 'PLAIN_TEXT' && ( parseInfo?.queryMode !== 'PLAIN_TEXT' && (
<SimilarQuestionItem <Tools
queryId={parseInfo?.queryId} isLastMessage={isLastMessage}
defaultExpanded={parseTip !== '' || executeTip !== ''} queryId={parseInfo?.queryId || 0}
similarQueries={data?.similarQueries} scoreValue={score}
onSelectQuestion={onSelectQuestion} isParserError={isParserError}
onExportData={() => {
onExportData();
}}
isSimpleMode={isSimpleMode}
onReExecute={queryId => {
deleteQueryInfo(queryId);
}}
/> />
)} )}
</div> </div>
{(parseTip !== '' || (executeMode && !executeLoading)) &&
parseInfo?.queryMode !== 'PLAIN_TEXT' && (
<Tools
isLastMessage={isLastMessage}
queryId={parseInfo?.queryId || 0}
scoreValue={score}
isParserError={isParserError}
onExportData={() => {
onExportData();
}}
onReExecute={queryId => {
deleteQueryInfo(queryId);
}}
/>
)}
</div> </div>
</div> </ChartItemContext.Provider>
); );
}; };

View File

@@ -7,10 +7,19 @@ import {
} from '../../../utils/utils'; } from '../../../utils/utils';
import type { ECharts } from 'echarts'; import type { ECharts } from 'echarts';
import * as echarts from 'echarts'; import * as echarts from 'echarts';
import React, { useEffect, useRef, useState } from 'react'; import {
forwardRef,
ForwardRefRenderFunction,
useContext,
useEffect,
useImperativeHandle,
useRef,
} from 'react';
import NoPermissionChart from '../NoPermissionChart'; import NoPermissionChart from '../NoPermissionChart';
import { ColumnType } from '../../../common/type'; import { ColumnType } from '../../../common/type';
import { Spin } from 'antd'; import { Spin } from 'antd';
import { ChartItemContext } from '../../ChatItem';
import { useExportByEcharts } from '../../../hooks';
type Props = { type Props = {
data: MsgDataType; data: MsgDataType;
@@ -30,7 +39,7 @@ const BarChart: React.FC<Props> = ({
onApplyAuth, onApplyAuth,
}) => { }) => {
const chartRef = useRef<any>(); const chartRef = useRef<any>();
const [instance, setInstance] = useState<ECharts>(); const instanceRef = useRef<ECharts>();
const { queryColumns, queryResults, entityInfo } = data; const { queryColumns, queryResults, entityInfo } = data;
@@ -41,11 +50,11 @@ const BarChart: React.FC<Props> = ({
const renderChart = () => { const renderChart = () => {
let instanceObj: any; let instanceObj: any;
if (!instance) { if (!instanceRef.current) {
instanceObj = echarts.init(chartRef.current); instanceObj = echarts.init(chartRef.current);
setInstance(instanceObj); instanceRef.current = instanceObj;
} else { } else {
instanceObj = instance; instanceObj = instanceRef.current;
} }
const data = (queryResults || []).sort( const data = (queryResults || []).sort(
(a: any, b: any) => b[metricColumnName] - a[metricColumnName] (a: any, b: any) => b[metricColumnName] - a[metricColumnName]
@@ -163,8 +172,8 @@ const BarChart: React.FC<Props> = ({
}, [queryResults]); }, [queryResults]);
useEffect(() => { useEffect(() => {
if (triggerResize && instance) { if (triggerResize && instanceRef.current) {
instance.resize(); instanceRef.current.resize();
} }
}, [triggerResize]); }, [triggerResize]);
@@ -180,6 +189,15 @@ const BarChart: React.FC<Props> = ({
const prefixCls = `${PREFIX_CLS}-bar`; const prefixCls = `${PREFIX_CLS}-bar`;
const { downloadChartAsImage } = useExportByEcharts({
instanceRef,
question,
});
const { register } = useContext(ChartItemContext);
register('downloadChartAsImage', downloadChartAsImage);
return ( return (
<div> <div>
<div className={`${prefixCls}-top-bar`}> <div className={`${prefixCls}-top-bar`}>

View File

@@ -8,12 +8,14 @@ import {
} from '../../../utils/utils'; } from '../../../utils/utils';
import type { ECharts } from 'echarts'; import type { ECharts } from 'echarts';
import * as echarts from 'echarts'; import * as echarts from 'echarts';
import React, { useEffect, useRef, useState } from 'react'; import React, { useContext, useEffect, useRef, useState } from 'react';
import moment from 'moment'; import moment from 'moment';
import { ColumnType } from '../../../common/type'; import { ColumnType } from '../../../common/type';
import NoPermissionChart from '../NoPermissionChart'; import NoPermissionChart from '../NoPermissionChart';
import classNames from 'classnames'; import classNames from 'classnames';
import { isArray } from 'lodash'; import { isArray } from 'lodash';
import { useExportByEcharts } from '../../../hooks';
import { ChartItemContext } from '../../ChatItem';
type Props = { type Props = {
model?: string; model?: string;
@@ -37,15 +39,15 @@ const MetricTrendChart: React.FC<Props> = ({
chartType, chartType,
}) => { }) => {
const chartRef = useRef<any>(); const chartRef = useRef<any>();
const [instance, setInstance] = useState<ECharts>(); const instanceRef = useRef<ECharts>();
const renderChart = () => { const renderChart = () => {
let instanceObj: any; let instanceObj: any;
if (!instance) { if (!instanceRef.current) {
instanceObj = echarts.init(chartRef.current); instanceObj = echarts.init(chartRef.current);
setInstance(instanceObj); instanceRef.current = instanceObj;
} else { } else {
instanceObj = instance; instanceObj = instanceRef.current;
instanceObj.clear(); instanceObj.clear();
} }
@@ -195,6 +197,15 @@ const MetricTrendChart: React.FC<Props> = ({
instanceObj.resize(); instanceObj.resize();
}; };
const { downloadChartAsImage } = useExportByEcharts({
instanceRef,
question: metricField.name,
});
const { register } = useContext(ChartItemContext);
register('downloadChartAsImage', downloadChartAsImage);
useEffect(() => { useEffect(() => {
if (metricField.authorized) { if (metricField.authorized) {
renderChart(); renderChart();
@@ -202,8 +213,8 @@ const MetricTrendChart: React.FC<Props> = ({
}, [resultList, metricField, chartType]); }, [resultList, metricField, chartType]);
useEffect(() => { useEffect(() => {
if (triggerResize && instance) { if (triggerResize && instanceRef.current) {
instance.resize(); instanceRef.current.resize();
} }
}, [triggerResize]); }, [triggerResize]);

View File

@@ -2,10 +2,12 @@ import { CHART_SECONDARY_COLOR, CLS_PREFIX, THEME_COLOR_LIST } from '../../../co
import { getFormattedValue } from '../../../utils/utils'; import { getFormattedValue } from '../../../utils/utils';
import type { ECharts } from 'echarts'; import type { ECharts } from 'echarts';
import * as echarts from 'echarts'; import * as echarts from 'echarts';
import React, { useEffect, useRef, useState } from 'react'; import React, { useContext, useEffect, useRef, useState } from 'react';
import moment from 'moment'; import moment from 'moment';
import { ColumnType } from '../../../common/type'; import { ColumnType } from '../../../common/type';
import { isArray } from 'lodash'; import { isArray } from 'lodash';
import { ChartItemContext } from '../../ChatItem';
import { useExportByEcharts } from '../../../hooks';
type Props = { type Props = {
dateColumnName: string; dateColumnName: string;
@@ -13,6 +15,7 @@ type Props = {
resultList: any[]; resultList: any[];
triggerResize?: boolean; triggerResize?: boolean;
chartType?: string; chartType?: string;
question: string;
}; };
const MultiMetricsTrendChart: React.FC<Props> = ({ const MultiMetricsTrendChart: React.FC<Props> = ({
@@ -21,17 +24,17 @@ const MultiMetricsTrendChart: React.FC<Props> = ({
resultList, resultList,
triggerResize, triggerResize,
chartType, chartType,
question,
}) => { }) => {
const chartRef = useRef<any>(); const chartRef = useRef<any>();
const [instance, setInstance] = useState<ECharts>(); const instanceRef = useRef<ECharts>();
const renderChart = () => { const renderChart = () => {
let instanceObj: any; let instanceObj: any;
if (!instance) { if (!instanceRef.current) {
instanceObj = echarts.init(chartRef.current); instanceObj = echarts.init(chartRef.current);
setInstance(instanceObj); instanceRef.current = instanceObj;
} else { } else {
instanceObj = instance; instanceObj = instanceRef.current;
instanceObj.clear(); instanceObj.clear();
} }
@@ -132,13 +135,22 @@ const MultiMetricsTrendChart: React.FC<Props> = ({
instanceObj.resize(); instanceObj.resize();
}; };
const { downloadChartAsImage } = useExportByEcharts({
instanceRef,
question,
});
const { register } = useContext(ChartItemContext);
register('downloadChartAsImage', downloadChartAsImage);
useEffect(() => { useEffect(() => {
renderChart(); renderChart();
}, [resultList, chartType]); }, [resultList, chartType]);
useEffect(() => { useEffect(() => {
if (triggerResize && instance) { if (triggerResize && instanceRef.current) {
instance.resize(); instanceRef.current.resize();
} }
}, [triggerResize]); }, [triggerResize]);

View File

@@ -100,6 +100,7 @@ const MetricTrend: React.FC<Props> = ({
<MultiMetricsTrendChart <MultiMetricsTrendChart
dateColumnName={dateColumnName} dateColumnName={dateColumnName}
metricFields={metricFields} metricFields={metricFields}
question={question}
resultList={queryResults} resultList={queryResults}
triggerResize={triggerResize} triggerResize={triggerResize}
chartType={chartType} chartType={chartType}

View File

@@ -1,16 +1,25 @@
import { isMobile } from '../../utils/utils'; import { isMobile } from '../../utils/utils';
import { DislikeOutlined, LikeOutlined, DownloadOutlined, RedoOutlined } from '@ant-design/icons'; import {
DislikeOutlined,
LikeOutlined,
DownloadOutlined,
RedoOutlined,
FileJpgOutlined,
} from '@ant-design/icons';
import { Button } from 'antd'; import { Button } from 'antd';
import { CLS_PREFIX } from '../../common/constants'; import { CLS_PREFIX } from '../../common/constants';
import { useState } from 'react'; import { useContext, useState } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { updateQAFeedback } from '../../service'; import { updateQAFeedback } from '../../service';
import { useMethodRegister } from '../../hooks';
import { ChartItemContext } from '../ChatItem';
type Props = { type Props = {
queryId: number; queryId: number;
scoreValue?: number; scoreValue?: number;
isLastMessage?: boolean; isLastMessage?: boolean;
isParserError?: boolean; isParserError?: boolean;
isSimpleMode?: boolean;
onExportData?: () => void; onExportData?: () => void;
onReExecute?: (queryId: number) => void; onReExecute?: (queryId: number) => void;
}; };
@@ -20,6 +29,7 @@ const Tools: React.FC<Props> = ({
scoreValue, scoreValue,
isLastMessage, isLastMessage,
isParserError = false, isParserError = false,
isSimpleMode = false,
onExportData, onExportData,
onReExecute, onReExecute,
}) => { }) => {
@@ -44,6 +54,8 @@ const Tools: React.FC<Props> = ({
[`${prefixCls}-feedback-active`]: score === 1, [`${prefixCls}-feedback-active`]: score === 1,
}); });
const { call } = useContext(ChartItemContext);
return ( return (
<div className={prefixCls}> <div className={prefixCls}>
{!isMobile && ( {!isMobile && (
@@ -68,6 +80,18 @@ const Tools: React.FC<Props> = ({
<DownloadOutlined /> <DownloadOutlined />
<span className={`${prefixCls}-font-style`}></span> <span className={`${prefixCls}-font-style`}></span>
</Button> </Button>
{!isSimpleMode && (
<Button
size="small"
onClick={() => {
call('downloadChartAsImage');
}}
type="text"
>
<FileJpgOutlined />
<span className={`${prefixCls}-font-style`}></span>
</Button>
)}
{isLastMessage && ( {isLastMessage && (
<Button <Button
size="small" size="small"

View File

@@ -0,0 +1,3 @@
export * from './useMethodRegister';
export * from './useComposing';
export * from './useExportByEcharts';

View File

@@ -0,0 +1,41 @@
import { message } from 'antd';
import { ECharts } from 'echarts';
export interface ExportByEchartsProps {
instanceRef: React.MutableRefObject<ECharts | undefined>;
question: string;
options?: Parameters<ECharts['getConnectedDataURL']>[0];
}
export const useExportByEcharts = ({ instanceRef, question, options }: ExportByEchartsProps) => {
const handleSaveAsImage = () => {
if (instanceRef.current) {
return instanceRef.current.getConnectedDataURL({
type: 'png',
pixelRatio: 2,
backgroundColor: '#fff',
excludeComponents: ['toolbox'],
...options,
});
}
};
const downloadImage = (url: string) => {
const a = document.createElement('a');
a.href = url;
a.download = `${question}.png`;
a.click();
};
const downloadChartAsImage = () => {
const url = handleSaveAsImage();
if (url) {
downloadImage(url);
message.success('导出图片成功');
} else {
message.error('该条消息暂不支持导出图片');
}
};
return { downloadChartAsImage };
};

View File

@@ -0,0 +1,25 @@
import { useCallback, useRef } from 'react';
export const useMethodRegister = (fallback?: (...args: any[]) => any) => {
const methodStore = useRef<Map<string, (...args: any[]) => any>>(new Map());
const register = useCallback<(key: string, method: (...args: any[]) => any) => any>(
(key, method) => {
methodStore.current.set(key, method);
},
[methodStore]
);
const call = useCallback<(key: string, ...args: any[]) => any>(
(key, ...args) => {
const method = methodStore.current.get(key);
if (method) {
return method(...args);
}
return fallback?.(...args);
},
[methodStore]
);
return { register, call };
};