大模型数据流处理实战: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. 安装依赖

首先安装必要的依赖:

bash 复制代码
npm install marked highlight.js dompurify

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

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

javascript 复制代码
//  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 || '请求发生错误'
    });
  }
  // 返回失败的 promise
  return 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;

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

javascript 复制代码
// 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;

    // 重置 buffer
    buffer = '';
    lastPosition = 0

    return 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:

javascript 复制代码
// 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组件来处理流式数据并渲染:

javascript 复制代码
<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. 节流渲染

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

javascript 复制代码
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. 自动滚动

保持最新内容可见:

javascript 复制代码
scrollToBottom() {
  this.$nextTick(() => {
    const container = this.$el.querySelector('.content');
    container.scrollTop = container.scrollHeight;
  });
}

// 在适当的时候调用,如onData或onComplete

3. 中断请求

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

javascript 复制代码
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内容
  • 提供良好的用户体验和性能优化
相关推荐
顾青30 分钟前
解决Vue+Element Plus的"AutoImport is not a function"错误:从根源到修复
前端·vue.js
前端小饭桌30 分钟前
watch用错性能掉一半
前端·vue.js
架构个驾驾33 分钟前
前端微服务框架乾坤(Qiankun)实战指南
前端·javascript·vue.js
Dignity_呱1 小时前
🤡官:深度讲讲vue3响应式的原理
前端·vue.js·面试
voyageracer1 小时前
Vue3 中 echarts resize() 不生效
vue.js·echarts
前端工作日常1 小时前
学习 Vue2 的发布说明生成器:自动化生成优雅的 Release Notes
vue.js
工业互联网专业2 小时前
基于python的内蒙古旅游景点数据分析系统-Flask+spider+vue
vue.js·python·flask·毕业设计·源码·课程设计·内蒙古旅游景点数据分析系统
银色的白10 小时前
工作记录:人物对话功能开发与集成
vue.js·学习·前端框架
萌萌哒草头将军11 小时前
🚀🚀🚀什么?浏览器也能修改项目源文件了?Chrome 团队开源的超强 Vite 插件!🚀🚀🚀
vue.js·react.js·vite
InternLM13 小时前
论文分类打榜赛Baseline(2):InternLM昇腾硬件微调实践
人工智能·分类·大模型·internlm·书生大模型