当前位置: 首页 > article >正文

大模型数据流处理实战: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.lryc.cn/news/2402433.html

相关文章:

  • python项目如何创建docker环境
  • Eureka 高可用集群搭建实战:服务注册与发现的底层原理与避坑指南
  • PyTorch--池化层(4)
  • GPU加速与非加速的深度学习张量计算对比Demo,使用PyTorch展示关键差异
  • Vue中的自定义事件
  • 2025年大模型平台落地实践研究报告|附75页PDF文件下载
  • PPTAGENT:让PPT生成更智能
  • Kotlin 中 companion object 扩展函数和普通函数区别
  • 《汇编语言》第13章 int指令
  • Redis实战-基于redis和lua脚本实现分布式锁以及Redission源码解析【万字长文】
  • Ubuntu崩溃修复方案
  • 计算机网络 : 应用层自定义协议与序列化
  • Python Day42 学习(日志Day9复习)
  • CMake在VS中使用远程调试
  • 《图解技术体系》How Redis Architecture Evolves?
  • 从零搭建到 App Store 上架:跨平台开发者使用 Appuploader与其他工具的实战经验
  • Spring Cloud 2025 正式发布啦
  • 一文速通Python并行计算:12 Python多进程编程-进程池Pool
  • 相机Camera日志分析之二十五:高通相机Camx 基于预览1帧的process_capture_request四级日志分析详解
  • React从基础入门到高级实战:React 实战项目 - 项目一:在线待办事项应用
  • 云部署实战:基于AWS EC2/Aliyun ECS与GitHub Actions的CI/CD全流程指南
  • golang 如何定义一种能够与自身类型值进行比较的Interface
  • Web前端之原生表格动态复杂合并行、Vue
  • 『uniapp』把接口的内容下载为txt本地保存 / 读取本地保存的txt文件内容(详细图文注释)
  • C/C++ 面试复习笔记(2)
  • 宝马集团推进数字化转型:强化生产物流与财务流程,全面引入SAP现代架构
  • 【Redis技术进阶之路】「原理分析系列开篇」分析客户端和服务端网络诵信交互实现(服务端执行命令请求的过程 - 时间事件处理部分)
  • 【DAY40】训练和测试的规范写法
  • C语言 标准I/O函数全面指南
  • el-select 实现分页加载,切换也数滚回到顶部,自定义高度