mirror of
https://github.com/csunny/DB-GPT.git
synced 2025-09-04 18:40:10 +00:00
feat: UI component rendering in agent dialog mode (#1083)
Co-authored-by: csunny <cfqsunny@163.com>
This commit is contained in:
39
web/components/chat/chat-content/agent-messages.tsx
Normal file
39
web/components/chat/chat-content/agent-messages.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import markdownComponents from './config';
|
||||
import { renderModelIcon } from '../header/model-selector';
|
||||
import { SwapRightOutlined } from '@ant-design/icons';
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
sender: string;
|
||||
receiver: string;
|
||||
model: string | null;
|
||||
markdown: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
function AgentMessages({ data }: Props) {
|
||||
if (!data || !data.length) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{data.map((item, index) => (
|
||||
<div key={index} className="rounded my-4 md:my-6">
|
||||
<div className="flex items-center mb-3 text-sm">
|
||||
{item.model ? renderModelIcon(item.model) : <div className="rounded-full w-6 h-6 bg-gray-100" />}
|
||||
<div className="ml-2 opacity-70">
|
||||
{item.sender}
|
||||
<SwapRightOutlined className="mx-2 text-base" />
|
||||
{item.receiver}
|
||||
</div>
|
||||
</div>
|
||||
<div className="whitespace-normal text-sm">
|
||||
<ReactMarkdown components={markdownComponents}>{item.markdown}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentMessages;
|
46
web/components/chat/chat-content/agent-plans.tsx
Normal file
46
web/components/chat/chat-content/agent-plans.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { CaretRightOutlined, CheckOutlined, ClockCircleOutlined } from '@ant-design/icons';
|
||||
import { Collapse } from 'antd';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import markdownComponents from './config';
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
name: string;
|
||||
num: number;
|
||||
status: 'complete' | 'todo';
|
||||
agent: string;
|
||||
markdown: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
function AgentPlans({ data }: Props) {
|
||||
if (!data || !data.length) return null;
|
||||
|
||||
return (
|
||||
<Collapse
|
||||
bordered
|
||||
className="my-3"
|
||||
expandIcon={({ isActive }) => <CaretRightOutlined rotate={isActive ? 90 : 0} />}
|
||||
items={data.map((item, index) => {
|
||||
return {
|
||||
key: index,
|
||||
label: (
|
||||
<div className="whitespace-normal">
|
||||
<span>
|
||||
{item.name} - {item.agent}
|
||||
</span>
|
||||
{item.status === 'complete' ? (
|
||||
<CheckOutlined className="!text-green-500 ml-2" />
|
||||
) : (
|
||||
<ClockCircleOutlined className="!text-gray-500 ml-2" />
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
children: <ReactMarkdown components={markdownComponents}>{item.markdown}</ReactMarkdown>,
|
||||
};
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentPlans;
|
38
web/components/chat/chat-content/chart-view.tsx
Normal file
38
web/components/chat/chat-content/chart-view.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Datum } from '@antv/ava';
|
||||
import { Table, Tabs, TabsProps } from 'antd';
|
||||
import React from 'react';
|
||||
import { format } from 'sql-formatter';
|
||||
import { AutoChart, BackEndChartType, getChartType } from '@/components/chart/autoChart';
|
||||
import { CodePreview } from './code-preview';
|
||||
|
||||
function ChartView({ data, type, sql }: { data: Datum[]; type: BackEndChartType; sql: string }) {
|
||||
const columns = data?.[0]
|
||||
? Object.keys(data?.[0])?.map((item) => {
|
||||
return {
|
||||
title: item,
|
||||
dataIndex: item,
|
||||
key: item,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
const ChartItem = {
|
||||
key: 'chart',
|
||||
label: 'Chart',
|
||||
children: <AutoChart data={data} chartType={getChartType(type)} />,
|
||||
};
|
||||
const SqlItem = {
|
||||
key: 'sql',
|
||||
label: 'SQL',
|
||||
children: <CodePreview language="sql" code={format(sql, { language: 'mysql' }) as string} />,
|
||||
};
|
||||
const DataItem = {
|
||||
key: 'data',
|
||||
label: 'Data',
|
||||
children: <Table dataSource={data} columns={columns} />,
|
||||
};
|
||||
const TabItems: TabsProps['items'] = type === 'response_table' ? [DataItem, SqlItem] : [ChartItem, SqlItem, DataItem];
|
||||
|
||||
return <Tabs defaultActiveKey={type === 'response_table' ? 'data' : 'chart'} items={TabItems} size="small" />;
|
||||
}
|
||||
|
||||
export default ChartView;
|
@@ -3,10 +3,18 @@ import { CopyOutlined } from '@ant-design/icons';
|
||||
import { oneDark, coldarkDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import { useContext } from 'react';
|
||||
import { CSSProperties, useContext } from 'react';
|
||||
import { ChatContext } from '@/app/chat-context';
|
||||
|
||||
export function CodePreview({ code, language }: { code: string; language: string }) {
|
||||
interface Props {
|
||||
code: string;
|
||||
language: string;
|
||||
customStyle?: CSSProperties;
|
||||
light?: { [key: string]: CSSProperties };
|
||||
dark?: { [key: string]: CSSProperties };
|
||||
}
|
||||
|
||||
export function CodePreview({ code, light, dark, language, customStyle }: Props) {
|
||||
const { mode } = useContext(ChatContext);
|
||||
|
||||
return (
|
||||
@@ -20,7 +28,7 @@ export function CodePreview({ code, language }: { code: string; language: string
|
||||
message[success ? 'success' : 'error'](success ? 'Copy success' : 'Copy failed');
|
||||
}}
|
||||
/>
|
||||
<SyntaxHighlighter language={language} style={mode === 'dark' ? coldarkDark : oneDark}>
|
||||
<SyntaxHighlighter customStyle={customStyle} language={language} style={mode === 'dark' ? dark ?? coldarkDark : light ?? oneDark}>
|
||||
{code}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
|
@@ -8,6 +8,13 @@ import { CodePreview } from './code-preview';
|
||||
import { Datum } from '@antv/ava';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import { IChunk } from '@/types/knowledge';
|
||||
import AgentPlans from './agent-plans';
|
||||
import AgentMessages from './agent-messages';
|
||||
import VisConvertError from './vis-convert-error';
|
||||
import VisChart from './vis-chart';
|
||||
import VisDashboard from './vis-dashboard';
|
||||
import VisPlugin from './vis-plugin';
|
||||
import VisCode from './vis-code';
|
||||
|
||||
type MarkdownComponent = Parameters<typeof ReactMarkdown>['0']['components'];
|
||||
|
||||
@@ -27,18 +34,82 @@ function matchCustomeTagValues(context: string) {
|
||||
|
||||
const basicComponents: MarkdownComponent = {
|
||||
code({ inline, node, className, children, style, ...props }) {
|
||||
const content = String(children);
|
||||
/**
|
||||
* @description
|
||||
* In some cases, tags are nested within code syntax,
|
||||
* so it is necessary to extract the tags present in the code block and render them separately.
|
||||
*/
|
||||
const { context, matchValues } = matchCustomeTagValues(Array.isArray(children) ? children.join('\n') : children);
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const { context, matchValues } = matchCustomeTagValues(content);
|
||||
const lang = className?.replace('language-', '') || 'javascript';
|
||||
|
||||
if (lang === 'agent-plans') {
|
||||
try {
|
||||
const data = JSON.parse(content) as Parameters<typeof AgentPlans>[0]['data'];
|
||||
return <AgentPlans data={data} />;
|
||||
} catch (e) {
|
||||
return <CodePreview language={lang} code={content} />;
|
||||
}
|
||||
}
|
||||
|
||||
if (lang === 'agent-messages') {
|
||||
try {
|
||||
const data = JSON.parse(content) as Parameters<typeof AgentMessages>[0]['data'];
|
||||
return <AgentMessages data={data} />;
|
||||
} catch (e) {
|
||||
return <CodePreview language={lang} code={content} />;
|
||||
}
|
||||
}
|
||||
|
||||
if (lang === 'vis-convert-error') {
|
||||
try {
|
||||
const data = JSON.parse(content) as Parameters<typeof VisConvertError>[0]['data'];
|
||||
return <VisConvertError data={data} />;
|
||||
} catch (e) {
|
||||
return <CodePreview language={lang} code={content} />;
|
||||
}
|
||||
}
|
||||
|
||||
if (lang === 'vis-dashboard') {
|
||||
try {
|
||||
const data = JSON.parse(content) as Parameters<typeof VisDashboard>[0]['data'];
|
||||
return <VisDashboard data={data} />;
|
||||
} catch (e) {
|
||||
return <CodePreview language={lang} code={content} />;
|
||||
}
|
||||
}
|
||||
|
||||
if (lang === 'vis-chart') {
|
||||
try {
|
||||
const data = JSON.parse(content) as Parameters<typeof VisChart>[0]['data'];
|
||||
return <VisChart data={data} />;
|
||||
} catch (e) {
|
||||
return <CodePreview language={lang} code={content} />;
|
||||
}
|
||||
}
|
||||
|
||||
if (lang === 'vis-plugin') {
|
||||
try {
|
||||
const data = JSON.parse(content) as Parameters<typeof VisPlugin>[0]['data'];
|
||||
return <VisPlugin data={data} />;
|
||||
} catch (e) {
|
||||
return <CodePreview language={lang} code={content} />;
|
||||
}
|
||||
}
|
||||
|
||||
if (lang === 'vis-code') {
|
||||
try {
|
||||
const data = JSON.parse(content) as Parameters<typeof VisCode>[0]['data'];
|
||||
return <VisCode data={data} />;
|
||||
} catch (e) {
|
||||
return <CodePreview language={lang} code={content} />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!inline ? (
|
||||
<CodePreview code={context} language={match?.[1] ?? 'javascript'} />
|
||||
<CodePreview code={context} language={lang} />
|
||||
) : (
|
||||
<code {...props} style={style} className="p-1 mx-1 rounded bg-theme-light dark:bg-theme-dark text-sm">
|
||||
{children}
|
||||
@@ -61,7 +132,7 @@ const basicComponents: MarkdownComponent = {
|
||||
},
|
||||
table({ children }) {
|
||||
return (
|
||||
<table className="my-2 rounded-tl-md rounded-tr-md max-w-full bg-white dark:bg-gray-900 text-sm rounded-lg overflow-hidden">{children}</table>
|
||||
<table className="my-2 rounded-tl-md rounded-tr-md max-w-full bg-white dark:bg-gray-800 text-sm rounded-lg overflow-hidden">{children}</table>
|
||||
);
|
||||
},
|
||||
thead({ children }) {
|
||||
|
19
web/components/chat/chat-content/vis-chart.tsx
Normal file
19
web/components/chat/chat-content/vis-chart.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { BackEndChartType } from '@/components/chart';
|
||||
import ChartView from './chart-view';
|
||||
import { Datum } from '@antv/ava';
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
data: Datum[];
|
||||
describe: string;
|
||||
title: string;
|
||||
type: BackEndChartType;
|
||||
sql: string;
|
||||
};
|
||||
}
|
||||
|
||||
function VisChart({ data }: Props) {
|
||||
return <ChartView data={data.data} type={data.type} sql={data.sql} />;
|
||||
}
|
||||
|
||||
export default VisChart;
|
69
web/components/chat/chat-content/vis-code.tsx
Normal file
69
web/components/chat/chat-content/vis-code.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import markdownComponents from './config';
|
||||
import { CodePreview } from './code-preview';
|
||||
import classNames from 'classnames';
|
||||
import { useState } from 'react';
|
||||
import { CheckOutlined, CloseOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { oneLight, oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
code: string[];
|
||||
exit_success: true;
|
||||
language: string;
|
||||
log: string;
|
||||
};
|
||||
}
|
||||
|
||||
function VisCode({ data }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [show, setShow] = useState(0);
|
||||
|
||||
return (
|
||||
<div className="bg-[#EAEAEB] rounded overflow-hidden border border-theme-primary dark:bg-theme-dark text-sm">
|
||||
<div>
|
||||
<div className="flex">
|
||||
{data.code.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={classNames('px-4 py-2 text-[#121417] dark:text-white cursor-pointer', {
|
||||
'bg-white dark:bg-theme-dark-container': index === show,
|
||||
})}
|
||||
onClick={() => {
|
||||
setShow(index);
|
||||
}}
|
||||
>
|
||||
CODE {index + 1}: {item[0]}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{data.code.length && (
|
||||
<CodePreview
|
||||
language={data.code[show][0]}
|
||||
code={data.code[show][1]}
|
||||
customStyle={{ maxHeight: 300, margin: 0 }}
|
||||
light={oneLight}
|
||||
dark={oneDark}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex">
|
||||
<div className="bg-white dark:bg-theme-dark-container px-4 py-2 text-[#121417] dark:text-white">
|
||||
{t('Terminal')} {data.exit_success ? <CheckOutlined className="text-green-600" /> : <CloseOutlined className="text-red-600" />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 max-h-72 overflow-y-auto whitespace-normal bg-white dark:dark:bg-theme-dark">
|
||||
<ReactMarkdown components={markdownComponents} remarkPlugins={[remarkGfm]}>
|
||||
{data.log}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VisCode;
|
24
web/components/chat/chat-content/vis-convert-error.tsx
Normal file
24
web/components/chat/chat-content/vis-convert-error.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { format } from 'sql-formatter';
|
||||
import { CodePreview } from './code-preview';
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
display_type: string;
|
||||
sql: string;
|
||||
thought: string;
|
||||
};
|
||||
}
|
||||
|
||||
function VisConvertError({ data }: Props) {
|
||||
return (
|
||||
<div className="rounded overflow-hidden">
|
||||
<div className="p-3 text-white bg-red-500 whitespace-normal">{data.display_type}</div>
|
||||
<div className="p-3 bg-red-50">
|
||||
<div className="mb-2 whitespace-normal">{data.thought}</div>
|
||||
<CodePreview code={format(data.sql)} language="sql" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VisConvertError;
|
58
web/components/chat/chat-content/vis-dashboard.tsx
Normal file
58
web/components/chat/chat-content/vis-dashboard.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { AutoChart, BackEndChartType, getChartType } from '@/components/chart';
|
||||
import { Datum } from '@antv/ava';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
data: {
|
||||
data: Datum[];
|
||||
describe: string;
|
||||
title: string;
|
||||
type: BackEndChartType;
|
||||
sql: string;
|
||||
}[];
|
||||
title: string | null;
|
||||
display_strategy: string;
|
||||
chart_count: number;
|
||||
};
|
||||
}
|
||||
|
||||
const chartLayout = [[2], [1, 2], [1, 3], [2, 1, 2], [2, 1, 3], [3, 1, 3], [3, 2, 3]];
|
||||
|
||||
function VisDashboard({ data }: Props) {
|
||||
const charts = useMemo(() => {
|
||||
if (data.chart_count > 1) {
|
||||
const layout = chartLayout[data.chart_count - 2];
|
||||
let prevIndex = 0;
|
||||
return layout.map((item) => {
|
||||
const items = data.data.slice(prevIndex, prevIndex + item);
|
||||
prevIndex = item;
|
||||
return items;
|
||||
});
|
||||
}
|
||||
return [data.data];
|
||||
}, [data.data, data.chart_count]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{charts.map((row, index) => (
|
||||
<div key={`row-${index}`} className="flex gap-3">
|
||||
{row.map((chart, subIndex) => (
|
||||
<div
|
||||
key={`chart-${subIndex}`}
|
||||
className="flex flex-1 flex-col justify-between p-4 rounded border border-gray-200 dark:border-gray-500 whitespace-normal"
|
||||
>
|
||||
<div>
|
||||
{chart.title && <div className="mb-2 text-lg">{chart.title}</div>}
|
||||
{chart.describe && <div className="mb-4 text-sm text-gray-500">{chart.describe}</div>}
|
||||
</div>
|
||||
<AutoChart data={chart.data} chartType={getChartType(chart.type)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VisDashboard;
|
64
web/components/chat/chat-content/vis-plugin.tsx
Normal file
64
web/components/chat/chat-content/vis-plugin.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { CheckOutlined, ClockCircleOutlined, CloseOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||
import classNames from 'classnames';
|
||||
import { ReactNode } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import markdownComponents from './config';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
|
||||
interface IVisPlugin {
|
||||
name: string;
|
||||
args: {
|
||||
query: string;
|
||||
};
|
||||
status: 'todo' | 'runing' | 'failed' | 'complete' | (string & {});
|
||||
logo: string | null;
|
||||
result: string;
|
||||
err_msg: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: IVisPlugin;
|
||||
}
|
||||
|
||||
const pluginViewStatusMapper: Record<IVisPlugin['status'], { bgClass: string; icon: ReactNode }> = {
|
||||
todo: {
|
||||
bgClass: 'bg-gray-500',
|
||||
icon: <ClockCircleOutlined className="ml-2" />,
|
||||
},
|
||||
runing: {
|
||||
bgClass: 'bg-blue-500',
|
||||
icon: <LoadingOutlined className="ml-2" />,
|
||||
},
|
||||
failed: {
|
||||
bgClass: 'bg-red-500',
|
||||
icon: <CloseOutlined className="ml-2" />,
|
||||
},
|
||||
complete: {
|
||||
bgClass: 'bg-green-500',
|
||||
icon: <CheckOutlined className="ml-2" />,
|
||||
},
|
||||
};
|
||||
|
||||
function VisPlugin({ data }: Props) {
|
||||
const { bgClass, icon } = pluginViewStatusMapper[data.status] ?? {};
|
||||
|
||||
return (
|
||||
<div className="bg-theme-light dark:bg-theme-dark-container rounded overflow-hidden my-2 flex flex-col lg:max-w-[80%]">
|
||||
<div className={classNames('flex px-4 md:px-6 py-2 items-center text-white text-sm', bgClass)}>
|
||||
{data.name}
|
||||
{icon}
|
||||
</div>
|
||||
{data.result ? (
|
||||
<div className="px-4 md:px-6 py-4 text-sm whitespace-normal">
|
||||
<ReactMarkdown components={markdownComponents} rehypePlugins={[rehypeRaw]}>
|
||||
{data.result ?? ''}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 md:px-6 py-4 text-sm">{data.err_msg}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VisPlugin;
|
Reference in New Issue
Block a user