关键词:AI 助手、流式问答、SSE、OpenSpec、Vue 3、Markdown 渲染、打字机、会话状态管理
适用读者:正在为业务系统接入 AI 对话能力的前端工程师
数据可视化类产品越做越多,但业务同学真实的诉求往往不是「多一张图」,而是一句话------
「帮我看看这周哪些指标不太对?」
过去这需要一次次筛选、导出后再做数据分析。这次我们在可视化平台里上线了一个智能助手: 在任意一个分析页的右下角点开悬浮入口,一个侧边抽屉展开,用户用自然语言提问,助手以流式的方式给出答案,中间还能看到思考过程。
这篇文章复盘了这一模块从设计到落地的全过程,希望能给同样在做「AI 前端」的同学一些参考。
一、我们想要什么样的智能助手?
产品侧要求其实很克制,归纳下来只有三条:
- 随处可用:不要求用户跳去独立的「对话页」,在可视化平台里的任意分析页面都能直接提问;
- 像聊天工具一样流畅:边打字边出结果、随时可中止、能看到思考过程;
- 不打扰主流程:默认收起、状态保留、再次打开不要丢上下文。
这三点翻译成技术需求大致是:
- 前端架构:一个全局可复用的抽屉组件(Drawer + FAB 悬浮按钮),多个分析页共享同一套实现;
- 通信协议 :调用智能体平台,基于 SSE(Server-Sent Events) 做流式返回;
- 交互体验:Markdown 渲染 + 打字机效果 + 思考过程折叠;
- 状态管理 :支持收起 / 展开不丢消息、跨路由基于
sessionStorage还原会话、可新建 / 清除历史; - 观测:接入埋点平台,按 action / 路由维度区分。
二、设计阶段:用 OpenSpec 把需求「焊死」
2.1 为什么选 OpenSpec
过去做这类中等复杂度的需求,我们遇到过几个常见痛点:
- PRD 太粗,开发到一半才发现「原型里没写的细节」很多;
- 设计稿给了样式但没给交互状态,比如抽屉收起时到底销毁不销毁?流式中途关闭抽屉要不要 abort?
- 错误文案、鉴权边界这些「边缘但关键」的事往往散落在 IM 聊天里,后面没人能追溯。
引入 OpenSpec 的本意很简单:在动手前,把修改范围、验收标准和任务拆解写清楚,PR 评审也就有了锚点。
我们用的就是仓库内的一份轻量约定:
text
openspec/
├── changes/
│ └── <change-name>/
│ ├── proposal.md # Why / What / 验收标准
│ ├── design.md # How:接口、目录、关键 Hook、边界
│ └── tasks.md # 可执行的任务清单,可勾选
└── guides/ # 团队通用规范
2.2 Proposal(Why / What)
proposal.md 里我们写清了三件事:
- 变更目标:在可视化平台的多个分析页右下角挂一个智能助手入口,使用智能体平台的流式对话能力;
- 修改范围:哪些新增组件、哪些新增 Hook、哪些改动到路由和接口配置;
- 验收标准 :大约 20 条,最关键的几条:
- FAB 的视觉与层级(
z-index必须低于 Drawer,否则遮罩弹出时会被覆盖); - 抽屉收起 ≠ 销毁 :不清空消息、不中止 SSE、不重置
conversationId; - 发送 / 中止 / 新建 / 清除 分别对应什么服务端语义;
- 错误要用消息提示 + 气泡尾部追加提示,但已中止请求不弹错;
- 用户身份从 Cookie 取,为空时仅禁用发送按钮,输入框仍可编辑。
- FAB 的视觉与层级(
这些看似琐碎的条目,后来全都真实踩到了,写在前面避免了多次返工。
2.3 Design(How)
design.md 面向实现细节,核心回答了以下问题:
-
用什么 SSE 库?
@microsoft/fetch-event-source;它比原生EventSource多了POST支持、请求头自定义与AbortController。 -
消息数据结构怎么设计?每条
assistant消息同时持有fullText/thoughtsText/status等字段。 -
会话怎么持久化?
sessionStorage,分「会话快照」和「抽屉宽度」两个 key。 -
状态机怎么走?画了一张简单的生命周期图:
scss页面路由 ┌──── QaDrawer 常驻 ────┐ │ │ messages / session │ ▼ │ useChatStream │ 路由卸载 ──► save() ──► sessionStorage 路由挂载 ──► restore() ──► 回填 messages & conversationId
2.4 Tasks(实施清单)
tasks.md 是一张可勾选的任务表,按依赖关系排序、每一行带预估工时:
text
| 序号 | 任务 | 依赖 | 预估 |
| ---- | ------------------------------------------ | ----- | ---- |
| 1 | 新增流式接口路径到接口配置 | --- | 0.2h |
| 2 | useChatStream:封装 SSE 与事件分派 | 1 | 2h |
| 3 | useConversationSession:sessionStorage 持久化 | --- | 0.5h |
| 4 | QaFab:右下角 FAB | --- | 0.5h |
| ... | ... | ... | ... |
效果 :我们在 PR 描述里直接引用 tasks.md 的勾选状态;Code Review 也围绕 proposal.md 的验收条目逐条核对,少了很多「这块儿产品到底要不要?」的来回确认。
三、技术方案概览
3.1 通信协议:为什么是 SSE
AI 对话类场景的三种主流方案:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 轮询 | 实现简单 | 延迟高、浪费带宽,完全没有「边说边出」的感觉 |
| WebSocket | 双向、实时性最好 | 过重,需要独立网关 / 心跳 / 鉴权通道,且大部分 AI 服务本质是单向推送 |
| SSE | 基于 HTTP、天然流式、可走现有网关、复用 HTTP/2 多路复用 | 只能服务端 → 客户端单向 |
我们选 SSE。响应 Content-Type: text/event-stream,事件流大致如下:
text
start → data(text) × N → end
每条事件是一段 JSON:
json
{ "type": "start", "sessionId": "...", "conversationId": "..." }
{ "type": "data", "type": "text", "content": "根据查询结果," }
{ "type": "data", "type": "text", "content": "具体分析情况是..." }
{ "type": "end", "end": true }
前端的工作就是逐块消费、把每一块 content 拼到对应 assistant 消息上,直到 end 事件到达。
3.2 请求体设计
请求体的核心字段可以抽象成三类:
- 身份 / 会话:智能体 ID、用户标识、可选的会话 ID(首次不传、由服务端生成回传,后续带上以延续上下文);
- 上下文 :把用户所在页的筛选状态(例如日期范围、筛选条件)注入进去,让助手在回答时能感知「此刻在看什么」;
- 操作语义:比如是否清除历史、用户原始 query。
几个工程细节:
- 用户标识 :从登录态 Cookie 中解析,为空时禁用发送、但输入框仍可编辑(避免用户刚输入完就被清空);
conversationId:纯前端驱动的生命周期------新建会话时置空、由start事件回填;- 上下文 :只传那些与助手行为真正相关的字段,避免把整个页面状态塞过去,既泄露信息又浪费 token。
3.3 目录与组件拆分
text
src/
├── components/
│ └── QaDrawer/ # 抽屉相关 UI(全局可复用)
│ ├── index.vue # 容器:FAB + el-drawer
│ ├── QaFab.vue # 右下角悬浮按钮
│ ├── MessageList.vue # 消息列表 + 自动滚动
│ ├── MessageItem.vue # 单条消息(用户气泡 / 助手气泡 / 欢迎态)
│ ├── ChatInput.vue # 输入区 + 免责声明
│ ├── QaWelcomeEmpty.vue # 空会话欢迎语 + 示例问法
│ └── buildQaContext.js # 页面状态 → 请求上下文
└── views/<Route>/hook/
├── useChatStream.js # SSE 消费 + 消息追加
├── useConversationSession.js # 会话快照
└── useTypewriter.js # 打字机
我们把纯展示 组件(
QaDrawer/*)和状态相关的 Hook 做了明确分层。 Hook 放在页面侧,UI 仅通过路径别名引用;好处是 UI 与具体路由解耦,未来可以搬到设计系统级仓库。
四、SSE 的几个核心实现
4.1 基于 fetch-event-source 的封装
原生 EventSource 不支持 POST + 自定义 header,我们用 @microsoft/fetch-event-source:
js
import { fetchEventSource } from '@microsoft/fetch-event-source'
const ctrl = new AbortController()
fetchEventSource(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream'
},
body: JSON.stringify(requestBody),
signal: ctrl.signal,
openWhenHidden: true,
async onopen (resp) {
const ct = resp.headers.get('content-type') || ''
if (!resp.ok || !ct.includes('text/event-stream')) {
throw new Error(`Invalid SSE response: ${resp.status} ${ct}`)
}
},
onmessage (msg) {
const payload = JSON.parse(msg.data)
dispatch(payload) // ① start / data / end / error 分派
},
onerror (err) {
if (err?.name === 'AbortError') throw err
ElMessage.error(extractFriendlyError(err?.message))
throw err // ② 不抛会触发自动重连
}
})
两处坑点:
- 必须校验
content-type:某些网关在错误时会改成application/json,如果不校验,前端会一直尝试把 JSON 当 SSE 解析; onerror不抛错 = 自动重连 :这是fetch-event-source的默认行为,但对话场景通常不希望自动重试(用户已经看到错误了),一定要显式throw。
4.2 消息结构:为什么要同时存 fullText 和 thoughtsText
有些模型不仅会返回最终答案,还会先返回一段思考过程(reasoning / thinking)。为了让 UI 能把两者区分展示(「思考中...」折叠 + 正文气泡),我们在 assistant 消息上同时维护:
js
{
__qaMsgId: 1,
role: 'assistant',
status: 'streaming', // streaming | done | aborted | error
thoughtsText: '', // 思考过程累积
fullText: '' // 正文累积
}
分派器的主干大致是这样的:
js
function dispatch (payload) {
const asst = lastAssistant()
if (payload.type === 'start') {
sessionId.value = payload.sessionId
conversationId.value = payload.conversationId
return
}
if (payload.type === 'data') {
if (isThinking(payload)) {
asst.thoughtsText += payload.content || ''
} else {
asst.fullText += payload.content || ''
}
return
}
if (payload.type === 'end') {
asst.status = 'done'
fillEmptyAssistantFallback(asst) // 末端兜底,见后文
return
}
if (payload.type === 'error') {
asst.status = 'error'
asst.fullText = resolveDisplayText(payload)
asst.streamErrorDetail = String(payload.error || '')
return
}
}
4.3 Markdown + 打字机:体验的点睛之笔
fullText / thoughtsText 会直接交给一个 MdRender 组件渲染(底层基于 markdown-it,支持表格、代码高亮、引用块等)。但如果直接 :content="message.fullText",用户看到的是一大段文本一下子糊上来,失去流式感。
我们接了一层 useTypewriter:
js
const { displayedText, catchUp } = useTypewriter({
sourceRef: computed(() => message.fullText),
speed: 20 // 毫秒 / 字
})
它做了三件事:
- 监听
sourceRef变化,按节奏把字符搬到displayedText; - 源文本变化太快时自动提速;
- 提供
catchUp()------在 SSE 收到end时调用,把剩余字符一次性推到末尾,避免「收到 end 但打字机还在慢悠悠地补」。
一个不起眼但重要的点 :当 status 从 streaming 变为终态(done / error / aborted)时,无论打字机当前追到哪里,都必须 catchUp(),否则用户点开一个旧消息时会看到半截文本。
五、状态管理:抽屉「收起 ≠ 销毁」
对话类 UI 最怕「一关就没了」。我们希望的行为是:
- 点标题栏「收起」、点遮罩、按 ESC、再点 FAB:都只是 把
visible置false; - 此时即便 SSE 还在流式返回,也不中断,消息继续累积;
- 再次打开时打字机直接追到最新位置,用户感觉「回到现场」;
- 只有点「新建会话」才会真正清空。
关键做法:
<el-drawer :destroy-on-close="false" :with-header="false" />,手绘标题栏;- 所有响应式状态(
messages/conversationId/loading/ ...)都挂在QaDrawer顶层组件,而不是 Drawer 的插槽里; - 在路由
onBeforeUnmount时把{ conversationId, messages }写入sessionStorage; - 下次进入任意页面、首次打开抽屉时
session.restore(),回填消息与会话 ID。
持久化时我们对消息做了几层压缩 / 保护:
- 只保留最近 N 条(避免历史无限增长);
- 每条正文 / 思考做字符数截断,防止单条超大 payload;
status: 'streaming'的消息落盘时改写为'done',避免下次加载还卡在流式态;- 如果序列化后的 payload 超过 4MB(
sessionStorage的常见软上限),再次截断。
六、真实踩过的坑与解法
下面这部分可能是本文最有价值的部分。
6.1 并发发送导致两条流穿插
现象:用户在流式中途再发一条,两条答案的文本块会交替出现在同一个气泡里。
解法:
- 每次
run()分配一个自增的runId; - 所有 SSE 回调里用闭包变量
thisRunId === runId做过滤; - 如果
loading === true时有新请求到来,先abort()上一次。
js
let runId = 0
function run (...) {
if (loading.value) abort()
runId += 1
const thisRunId = runId
loading.value = true
...
fetchEventSource(url, {
onmessage (msg) {
if (thisRunId !== runId) return // 过期消息直接丢
...
}
})
}
6.2 错误消息里的 JSON「套娃」
现象:部分错误走 HTTP 4xx(如被安全策略拦截),返回体是网关包裹的 JSON,里面又嵌着一层服务返回的 JSON,里面才是真正的业务错误文案。如果直接展示最外层,用户看到的是一串乱码。
解法 :写了一个简单的「JSON 剥洋葱」工具 extractNestedErrorMessage:
- 按
|分段、按Response body:前缀、按花括号平衡分别切片; - 每一段尝试
JSON.parse; - 递归从
message/error.message/error(如果还是 JSON 字符串)里取最深的一层; - 如果命中到常见的
choices[0].message.content结构,也把它提出来作为展示文案。
错误渲染的时候,我们同时暴露了两种入口:
- 气泡主体:展示用户能看懂的那一条;
- 错误图标 tooltip:悬浮显示原始错误串,方便排查。
6.3 跨页面共享会话 or 隔离?
最初的想法是每个分析页一个 storageKey,严格隔离。上线灰度后用户反馈:
「我在 A 页和助手聊了一半,切到 B 页想继续聊 ------ 怎么又重置了?」
所以我们把同一产品域内的页面统一到同一个 storageKey,表现为跨页面共享同一会话。实现代价只是一行默认参数,但用户体验好得多。
6.4 抽屉宽度要「用户可调 + 记忆」
600px 对一部分数据密集的回答来说还是太窄。我们在抽屉左缘加了一个 6px 宽的 resize-handle,鼠标按下后监听 document 的 mousemove / mouseup,按 RTL 方向(向左拖 = 加宽)更新 size。clamp 在 400px 到 min(1200px, 95vw) 之间,避免拖到看不见。
宽度用另一个独立的 sessionStorage key 存,和会话内容解耦。
七、一些工程化的「边角料」
- 埋点:所有交互事件(打开 / 关闭 / 发送 / 中止 / 新建 / 清除)都接入埋点平台;
- 类型 :关键数据结构在 JSDoc 里写清楚(项目本身为 JS,没有开启 TS,但 JSDoc 配合
vue-tsc也能拿到类型提示); - Lint 规则 :团队约定 Hook 入参必须是对象解构,避免长位置参数混淆;
- 回滚方案:整个智能助手用一个环境变量 / 配置开关包起来,线上出问题可以秒级关闭入口,不影响主产品流程。
八、把经验沉淀成 Skill:让下一个项目可以直接「接着做」
OpenSpec 解决的是这次 怎么做,下次 做类似的事情呢?我们在团队里试的一种做法是:项目交付的同时,把可复用的部分抽成一份 Agent Skill,随代码一起沉淀到仓库。
下次再做类似的接入------可能是另一个业务线、另一种 Agent 平台------AI 编辑器能自动识别、加载这份 Skill,新同学从一开始就站在「已经踩过坑」的起点上。
我们的几条经验:
- 描述要写满触发词:业务关键词 + 技术关键词 + 症状关键词都铺进去,Agent 才接得住语义;
- 渐进式披露:主文档只写最小必要信息,细节示例和可直接复制的脚本拆到附属文件;
- 脚本独立可复制:核心逻辑(SSE 消费、错误解析、打字机等)写成独立文件而不是让 Agent 每次现写,更稳也更省 token;
- 不绑死项目路径:把项目特有的约定抽成回调(如用户标识、上下文构造),由调用方注入------Skill 才能跨项目复用。
与其他沉淀形式相比:文档要人主动去找,模板 / 脚手架容易版本漂移、改动不回灌;Skill 介于两者之间,Agent 触发加载、内容随仓库一起演进、脚本可以直接拿走用。
一个更实际的衡量------同类需求的第二次,能不能在更短时间内跑通最小 Demo? 这次把经验沉淀完后,我们在另一个业务线上复用了一遍,从依赖安装到看到流式文本出现在气泡里,整体时间比第一次短了一个数量级。
九、总结:OpenSpec + 流式 + 小步迭代 + Skill 沉淀
这次模块从立项到灰度上线大约 2 周。回过头看,有几个关键动作是值得沉淀的:
- 动手前写
proposal.md/design.md/tasks.md:让「模糊的产品需求」与「模糊的技术方案」都显性化,避免在 PR 阶段反复 rebase; - 把 SSE 消费和 UI 分层:Hook 只关心协议、UI 只关心渲染,两者通过响应式数据通信。未来换智能体平台,UI 几乎不用改;
- 早做状态机 :
streaming/done/aborted/error四种状态必须在一开始就想清楚------每一次「加一个分支」都要回头检查所有下游的兜底逻辑,否则很容易在「中止 / 空响应 / 错误」边界处漏判一个状态; - 用户视角的「细节」才是 AI 产品的生死线:中止体验、错误文案、收起/打开保留、会话恢复,这些没有一条是 demo 里能看出来的,但少一条用户就会用得难受;
- 交付即沉淀 Skill:把这次解决的「协议对接 + 踩坑清单 + 可复用脚本」打包成 Skill 提交到仓库。下一次同类需求------不管是自己做、还是新同学做------都能直接从已经踩过坑的基线开始,而不是从零开始重走一遍。
希望这篇复盘能帮到正在接入 AI 对话能力的同行。我们未来还会继续迭代:
- 支持附件(图片 / 表格截图)上传;
- 对话历史的服务端漫游;
- 基于用户行为的主动提醒。
如果你的团队也在用类似的技术栈(Vue 3 + SSE + Markdown 渲染),欢迎在评论区交流。