大模型数据流处理实战:Vue+NDJSON的Markdown安全渲染架构

在Vue中使用HTTP流接收大模型NDJSON数据并安全渲染

在构建现代Web应用时,处理大模型返回的流式数据并安全地渲染到页面是一个常见需求。本文将介绍如何在Vue应用中通过普通HTTP流接收NDJSON格式的大模型响应,使用marked、highlight.js和DOMPurify等库进行安全渲染。

效果预览

在这里插入图片描述

技术栈概览

  • Vue 3:现代前端框架
  • NDJSON (Newline Delimited JSON):大模型常用的流式数据格式
  • marked:Markdown解析器
  • highlight.js:代码高亮
  • DOMPurify:HTML净化,防止XSS攻击

实现步骤

1. 安装依赖

首先安装必要的依赖:

npm install marked highlight.js dompurify

2. 创建流式请求工具函数

创建一个工具函数来处理NDJSON流,我使用axios,但更推荐直接是使用fetch,由于本地部署的大模型,采用的是普通HTTP的流(chunked),目前采用SSE方式的更多:

//  utils/request.js
import axios from "axios"
import { ElMessage } from 'element-plus'const request = axios.create({baseURL: import.meta.env.VITE_APP_BASE_API,timeout: 0
});// 存储所有活动的 AbortController
const activeRequests = new Map();// 生成唯一请求 ID 的函数
export function generateRequestId(config) {// 包含请求 URL、方法、参数和数据,确保唯一性const params = JSON.stringify(config.params || {});const data = JSON.stringify(config.data || {});return `${config.url}-${config.method.toLowerCase()}-${params}-${data}`;
}// 请求拦截器
request.interceptors.request.use((config) => {const requestId = generateRequestId(config);// 如果已有相同请求正在进行,则取消前一个if (activeRequests.has(requestId)) {activeRequests.get(requestId).abort('取消重复请求');}// 创建新的 AbortController 并存储const controller = new AbortController();activeRequests.set(requestId, controller);// 绑定 signal 到请求配置config.signal = controller.signal;return config;
});// 响应拦截器
request.interceptors.response.use((response) => {const requestId = generateRequestId(response.config);activeRequests.delete(requestId); // 请求完成,清理控制器return response;
}, (error) => {if (axios.isCancel(error)) {console.log('over');} else {// 修正 ElMessage 的使用,正确显示错误信息ElMessage({type: 'error',message: error.message || '请求发生错误'});}// 返回失败的 promisereturn Promise.reject(error);
});/*** 手动取消请求* @param {string} requestId 请求 ID*/
export function cancelRequest(requestId) {if (activeRequests.has(requestId)) {activeRequests.get(requestId).abort('用户手动取消');activeRequests.delete(requestId);} else {console.log(`未找到请求 ID: ${requestId},可能已完成或取消`);}
}// 导出请求实例
export default request;

通过请求封装,提升模块化能力

// apis/stream.js
import request, { cancelRequest, generateRequestId } from '@/utils/request.js'// 全局缓冲不完整的行
let buffer = '';
let currentRequestConfig = null; // 存储当前请求的配置
let lastPosition = 0;/*** qwen对话* @param {*} data 对话数据*/
export function qwenTalk(data, onProgress) {const config = {url: '/api/chat',method: 'POST',data,responseType: 'text'};currentRequestConfig = config;// 重置 bufferbuffer = '';lastPosition = 0return request({...config,onDownloadProgress: (progressEvent) => {const responseText = progressEvent.event.target?.responseText || '';const newText = responseText.slice(lastPosition);lastPosition = responseText.length;parseStreamData(newText, onProgress);},})
}/*** 解析流式 NDJSON 数据* @param {string} text 原始流文本* @param {function} onProgress 回调函数,用于处理解析后的 JSON 数据*/
function parseStreamData(text, onProgress) {// 将新接收到的文本追加到全局缓冲 buffer 中buffer += text;const lines = buffer.split('\n');// 处理完整的行for (let i = 0; i < lines.length - 1; i++) {const line = lines[i].trim();if (line) {try {const data = JSON.parse(line);onProgress(data);} catch (err) {console.error('JSON 解析失败:', err, '原始数据:', line);}}}// 保留最后一行作为不完整的部分buffer = lines[lines.length - 1];
}/*** 取消请求*/
export function cancelQwenTalk() {if (currentRequestConfig) {const requestId = generateRequestId(currentRequestConfig);cancelRequest(requestId);currentRequestConfig = null;}
}

3. 创建Markdown渲染工具

配置marked、highlight.js和DOMPurify:

// utils/markdown.js
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import hljs from 'highlight.js';
import 'highlight.js/styles/github-dark.css'; // 选择一个高亮主题// 配置 marked
marked.setOptions({langPrefix: 'hljs language-', // 高亮代码块的class前缀breaks: true,gfm: true,highlight: (code, lang) => {// 如果指定了语言,尝试使用该语言高亮if (lang && hljs.getLanguage(lang)) {try {return hljs.highlight(code, { language: lang }).value;} catch (e) {console.warn(`代码高亮失败 (${lang}):`, e);}}// 否则尝试自动检测语言try {return hljs.highlightAuto(code).value;} catch (e) {console.warn('自动代码高亮失败:', e);return code; // 返回原始代码}}
});// 导出渲染函数
export function renderMarkdown(content) {const html = marked.parse(content);const sanitizedHtml = DOMPurify.sanitize(html);// 确保 highlight.js 应用样式setTimeout(() => {if (typeof window !== 'undefined') {document.querySelectorAll('pre code').forEach((block) => {// 检查是否已经高亮过if (!block.dataset.highlighted) {hljs.highlightElement(block);block.dataset.highlighted = 'true'; // 标记为已高亮}});}}, 0);return sanitizedHtml;
}

4. 在Vue组件中使用

创建一个Vue组件来处理流式数据并渲染:

<template><div class="chat-container"><!-- 对话消息展示区域,添加 ref 属性 --><div ref="chatMessagesRef" class="chat-messages"><div v-for="(message, index) in messages" :key="index" :class="['message', message.type]"><el-avatar :src="message.avatar" :size="48" class="avatar"></el-avatar><div class="markdown-container"><div class="markdown-content" v-html="message.content"></div><div v-if="message.loading" class="loading-dots"><span></span><span></span><span></span></div></div></div></div><!-- 输入区域 --><div class="chat-input"><el-input v-model="inputMessage" type="textarea" :rows="2" placeholder="请输入您的问题..."@keyup.enter="canSend && sendMessage()"></el-input><el-button type="primary" @click="sendMessage" :disabled="!canSend">发送</el-button><!-- 添加请求状态图标 --><el-icon v-if="currentAIReply" @click="cancelRequest"><Close /></el-icon><el-icon v-else><CircleCheck /></el-icon></div></div>
</template><script setup>
import { ref, computed, nextTick } from 'vue';
import { qwenTalk, cancelQwenTalk } from "@/api/aiAgent.js";
import { ElMessage } from 'element-plus';
// 引入图标
import { Close, CircleCheck } from '@element-plus/icons-vue';
import md from '@/utils/markdownRenderer'
import { renderMarkdown } from '@/utils/markedRenderer';const chatMessagesRef = ref(null);
const messages = ref([{type: 'assistant',content: '您好!有什么我可以帮助您的?',avatar: 'https://picsum.photos/48/48?random=2'}
]);
const inputMessage = ref('');
const canSend = computed(() => {return inputMessage.value.trim().length > 0;
});
const currentAIReply = ref(null);
// 添加请求取消标志位
const isRequestCancelled = ref(false);const scrollToBottom = () => {nextTick(() => {if (chatMessagesRef.value) {chatMessagesRef.value.scrollTop = chatMessagesRef.value.scrollHeight;}});
};const sendMessage = () => {if (!canSend.value) return;isRequestCancelled.value = false;messages.value.push({type: 'user',content: inputMessage.value,avatar: 'https://picsum.photos/48/48?random=1'});messages.value.push({type: 'assistant',content: '',avatar: 'https://picsum.photos/48/48?random=2',loading: true});const aiMessageIndex = messages.value.length - 1;currentAIReply.value = {index: aiMessageIndex,content: ''};scrollToBottom();let accumulatedContent = '';qwenTalk({"model": "qwen2.5:32b","messages": [{"role": "user","content": inputMessage.value,"currentModel": "qwen2.5:32b"},{"role": "assistant","content": "","currentModel": "qwen2.5:32b"}],"stream": true,}, (data) => {// 如果请求已取消,不再处理后续数据if (isRequestCancelled.value) return;if (data.message?.content !== undefined) {accumulatedContent += data.message.content;try {// 实时进行 Markdown 渲染const renderedContent = renderMarkdown(accumulatedContent);messages.value[aiMessageIndex].content = renderedContent;} catch (err) {console.error('Markdown 渲染失败:', err);messages.value[aiMessageIndex].content = accumulatedContent;}scrollToBottom();}if (data.done) {messages.value[aiMessageIndex].loading = false;currentAIReply.value = null;}}).catch(error => {messages.value[aiMessageIndex].loading = false;currentAIReply.value = null;scrollToBottom();});inputMessage.value = '';
};const cancelRequest = () => {if (currentAIReply.value) {cancelQwenTalk();const aiMessageIndex = currentAIReply.value.index;messages.value[aiMessageIndex].loading = false;currentAIReply.value = null;ElMessage.warning('请求已取消');// 设置请求取消标志位isRequestCancelled.value = true;scrollToBottom();}
};
</script><style scoped>
.chat-container {display: flex;flex-direction: column;height: 80vh;width: 100%;margin: 0;padding: 0;background-color: #f5f5f5;
}.chat-messages {flex: 1;/* 消息区域占据剩余空间 */overflow-y: auto;/* 内容超出时垂直滚动 */padding: 20px;background-color: #ffffff;
}.message {display: flex;margin-bottom: 20px;align-items: flex-start;
}.user {flex-direction: row-reverse;
}.avatar {margin: 0 12px;
}/* 添加基本的 Markdown 样式 */
.markdown-container {max-width: 70%;padding: 8px;border-radius: 8px;font-size: 16px;line-height: 1.6;
}.markdown-container h1,
.markdown-container h2,
.markdown-container h3 {margin-top: 1em;margin-bottom: 0.5em;
}.markdown-container p {margin-bottom: 1em;
}.user .markdown-container {background-color: #409eff;color: white;
}.assistant .markdown-container {background-color: #eeecec;color: #333;text-align: left;
}.chat-input {display: flex;gap: 12px;padding: 20px;background-color: #ffffff;border-top: 1px solid #ddd;
}/* 代码样式---------------| */
.markdown-content {line-height: 1.6;
}.markdown-container pre code.hljs {display: block;overflow-x: auto;padding: 1em;border-radius: 10px;
}.markdown-container code {font-family: 'Fira Code', 'Consolas', 'Monaco', 'Andale Mono', monospace;font-size: 14px;line-height: 1.5;
}
.chat-input .el-input {flex: 1;/* 输入框占据剩余空间 */
}/* 添加禁用状态样式------------------- */
.chat-input .el-button:disabled {opacity: 0.6;cursor: not-allowed;
}.loading-dots {display: inline-flex;align-items: center;height: 1em;margin-left: 8px;
}.loading-dots span {display: inline-block;width: 8px;height: 8px;border-radius: 50%;background-color: #999;margin: 0 2px;animation: bounce 1.4s infinite ease-in-out both;
}.loading-dots span:nth-child(1) {animation-delay: -0.32s;
}.loading-dots span:nth-child(2) {animation-delay: -0.16s;
}@keyframes bounce {0%,80%,100% {transform: scale(0);}40% {transform: scale(1);}
}.chat-input .el-icon {font-size: 24px;cursor: pointer;color: #409eff;
}.chat-input .el-icon:hover {color: #66b1ff;
}
</style>

高级优化

1. 节流渲染

对于高频更新的流,可以使用节流来优化性能:

let updateTimeout;
const throttledUpdate = (newContent) => {clearTimeout(updateTimeout);updateTimeout = setTimeout(() => {this.content = newContent;}, 100); // 每100毫秒更新一次
};// 在onData回调中使用
(data) => {if (data.content) {throttledUpdate(this.content + data.content);}
}

2. 自动滚动

保持最新内容可见:

scrollToBottom() {this.$nextTick(() => {const container = this.$el.querySelector('.content');container.scrollTop = container.scrollHeight;});
}// 在适当的时候调用,如onData或onComplete

3. 中断请求

添加中断流的能力,取消请求,详见上篇文章:


const cancelRequest = () => {if (currentAIReply.value) {cancelQwenTalk();const aiMessageIndex = currentAIReply.value.index;messages.value[aiMessageIndex].loading = false;currentAIReply.value = null;ElMessage.warning('请求已取消');// 设置请求取消标志位isRequestCancelled.value = true;scrollToBottom();}
};

安全注意事项

  1. 始终使用DOMPurify:即使你信任数据来源,也要净化HTML
  2. 内容安全策略(CSP):设置适当的CSP头来进一步保护应用
  3. 避免直接使用v-html:虽然我们这里使用了,但确保内容已经过净化
  4. 限制数据大小:对于特别大的流,考虑设置最大长度限制

总结

通过结合Vue的响应式系统、NDJSON流式处理、Markdown渲染和安全净化,我们构建了一个能够高效处理大模型流式响应的解决方案。这种方法特别适合需要实时显示大模型生成内容的场景,如AI聊天、代码生成或内容创作工具。

关键点在于:

  • 使用NDJSON格式高效传输流数据
  • 正确解析和处理流式响应
  • 安全地渲染Markdown内容
  • 提供良好的用户体验和性能优化

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.pswp.cn/web/82673.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

第11期_网站搭建_极简云 单码网络验证修复版本 虚拟主机搭建笔记

系统搭建环境 1、Nginx 最佳 2、php 7.2 3、MySql 5.6 后台地址 域名/admin 后台账号 admin 密码 123456 我使用宝塔面板的后门校验&#xff0c;没有发现有后门的现象&#xff0c;使用的话&#xff0c;建议再次核查一下。也希望各位 有能力的也核查一下。 夸克网盘下载地址&…

.net ORM框架dapper批量插入

.NET ORM 框架 Dapper 批量插入全解析 在 .NET 开发中&#xff0c;与数据库交互是常见需求。Dapper 作为轻量级的 ORM&#xff08;对象关系映射&#xff09;库&#xff0c;在简化数据库交互方面表现出色。今天我们就来深入探讨 Dapper 实现批量插入的几种方法。 为什么需要批…

虚拟机CentOS 7 网络连接显示“以太网(ens33,被拔出)“、有线已拔出、CentOS7不显示网络图标

文章目录 一、问题描述二、解决方法1、查看网络连接方式2、开启相关服务3、确认虚拟机网络连接 一、问题描述 问题描述&#xff1a;在VmWare中安装CentOS7, 启动后界面不显示网络的图标。 在GONE桌面—》设置中找到网络设置&#xff0c;发现显示线缆已拔出。 二、解决方法 …

安卓Compose实现鱼骨加载中效果

安卓Compose实现鱼骨加载中效果 文章目录 安卓Compose实现鱼骨加载中效果背景与简介适用场景Compose骨架屏与传统View实现对比Shimmer动画原理简介常见问题与优化建议参考资料 本文首发地址 https://h89.cn/archives/404.html 背景与简介 在移动应用开发中&#xff0c;加载中占…

基于C++处理Modbus报文的完整指南

目录 &#x1f4e6; 一、Modbus报文结构解析1. RTU模式帧格式2. TCP模式帧格式 &#x1f527; 二、C实现方案与库选择示例1&#xff1a;libmodbus读取保持寄存器 (TCP) ⚙️ 三、核心处理技术1. 报文构建与发送2. 响应解析与错误处理3. 数据类型转换 &#x1f680; 四、高级应用…

【性能调优系列】深入解析火焰图:从基础阅读到性能优化实战

博客目录 一、火焰图基础&#xff1a;结构与阅读方法二、深入分析火焰图&#xff1a;关键观察点与性能瓶颈识别1. 识别最宽的函数块2. HTTP 请求处理分析3. 数据库操作分析4. 业务逻辑分析 三、性能优化实战&#xff1a;从火焰图到解决方案1. 线程池性能优化2. 数据库访问优化3…

基于 OpenCV 和 DLib 实现面部特征调整(眼间距、鼻子、嘴巴)

摘 要 本文介绍如何利用Dlib面部特征点检测和OpenCV图像处理技术&#xff0c;通过Python实现面部特征的精准调整。我们将以改变眼间距为例&#xff0c;演示包括地标检测、三角剖分变形等关键技术&#xff0c;该方法可扩展至嘴唇、眉毛等面部特征的调整。 技术栈 Python 3.8 …

Spring Data Redis 实战指南

Spring Data Redis 核心特性 Spring Data Redis 是基于 Redis 的 NoSQL 内存数据结构存储解决方案,为 Spring 应用程序提供与 Redis 交互的高级抽象层。其核心架构设计体现了对现代应用需求的深度适配,主要技术特性可归纳为以下维度: 数据结构支持体系 作为多模型数据存储…

AI IDE 正式上线!通义灵码开箱即用

近期&#xff0c;通义灵码AI IDE正式上线&#xff0c;即日起用户可在通义灵码官网免费下载开箱即用。 作为AI原生的开发环境工具&#xff0c;通义灵码AI IDE深度适配了最新的千问3大模型&#xff0c;并全面集成通义灵码插件能力&#xff0c;具备编程智能体、行间建议预测、行间…

如何搭建Z-Blog PHP版本:详细指南

Z-Blog是一款功能强大且易于使用的博客平台&#xff0c;支持PHP和ASP两种环境。本文将重点介绍如何在PHP环境下搭建Z-Blog博客系统&#xff0c;帮助您快速上线自己的个人博客站点。 准备工作 1. 获取Z-Blog PHP版本 首先&#xff0c;访问Z-Blog官方网站下载最新版本的Z-Blog…

App使用webview套壳引入h5(二)—— app内访问h5,顶部被手机顶部菜单遮挡问题,保留顶部安全距离

引入webview的页面添加safeAreaInsets&#xff0c;对weview的webviewStyles做处理 在myApp中改造 entry.vue代码如下 template><view class"entry-page" :style"{ paddingTop: safeAreaInsets.top px }"><web-view :webview-styles"we…

机器学习:支持向量机(SVM)原理解析及垃圾邮件过滤实战

一、什么是支持向量机&#xff08;SVM&#xff09; 1. 基本概念 1.1 二分类问题的本质 在机器学习中&#xff0c;分类问题是最常见的任务之一。最简单的情况就是二分类&#xff1a;比如一封邮件是“垃圾邮件”还是“正常邮件”&#xff1f;一个病人是“患病”还是“健康”&a…

腾讯云V3签名

想要接入腾讯云的Api&#xff0c;必然先按其文档计算出所要求的签名。 之前也调用过腾讯云的接口&#xff0c;但总是卡在签名这一步&#xff0c;最后放弃选择SDK&#xff0c;这次终于自己代码实现。 可能腾讯云翻新了接口文档&#xff0c;现在阅读起来&#xff0c;清晰了很多&…

STM32中自动生成Flash地址的方法

每页大小为 2KB(0x800 字节),地址间隔为 0x800 总地址空间覆盖范围:0x08000000 ~ 0x0803F800(共 256KB) 适用于 STM32 大容量 / 中容量产品(如 F103 系列) 代码如下 // 通用定义(需根据实际页大小调整) #define FLASH_BASE_ADDR 0x08000000 #define FLASH_PAGE_SIZ…

(12)java+ selenium->元素定位大法之By_link_text

1.简介 本章节介绍元素定位中的link_text,顾名思义是通过链接定位的(官方说法:超链接文本定位)。什么是link_text呢,就是我们在任何一个网页上都可以看到有一个或者多个链接,上面有一个文字描述,点击这个文字,就可以跳转到其他页面。这个就是link_Text。 注意:link_t…

Tomcat 线程模型详解性能调优

1. Tomcat I/O模型详解**&#xff08;了解&#xff09;** 1.1 Linux I/O模型详解 I/O要解决什么问题 I/O&#xff1a;在计算机内存与外部设备之间拷贝数据的过程。 程序通过CPU向外部设备发出读指令&#xff0c;数据从外部设备拷贝至内存需要一段时间&#xff0c;这段时间CPU就…

C++课设:智能优惠快餐点餐系统

名人说&#xff1a;路漫漫其修远兮&#xff0c;吾将上下而求索。—— 屈原《离骚》 创作者&#xff1a;Code_流苏(CSDN)&#xff08;一个喜欢古诗词和编程的Coder&#x1f60a;&#xff09; 专栏介绍&#xff1a;《编程项目实战》 目录 一、项目介绍与亮点功能1. 项目背景2.完…

PHP的namespace

文章目录 环境Java的packagepackage关键字包结构和目录结构访问权限import关键字总结 PHP的namespacenamespace关键字在同一个文件里使用资源限定&#xff0c;完全限定&#xff0c;非限定限定完全限定非限定 use关键字use VS 直接指定资源在不同的文件里使用总结 环境 Windows…

矩阵分解相关知识点总结(二)

文章目录 三、矩阵的QR分解3.1、Givens矩阵与Givens变换3.2、Householder矩阵与Householder变换3.3、QR分解 书接上文矩阵分解相关知识点总结&#xff08;一&#xff09; 三、矩阵的QR分解 3.1、Givens矩阵与Givens变换 设非零列向量 x ∈ R n \bm{x}\in {\bf{R}}^n x∈Rn及单…

Chorme如何对于youtube视频进行画中画背景播放?

画中画可以让你小窗播放&#xff0c;然后浏览器放后台还可以做点别的事情。 B站直接可以选择小窗播放&#xff0c;游览器最小化就可以&#xff0c;但是youtube的小窗播放游览器一切换就不显示了。 其实是因为youtube的小窗播放不是真的小窗播放。要想真的实现需要在youtube视…