上阶段接到一个新需求,大模型流式输出过程中,要求把部分表格数据用图表展示,很离谱的是,产品叫前端自己从非结构化数据自己抓数据来渲染,然后数据还是流式输出的markdown格式文本,离谱归离谱,也得干啊,事先给他说了,不确定因素很多,可能十个问题只有一个才能触发返回图表格式文本
话不多说,直接贴代码
vue
import { Chart, registerables } from 'chart.js';
import ChartDataLabels from 'chartjs-plugin-datalabels';
const renderedMessages = computed(() => {
return messages.value.map((message,index) => {
if (message.type === "robot") {
// 正则表达式匹配表格
const tableRegex = /(\|.*?\|\n)+/g;
// 正则表达式匹配注释内容
// const noteRegex = /\s*\*\*(.*?)\*\*/;
// 检测是否存在多个表格
const tableMatches = message.output.match(tableRegex);
// 提取注释内容
if (tableMatches && tableMatches.length > 1) {
console.log("存在多个表格,跳过图表渲染");
return renderedMarkdown(message.output);
}
// 检查是否已经绘制过图表且包含表格结构
if (!message.chartRendered && tableRegex.test(message.output)) {
try {
// 提取表格部分
const tableMatch = message.output.match(tableRegex);
if (tableMatch) {
const tableContent = tableMatch.join(''); // 合并所有匹配的表格内容
// const noteMatch = message.output.match(noteRegex);
// let noteContent = '';
// // 把包含"data:"的部分替换成""
// // const cleanedOutput = message.output.replace(/data:\s*/g, '');
// if (noteMatch) {
// noteContent = noteMatch[1]; // 获取注释内容
// console.log("注释内容noteContent",noteContent)
// }
// 将表格内容按行分割
const lines = tableContent.split('\n').map(line => line.trim()).filter(line => line !== '');
// 提取表头
const headers = lines[0].split('|').map(header => header.trim()).filter(header => header !== '');
// 判断列数是否小于等于三列
if (headers.length <= 3) {
const labelIndex = headers.findIndex(header => header.includes('当前月份'));
const dataIndex = headers.findIndex(header => header.includes('当前水量'));
const schoolIndex = headers.findIndex(header => header.includes('学校'));
if (labelIndex < 0 || dataIndex < 0 || schoolIndex < 0) {
console.log("表头不包含 '当前月份' 或 '当前水量'");
return renderedMarkdown(message.output); // 返回原始 Markdown 渲染
}
// 提取数据行
const dataRows = lines.slice(2).map(row => {
return row.split('|').map(cell => cell.trim()).filter(cell => cell !== '');
});
// 提取指定列的数据
const labels = dataRows.map(row => row[labelIndex]); // 包含"当前月份"的列作为 X 轴标签
const data = dataRows.map(row => {
const value = row[dataIndex];
return value === 'nan%' ? 0 : parseFloat(value); // 处理 'nan%' 的情况
}); // 包含"当前水量"的列作为 Y 轴数据
// 组装 chartJson 结构
const chartJson = {
labels: labels,
datasets: [
{
data: data,
// order属性用于控制数据集的绘制顺序 值越小,越先绘制
// order:2,
// barThickness: (context: any) => {
// // 根据数据点的数量动态调整柱子宽度
// const numDataPoints = context.chart.data.labels.length;
// return Math.max(30, 80 / numDataPoints);
// }
},
// {
// type: 'line', // 指定类型为折线图
// data: data,
// order:1,
// borderColor: "rgba(255, 99, 132, 1)", // 折线图的颜色
// // fill: false, // 不填充折线图下方的区域
// // tension: 0.1 // 控制折线的平滑度
// }
],
};
console.log("chartJson:", chartJson);
const canvasElement = renderChart(chartJson, message.uid || index);
// @ts-ignore
const canvasHTML = canvasElement.outerHTML;
const renderedMarkdownText = renderedMarkdown(message.output); // 先渲染原有 Markdown
const replacedText = canvasHTML ? `${renderedMarkdownText}<div>${canvasHTML}</div>` : renderedMarkdownText;
message.chartRendered = true; // 添加标识符
// setTimeout 来延迟执行代码,以确保 DOM 元素已经渲染。
setTimeout(() => {
const targetSpan = document.querySelector(`[data-id="unique-${message.uid}"]`);
if (targetSpan) {
const table = targetSpan.querySelector('table');
if (table) {
table.style.display = 'none';
}
}
}, 50);
return replacedText; // 返回包含图表的内容
}
}
} catch (error) {
console.error("渲染图表时出错:", error);
return renderedMarkdown(message.output); // 返回原始 Markdown 渲染
}
}
return renderedMarkdown(message.output); // 保留原有的 Markdown 渲染
}
return message.output; // 用户消息直接返回
});
});
// 渲染图表的函数
const renderChart = async(chartData:any,id: string | number) => {
const canvasId = `chart-${Date.now()}`;
const divEle = document.createElement('div');
divEle.style.minWidth = '340px';
divEle.style.minHeight = '340px';
const canvasElement = document.createElement('canvas');
canvasElement.id = canvasId;
// canvasElement.width = 400;
// canvasElement.height = 400;
// chartjs-plugin-zoom:支持图表的缩放功能
// 在下一个 tick 中渲染图表
await nextTick(() => {
const ctx = canvasElement.getContext('2d');
if (!ctx) {
console.error("无法获取 Canvas 上下文");
return;
}
new Chart(ctx, {
type: 'bar', // 或 'line',根据需要选择
data: chartData,
options: {
aspectRatio: 1,
responsive: true,
maintainAspectRatio: false,
// 柱状图背景色
backgroundColor:["rgba(54, 162, 235, 0.7)"],
plugins: {
// 隐藏图例
legend: {
display: false,
},
tooltip: {
enabled: false,
},
// 自定义插件来显示数据值
datalabels: {
// 用于设置数据标签的锚点位置,即数据标签相对于数据点的相对位置。
anchor: (context) => {
// 当前数据集的索引 等于0通常是柱状图
const datasetIndex = context.datasetIndex;
return datasetIndex === 0 ? 'end' : 'start';
},
// 用于设置数据标签的对齐方式,即数据标签相对于锚点的对齐方向。
align: (context) => {
const datasetIndex = context.datasetIndex;
return datasetIndex === 0 ? 'end' : 'start'; // 柱形图在顶部,折线图在顶部
},
// anchor: 'end',
// align: 'end',
// 用于自定义标签的格式
formatter: (value,context:any) => {
// console.log("context.dataset.type",context.dataset.type);
// 混合图表 只显示一个数据标签
// if (context.dataset.type == 'line') {
// return "";
// }
return value; // 返回要显示的值
},
}
},
scales: {
x: {
ticks: {
autoSkip: false, // 禁用自动跳过标签
maxRotation: 90, // 标签旋转角度
minRotation: 45,
},
},
y: {
beginAtZero: true,
title: {
display: true,
text: '用水量', // 注意:这种方法通常用于显示Y轴的全局标题,而非顶部单独的文本标签。
font: {
size: 16 // 设置字体大小等属性
}
},
},
},
},
});
});
// 设置图表大小
// myChart.canvas.width = 600;
// myChart.canvas.height = 400;
// 将 canvas 元素插入到指定的 <span> 元素中
const targetSpan = document.querySelector(`[data-id="unique-${id}"]`);
if (targetSpan) {
divEle.appendChild(canvasElement)
targetSpan.appendChild(divEle);
}
return canvasElement;
};
// 在组件挂载时触发搜索
onMounted(() => {
// 注册chatjs
Chart.register(...registerables);
// 注册显示数据集插件
Chart.register(ChartDataLabels);
// renderChart1();
});
实现图表渲染的关键在renderChart函数,因为是大模型多轮对话输出markdown格式文本,前端用v-html处理渲染,不确定性因素很多,不适合直接用html标签创建图表,所以需要通过js创建,其他倒没什么,可以参考官方文档或者网上搜一下
过程中遇到一个非常烦的问题,就是需要适配移动端,用手机打开网页的时候,图表被压缩,因为canvas不能通过css直接设置宽高,其实也可以设置,就是会被压缩画质,然后 canvasElement.width = 400;canvasElement.height = 400;设置宽高居然不起作用,看chartjs官方文档也没找到设置宽高的方法,不知道各位大佬有没有好的办法,没办法最后只能通过给canvas父级元素加个div元素,通过给父元素设置宽高才解决问题