Vue2 SSE 流式对话完整前端代码
模板部分templatetemplate div classchat-container !-- 对话列表容器不频繁销毁气泡 -- div classchat-left left refchatScrollWrap !-- 单条AI气泡多气泡可循环每条独立sessionId隔离SSE实例 -- div classai-bubble v-foritem in chatList :keyitem.sessionId !-- white-space: pre-wrap 后端原生换行前端不处理转义 -- div classmd-content v-htmlitem.answerHtml stylewhite-space: pre-wrap; /div div v-ifitem.loading classloading-tipAI思考中.../div div v-ifitem.errorMsg classerror-tip{{ item.errorMsg }}/div /div /div !-- 提问输入区 -- div classchat-input textarea v-modeluserInput placeholder输入提问/textarea button clicksendChat发送对话/button /div /div /template style scoped .chat-container { display: flex; flex-direction: column; height: 100vh; } .left { flex: 1; overflow-y: auto; scroll-behavior: auto; /* 超大文本禁用平滑滚动降低重绘 */ } .ai-bubble { margin: 12px 0; padding: 10px; background: #f5f7fa; border-radius: 8px; } .md-content { line-height: 1.6; } .loading-tip { color: #666; font-size: 13px; margin-top: 4px; } .error-tip { color: #f53f3f; font-size: 13px; margin-top: 4px; } /styleJS 逻辑scriptVue2 Options APIimport marked from marked // markdown渲染库按需引入 export default { data() { return { userInput: , // 全局滚动节流定时器 scrollThrottleTimer: null, // 防抖loading定时器避免断网频繁闪烁loading loadingDebounceTimer: null, // 存储所有对话气泡每条独立sessionId隔离SSE chatList: [], // SSE全局配置常量 SSE_BASE_URL: /api/stream/connect, SUBMIT_QUESTION_API: /api/chat/submit, QUERY_STREAM_STATUS_API: /api/stream/status, MAX_RETRY_COUNT: 5, // 最大重连次数 BASE_RETRY_DELAY: 1000, // 初始1s指数退避 1→2→4s MSG_TIMEOUT: 25000, // 单条分片25s超时兜底查询 RENDER_DEBOUNCE: 100, // markdown防抖渲染间隔 // 强制渲染标记 forceRenderMarkers: [###, ##, \n\n, 。, , ], // 全局存储所有SSE实例、定时器、重连状态、超时计时器 sseMap: new Map(), timeoutTimerMap: new Map(), retryStateMap: new Map() } }, mounted() { // 页面刷新兜底加载本地持久化对话历史 this.loadLocalChatHistory() }, beforeDestroy() { // 组件销毁关闭所有SSE、清除全部定时器、销毁监听 this.destroyAllSSE() this.clearAllTimer() }, methods: { /************************ 基础工具方法 ************************/ // 生成唯一会话sessionId generateSessionId() { return sse_${Date.now()}_${Math.random().toString(36).slice(2)} }, // 持久化对话到localStorage页面刷新兜底 saveChatToLocal(list) { localStorage.setItem(chat_history, JSON.stringify(list)) }, // 初始化加载本地历史 loadLocalChatHistory() { const local localStorage.getItem(chat_history) if (!local) return const list JSON.parse(local) this.chatList list // 对未完成中断消息自动重连恢复增量 this.chatList.forEach(item { if (!item.isFinish item.sessionId) { this.initSSEConnect(item.sessionId, item.lastChunkId ?? -1) } }) }, // 清除全部定时器 clearAllTimer() { clearTimeout(this.scrollThrottleTimer) clearTimeout(this.loadingDebounceTimer) // 清除所有分片超时计时器 this.timeoutTimerMap.forEach(timer clearTimeout(timer)) this.timeoutTimerMap.clear() }, // 销毁单条SSE连接清除对应监听、重连状态、超时计时器 destroySingleSSE(sessionId) { const source this.sseMap.get(sessionId) if (source) { // 移除所有事件监听防止销毁后回调执行报错 source.onmessage null source.onerror null source.onopen null source.close() this.sseMap.delete(sessionId) } // 清除重连状态 this.retryStateMap.delete(sessionId) // 清除该会话超时计时器 if (this.timeoutTimerMap.has(sessionId)) { clearTimeout(this.timeoutTimerMap.get(sessionId)) this.timeoutTimerMap.delete(sessionId) } }, // 销毁全部SSE连接 destroyAllSSE() { Array.from(this.sseMap.keys()).forEach(id this.destroySingleSSE(id)) }, // 判断文本是否包含强制渲染标记 hasForceMarker(text) { return this.forceRenderMarkers.some(marker text.includes(marker)) }, // Markdown渲染防抖函数 debounceRenderMarkdown(sessionId) { const chatItem this.chatList.find(i i.sessionId sessionId) if (!chatItem) return clearTimeout(chatItem.renderTimer) chatItem.renderTimer setTimeout(() { this.renderMarkdown(chatItem) }, this.RENDER_DEBOUNCE) }, // 执行markdown渲染仅追加完整文本不重组DOM renderMarkdown(item) { try { const html marked.parse(item.fullText) this.$nextTick(() { item.answerHtml html item.buffer // 清空增量缓冲区 // 节流滚动置底 this.throttleScrollBottom() }) } catch (err) { console.error(markdown渲染失败, err) } }, // 滚动置底节流高频onmessage防卡顿 throttleScrollBottom() { if (this.scrollThrottleTimer) return this.scrollThrottleTimer setTimeout(() { const wrap this.$refs.chatScrollWrap if (wrap) wrap.scrollTop wrap.scrollHeight clearTimeout(this.scrollThrottleTimer) this.scrollThrottleTimer null }, 50) }, /************************ 发送对话入口POST提交获取sessionId ************************/ async sendChat() { const question this.userInput.trim() if (!question) return const sessionId this.generateSessionId() // 初始化气泡数据 const chatItem { sessionId, question, fullText: , // 缓存完整文本重连只拼接增量 buffer: , // 增量缓冲区 answerHtml: , loading: false, errorMsg: , isFinish: false, lastChunkId: -1, // 最后接收分片id renderTimer: null } this.chatList.push(chatItem) this.saveChatToLocal(this.chatList) this.userInput try { // POST提交提问到后端缓存获取会话标识 const res await this.$axios.post(this.SUBMIT_QUESTION_API, { sessionId, question }) if (res.data.code ! 200) throw new Error(res.data.msg || 提交提问失败) // 防抖开启loading避免断网频繁闪烁 clearTimeout(this.loadingDebounceTimer) this.loadingDebounceTimer setTimeout(() { chatItem.loading true }, 150) // 建立SSE长连接 this.initSSEConnect(sessionId, -1) } catch (err) { chatItem.errorMsg err.message chatItem.loading false } }, /************************ SSE长连接初始化核心 ************************/ initSSEConnect(sessionId, lastChunkId) { // 销毁该会话旧连接防止多实例串消息 this.destroySingleSSE(sessionId) const chatItem this.chatList.find(i i.sessionId sessionId) if (!chatItem) return // 构造SSE地址携带sessionId浏览器自动追加Last-Event-ID请求头 const url ${this.SSE_BASE_URL}?sessionId${sessionId} const source new EventSource(url) this.sseMap.set(sessionId, source) // 初始化重连状态 if (!this.retryStateMap.has(sessionId)) { this.retryStateMap.set(sessionId, { count: 0, delay: this.BASE_RETRY_DELAY }) } const retryState this.retryStateMap.get(sessionId) // 初始化分片超时计时器25s兜底查询 this.resetMsgTimeoutTimer(sessionId) // 连接成功 source.onopen () { retryState.count 0 // 连接成功重置重连计数 chatItem.errorMsg chatItem.loading true } // 接收分片消息核心流式逻辑 source.onmessage (event) { // 重置分片超时计时器收到新分片刷新倒计时 this.resetMsgTimeoutTimer(sessionId) try { const payload JSON.parse(event.data) const { chunkId, text, type, errorCode, msg } payload // 存储当前分片id重连携带Last-Event-ID chatItem.lastChunkId chunkId // 分支1正常增量文本推送 if (type chunk) { // 仅单次追加文本不重复处理换行后端已处理\n\n chatItem.fullText text chatItem.buffer text // 存在强制标记立即渲染否则防抖渲染 if (this.hasForceMarker(text)) { this.renderMarkdown(chatItem) } else { this.debounceRenderMarkdown(sessionId) } } // 分支2AI生成完成 if (type end) { chatItem.isFinish true chatItem.loading false this.renderMarkdown(chatItem) this.destroySingleSSE(sessionId) this.saveChatToLocal(this.chatList) } // 分支3后端业务错误推送 if (type error) { chatItem.loading false chatItem.errorMsg msg this.destroySingleSSE(sessionId) // 鉴权401跳转登录 if (errorCode 401) { localStorage.removeItem(token) this.$router.push(/login) } // 限流429停止重试 if (errorCode 429) retryState.count this.MAX_RETRY_COUNT } } catch (parseErr) { console.error(SSE消息解析失败, parseErr) } } // 连接错误监听区分错误类型 source.onerror (err) { chatItem.loading false // 已达到最大重连次数永久关闭连接 if (retryState.count this.MAX_RETRY_COUNT) { chatItem.errorMsg 网络异常已达最大重连次数请刷新重试 this.destroySingleSSE(sessionId) return } // 指数退避重试1s → 2s → 4s retryState.count 1 retryState.delay retryState.delay * 2 const delay retryState.delay setTimeout(() { // 重连携带最后分片id this.initSSEConnect(sessionId, chatItem.lastChunkId) }, delay) } }, /************************ 25s分片超时兜底逻辑 ************************/ resetMsgTimeoutTimer(sessionId) { // 清除旧计时器 if (this.timeoutTimerMap.has(sessionId)) { clearTimeout(this.timeoutTimerMap.get(sessionId)) } // 创建新25s超时计时器 const timer setTimeout(async () { const chatItem this.chatList.find(i i.sessionId sessionId) if (!chatItem || chatItem.isFinish) return // 调用状态同步接口兜底 try { const res await this.$axios.get(${this.QUERY_STREAM_STATUS_API}?sessionId${sessionId}) const { status, fullText, msg } res.data if (status finished) { // 任务已完成覆盖全文渲染 chatItem.fullText fullText chatItem.isFinish true chatItem.loading false this.renderMarkdown(chatItem) this.destroySingleSSE(sessionId) this.saveChatToLocal(this.chatList) } else if (status failed) { // 任务失败展示异常 chatItem.errorMsg msg || AI生成任务失败请重试 chatItem.loading false this.destroySingleSSE(sessionId) } else if (status running) { // 仍在生成重置超时计时器继续等待 this.resetMsgTimeoutTimer(sessionId) } } catch (err) { console.error(查询流状态兜底接口失败, err) // 查询接口失败继续等待 this.resetMsgTimeoutTimer(sessionId) } }, this.MSG_TIMEOUT) this.timeoutTimerMap.set(sessionId, timer) } } }方案完全匹配需求点说明1. 前后端交互流程POST /api/chat/submit提交提问后端缓存对话返回唯一sessionIdGET /api/stream/connect?sessionIdxxxEventSource 建立长连接浏览器自动携带Last-Event-ID请求头断点续传后端每条推送携带id: 数字分片下标前端自动记录lastChunkId2. 文本渲染优化无频繁 DOM 重组容器white-space: pre-wrap后端直接输出\n\n前端不做换行替换处理每条对话维护独立fullText完整缓存增量仅做字符串单次追加缓冲区buffer防抖渲染遇到###/##/。//强制实时渲染Markdown 只在防抖 / 强制节点执行一次渲染不循环拆分重组 DOM3. 滚动性能优化滚动置底使用节流 50ms解决 onmessage 高频触发滚动卡顿容器设置scroll-behavior: auto超大文本禁用平滑滚动减少浏览器重绘开销4. 多气泡隔离防串文每条 AI 气泡独立sessionIdsseMap存储多个独立 EventSource 实例分片携带sessionIdchunkId数据按会话隔离多对话不会文字错乱不使用 v-if 销毁气泡对话列表持久存在仅更新内部 html 内容5. 重连与指数退避解决原生 EventSource 无限快速重试最大重连 5 次退避策略1000ms → 2000ms → 4000ms连接成功重置重连计数达到上限永久关闭连接提示网络异常浏览器原生自动携带Last-Event-ID后端从对应分片续传无需重新生成全文6. 错误分层处理网络断连进入指数退避重连流程401 鉴权失效清除 token跳转登录页停止重试429 限流 / 额度耗尽终止重连展示友好错误提示自定义 event: error 业务推送立即关闭 SSE停止接收分片25s 分片超时兜底调用/stream/status同步任务状态卡死场景兜底恢复完整文本7. 生命周期资源释放防内存泄漏 / 销毁后回调报错beforeDestroy遍历关闭所有 EventSource手动置空onmessage/onerror/onopen监听全局清除滚动、loading、分片超时所有定时器单条会话销毁时同步清除对应重连状态、超时计时器、SSE 实例8. 页面刷新兜底持久化localStorage存储完整对话列表页面初始化自动读取历史未完成中断消息自动执行重连基于lastChunkId恢复增量流式输出9. Loading 防抖优化发送提问 150ms 延迟才展示 loading短网络波动不会频繁闪烁加载状态10. 任务卡死兜底机制每条会话独立 25s 超时计时器无新分片时主动轮询后端状态接口三种分支处理已完成直接覆盖完整全文渲染关闭连接任务失败展示异常文案仍生成中重置计时器继续等待 SSE 增量后端 SSE 推送数据格式规范配套前端每条推送标准 JSON包裹在data: {json}\n\n// 正常分片增量 { type: chunk, chunkId: 1, text: ### 标题\n\n正文内容。, sessionId: sse_xxxx } // AI生成结束 { type: end, chunkId: 99, text: } // 业务错误鉴权/限流/超时 { type: error, errorCode: 401, msg: 登录已失效请重新登录 }每条 SSE 消息头部必须携带id: {chunkId}\n浏览器自动存入 Last-Event-ID 请求头用于断点续传。

相关新闻

轻松掌握MIFARE Classic图形化工具:3步操作Windows NFC工具完整教程

轻松掌握MIFARE Classic图形化工具:3步操作Windows NFC工具完整教程

轻松掌握MIFARE Classic图形化工具:3步操作Windows NFC工具完整教程 【免费下载链接】MifareOneTool A GUI Mifare Classic tool on Windows(停工/最新版v1.7.0) 项目地址: https://gitcode.com/gh_mirrors/mi/MifareOneTool 想要在Wi…

2026/10/13 8:46:44 阅读更多 →
【Cursor AI代码审查实战指南】:20年资深架构师亲授5大避坑法则与实时修复技巧

【Cursor AI代码审查实战指南】:20年资深架构师亲授5大避坑法则与实时修复技巧

更多请点击: https://intelliparadigm.com 第一章:Cursor AI代码审查的核心价值与适用边界 Cursor AI 的代码审查能力并非通用型静态分析工具的简单替代,而是聚焦于开发者实时协作场景中“语义级意图对齐”这一关键痛点。其核心价值体现在三…

2026/10/14 0:50:13 阅读更多 →
高效解决Navicat试用到期问题:macOS用户必备的重置工具指南

高效解决Navicat试用到期问题:macOS用户必备的重置工具指南

高效解决Navicat试用到期问题:macOS用户必备的重置工具指南 【免费下载链接】navicat_reset_mac navicat mac版无限重置试用期脚本 Navicat Mac Version Unlimited Trial Reset Script 项目地址: https://gitcode.com/gh_mirrors/na/navicat_reset_mac 如果你…

2026/10/11 17:00:36 阅读更多 →

最新新闻

51单片机矩阵键盘与数码管动态显示:完整电路、代码与调试指南

51单片机矩阵键盘与数码管动态显示:完整电路、代码与调试指南

矩阵键盘按键识别加数码管动态显示,这个组合在51单片机学习里属于“怎么绕都绕不开”的项目之一。很多人第一次看到“矩阵键盘”“数码管”“动态显示”这几个词凑在一起就有点慌,觉得既要读键盘又要刷新屏幕,逻辑会不会很乱。其实这个项目的…

2026/10/14 0:49:56 阅读更多 →
全球柯本气候区划SHP数据:GIS空间分析的气候语义锚点

全球柯本气候区划SHP数据:GIS空间分析的气候语义锚点

简介:本资源为全球柯本气候区划矢量数据集,面向地理信息、环境科学、气候研究及城乡规划领域的科研人员、高校师生与GIS应用工程师,用于支撑气候空间分析、生态区划建模、农业适应性评估及气候变化教学实践。数据以标准Shapefile格式为核心&a…

2026/10/14 0:49:56 阅读更多 →
OP-TEE最小测试工程实战:TA/CA通信与QEMU部署避坑指南

OP-TEE最小测试工程实战:TA/CA通信与QEMU部署避坑指南

简介:OP-TEE_my_test 是一份面向嵌入式安全开发者的实践型代码包,聚焦 ARM 架构下可信执行环境 OP-TEE 中自定义 Trusted Application(TA)与 Client Application(CA)的集成与验证,适合已具备 C …

2026/10/14 0:49:56 阅读更多 →
QMI8658国产六轴IMU驱动移植与姿态解算实战指南

QMI8658国产六轴IMU驱动移植与姿态解算实战指南

简介:QMI8658驱动参考是一份面向嵌入式开发者的国产IMU(惯性测量单元)驱动资源,覆盖STM32与51单片机两种平台,以C语言实现底层硬件访问与数据读取。压缩包共2个文件,含STM32F103库文件及imualgo_axis9.lib、…

2026/10/14 0:49:56 阅读更多 →
Statsmodels一元OLS实战:从拟合到残差诊断的完整指南

Statsmodels一元OLS实战:从拟合到残差诊断的完整指南

1. 从一次"翻车"的拟合说起:为什么Statsmodels的OLS值得单独拎出来讲很多人第一次接触线性回归,都是从scikit-learn的LinearRegression开始的。几行代码,.fit()一下,.coef_和.intercept_就出来了,干净利落。…

2026/10/14 0:49:56 阅读更多 →
金融时间序列建模全流程:从平稳性检验到GARCH波动率预测

金融时间序列建模全流程:从平稳性检验到GARCH波动率预测

1. 从第一节课到第四章:金融时间序列到底在研究什么如果你正在啃金融相关的课程,大概率会在一门叫《金融时间序列分析》或类似名字的课上卡住。这门课和传统统计学、计量经济学都不一样,它研究的是股票收益率、汇率波动、债券利差这类有“先后…

2026/10/14 0:48:55 阅读更多 →

日新闻

AnyPS5通用适配层设计:兼容性与性能的工程取舍

AnyPS5通用适配层设计:兼容性与性能的工程取舍

1. 从"AnyPS5"这个名字说起:它到底想解决什么问题第一次看到"AnyPS5"这个标题,我脑子里冒出来的第一个念头是:这大概率是一个围绕"跨平台运行"或者"通用适配"做文章的项目。名字里的"Any"…

2026/10/14 0:00:41 阅读更多 →
Flutter跨平台开发实战:二手交易应用与HarmonyOS适配

Flutter跨平台开发实战:二手交易应用与HarmonyOS适配

1. 项目概述:Flutter跨平台二手交易应用开发作为一名长期从事移动应用开发的工程师,我最近完成了一个基于Flutter框架的二手物品交易平台项目。这个项目最吸引我的地方在于它完美结合了Flutter的跨平台优势与HarmonyOS的生态特性,实现了"…

2026/10/14 0:00:41 阅读更多 →
DFS算法解决飞机降落调度问题实战

DFS算法解决飞机降落调度问题实战

1. 项目背景与核心思路飞机降落问题是一个经典的调度优化场景,也是算法竞赛和面试中的高频题目。这个题目要求我们合理安排多架飞机的降落顺序和时间,确保所有飞机都能安全着陆而不发生冲突。在实际航空管制中,这类问题直接关系到飞行安全和机…

2026/10/14 0:00:41 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/13 2:54:56 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/13 16:22:04 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/13 1:58:08 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/13 22:01:27 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/13 14:57:39 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/12 18:11:17 阅读更多 →