首页 / 资讯中心 / 文章详情

前端与流式交互:单 jar 里的 SPA 与一套手撸 SSE 协议

前端与流式交互:单 jar 里的 SPA 与一套手撸 SSE 协议 ★ FEATURED ARTICLE
前端与流式交互单 jar 里的 SPA 与一套手撸 SSE 协议语言 / Language中文 系列第十章目录 上一章评测体系 下一章可观测性与审计项目Agentdemo007 —— 电商智能客服 Agent技术栈Vue 3.5 / Vite 8 / Pinia / TypeScript 6 / vitest 3构建产物直出后端 jar周期2026-09-03 前端设计 spec → 09-14 P0 意图切换与流式事件 → 09-18 闸口/登录前置验证规模sse.test.ts 264 行27 例钉死解析器与消费器语义chat store / api 层 vitest 覆盖源码github.com/Gavincui123/Agentdemo007前言前端在我这里从来不是配一个聊天页面而是后端每条纪律在浏览器里的镜像HTTP 200 的口径、降级标签、权威终态、协作取消每一项都能在前端找到对应实现。这一章讲三件事SPA 为什么住进后端的 jar 里、SSE 协议为什么手撸以及为此付过的一次 P0最高优先级缺陷学费、会话状态机怎么保证用户永远看不到缝合怪回答。一、单 jar 里的 SPA两个决策写前端之前部署形态已经定了单 jar一进程即整站。这个前提倒推出两个前端决策。第一个构建产物直出后端classpath:/static/——vite.config.ts的注释把理由写得很直白生产构建打进 jar由后端 Controller 加静态资源同源服务不再需要独立的前端服务器。第二个路由用 hash 模式。history 模式当然更现代但它把 SPA fallback 的责任推给服务端——单 jar 形态下后端要为所有未命中的路径兜底返回 index.html这会跟/chat、/admin/*、/eval/run这些 API 路由表打架。hash 不发往服务端导航路径和 API 路径天然零冲突dev 代理和生产单 jar 两种形态都不需要 fallback。一个 URL 里的#换掉了整类部署问题代价是对 SEO 不友好——对一个作品集演示站这个代价不存在。还有一行注释值得单独抄下来是 dev 代理配置里的/chat代理项注明SSE (text/event-stream) 不可缓冲关代理压缩、保流式。代理缓冲是流式的第一杀手nginx 侧的同款战斗在第十二章——同一类问题在链路上会出现两次一次在 dev 代理一次在生产网关。二、为什么手撸 SSEEventSource 天生不够决定自己实现 SSE 客户端不是喜欢造轮子是原生 EventSource 有一个过不去的硬伤// utils/sse.ts 文件头 —— 为什么不用原生 EventSource// 后端 /chat/stream 为 POST原生 EventSource 仅 GET 无法 POST// 故采用 fetch ReadableStream 流式读取 本解析器切分 data: 事件。// 本模块仅含纯函数无网络/无时钟副作用。后端的对话接口是 POST要带请求体EventSource 只支持 GET这条路从浏览器标准层面就是死的。fetch 加 ReadableStream 自己读流顺便带来一个意外的好处解析可以做成纯函数离线测试不用起服务器。所以解析器是三件套纯函数splitSseStream归一 CRLF、按空行切事件extractSseData拼接多条data:行、剥恰好一个前导空格——SSE 规范就是这么写的两个空格只剥一个这里有专测钉着extractSseEventName抽事件名:开头的注释行跳过。网络、重试这些有副作用的活全在消费器streamChat里跟解析彻底分开。消费器里有个容易被忽略的分支前置拒绝不可重试。HTTP 200 返回 JSON而不是 event-stream是闸口或鉴权在对话入口的拒绝——SseRejectionError的 message 就是后端面向用户的那句话术注释里明说“不可重试重试也不会变……由调用方透传气泡”重试只留给网络类失败退避 base 500ms 按两次方涨封顶 8 秒确定性间隔、无抖动。三、一次真实事故事件名被 parse 层扔掉P0 可观测联调的时候我碰到一个很典型的静默失效后端明明在发命名事件step_started/step_finished/reply_chunk/reply_ready前端的进度条纹却丝纹不动流式 token 也一个都看不到。没有报错没有异常功能就是蒸发。修复提交里的注释原文记录了根因sse.tsP0 可观测后端 /chat/stream 用命名事件step_started/step_finished/reply_chunk/reply_ready前端此前只抽 data 丢弃事件名——逐步进度与流式 token 因此全被 parse 层扔掉。我的解析器只实现了 SSE 规范的 data 通道没实现 event 通道——事件名在 parse 层被丢弃而且丢弃得悄无声息。这个事故给我两层教训。第一协议的两端要有一份共同的事件名清单发送侧的SseProgressEmitter映射 step_started/step_finished/reply_chunkChatController终端发 reply_ready两端用同一套名字任何一侧单独改名都会在测试里炸出来。第二parse 层丢信息是静默的——它不算错误只是功能消失这类问题靠异常监控抓不到只能靠端到端联调或协议测试暴露。四、用测试钉住协议sse.test.ts 的五组关键语义264 行的测试文件7 组 describe、27 例把 SSE 客户端语义逐条钉死。挑五组关键的语义断言要点切分与残留跨 read 重组[data:hel,lo\n\n,data:world\n\n]→ hello/worldCRLF 归一空格剥离“strips exactly one leading space”——两个空格只剥一个重试与中止500 耗尽 → onError(willRetryfalse)中止 → 只 onClose退避期间立即唤醒是实现行为——delay()被中止即 resolve非测试断言命名事件onEvent 优先通道、onMessage 不再投递无名事件缺省 ‘message’前置拒绝{code:429, message:今日体验轮次已用完欢迎明天再来}→ fetch 恰 1 次、onOpen/onEvent 不触发、话术逐字断言最后一组最有意思后端改一句话术前端测试直接打红。这是故意的——话术在这套系统里是契约不是文案契约的变更就该有仪式感。五、会话状态机乐观占位、权威终态、重试清场流式会话的状态收口就三条规则stores/chat.ts里写成了代码注释// stores/chat.ts —— 流式会话的收口三规则简化示意源码注释逐字保留sendStream(){// ① 乐观占位先 push user 消息 空内容 assistant 占位}onChunk(text){this.m.contenttext// ② 流式分片累积}onTurn(res){// 终态完整回复为权威口径整体替换流式半截内容this.m.contentres.reply}onError(err,willRetry){if(willRetry){// 退避重试将重开整条流清掉半截 token 与步骤轨迹避免跨 attempt 拼接this.m.content;this.steps[]return}if(!gotTurn){// 闸口/鉴权拒绝SseRejectionError透传后端话术其余网络错误用通用兜底this.m.contenterr?.message||FALLBACK}}非重试的错误再分两路还没收到终态gotTurnfalse就把err.message或通用兜底话术写进气泡、置degraded已渲染的占位不清已经收到终态就什么都不动权威回复保留。三条规则里最关键的是整体替换流式分片只是正在打字的表演reply_ready的完整回复才是权威口径。没有这一步整体替换任何分片丢失或乱序都会给用户留下残缺的回答有了它分片通道可以大胆做 best-effort——api/chat.ts的注释写明未知事件名/载荷形状不符→静默丢弃进度 best-effort。后端的降级口径在前端长这样HTTP 200 加 codeapi/http.ts注释“后端始终 HTTP 200含话术短路/降级①②不暴露技术码逻辑结果在 code0成功非 0逻辑错误”degraded:true的消息气泡带琥珀边框和降级·系统仍答标签——系统还答了这个事实要在界面上诚实地说出来。六、视图三件套与 citations 的诚实本节过一遍四处界面落点——流水线脊、引用标签、评测页、可观测页每处都是一条后端纪律的镜像。**PipelineSpine流水线脊**把七层架构直接做成了 UI——这是 Agentdemo007 真实的请求处理流不是装饰。step_finished事件驱动它逐节点亮灯活跃节点磷光青降级态整脊转琥珀健康实时和系统仍答两种核心态在一条脊柱上分得清清楚楚。citations 标签上我写了一句跟 RAG 红队实验第五章直接相关的话“参考来源 · 可追溯不等于绝对正确”。红队证明过语义相关的错误知识能穿过检索闸门直通大模型——那么引用来源的标签就不能暗示有出处即正确产品文案得对工程结论诚实。EvalView用 1 秒轮询GET /eval/progress遇到 409 就自动接上正在跑的进度页面刷新后同样可续看单次轮询失败静默等下一轮——这是第九章评测异步化的前端侧。ObservabilityView消费GET /api/obs/summary的 16 个字段亮灯口径旁边留了专门注释“totalTurns 走异步落库MQ→MySQL有滞后——只看 chatRequests/totalTurns 会误判’尚无流量’”——不了解数据链路的指标口径会得出完全错误的结论。身份切换器游客 V0 到 10086 的 V5是演示的灵魂stores/identity.ts定义 6 个演示身份切身份时store.clear()清空会话——注释原话是旧会话历史可能含高等级档知识答案携带进低等级会话会污染演示观感。权限纪律连演示细节都没放过。七、三重前端闸口与常驻登录三重闸口路由守卫、对话入口口令、401 自动回跳登录入口常驻可见。ADMIN_GUARDED/admin /kb /obs/chat是否后端不可达降级放行对话 401gateLogin不消耗额度→ 回跳路由守卫无 admin token → /authfetchGateStatusenabled 且无口令/gate 输入口令/chat 对话页三份凭证两类去处访问口令与 admin token 各占一个localStorage键前者轮换后旧值自然失效——401 自动回登录页eval token 只在视图内输入、经请求头出站不落浏览器存储权威值在后端配置。登录入口的位置有一个明确的产品裁决写在 ChatView 注释里2026-09-18 用户裁决发布为简历项目登录入口前置可见不再只靠 401 被动跳转。路由守卫还留了一手后端不可达时不拦、降级放行因为 AccessGateFilter 仍在对话入口兜底。前端守卫是体验优化后端过滤器才是安全边界——两道闸的职责分工代码注释里就写死了从不混淆。八、这一章带走的五条hash 路由 产物进 jar 是被部署形态倒推的决策单 jar 里 SPA fallback 的麻烦比 hash 的 URL 难看值钱。协议解析器要纯函数化SSE 语义空格、残留、注释、事件名全部离线可测网络壳单独测。流式是表演终态是权威乐观占位、分片累积、整体替换、重试清场——四步缺一都会有缝合怪回答。话术即契约前端逐字断言后端话术改文案会打红测试——这是故意的。前端守卫是体验后端过滤器是安全降级放行的注释把这条边界写在了代码里。九、已知边界诚实清单ECharts 体积ObservabilityView 懒加载 chunk ~1MB“全量 ECharts 打入该懒加载块……后续可改按需 import 瘦身”DEPLOY.md 构建 chunk 清单登记。EventSource 降级轮询只是预案代理缓冲导致 SSE 失效时的降级方案在计划文档里登记未实现——当前靠 nginxproxy_buffering off第十二章。身份即 mock6 个演示身份由客户端声明真鉴权接入后收口第一章诚实清单同条。口令存 localStorage演示站可接受的安全预算token 类凭证的生产化要换 HttpOnly Cookie 体系。本文机制出处frontend/src/utils/sse.tssse.test.ts、frontend/src/stores/chat.ts/identity.ts、frontend/src/api/{chat,http,gate,auth}.ts、frontend/src/views/{chat,eval,observability,auth,gate}/、frontend/vite.config.ts发送侧协议见web/SseProgressEmitter。相关阅读系列目录 · 上一章评测体系 · 下一章可观测性与审计 · 第十二章·nginx 侧的 SSE 缓冲治理
阅读完成 · 觉得有帮助?
咨询建站