SSE 事件设计与可中断的打字机效果
上一篇讲了 SSE 怎么连EventSourcevsfetch流。连上之后真正决定体验的是两件事事件协议怎么设计双方约定看什么字段前端怎么边收边展示并且能中途停下本文按「最小可用协议 → 前端打字机 → 可中断」往下写。1. 先定一条生命周期流式生成可以想成一次「有始有终」的任务开始 → 持续产出内容 → 正常结束 ↘ 中途失败对应到 SSE最小事件集通常是四个事件何时发前端干什么start任务真正开始时发一次清空旧文案、显示「生成中」、记下traceIdcontent产出增量文本时多次发追加到本地字符串打字机end成功收尾时发一次定稿、关 loading、展示引用/校验结果error失败时发一次展示错误、关 loading一般不再等end一条健康的成功流start content content content ... end失败流start content ← 可选失败前可能已有部分输出 error用户主动中断时服务端未必来得及发error前端应靠AbortError自己收尾后面细说。2. 事件载荷怎么设计推荐每条data:都是一个 JSON里面用event字段区分类型。data: {event:start,traceId:t_123,chapterNo:12} data: {event:content,data:夜色,traceId:t_123} data: {event:content,data:渐深,traceId:t_123} data: {event:end,traceId:t_123,finalText:夜色渐深……,citations:[]}建议字段start{event:start,traceId:t_123,chapterNo:12}traceId整次任务的关联 ID日志/客服排障靠它其它上下文章节号、模式名可按业务加但保持「只发一次」content{event:content,data:增量文本,traceId:t_123}data是增量不是全文否则越推越大也难做打字机换行若怕被传输层弄乱可约定服务端把\n写成\\n前端再还原end{event:end,traceId:t_123,finalText:完整最终稿可选,citations:[],consistencyNotes:[]}放「只在结束时才有」的汇总信息引用、校验结果、最终全文前端可以用流式拼接结果finalText适合做一次校准防拼接误差error{event:error,data:上游模型超时,traceId:t_123}data给用户可读原因发完error后应尽快结束响应不要再继续推content可选增强有需要再加事件用途progress/ 阶段事件「检索中 / 拼装 Prompt / 等待模型」content_replace用全文替换当前缓冲例如安全改写后整段重写原则最小四件套先跑通阶段信息是锦上添花别一上来把协议设计成状态机百科。3. 服务端按约定推事件typeSsePayload|{event:start;traceId:string;chapterNo?:number}|{event:content;data:string;traceId:string}|{event:end;traceId:string;finalText?:string}|{event:error;data:string;traceId:string};functionwriteEvent(res:Response,payload:SsePayload){res.write(data:${JSON.stringify(payload)}\n\n);}asyncfunctionstreamGenerate(res:Response,traceId:string){writeEvent(res,{event:start,traceId});try{forawait(constchunkofcallLlmStream()){writeEvent(res,{event:content,data:chunk.replace(/\n/g,\\n),traceId,});}writeEvent(res,{event:end,traceId,finalText:fullText,});}catch(err){writeEvent(res,{event:error,data:errinstanceofError?err.message:生成失败,traceId,});}finally{res.end();}}几个纪律start尽早发让前端立刻进入「生成中」content只发增量成功只走end失败只走error不要两个都发把前端搞懵客户端断开后停止write听req.on(close)4. 前端打字机效果怎么做打字机不是动画库核心就是每次收到content把增量拼到一个字符串上模板绑定这个字符串。conststreamingTextref();construnningref(false);consterrorMessageref();constfinalResultrefnull|{finalText?:string}(null);functionhandleSseDataLine(dataLine:string){constmsgJSON.parse(dataLine)as{event:string;data?:string;traceId?:string;finalText?:string;};switch(msg.event){casestart:streamingText.value;errorMessage.value;finalResult.valuenull;running.valuetrue;break;casecontent:streamingText.value(msg.data||).replace(/\\n/g,\n);break;caseend:running.valuefalse;finalResult.value{finalText:msg.finalText};// 可选用最终稿校准if(msg.finalText){streamingText.valuemsg.finalText;}break;caseerror:running.valuefalse;errorMessage.valuemsg.data||生成失败;break;}}模板里直接渲染pre v-ifrunning || streamingText{{ streamingText }}/pre p v-iferrorMessage classerror{{ errorMessage }}/p如果内容是 Markdown注意别每个 token 都做重型全量渲染。可以流式阶段用纯文本 / 轻量渲染或给 Markdown 组件加节流例如 100–200ms 再解析一次MarkdownContent :sourcestreamingText :throttle-ms200 /5. 可中断AbortController 一条线打通「可中断」要同时管三层用户点「停止」 → abortController.abort() → fetch 取消 / reader 停止 → 前端 catch 到 AbortError不当成业务失败 → UI 退出「生成中」5.1 一个小的流控制封装exportfunctionuseAbortableSse(){letcontroller:AbortController|nullnull;conststreamingref(false);functionabort(){controller?.abort();controllernull;streaming.valuefalse;}functionbegin(){abort();// 开新流前先停旧流避免串流controllernewAbortController();streaming.valuetrue;returncontroller.signal;}return{streaming,begin,abort};}5.2 发起请求时带上 signalconstsseuseAbortableSse();asyncfunctionstartGenerate(){constsignalsse.begin();streamingText.value;try{awaitfetchSse(/api/generate/draft,{method:POST,headers:{Content-Type:application/json,Authorization:Bearer${token},},body:JSON.stringify({/* task */}),signal,},handleSseDataLine);}catch(error){if(isAbortError(error)){// 用户主动停止静默收尾即可return;}errorMessage.valueerrorinstanceofError?error.message:生成失败;}finally{sse.abort();running.valuefalse;}}functionstopGenerate(){sse.abort();}5.3 识别「中断」和「真错误」functionisAbortError(error:unknown):boolean{return((errorinstanceofDOMExceptionerror.nameAbortError)||(errorinstanceofErrorerror.nameSseAbortError));}情况用户提示主动中断「已停止生成」不要弹红错业务error事件展示服务端data网络/HTTP 失败展示请求失败原因这是体验分水岭很多产品把 Abort 也当成异常 Toast用户会觉得「我点停止怎么还报错」。5.4 按钮状态button :disabledstreaming clickstartGenerate开始生成/button button :disabled!streaming clickstopGenerate中断/button6. 把协议和 UI 绑在一起的完整时序点击「开始」 begin() 得到 signal 清空 streamingText POST fetch 流 收到 start running true 多次 content streamingText delta ← 打字机 收到 end running false 可用 finalText 校准 —— 或 —— 用户点「中断」 abort() fetch/reader 停止 catch AbortError → 静默 running false 保留已生成的 streamingText或按产品决定清空 —— 或 —— 收到 error running false 展示错误文案产品决策一句说清即可中断后保留已生成文本写作场景更常见中断后清空适合「要么完整结果、要么不要」的任务7. 常见坑content误发全文每次都推完整文章流量大前端还要从头刷新打字机变「闪烁全文」。成功后又发error或失败后还发end前端状态机乱套。约定互斥。中断当失败一定要分流AbortError。开新任务不停旧流begin()里先abort()旧的避免两个流同时往同一个streamingText上追加。Markdown 每个 token 重渲染长文会卡。节流或流式期降级为纯文本。只有前端 abort、服务端还在生成连接断了服务端应停止写前端 abort 至少能立刻恢复 UI。服务端最好也听close。8. 最小回调接口便于复用业务一多建议把「解析 SSE」和「页面怎么展示」拆开interfaceSseCallbacks{onStart?:(traceId:string)void;onContent?:(text:string)void;onEnd?:(payload:{traceId:string;finalText?:string})void;onError?:(message:string)void;}页面只关心awaitgenerateDraftSSE(params,{onStart:(){streamingText.value;},onContent:(text){streamingText.valuetext;},onEnd:({finalText}){if(finalText)streamingText.valuefinalText;},onError:(message){errorMessage.valuemessage;},},{signal});API Client 负责读流、拆包、JSON.parse、按event分发。页面负责字符串累加、loading、中断按钮。9. 小结先约定最小四事件start/content/end/errorcontent发增量end带汇总error与end互斥打字机 streamingText delta 模板绑定可中断 AbortController贯穿 fetch/reader并且Abort ≠ 业务错误开新流前停旧流重渲染要节流上一篇讲「怎么连上 SSE」这篇讲「连上之后双方怎么说话、前端怎么演」。两者合在一起才是完整的流式生成体验。扩展中断后「继续生成」怎么做上面讲的是中断就停已有内容保留。有一种更进一步的产品形态是用户中断后已生成的内容作为「前缀」点「继续」让 AI 接着往下写。思路不复杂拆成三段来说。思路一纯前端拼接无状态续写最简单。中断时保留streamingText点「继续」时把它作为参数带进下一次请求。服务端接收一个prefixText字段把它拼到 Prompt 里让模型从这里接着写// 前端续写请求awaitgenerateDraftSSE({prefixText:streamingText.value,// 已生成的内容task:currentTask,},callbacks,{signal});// 服务端拼进 promptconstprompt已生成内容请直接续写不要重复${prefixText}--- 请继续;收到content事件时照常追加casecontent:streamingText.valuedelta;// 直接接在后面优点改动极小纯协议层扩展。缺点每次续写都要把前缀全量发给服务端前缀很长时 token 费用和延迟都会上升。思路二服务端记 session有状态续写更健壮的方案。服务端维护一个sessionId记住本次任务的上下文已生成内容、参数、进度续写时只传sessionId服务端自己知道从哪里接。事件协议加一个字段{event:start,traceId:t_123,sessionId:sess_abc}前端把sessionId存起来casestart:currentSessionId.valuemsg.sessionId;break;续写接口只需awaitresumeGenerateSSE({sessionId:currentSessionId.value,},callbacks,{signal});服务端用sessionId拿回上下文继续生成。优点前端不用传大量前缀内容服务端可以做更精细的断点控制。缺点服务端要存 session增加状态管理复杂度。前端状态机加一个interrupted状态不管哪种方案前端的状态都需要区分「空闲」和「已中断可续写」typeGenerationStatusidle|streaming|interrupted|done|error;functioninterruptStreaming(){abortController?.abort();if(status.valuestreaming){status.valueinterrupted;// 不是 idle而是可续写}}按钮根据状态切换button v-ifstatus idle || status done clickstartGenerate 开始生成 /button button v-ifstatus streaming clickinterruptStreaming 中断 /button button v-ifstatus interrupted clickresumeGenerate 继续生成 /buttonresumeGenerate内部就是带上prefixText思路一或sessionId思路二重新发 SSE 请求成功后把状态从interrupted切回streaming。一个需要注意的细节续写拼接点AI 续写时很可能从一句话中间接上前后都是完整文字看不出断点——这是最理想的效果。但也可能重复最后一句或者在语义上跳跃。实践里常见的处理传前缀时截去最后一个不完整的句子按标点找最后的句号/问号/感叹号截断在 Prompt 里明确说「已有内容的最后几句」而不是全文降低 token 用量的同时也给模型清晰的锚点end事件里加canResume: true/false标记告诉前端这次生成是否支持续写小结方案适合场景关键改动纯前端拼接内容短、快速验证请求加prefixTextPrompt 拼接服务端 session长文、多轮续写服务端存上下文事件带sessionId共同点前端都需要interrupted状态 「继续」按钮核心流控和打字机逻辑不用改。系列导航上一篇SSE 的两种连接方式

相关新闻

五分钟极速上手:用abogen轻松制作专业有声书完整指南

五分钟极速上手:用abogen轻松制作专业有声书完整指南

五分钟极速上手:用abogen轻松制作专业有声书完整指南 【免费下载链接】abogen Generate audiobooks from EPUBs, PDFs and text with synchronized captions. 项目地址: https://gitcode.com/GitHub_Trending/ab/abogen 想要将文字内容快速转换为专业有声书吗…

2026/10/18 14:41:53 阅读更多 →
升到 Spring Boot 3 那天,7 个自研 starter 集体静默失效:自动装配的 5 个加载边界

升到 Spring Boot 3 那天,7 个自研 starter 集体静默失效:自动装配的 5 个加载边界

title: 升到 Spring Boot 3 那天,7 个自研 starter 集体静默失效:自动装配的 5 个加载边界 tags: [Java, Spring Boot, 自动装配, 源码解析, 版本升级] category: Java 后端编译过了、启动过了,然后 NPE我们去年做 Spring Boot 2.7.14 → 3.1…

2026/10/16 21:58:43 阅读更多 →
Cryptomatte完全指南:革命性影视后期ID遮罩工具如何自动处理运动模糊与景深

Cryptomatte完全指南:革命性影视后期ID遮罩工具如何自动处理运动模糊与景深

Cryptomatte完全指南:革命性影视后期ID遮罩工具如何自动处理运动模糊与景深 【免费下载链接】Cryptomatte Cryptomatte Nuke plugin, Fusion plugin, sample images, and specification 项目地址: https://gitcode.com/gh_mirrors/cr/Cryptomatte Cryptomatt…

2026/10/18 15:47:32 阅读更多 →

最新新闻

Spring Boot社区在线问诊系统实战:从需求到部署的完整技术拆解

Spring Boot社区在线问诊系统实战:从需求到部署的完整技术拆解

最近后台经常有人问社区家庭医生在线问诊系统怎么做,正好我前阵子完整跟过一个类似项目,从需求梳理到部署上线走了不少弯路。我干脆把这套系统的技术拆解和踩坑记录整理成一篇长文,一方面给自己做个复盘,另一方面给正在做毕业设计…

2026/10/20 13:09:01 阅读更多 →
Git克隆Demo代码仓库实战:从地址到跑通全流程

Git克隆Demo代码仓库实战:从地址到跑通全流程

1. 拿到仓库地址后,先别急着敲命令很多同学第一次接触代码仓库时,最容易犯的毛病就是:复制了地址,打开终端,直接输入git clone,回车,然后开始疯狂刷新浏览器看着进度条发呆。如果仓库小&#xf…

2026/10/20 13:09:01 阅读更多 →
OpenHarmony设备上Flutter淡出过渡动画移植实战与性能优化

OpenHarmony设备上Flutter淡出过渡动画移植实战与性能优化

上个月我把一个原本跑在标准Flutter环境里的页面过渡动画方案移植到OpenHarmony设备上,遇到的第一个问题倒不是动画本身,而是animations 这个官方维护的三方动画库直接编译不过。项目里本来打算用它实现页面切换时的淡出过渡效果,想着省一笔自…

2026/10/20 13:09:01 阅读更多 →
轻量级技能管理方案:从零搭建可量化可组合的技能表

轻量级技能管理方案:从零搭建可量化可组合的技能表

1. 从“skills”这个词说起:它到底指什么“skills”这个词看起来简单,但放在不同语境里,指向完全不同。做招聘的人看到它,想到的是岗位能力模型;做在线教育的人看到它,想到的是课程标签体系;做开…

2026/10/20 13:09:01 阅读更多 →
企业级RAG问答系统实战:知识库、检索链路与大模型应用的完整落地指南

企业级RAG问答系统实战:知识库、检索链路与大模型应用的完整落地指南

做了将近半年的基于大模型的智能问答系统,上线之后我最大的感受是:**决定这套系统好不好用的,往往不是大模型本身,而是知识库和检索链路。**模型答得再聪明,检索不到对的资料,它也只能一本正经地编。我们做…

2026/10/20 13:09:01 阅读更多 →
GraphRAG工程实践:知识图谱如何补足向量检索的推理短板

GraphRAG工程实践:知识图谱如何补足向量检索的推理短板

向量检索失效的那一刻,我才决定认真搞GraphRAG年初做过一个企业内部制度问答系统,刚开始用的是经典的向量库RAG方案:文档切块、embedding、top-k召回、拼prompt喂给大模型。原型阶段跑得很顺,演示的时候领导问了一个问题——"…

2026/10/20 13:08:00 阅读更多 →

日新闻

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

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

2026/10/20 1:01:30 阅读更多 →
ESP32舵机控制指南:原理、多路驱动与避坑实战

ESP32舵机控制指南:原理、多路驱动与避坑实战

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

2026/10/20 1:01:30 阅读更多 →
位移法实现一阶低通滤波:单片机ADC采样降噪实战

位移法实现一阶低通滤波:单片机ADC采样降噪实战

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

2026/10/20 1:01:30 阅读更多 →

周新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型(GMM)的语音识别系统,很多人第一反应就是一堆数学公式,觉得门槛很高。但如果你真的在MATLAB里把它落地跑通,会发现核心其实就是三件事:把声音变成特征向量,用GMM给每个类别…

2026/10/20 10:02:13 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人,但落到具体操作上,无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程:对图像做反白、改调色板、彩色转灰度,最后把像素数据导出成txt文件。单看每一…

2026/10/19 17:32:41 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词,你会发现一个很有意思的现象:每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型,有人指的是运动控制系统,还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/20 2:50:50 阅读更多 →

月新闻

我发现了一个新思路:用 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/20 10:02:08 阅读更多 →
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/20 9:50:17 阅读更多 →
黑夜航拍船只数据集训练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/20 9:50:45 阅读更多 →