Vue3 实战:从前端流式请求到 ECharts 图表,深度解析人机对话界面实现

Vue3 实战:从前端流式请求到 ECharts 图表,深度解析人机对话界面实现

好的,这是一篇基于您提供的 index.vue 文件,详细分析如何使用 Vue3 构建人机对话功能的文章,特别聚焦于流式数据处理、Markdown 渲染和 ECharts 图表集成。

摘要: 本文将深入剖析一个基于 Vue3 构建的智能人机对话界面的前端实现。我们将以具体的代码为例,详细讲解如何利用 fetchStream 实现高效的流式数据请求与处理,如何集成 markdown-it 并配合自定义预处理器优雅地展示 Markdown 内容,以及如何动态接收后端数据并使用 ECharts 在前端渲染多种类型的图表。通过解读 index.vue 中的关键代码片段,带您掌握这些核心功能的实现原理。

关键词: Vue3, 人机对话, 流式请求, Stream, fetchStream, markdown-it, Markdown 渲染, ECharts, 图表可视化, preprocessMarkdown2

正文:

大家好!今天我们来深入探讨一个现代前端应用中非常酷的功能——人机对话界面。我们将以一个实际的 Vue3 组件 index.vue 为蓝本,重点分析其背后几个关键技术点的实现细节。

1. 流式响应:告别漫长等待,实现即时打字机效果

传统的 API 请求模式是“发送请求 -> 等待 -> 接收完整响应”。这对于 AI 生成长文本的场景来说,用户体验很差。更好的方式是像 ChatGPT 那样,实现流式响应,即 AI 边生成边返回结果,前端边接收边显示,营造出“打字机”般的效果。

fetchStream 函数

// utils/streamUtils.js 或直接写在组件中 export async function fetchStream(url, data, onChunk, onError, onComplete,signal,) { try { const response = await fetch(url, { method: 'POST', headers: { ​ 'Content-Type': 'application/json', // 根据后端要求调整 ​ // 'Accept': 'text/event-stream', ​ // 'Cache-Control': 'no-cache', ​ // 'Connection': 'keep-alive', }, body: JSON.stringify(data), signal, }); if (!response.ok || !response.body) { throw new Error('Response body is not readable'); } const reader = response.body.getReader(); const decoder = new TextDecoder('utf-8'); let done = false; try{ while (!done) { const { value, done: streamDone } = await reader.read(); done = streamDone; if (value) { ​ const chunk = decoder.decode(value, { stream: true }); ​ onChunk(chunk); // 实时回调 } } onComplete?.(); }finally { reader.releaseLock(); } } catch (error) { if (error.name === 'AbortError') { console.log('请求已被中止'); return; // 静默处理,不触发 onError } onError?.(error); } }

index.vue 中,handleSend 函数是发起对话的核心。让我们看看它是如何实现流式处理的:

// index.vue - handleSend 函数片段 const handleSend = async () => { // ... 用户输入校验、清空旧请求逻辑 ... let; // 用于累积原始文本 const controller = new AbortController(); abortController.value = controller; try { await fetchStream( "/knowledge/api/knowledge/query", // API 地址 requestData, // 请求参数 (chunk) => { // onData 回调:处理接收到的每一个数据块 if (controller.signal.aborted) return; // 如果请求被取消,直接返回 // 1. 将 chunk 转为字符串 const chunkStr = typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk); // 2. 按行分割(SSE 常用格式) const lines = chunkStr.split("\n"); let hasNewVisibleContent = false; for (const line of lines) { if (line.startsWith("data:")) { // SSE 格式,内容在 data: 后面 let content = line.slice(5).trim(); // 去掉 "data:" // 清理掉 AI 的思考过程标签 content = content.replace(/<think>[\s\S]*?<\/think>/g, "").trim(); if (!content) continue; // 跳过空内容 rawText += content + "\n"; // 累积到 rawText hasNewVisibleContent = true; } } if (hasNewVisibleContent) { if (!loadingAnswer.hasContent) { loadingAnswer.hasContent = true; // 标记已有内容 } // 实现渲染节流,避免频繁 DOM 操作 const now = Date.now(); if (now - lastRenderTime.value >= renderThrottleDelay && !pendingRender.value) { pendingRender.value = true; setTimeout(() => { // 异步执行渲染 try { let processedText = preprocessMarkdown2(rawText); // 预处理 const renderedContent = md.render(processedText); // 渲染为 HTML if (renderedContent !== loadingAnswer.content) { loadingAnswer.content = renderedContent; // 更新 DOM lastRenderedLength.value = rawText.length; // 更新缓存长度 } } catch (error) { console.error('渲染错误:', error); } finally { lastRenderTime.value = Date.now(); pendingRender.value = false; } }, 0); } } }, (error) => { /* onError 回调 */ }, () => { /* onComplete 回调:处理请求结束 */ } ); } catch (error) { /* ... */ } }; 

关键点分析:

  • fetchStream 函数: 这是一个封装了 fetchReadableStream 的工具函数(虽然代码未提供,但其作用是处理流式响应)。它接收 url, data, onData, onError, onCompletesignal 参数。
  • AbortController: 用于控制请求的生命周期。当用户再次点击发送或开始新对话时,abortController.value.abort() 会被调用,controller.signal.aborted 会在回调中被检查,从而中断正在进行的请求和处理。
  • rawText 累积: 每次 onData 回调接收到数据块后,会将其内容(去掉 data: 前缀)追加到 rawText 变量中。这是后续渲染的基础。
  • 渲染节流 (renderThrottleDelay, pendingRender): 直接将每次接收到的新内容立即渲染到 DOM 是低效且会导致视觉闪烁的。因此,代码使用 setTimeout 和时间戳 lastRenderTime 来限制渲染频率(例如 200ms 一次),并在上一次渲染任务执行完毕前阻止新的渲染任务被加入队列(通过 pendingRender 标志)。
  • preprocessMarkdown2md.render: 累积的 rawText 会经过 preprocessMarkdown2 预处理,然后传递给 md.render 生成 HTML,最后赋值给 loadingAnswer.content 更新视图。

2. 内容渲染:markdown-itpreprocessMarkdown2 的协作

AI 返回的内容往往是 Markdown 格式,包含各种样式和结构。index.vue 使用 markdown-it 库来处理这些内容。

// index.vue - 初始化 markdown-it const md = markdownit({ html: true, linkify: true, highlight: function (str, lang) { // ... 语法高亮处理 ... }, }); // index.vue - preprocessMarkdown2 函数片段 (仅举几例) function preprocessMarkdown2(text) { if (!text) return ""; // 标准化换行 text = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); // 🔥 防止 --- 被识别为 Setext 标题 text = text.replace(/([^\n])\n---\n/g, "$1\n\n---\n"); // 🔧【✅ 核心修复】提取所有 **###标题** → 转为标准 Markdown 标题 text = text.replace( /\*\*(#{1,6})\s*([^*\n]+?)\s*\*\*/g, (match, hashes, title) => { return `\n\n${hashes} ${title.trim()}\n`; } ); // 🔧 修复列表项之间缺少换行 text = text.replace(/(-\s+[^\n]+?)(?<!-)(-\s+(?!-)[^\n]+)/g, '$1\n$2'); // 🔧 清理 ** 内的 HTML 标签 text = text.replace(/\*\*([^*]*?)\*\*/g, (match, content) => { const clean = content.replace(/<[^>]+>/g, ''); return `**${clean}**`; }); // ... 更多修复规则 ... // 🔧 提取并渲染图表 (调用 extractAndRenderCharts) text = extractAndRenderCharts(text); return text.trim(); } 

关键点分析:

  • markdown-it 配置:html: true 允许原始 HTML;linkify: true 自动识别 URL 并转为链接;highlight 函数用于处理代码块的语法高亮(结合 highlight.js)。
  • preprocessMarkdown2 的作用: AI 生成的 Markdown 可能不规范,直接交给 markdown-it 渲染可能会出错或不符合预期。这个函数就像一个“过滤器”和“修正器”,它通过一系列正则表达式替换,处理常见的格式问题:
    • 修复标题格式:**### 标题** 会被转换为标准的 ### 标题
    • 修复列表格式: 确保列表项之间有正确的换行。
    • 清理标签: 移除加粗标签 ** 内部的 HTML 标签。
    • 标准化换行: 统一换行符。
    • extractAndRenderCharts: 这个函数在处理完常规 Markdown 之后被调用,用于处理图表数据。
  • v-html 指令: 在模板中,<div v-html="item.content"></div> 使用 v-htmlmd.render 输出的 HTML 字符串插入到 DOM 中,从而显示富文本内容。

3. 图表可视化:extractAndRenderCharts 与 ECharts 的集成

当 AI 需要展示数据时,它可能返回一个 JSON 格式的代码块。前端需要解析这个 JSON 并使用 ECharts 进行可视化。

// index.vue - extractAndRenderCharts 函数 function extractAndRenderCharts(text) { const jsonRegex = /```(?:\w*)?\s*([\s\S]*?)\s*```/g; // 匹配代码块 let match; let chartDataMap = new Map(); // 存储图表ID和数据的映射 let processedText = text; let chartIndex = 0; const conversationId = Date.now() + '-' + Math.random().toString(36).substr(2, 9); while ((match = jsonRegex.exec(text)) !== null) { // 查找所有JSON代码块 try { let jsonContent = match[1].trim(); jsonContent = fixJsonFormat(jsonContent); // 修复JSON格式 let data = JSON.parse(jsonContent); if (Array.isArray(data) && data.length > 0) { // 验证数据有效性 const chartId = `${conversationId}-${chartIndex}`; chartDataMap.set(chartId, data); // 用占位符替换原代码块 const originalCodeBlock = match[0]; if (processedText.includes(originalCodeBlock)) { processedText = processedText.replace( originalCodeBlock, `<div></div>\n\n` ); } chartIndex++; } } catch (error) { console.error('解析JSON数据失败:', error); } } if (chartDataMap.size > 0) { nextTick(() => { // 确保DOM更新后渲染 renderCharts(chartDataMap); }); } return processedText; // 返回替换后的文本 } // index.vue - renderCharts 函数 function renderCharts(chartDataMap) { nextTick(() => { chartDataMap.forEach((data, chartId) => { const container = document.querySelector(`.echartContainer[data-chart-id="${chartId}"]`); if (!container) return; // 销毁旧实例(如果有) if (container.chartInstance) { container.chartInstance.dispose(); } if (!data || data.length === 0) return; // 初始化 ECharts 实例 const chart = echarts.init(container); container.chartInstance = chart; // 存储实例引用 // 解构数据 const dataX = data.map(item => item.data_time); const dataY = data.map(item => item.data_value); const pointName = data[0].point_name; const showType = data[0].show_type || '折线图'; // 根据 showType 生成不同的 ECharts 配置 let option; switch (showType) { case '柱状图': // ... 配置柱状图 option ... break; case '饼状图': // ... 配置饼状图 option ... break; case '折线图': default: // ... 配置折线图 option ... break; } chart.setOption(option); // 应用配置 }); // 添加窗口大小监听器,用于图表响应式 if (!window.resizeEventListenerAdded) { window.addEventListener('resize', () => { document.querySelectorAll('.echartContainer').forEach(container => { if (container.chartInstance) { container.chartInstance.resize(); } }); }); window.resizeEventListenerAdded = true; } }) } 

关键点分析:

  • extractAndRenderCharts:
    • 使用正则表达式 /```(?:\w*)?\s*([\s\S]*?)\s*```/g 从 Markdown 文本中提取所有代码块内容。
    • 对提取的内容进行 JSON.parse 解析和有效性检查。
    • 成功解析后,生成一个唯一的 chartId,并用包含该 ID 的 <div> 占位符替换原始的代码块文本。
    • chartIddata 存入 chartDataMap,并返回处理后的文本(processedText),这个文本最终会被 md.render 处理,生成包含图表容器的 HTML。
  • renderCharts:
    • 遍历 chartDataMap,为每个 chartId 找到对应的 DOM 容器。
    • 实例管理: 检查容器是否已有 chartInstance,若有则先 dispose 掉,防止内存泄漏和重复初始化。
    • ECharts 初始化: 调用 echarts.init(container) 创建实例,并存储引用。
    • 数据解析与配置: 从 JSON 数据中提取 X/Y 轴数据 (dataX, dataY)、站点名 (pointName)、图表类型 (showType)。
    • 动态配置: 使用 switch 语句根据 showType 生成不同的 option 配置对象(包含标题、坐标轴、系列等)。
    • 应用配置:chart.setOption(option) 将配置应用到 ECharts 实例,完成渲染。
  • 生命周期与性能:nextTick 确保在 DOM 更新(占位符 <div> 创建)后才执行渲染逻辑。onUnmounted 钩子和新对话逻辑中会销毁所有图表实例,防止内存泄漏。全局的 resize 事件监听器确保图表能响应窗口大小变化。

总结

通过以上对 index.vue 代码的详细解读,我们看到了一个功能完整的前端对话界面是如何运作的。fetchStream 实现了流畅的流式体验,preprocessMarkdown2markdown-it 共同保障了 Markdown 内容的正确展示,而 extractAndRenderCharts 与 ECharts 的结合则赋予了界面强大的数据可视化能力。掌握这些技术点,对于开发复杂的前端应用具有重要意义。


注意: 请确保 fetchStream 工具函数的实现与 index.vue 中的调用方式相匹配,它是实现流式响应的关键。

Vue3 + Element Plus 实现聊天对话自动滚动到底部的完整方案

Read more

告别 Copilot 时代:Cursor, Kiro 与 Google Antigravity 如何重新定义编程?

如果说 GitHub Copilot 开启了 AI 辅助编程的“副驾驶”时代,那么 2024-2025 年则是 AI Agent(智能体) 全面接管 IDE 的元年。 现在的开发者不再满足于简单的代码补全,我们需要的是能理解整个项目架构、能自主规划任务、甚至能像真人同事一样工作的“编程搭子”。 今天,我们盘点三款目前最受瞩目、处于风口浪尖的 AI 编程工具:Cursor、Kiro 以及 Google 的重磅新品 Antigravity。无论你是想提升效率,还是想尝鲜最前沿的 Agentic Workflow,这三款神器都不容错过。 1. Cursor:当下体验最好的 AI 代码编辑器 定位:目前最成熟、最流畅的 VS Code 替代者 Cursor

By Ne0inhk
AI大模型实战——如何本地化部署开源大模型ChatGLM3-6B

AI大模型实战——如何本地化部署开源大模型ChatGLM3-6B

一、大模型的选择 * 当然,也有不少厂商是基于 LLaMA 爆改的,或者叫套壳,不是真正意义上的自研大模型。 * ChatGLM-6B 和 LLaMA2 是目前开源项目比较热的两个,早在 2023 年年初,国内刚兴起大模型热潮时,智谱 AI 就开源了 ChatGLM-6B,当然 130B 也可以拿过来跑,只不过模型太大,需要比较多的显卡,所以很多人就部署 6B 试玩。 * 从长远看,信创大潮下,国产大模型肯定是首选,企业布局 AI 大模型,要么选择 MaaS 服务,调用大厂大模型 API,要么选择开源大模型,自己微调、部署,为上层应用提供服务。使用 MaaS 服务会面临数据安全问题,所以一般企业会选择私有化部署 + 公有云 MaaS 混合的方式来架构。

By Ne0inhk
AI绘画建筑设计提示词:从基础到高级的完整创作指南

AI绘画建筑设计提示词:从基础到高级的完整创作指南

一、核心逻辑:高质量建筑提示词的 7 大组成部分 AI 对建筑的理解需要 “分层引导”,一个完整的提示词通常包含 7 个关键模块,你可根据需求灵活组合或删减,基础逻辑为:先明确 “画什么”,再定义 “怎么画”,最后优化 “画得好”。具体结构如下: [主体/建筑类型] + [风格/建筑师参考] + [环境/场景设定] + [细节与材质] + [构图与视角] + [灯光与氛围] + [画质/技术参数] 这一结构能让 AI 清晰捕捉设计核心,避免因信息模糊导致的 “偏离预期”,是高效创作的基础框架。 二、分模块详解:建筑提示词词汇库与应用技巧 1. 主体 / 建筑类型:明确 “画什么” 的核心 这是提示词的 “根基”,需精准定义建筑的功能与形态,避免笼统表述。

By Ne0inhk

Copilot代理与网络配置全攻略(突破访问限制的终极方法)

第一章:Copilot代理与网络配置全攻略(突破访问限制的终极方法) 在使用 GitHub Copilot 的过程中,开发者常因网络策略或区域限制无法正常激活服务。通过合理配置代理与网络环境,可有效绕过此类问题,确保代码补全功能稳定运行。 配置本地代理服务器 为确保 Copilot 能够连接至远程 API,建议在本地部署 HTTP 代理服务。以下是一个基于 Node.js 的简易代理示例: // proxy-server.js const http = require('http'); const net = require('net'); // 创建 HTTP 代理服务器 const server = http.createServer((req, res) => { // 允许跨域请求 res.setHeader(

By Ne0inhk