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

news2025/7/23 7:00:32

在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 || '请求发生错误'
    });
  }
  // 返回失败的 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;

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

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

// 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.coloradmin.cn/o/2402433.html

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

相关文章

python项目如何创建docker环境

这里写自定义目录标题 python项目创建docker环境docker配置国内镜像源构建一个Docker 镜像验证镜像合理的创建标题&#xff0c;有助于目录的生成如何改变文本的样式插入链接与图片如何插入一段漂亮的代码片生成一个适合你的列表创建一个表格设定内容居中、居左、居右SmartyPant…

PyTorch--池化层(4)

池化层&#xff08;Pooling Layer&#xff09; 用于降低特征图的空间维度&#xff0c;减少计算量和参数数量&#xff0c;同时保留最重要的特征信息。 池化作用&#xff1a;比如1080p视频——720p 池化层的步长默认是卷积核的大小 ceil 允许有出界部分&#xff1b;floor 不允许…

2025年大模型平台落地实践研究报告|附75页PDF文件下载

本报告旨在为各行业企业在建设落地大模型平台的过程中&#xff0c;提供有效的参考和指引&#xff0c;助力大模型更高效更有价值地规模化落地。本报告系统性梳理了大模型平台的发展背景、历程和现状&#xff0c;结合大模型平台的特点提出了具体的落地策略与路径&#xff0c;同时…

PPTAGENT:让PPT生成更智能

想要掌握如何将大模型的力量发挥到极致吗&#xff1f;叶梓老师带您深入了解 Llama Factory —— 一款革命性的大模型微调工具。 1小时实战课程&#xff0c;您将学习到如何轻松上手并有效利用 Llama Factory 来微调您的模型&#xff0c;以发挥其最大潜力。 CSDN教学平台录播地址…

《汇编语言》第13章 int指令

中断信息可以来自 CPU 的内部和外部&#xff0c;当 CPU 的内部有需要处理的事情发生的时候&#xff0c;将产生需要马上处理的中断信息&#xff0c;引发中断过程。在第12章中&#xff0c;我们讲解了中断过程和两种内中断的处理。 这一章中&#xff0c;我们讲解另一种重要的内中断…

Redis实战-基于redis和lua脚本实现分布式锁以及Redission源码解析【万字长文】

前言&#xff1a; 在上篇博客中&#xff0c;我们探讨了单机模式下如何通过悲观锁&#xff08;synchronized&#xff09;实现"一人一单"功能。然而&#xff0c;在分布式系统或集群环境下&#xff0c;单纯依赖JVM级别的锁机制会出现线程并发安全问题&#xff0c;因为这…

计算机网络 : 应用层自定义协议与序列化

计算机网络 &#xff1a; 应用层自定义协议与序列化 目录 计算机网络 &#xff1a; 应用层自定义协议与序列化引言1. 应用层协议1.1 再谈协议1.2 网络版计算器1.3 序列化与反序列化 2. 重新理解全双工3. socket和协议的封装4. 关于流失数据的处理5. Jsoncpp5.1 特性5.2 安装5.3…

Python Day42 学习(日志Day9复习)

补充&#xff1a;关于“箱线图”的阅读 以下图为例 浙大疏锦行 箱线图的基本组成 箱体&#xff08;Box&#xff09;&#xff1a;中间的矩形&#xff0c;表示数据的中间50%&#xff08;从下四分位数Q1到上四分位数Q3&#xff09;。中位线&#xff08;Median&#xff09;&#…

CMake在VS中使用远程调试

选中CMakeLists.txt, 右键-添加调试配置-选中"C\C远程windows调试" 之后将 aunch.vs.json文件改为如下所示: CMake在VS中使用远程调试时,Launch.vs.json中远程调试设置 ,远程电脑开启VS专用的RemoteDebugger {"version": "0.2.1","defaul…

《图解技术体系》How Redis Architecture Evolves?

Redis架构的演进经历了多个关键阶段&#xff0c;从最初的内存数据库发展为支持分布式、多模型和持久化的高性能系统。以下为具体演进路径&#xff1a; 单线程模型与基础数据结构 Redis最初采用单线程架构&#xff0c;利用高效的I/O多路复用&#xff08;如epoll&#xff09;处…

一文速通Python并行计算:12 Python多进程编程-进程池Pool

一文速通 Python 并行计算&#xff1a;12 Python 多进程编程-进程池 Pool 摘要&#xff1a; 在Python多进程编程中&#xff0c;Pool类用于创建进程池&#xff0c;可并行执行多个任务。通过map、apply等方法&#xff0c;将函数和参数分发到子进程&#xff0c;提高CPU利用率&…

Web前端之原生表格动态复杂合并行、Vue

MENU 效果公共数据纯原生StyleJavaScript vue原生table 效果 原生的JavaScript原生table null 公共数据 const list [{id: "a1",title: "第一列",list: [{id: "a11",parentId: "a1",title: "第二列",list: [{ id: "…

『uniapp』把接口的内容下载为txt本地保存 / 读取本地保存的txt文件内容(详细图文注释)

目录 预览效果思路分析downloadTxt 方法readTxt 方法 完整代码总结 欢迎关注 『uniapp』 专栏&#xff0c;持续更新中 欢迎关注 『uniapp』 专栏&#xff0c;持续更新中 预览效果 思路分析 downloadTxt 方法 该方法主要完成两个任务&#xff1a; 下载 txt 文件&#xff1a;通…

【Redis技术进阶之路】「原理分析系列开篇」分析客户端和服务端网络诵信交互实现(服务端执行命令请求的过程 - 时间事件处理部分)

揭秘高效存储模型与数据结构底层实现 【专栏简介】【技术大纲】【专栏目标】【目标人群】1. Redis爱好者与社区成员2. 后端开发和系统架构师3. 计算机专业的本科生及研究生 时间事件&#xff1a;serverCron函数更新服务器时间缓存更新LRU时钟-lruclock更新服务器每秒执行命令次…

【DAY40】训练和测试的规范写法

内容来自浙大疏锦行python打卡训练营 浙大疏锦行 知识点&#xff1a; 彩色和灰度图片测试和训练的规范写法&#xff1a;封装在函数中展平操作&#xff1a;除第一个维度batchsize外全部展平dropout操作&#xff1a;训练阶段随机丢弃神经元&#xff0c;测试阶段eval模式关闭drop…

el-select 实现分页加载,切换也数滚回到顶部,自定义高度

el-select 实现分页加载&#xff0c;切换也数滚回到顶部&#xff0c;自定义高度 1.html <el-form-item label"俱乐部&#xff1a;" prop"club_id" label-width"120px"><el-select :disabled"Boolean(match_id)" style"w…

Langchaine4j 流式输出 (6)

Langchaine4j 流式输出 大模型的流式输出是指大模型在生成文本或其他类型的数据时&#xff0c;不是等到整个生成过程完成后再一次性 返回所有内容&#xff0c;而是生成一部分就立即发送一部分给用户或下游系统&#xff0c;以逐步、逐块的方式返回结果。 这样&#xff0c;用户…

学习经验分享【40】目标检测热力图制作

目标检测热力图在学术论文&#xff08;尤其是计算机视觉、深度学习领域&#xff09;中是重要的可视化分析工具和论证辅助手段&#xff0c;可以给论文加分不少。主要作用一是增强论文的可解释性与说服力&#xff1a;论文中常需解释模型 “如何” 或 “为何” 检测到目标&#xf…

C#里与嵌入式系统W5500网络通讯(3)

有与W5500通讯时,需要使用下面的寄存器: PHYCFGR (W5500 PHY Configuration Register) [R/W] [0x002E] [0b10111XXX] PHYCFGR configures PHY operation mode and resets PHY. In addition, PHYCFGR indicates the status of PHY such as duplex, Speed, Link. 这张表格详细…

用OpenNI2获取奥比中光Astra Pro输出的深度图(win,linux arm64 x64平台)

搞了一个奥比中光Astra Pro&#xff0c;想在windows平台&#xff0c;和linux rk3588 &#xff08;香橙派&#xff0c;ubuntu2404,debian)上获取深度信息&#xff0c;之前的驱动下载已经不好用了,参考如下 Astra 3D相机选型建议 - 知乎https://zhuanlan.zhihu.com/p/594485674 …