vivo灵动岛
115.80M · 2026-04-24
数据可视化类产品越做越多,但业务同学真实的诉求往往不是「多一张图」,而是一句话——
过去这需要一次次筛选、导出后再做数据分析。这次我们在可视化平台里上线了一个智能助手: 在任意一个分析页的右下角点开悬浮入口,一个侧边抽屉展开,用户用自然语言提问,助手以流式的方式给出答案,中间还能看到思考过程。
这篇文章复盘了这一模块从设计到落地的全过程,希望能给同样在做「AI 前端」的同学一些参考。
产品侧要求其实很克制,归纳下来只有三条:
这三点翻译成技术需求大致是:
sessionStorage 还原会话、可新建 / 清除历史;过去做这类中等复杂度的需求,我们遇到过几个常见痛点:
引入 OpenSpec 的本意很简单:在动手前,把修改范围、验收标准和任务拆解写清楚,PR 评审也就有了锚点。
我们用的就是仓库内的一份轻量约定:
openspec/
├── changes/
│ └── <change-name>/
│ ├── proposal.md # Why / What / 验收标准
│ ├── design.md # How:接口、目录、关键 Hook、边界
│ └── tasks.md # 可执行的任务清单,可勾选
└── guides/ # 团队通用规范
proposal.md 里我们写清了三件事:
z-index 必须低于 Drawer,否则遮罩弹出时会被覆盖);conversationId;这些看似琐碎的条目,后来全都真实踩到了,写在前面避免了多次返工。
design.md 面向实现细节,核心回答了以下问题:
@microsoft/fetch-event-source;它比原生 EventSource 多了 POST 支持、请求头自定义与 AbortController。assistant 消息同时持有 fullText / thoughtsText / status 等字段。sessionStorage,分「会话快照」和「抽屉宽度」两个 key。 页面路由 ┌──── QaDrawer 常驻 ────┐
│ │ messages / session │
▼ │ useChatStream │
路由卸载 ──► save() ──► sessionStorage
路由挂载 ──► restore() ──► 回填 messages & conversationId
tasks.md 是一张可勾选的任务表,按依赖关系排序、每一行带预估工时:
| 序号 | 任务 | 依赖 | 预估 |
| ---- | ------------------------------------------ | ----- | ---- |
| 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 的验收条目逐条核对,少了很多「这块儿产品到底要不要?」的来回确认。
AI 对话类场景的三种主流方案:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 轮询 | 实现简单 | 延迟高、浪费带宽,完全没有「边说边出」的感觉 |
| WebSocket | 双向、实时性最好 | 过重,需要独立网关 / 心跳 / 鉴权通道,且大部分 AI 服务本质是单向推送 |
| SSE | 基于 HTTP、天然流式、可走现有网关、复用 HTTP/2 多路复用 | 只能服务端 → 客户端单向 |
我们选 SSE。响应 Content-Type: text/event-stream,事件流大致如下:
start → data(text) × N → end
每条事件是一段 JSON:
{ "type": "start", "sessionId": "...", "conversationId": "..." }
{ "type": "data", "type": "text", "content": "根据查询结果," }
{ "type": "data", "type": "text", "content": "具体分析情况是..." }
{ "type": "end", "end": true }
前端的工作就是逐块消费、把每一块 content 拼到对应 assistant 消息上,直到 end 事件到达。
请求体的核心字段可以抽象成三类:
几个工程细节:
conversationId:纯前端驱动的生命周期——新建会话时置空、由 start 事件回填;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 # 打字机
fetch-event-source 的封装原生 EventSource 不支持 POST + 自定义 header,我们用 @microsoft/fetch-event-source:
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。fullText 和 thoughtsText有些模型不仅会返回最终答案,还会先返回一段思考过程(reasoning / thinking)。为了让 UI 能把两者区分展示(「思考中...」折叠 + 正文气泡),我们在 assistant 消息上同时维护:
{
__qaMsgId: 1,
role: 'assistant',
status: 'streaming', // streaming | done | aborted | error
thoughtsText: '', // 思考过程累积
fullText: '' // 正文累积
}
分派器的主干大致是这样的:
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
}
}
fullText / thoughtsText 会直接交给一个 MdRender 组件渲染(底层基于 markdown-it,支持表格、代码高亮、引用块等)。但如果直接 :content="message.fullText",用户看到的是一大段文本一下子糊上来,失去流式感。
我们接了一层 useTypewriter:
const { displayedText, catchUp } = useTypewriter({
sourceRef: computed(() => message.fullText),
speed: 20 // 毫秒 / 字
})
它做了三件事:
sourceRef 变化,按节奏把字符搬到 displayedText;catchUp()——在 SSE 收到 end 时调用,把剩余字符一次性推到末尾,避免「收到 end 但打字机还在慢悠悠地补」。一个不起眼但重要的点:当 status 从 streaming 变为终态(done / error / aborted)时,无论打字机当前追到哪里,都必须 catchUp(),否则用户点开一个旧消息时会看到半截文本。
对话类 UI 最怕「一关就没了」。我们希望的行为是:
visible 置 false;关键做法:
<el-drawer :destroy-on-close="false" :with-header="false" />,手绘标题栏;messages / conversationId / loading / …)都挂在 QaDrawer 顶层组件,而不是 Drawer 的插槽里;onBeforeUnmount 时把 { conversationId, messages } 写入 sessionStorage;session.restore(),回填消息与会话 ID。持久化时我们对消息做了几层压缩 / 保护:
status: 'streaming' 的消息落盘时改写为 'done',避免下次加载还卡在流式态;sessionStorage 的常见软上限),再次截断。下面这部分可能是本文最有价值的部分。
现象:用户在流式中途再发一条,两条答案的文本块会交替出现在同一个气泡里。
解法:
run() 分配一个自增的 runId;thisRunId === runId 做过滤;loading === true 时有新请求到来,先 abort() 上一次。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 // 过期消息直接丢
...
}
})
}
现象:部分错误走 HTTP 4xx(如被安全策略拦截),返回体是网关包裹的 JSON,里面又嵌着一层服务返回的 JSON,里面才是真正的业务错误文案。如果直接展示最外层,用户看到的是一串乱码。
解法:写了一个简单的「JSON 剥洋葱」工具 extractNestedErrorMessage:
| 分段、按 Response body: 前缀、按花括号平衡分别切片;JSON.parse;message / error.message / error(如果还是 JSON 字符串)里取最深的一层;choices[0].message.content 结构,也把它提出来作为展示文案。错误渲染的时候,我们同时暴露了两种入口:
最初的想法是每个分析页一个 storageKey,严格隔离。上线灰度后用户反馈:
所以我们把同一产品域内的页面统一到同一个 storageKey,表现为跨页面共享同一会话。实现代价只是一行默认参数,但用户体验好得多。
600px 对一部分数据密集的回答来说还是太窄。我们在抽屉左缘加了一个 6px 宽的 resize-handle,鼠标按下后 document 的 mousemove / mouseup,按 RTL 方向(向左拖 = 加宽)更新 size。clamp 在 400px 到 min(1200px, 95vw) 之间,避免拖到看不见。
宽度用另一个独立的 sessionStorage key 存,和会话内容解耦。
vue-tsc 也能拿到类型提示);OpenSpec 解决的是这次怎么做,下次做类似的事情呢?我们在团队里试的一种做法是:项目交付的同时,把可复用的部分抽成一份 Agent Skill,随代码一起沉淀到仓库。
下次再做类似的接入——可能是另一个业务线、另一种 Agent 平台——AI 编辑器能自动识别、加载这份 Skill,新同学从一开始就站在「已经踩过坑」的起点上。
我们的几条经验:
与其他沉淀形式相比:文档要人主动去找,模板 / 脚手架容易版本漂移、改动不回灌;Skill 介于两者之间,Agent 触发加载、内容随仓库一起演进、脚本可以直接拿走用。
一个更实际的衡量——同类需求的第二次,能不能在更短时间内跑通最小 Demo? 这次把经验沉淀完后,我们在另一个业务线上复用了一遍,从依赖安装到看到流式文本出现在气泡里,整体时间比第一次短了一个数量级。
这次模块从立项到灰度上线大约 2 周。回过头看,有几个关键动作是值得沉淀的:
proposal.md / design.md / tasks.md:让「模糊的产品需求」与「模糊的技术方案」都显性化,避免在 PR 阶段反复 rebase;streaming / done / aborted / error 四种状态必须在一开始就想清楚——每一次「加一个分支」都要回头检查所有下游的兜底逻辑,否则很容易在「中止 / 空响应 / 错误」边界处漏判一个状态;希望这篇复盘能帮到正在接入 AI 对话能力的同行。我们未来还会继续迭代:
如果你的团队也在用类似的技术栈(Vue 3 + SSE + Markdown 渲染),欢迎在评论区交流。