在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();}
};
安全注意事项
- 始终使用DOMPurify:即使你信任数据来源,也要净化HTML
- 内容安全策略(CSP):设置适当的CSP头来进一步保护应用
- 避免直接使用v-html:虽然我们这里使用了,但确保内容已经过净化
- 限制数据大小:对于特别大的流,考虑设置最大长度限制
总结
通过结合Vue的响应式系统、NDJSON流式处理、Markdown渲染和安全净化,我们构建了一个能够高效处理大模型流式响应的解决方案。这种方法特别适合需要实时显示大模型生成内容的场景,如AI聊天、代码生成或内容创作工具。
关键点在于:
- 使用NDJSON格式高效传输流数据
- 正确解析和处理流式响应
- 安全地渲染Markdown内容
- 提供良好的用户体验和性能优化