基于SSE+Worker+MarkdownIt 实现流式对话

1. SSE实现流式对话

1.1 什么是SSE?

Server-Sent Events (SSE) 是一种基于HTTP的服务器推送技术,允许服务器向客户端发送事件流。与WebSocket不同,SSE是单向的(只从服务器到客户端),专为数据流式传输设计,特别适合AI对话这类需要实时展示生成内容的场景。

SSE的主要优势:

  • 基于标准HTTP,无需额外协议
  • 自动重连机制
  • 支持事件ID和自定义事件类型
  • 轻量级,实现简单

1.2 SSE流式对话框架

下面是一个实现SSE流式对话的通用框架:
主要搭配EventSource API实现,EventSource是专门为服务器发送事件设计的API。

<template>
  <div class="sse-demo">
    <h2>SSE Demo</h2>
    
    <div class="controls">
      <button @click="startSSEConnection" :disabled="isConnected">
        开始接收消息
      </button>
      <button @click="closeSSEConnection" :disabled="!isConnected">
        停止接收
      </button>
    </div>
    
    <div class="messages">
      <h3>接收到的消息:</h3>
      <div v-if="messages.length === 0" class="no-messages">
        暂无消息
      </div>
      <ul v-else>
        <li v-for="(message, index) in messages" :key="index">
          {{ message }}
        </li>
      </ul>
    </div>
  </div>
</template>

<script>
export default {
  name: 'SSEDemo',
  
  data() {
    return {
      // SSE连接对象
      eventSource: null,
      // 是否已连接
      isConnected: false,
      // 接收到的消息列表
      messages: []
    }
  },
  
  methods: {
    // 开始SSE连接
    startSSEConnection() {
      // 关闭可能存在的连接
      this.closeSSEConnection();
      
      try {
        // 创建EventSource连接
        // 注意:这里的URL应该指向你的SSE服务端点
        this.eventSource = new EventSource('/api/sse-stream');
        this.isConnected = true;
        
        // 监听消息事件
        this.eventSource.onmessage = (event) => {
          try {
            // 解析消息数据
            const data = JSON.parse(event.data);
            // 添加到消息列表
            this.messages.push(`${new Date().toLocaleTimeString()}: ${data.content}`);
          } catch (error) {
            console.error('解析SSE消息失败:', error);
            this.messages.push(`[错误] 无法解析消息: ${event.data}`);
          }
        };
        
        // 监听连接打开事件
        this.eventSource.onopen = () => {
          console.log('SSE连接已建立');
          this.messages.push(`[系统] 连接已建立`);
        };
        
        // 监听错误事件
        this.eventSource.onerror = (error) => {
          console.error('SSE连接错误:', error);
          this.messages.push(`[错误] 连接发生错误`);
          
          // 如果连接关闭,更新状态
          if (this.eventSource.readyState === EventSource.CLOSED) {
            this.isConnected = false;
          }
        };
      } catch (error) {
        console.error('创建SSE连接失败:', error);
        this.isConnected = false;
      }
    },
    
    // 关闭SSE连接
    closeSSEConnection() {
      if (this.eventSource) {
        this.eventSource.close();
        this.eventSource = null;
        this.isConnected = false;
        this.messages.push(`[系统] 连接已关闭`);
      }
    }
  },
  
  // 组件销毁时关闭连接
  beforeDestroy() {
    this.closeSSEConnection();
  }
}
</script>

<style scoped>
.sse-demo {
  padding: 20px;
  max-width: 600px;
  margin: 0 auto;
}

.controls {
  margin-bottom: 20px;
}

button {
  margin-right: 10px;
  padding: 8px 16px;
}

.messages {
  border: 1px solid #ddd;
  padding: 15px;
  height: 300px;
  overflow-y: auto;
}

.no-messages {
  color: #999;
  font-style: italic;
}

ul {
  list-style-type: none;
  padding: 0;
}

li {
  padding: 8px;
  border-bottom: 1px solid #eee;
}
</style>

2.后端实现 (Node.js/Express)

const express = require('express');
const app = express();

// SSE端点
app.get('/api/sse-stream', (req, res) => {
  // 设置SSE所需的响应头
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  
  // 发送初始消息
  sendSSEMessage(res, { content: '连接成功,开始接收消息' });
  
  // 每2秒发送一条消息
  const intervalId = setInterval(() => {
    sendSSEMessage(res, { 
      content: `服务器时间: ${new Date().toLocaleTimeString()}` 
    });
  }, 2000);
  
  // 客户端断开连接时清理资源
  req.on('close', () => {
    clearInterval(intervalId);
    res.end();
    console.log('客户端断开连接');
  });
});

// 发送SSE消息的辅助函数
function sendSSEMessage(res, data) {
  res.write(`data: ${JSON.stringify(data)}\n\n`);
}

// 启动服务器
const PORT = 3000;
app.listen(PORT, () => {
  console.log(`SSE服务器运行在端口 ${PORT}`);
});

2. Web Worker 的优势和基本使用

2.1 什么是Worker?

Web Worker是现代Web应用中的一个强大特性,它通过在后台线程中运行JavaScript代码来提升应用性能和用户体验。

Worker的核心优势

1.1 不阻塞主线程

  • 主线程负责UI渲染和用户交互
  • Worker在独立线程中运行,不会影响主线程的响应性
  • 用户界面保持流畅,不会出现"卡顿"现象

1.2 并行处理能力

  • 可以同时运行多个Worker
  • 充分利用多核CPU的优势
  • 提高复杂计算的处理速度

1.3 内存隔离

  • Worker有独立的内存空间
  • 不会直接影响主线程的内存使用
  • 降低内存相关问题的风险

Web Worker允许在后台线程中运行JavaScript代码,不会阻塞主线程的UI渲染。

2.2下面是一个简单的Web Worker示例。

<template>
  <div class="worker-demo">
    <h2>Web Worker Demo</h2>
    
    <div class="controls">
      <div class="input-group">
        <label for="number">输入一个数字:</label>
        <input 
          id="number" 
          type="number" 
          v-model.number="inputNumber" 
          min="1" 
          max="45"
        />
      </div>
      
      <button @click="calculateInMainThread" :disabled="isCalculating">
        在主线程计算斐波那契数
      </button>
      
      <button @click="calculateInWorker" :disabled="isCalculating">
        在Worker中计算斐波那契数
      </button>
    </div>
    
    <div class="results">
      <div class="result-item">
        <strong>计算结果:</strong> {{ result }}
      </div>
      <div class="result-item">
        <strong>计算时间:</strong> {{ calculationTime }}ms
      </div>
      <div class="result-item">
        <strong>计算位置:</strong> {{ calculationMethod }}
      </div>
    </div>
    
    <div class="animation">
      <div class="animation-box" :class="{ 'smooth': !isMainThreadBlocked }"></div>
      <p>这个动画应该保持流畅。如果卡顿,说明主线程被阻塞了。</p>
    </div>
  </div>
</template>

<script>
export default {
  name: 'WorkerDemo',
  
  data() {
    return {
      // 输入数字
      inputNumber: 35,
      // 计算结果
      result: null,
      // 计算时间(ms)
      calculationTime: 0,
      // 计算方法
      calculationMethod: '-',
      // 是否正在计算
      isCalculating: false,
      // Worker实例
      worker: null,
      // 主线程是否被阻塞
      isMainThreadBlocked: false
    }
  },
  
  created() {
    // 创建Worker
    this.initWorker();
  },
  
  methods: {
    // 初始化Worker
    initWorker() {
      // 创建Worker
      // 注意:Worker文件路径是相对于项目根目录的公共路径
      this.worker = new Worker('/fibonacci-worker.js');
      
      // 监听Worker消息
      this.worker.onmessage = (event) => {
        const { result, time } = event.data;
        
        // 更新结果
        this.result = result;
        this.calculationTime = time;
        this.calculationMethod = 'Web Worker (后台线程)';
        this.isCalculating = false;
      };
      
      // 监听Worker错误
      this.worker.onerror = (error) => {
        console.error('Worker错误:', error);
        this.calculationMethod = 'Worker错误';
        this.isCalculating = false;
      };
    },
    
    // 在主线程中计算斐波那契数
    calculateInMainThread() {
      this.isCalculating = true;
      this.isMainThreadBlocked = true;
      
      // 使用setTimeout让UI有机会更新
      setTimeout(() => {
        const startTime = performance.now();
        
        // 计算斐波那契数
        this.result = this.fibonacci(this.inputNumber);
        
        const endTime = performance.now();
        this.calculationTime = Math.round(endTime - startTime);
        this.calculationMethod = '主线程 (UI可能卡顿)';
        
        this.isCalculating = false;
        this.isMainThreadBlocked = false;
      }, 100);
    },
    
    // 在Worker中计算斐波那契数
    calculateInWorker() {
      if (!this.worker) {
        this.initWorker();
      }
      
      this.isCalculating = true;
      
      // 发送消息到Worker
      this.worker.postMessage({
        number: this.inputNumber
      });
    },
    
    // 递归计算斐波那契数 (故意使用低效算法来演示阻塞)
    fibonacci(n) {
      if (n <= 1) return n;
      return this.fibonacci(n - 1) + this.fibonacci(n - 2);
    }
  },
  
  // 组件销毁时终止Worker
  beforeDestroy() {
    if (this.worker) {
      this.worker.terminate();
      this.worker = null;
    }
  }
}
</script>

<style scoped>
.worker-demo {
  padding: 20px;
  max-width: 600px;
  margin: 0 auto;
}

.controls {
  margin-bottom: 20px;
}

.input-group {
  margin-bottom: 15px;
}

button {
  margin-right: 10px;
  margin-bottom: 10px;
  padding: 8px 16px;
}

.results {
  border: 1px solid #ddd;
  padding: 15px;
  margin-bottom: 20px;
}

.result-item {
  margin-bottom: 10px;
}

.animation {
  padding: 15px;
  border: 1px solid #ddd;
}

.animation-box {
  width: 50px;
  height: 50px;
  background-color: #3498db;
  margin-bottom: 15px;
}

.smooth {
  animation: move 2s infinite alternate;
}

@keyframes move {
  from { transform: translateX(0); }
  to { transform: translateX(500px); }
}
</style>

Web Worker文件 (public/fibonacci-worker.js)

// fibonacci-worker.js
// 注意:这个文件需要放在public目录下,以便浏览器可以直接访问

// 监听主线程消息
self.onmessage = function(event) {
  const { number } = event.data;
  
  // 记录开始时间
  const startTime = performance.now();
  
  // 计算斐波那契数
  const result = fibonacci(number);
  
  // 记录结束时间
  const endTime = performance.now();
  const time = Math.round(endTime - startTime);
  
  // 发送结果回主线程
  self.postMessage({
    result,
    time
  });
};

// 递归计算斐波那契数 (故意使用低效算法来演示Worker的优势)
function fibonacci(n) {
  if (n <= 1) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
}

3.SSE+Worker实现流式对话demo

安装Worker

npm install vue-worker

安装MarkdownIt

npm install markdown-it --save
<template>
  <div class="stream-chat">
    <h2>流式对话Demo</h2>
    
    <!-- 消息列表 -->
    <div class="messages" ref="messagesContainer">
      <div 
        v-for="(message, index) in messages" 
        :key="index"
        :class="['message', message.role]"
      >
        <div class="avatar">{{ message.role === 'user' ? '👤' : '🤖' }}</div>
        <div class="content">
          <!-- 使用v-html显示渲染后的内容 -->
          <div v-if="message.rendered" v-html="message.rendered"></div>
          <!-- 未渲染时显示原始内容 -->
          <div v-else>{{ message.content }}</div>
          
          <!-- 加载指示器 -->
          <div v-if="message.isLoading" class="loading-indicator">
            <span></span><span></span><span></span>
          </div>
        </div>
      </div>
    </div>
    
    <!-- 输入区域 -->
    <div class="input-area">
      <input 
        type="text" 
        v-model="userInput" 
        placeholder="输入消息..." 
        @keyup.enter="sendMessage"
        :disabled="isReceiving"
      />
      <button @click="sendMessage" :disabled="isReceiving || !userInput.trim()">
        发送
      </button>
    </div>
  </div>
</template>

<script>
export default {
  name: 'StreamChat',
  
  data() {
    return {
      // 消息列表
      messages: [],
      // 用户输入
      userInput: '',
      // SSE连接
      eventSource: null,
      // 是否正在接收消息
      isReceiving: false,
      // Web Worker
      worker: null,
      // 当前接收的消息
      currentMessage: '',
      // 当前消息索引
      currentMessageIndex: -1
    }
  },
  
  created() {
    // 初始化Web Worker
    this.initWorker();
  },
  
  methods: {
    // 初始化Web Worker
    initWorker() {
      // 创建Worker实例
      this.worker = new Worker('/message-worker.js');
      
      // 监听Worker消息
      this.worker.onmessage = (event) => {
        const { messageIndex, renderedContent } = event.data;
        
        // 更新消息的渲染内容
        if (messageIndex >= 0 && messageIndex < this.messages.length) {
          this.$set(this.messages[messageIndex], 'rendered', renderedContent);
        }
      };
    },
    
    // 发送消息
    sendMessage() {
      const message = this.userInput.trim();
      if (!message || this.isReceiving) return;
      
      // 清空输入框
      this.userInput = '';
      
      // 添加用户消息
      this.messages.push({
        role: 'user',
        content: message,
        rendered: message // 用户消息不需要特殊渲染
      });
      
      // 添加AI消息占位
      this.messages.push({
        role: 'assistant',
        content: '',
        isLoading: true
      });
      
      // 记录当前消息索引
      this.currentMessageIndex = this.messages.length - 1;
      this.currentMessage = '';
      
      // 滚动到底部
      this.$nextTick(() => {
        this.scrollToBottom();
      });
      
      // 开始接收流式响应
      this.startSSEConnection(message);
    },
    
    // 建立SSE连接
    startSSEConnection(message) {
      // 关闭可能存在的连接
      this.closeSSEConnection();
      
      this.isReceiving = true;
      
      try {
        // 创建SSE连接
        const url = `/api/chat-stream?message=${encodeURIComponent(message)}`;
        this.eventSource = new EventSource(url);
        
        // 监听消息事件
        this.eventSource.onmessage = (event) => {
          this.handleSSEMessage(event);
        };
        
        // 监听错误
        this.eventSource.onerror = (error) => {
          console.error('SSE连接错误:', error);
          this.handleSSEError('连接错误,请重试');
        };
      } catch (error) {
        console.error('创建SSE连接失败:', error);
        this.handleSSEError('无法建立连接');
      }
    },
    
    // 处理SSE消息
    handleSSEMessage(event) {
      try {
        const data = JSON.parse(event.data);
        
        switch (data.type) {
          case 'content':
            // 添加内容
            this.currentMessage += data.content;
            this.updateAssistantMessage(this.currentMessage, true);
            break;
            
          case 'end':
            // 结束消息
            this.updateAssistantMessage(this.currentMessage, false);
            // 使用Worker渲染消息
            this.renderMessageContent(this.currentMessageIndex, this.currentMessage);
            this.isReceiving = false;
            this.closeSSEConnection();
            break;
            
          default:
            console.warn('未知的消息类型:', data.type);
        }
      } catch (error) {
        console.error('处理SSE消息失败:', error);
        this.handleSSEError('处理消息失败');
      }
    },
    
    // 处理SSE错误
    handleSSEError(errorMessage) {
      this.updateAssistantMessage(errorMessage || '发生错误', false);
      this.isReceiving = false;
      this.closeSSEConnection();
    },
    
    // 更新助手消息
    updateAssistantMessage(content, isLoading) {
      if (this.currentMessageIndex >= 0) {
        this.$set(this.messages, this.currentMessageIndex, {
          role: 'assistant',
          content: content,
          isLoading: isLoading
        });
        
        // 滚动到底部
        this.$nextTick(() => {
          this.scrollToBottom();
        });
      }
    },
    
    // 使用Worker渲染消息内容
    renderMessageContent(messageIndex, content) {
      // 发送消息到Worker进行处理
      this.worker.postMessage({
        messageIndex,
        content
      });
    },
    
    // 关闭SSE连接
    closeSSEConnection() {
      if (this.eventSource) {
        this.eventSource.close();
        this.eventSource = null;
      }
    },
    
    // 滚动到底部
    scrollToBottom() {
      const container = this.$refs.messagesContainer;
      if (container) {
        container.scrollTop = container.scrollHeight;
      }
    }
  },
  
  // 组件销毁时清理资源
  beforeDestroy() {
    this.closeSSEConnection();
    
    if (this.worker) {
      this.worker.terminate();
      this.worker = null;
    }
  }
}
</script>

<style scoped>
.stream-chat {
  max-width: 800px;
  margin: 0 auto;
  padding: 20px;
  font-family: Arial, sans-serif;
}

.messages {
  height: 400px;
  overflow-y: auto;
  border: 1px solid #ddd;
  padding: 10px;
  margin-bottom: 20px;
  border-radius: 5px;
}

.message {
  display: flex;
  margin-bottom: 15px;
}

.avatar {
  width: 40px;
  height: 40px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  margin-right: 10px;
  font-size: 20px;
}

.content {
  flex: 1;
  padding: 10px;
  border-radius: 10px;
  max-width: calc(100% - 60px);
}

.user .content {
  background-color: #e1f5fe;
}

.assistant .content {
  background-color: #f1f1f1;
}

.input-area {
  display: flex;
}

input {
  flex: 1;
  padding: 10px;
  border: 1px solid #ddd;
  border-radius: 5px;
  margin-right: 10px;
}

button {
  padding: 10px 20px;
  background-color: #2196f3;
  color: white;
  border: none;
  border-radius: 5px;
  cursor: pointer;
}

button:disabled {
  background-color: #b0bec5;
  cursor: not-allowed;
}

.loading-indicator {
  display: flex;
  margin-top: 5px;
}

.loading-indicator span {
  width: 8px;
  height: 8px;
  margin-right: 5px;
  background-color: #999;
  border-radius: 50%;
  display: inline-block;
  animation: bounce 1.4s infinite ease-in-out both;
}

.loading-indicator span:nth-child(1) {
  animation-delay: -0.32s;
}

.loading-indicator span:nth-child(2) {
  animation-delay: -0.16s;
}

@keyframes bounce {
  0%, 80%, 100% { transform: scale(0); }
  40% { transform: scale(1.0); }
}
</style>

2.Web Worker文件 (public/message-worker.js)

/ message-worker.js
// 这个Worker负责处理消息内容,如Markdown渲染

// 导入MarkdownIt插件
import MarkdownIt from 'markdown-it';

// 简单的Markdown渲染函数
function renderMarkdown(text) {
  if (!text) return '';
  
  // 这里是一个简化的Markdown渲染实现
  // 在实际项目中,你可能会使用成熟的库如marked或markdown-it
  
  // 处理标题
  text = text.replace(/^### (.*$)/gm, '<h3>$1</h3>');
  text = text.replace(/^## (.*$)/gm, '<h2>$1</h2>');
  text = text.replace(/^# (.*$)/gm, '<h1>$1</h1>');
  
  // 处理粗体
  text = text.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
  
  // 处理斜体
  text = text.replace(/\*(.*?)\*/g, '<em>$1</em>');
  
  // 处理代码块
  text = text.replace(/```([\s\S]*?)```/g, '<pre><code>$1</code></pre>');
  
  // 处理行内代码
  text = text.replace(/`(.*?)`/g, '<code>$1</code>');
  
  // 处理链接
  text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
  
  // 处理列表
  text = text.replace(/^\s*-\s*(.*$)/gm, '<li>$1</li>');
  text = text.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>');
  
  // 处理段落
  text = text.replace(/^(?!<[a-z])(.*$)/gm, '<p>$1</p>');
  
  // 处理换行
  text = text.replace(/\n/g, '<br>');
  
  return text;
}

// 处理代码高亮
function highlightCode(html) {
  // 这里可以实现代码高亮逻辑
  // 在实际项目中,你可能会使用highlight.js等库
  return html;
}

// 监听主线程消息
self.onmessage = function(event) {
  const { messageIndex, content } = event.data;
  
  try {
    // 模拟耗时操作
    const startTime = performance.now();
    
    // 渲染Markdown
    let renderedContent = renderMarkdown(content);
    
    // 代码高亮
    renderedContent = highlightCode(renderedContent);
    
    // 模拟复杂处理耗时
    if (content.length > 100) {
      // 对于长内容,模拟更长的处理时间
      const sleepTime = Math.min(content.length / 10, 500);
      const endTime = performance.now() + sleepTime;
      while (performance.now() < endTime) {
        // 空循环模拟耗时操作
      }
    }
    
    // 发送处理结果回主线程
    self.postMessage({
      messageIndex,
      renderedContent,
      processingTime: performance.now() - startTime
    });
  } catch (error)
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值