从零到一落地「智能助手」:一次基于 OpenSpec 的流式对话前端实践

0 阅读14分钟

关键词:AI 助手、流式问答、SSE、OpenSpec、Vue 3、Markdown 渲染、打字机、会话状态管理
适用读者:正在为业务系统接入 AI 对话能力的前端工程师

数据可视化类产品越做越多,但业务同学真实的诉求往往不是「多一张图」,而是一句话——

「帮我看看这周哪些指标不太对?」

过去这需要一次次筛选、导出后再做数据分析。这次我们在可视化平台里上线了一个智能助手: 在任意一个分析页的右下角点开悬浮入口,一个侧边抽屉展开,用户用自然语言提问,助手以流式的方式给出答案,中间还能看到思考过程。

这篇文章复盘了这一模块从设计到落地的全过程,希望能给同样在做「AI 前端」的同学一些参考。


一、我们想要什么样的智能助手?

产品侧要求其实很克制,归纳下来只有三条:

  1. 随处可用:不要求用户跳去独立的「对话页」,在可视化平台里的任意分析页面都能直接提问;
  2. 像聊天工具一样流畅:边打字边出结果、随时可中止、能看到思考过程;
  3. 不打扰主流程:默认收起、状态保留、再次打开不要丢上下文。

这三点翻译成技术需求大致是:

  • 前端架构:一个全局可复用的抽屉组件(Drawer + FAB 悬浮按钮),多个分析页共享同一套实现;
  • 通信协议:调用智能体平台,基于 SSE(Server-Sent Events) 做流式返回;
  • 交互体验:Markdown 渲染 + 打字机效果 + 思考过程折叠;
  • 状态管理:支持收起 / 展开不丢消息、跨路由基于 sessionStorage 还原会话、可新建 / 清除历史;
  • 观测:接入埋点平台,按 action / 路由维度区分。

二、设计阶段:用 OpenSpec 把需求「焊死」

2.1 为什么选 OpenSpec

过去做这类中等复杂度的需求,我们遇到过几个常见痛点:

  • PRD 太粗,开发到一半才发现「原型里没写的细节」很多;
  • 设计稿给了样式但没给交互状态,比如抽屉收起时到底销毁不销毁?流式中途关闭抽屉要不要 abort?
  • 错误文案、鉴权边界这些「边缘但关键」的事往往散落在 IM 聊天里,后面没人能追溯。

引入 OpenSpec 的本意很简单:在动手前,把修改范围、验收标准和任务拆解写清楚,PR 评审也就有了锚点。

我们用的就是仓库内的一份轻量约定:

openspec/
├── changes/
│   └── <change-name>/
│       ├── proposal.md   # Why / What / 验收标准
│       ├── design.md     # How:接口、目录、关键 Hook、边界
│       └── tasks.md      # 可执行的任务清单,可勾选
└── guides/               # 团队通用规范

2.2 Proposal(Why / What)

proposal.md 里我们写清了三件事:

  1. 变更目标:在可视化平台的多个分析页右下角挂一个智能助手入口,使用智能体平台的流式对话能力;
  2. 修改范围:哪些新增组件、哪些新增 Hook、哪些改动到路由和接口配置;
  3. 验收标准:大约 20 条,最关键的几条:
    • FAB 的视觉与层级(z-index 必须低于 Drawer,否则遮罩弹出时会被覆盖);
    • 抽屉收起 ≠ 销毁:不清空消息、不中止 SSE、不重置 conversationId
    • 发送 / 中止 / 新建 / 清除 分别对应什么服务端语义;
    • 错误要用消息提示 + 气泡尾部追加提示,但已中止请求不弹错
    • 用户身份从 Cookie 取,为空时仅禁用发送按钮,输入框仍可编辑

这些看似琐碎的条目,后来全都真实踩到了,写在前面避免了多次返工。

2.3 Design(How)

design.md 面向实现细节,核心回答了以下问题:

  • 用什么 SSE 库?@microsoft/fetch-event-source;它比原生 EventSource 多了 POST 支持、请求头自定义与 AbortController
  • 消息数据结构怎么设计?每条 assistant 消息同时持有 fullText / thoughtsText / status 等字段。
  • 会话怎么持久化?sessionStorage,分「会话快照」和「抽屉宽度」两个 key。
  • 状态机怎么走?画了一张简单的生命周期图:
       页面路由       ┌──── QaDrawer 常驻 ────┐
          │          │  messages / session  │
          ▼          │  useChatStream       │
       路由卸载 ──► save() ──► sessionStorage
       路由挂载 ──► restore() ──► 回填 messages & conversationId
    

2.4 Tasks(实施清单)

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 的验收条目逐条核对,少了很多「这块儿产品到底要不要?」的来回确认。


三、技术方案概览

3.1 通信协议:为什么是 SSE

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 事件到达。

3.2 请求体设计

请求体的核心字段可以抽象成三类:

  • 身份 / 会话:智能体 ID、用户标识、可选的会话 ID(首次不传、由服务端生成回传,后续带上以延续上下文);
  • 上下文:把用户所在页的筛选状态(例如日期范围、筛选条件)注入进去,让助手在回答时能感知「此刻在看什么」;
  • 操作语义:比如是否清除历史、用户原始 query。

几个工程细节:

  • 用户标识:从登录态 Cookie 中解析,为空时禁用发送、但输入框仍可编辑(避免用户刚输入完就被清空);
  • conversationId:纯前端驱动的生命周期——新建会话时置空、由 start 事件回填;
  • 上下文:只传那些与助手行为真正相关的字段,避免把整个页面状态塞过去,既泄露信息又浪费 token。

3.3 目录与组件拆分

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

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               // ② 不抛会触发自动重连
  }
})

两处坑点:

  1. 必须校验 content-type:某些网关在错误时会改成 application/json,如果不校验,前端会一直尝试把 JSON 当 SSE 解析;
  2. onerror 不抛错 = 自动重连:这是 fetch-event-source 的默认行为,但对话场景通常不希望自动重试(用户已经看到错误了),一定要显式 throw

4.2 消息结构:为什么要同时存 fullTextthoughtsText

有些模型不仅会返回最终答案,还会先返回一段思考过程(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
  }
}

4.3 Markdown + 打字机:体验的点睛之笔

fullText / thoughtsText 会直接交给一个 MdRender 组件渲染(底层基于 markdown-it,支持表格、代码高亮、引用块等)。但如果直接 :content="message.fullText",用户看到的是一大段文本一下子糊上来,失去流式感。

我们接了一层 useTypewriter

const { displayedText, catchUp } = useTypewriter({
  sourceRef: computed(() => message.fullText),
  speed: 20 // 毫秒 / 字
})

它做了三件事:

  1. 监听 sourceRef 变化,按节奏把字符搬到 displayedText
  2. 源文本变化太快时自动提速;
  3. 提供 catchUp()——在 SSE 收到 end 时调用,把剩余字符一次性推到末尾,避免「收到 end 但打字机还在慢悠悠地补」。

一个不起眼但重要的点:当 statusstreaming 变为终态(done / error / aborted)时,无论打字机当前追到哪里,都必须 catchUp(),否则用户点开一个旧消息时会看到半截文本。


五、状态管理:抽屉「收起 ≠ 销毁」

对话类 UI 最怕「一关就没了」。我们希望的行为是:

  • 点标题栏「收起」、点遮罩、按 ESC、再点 FAB:都只是visiblefalse
  • 此时即便 SSE 还在流式返回,也不中断,消息继续累积;
  • 再次打开时打字机直接追到最新位置,用户感觉「回到现场」;
  • 只有点「新建会话」才会真正清空。

关键做法:

  1. <el-drawer :destroy-on-close="false" :with-header="false" />,手绘标题栏;
  2. 所有响应式状态(messages / conversationId / loading / …)都挂在 QaDrawer 顶层组件,而不是 Drawer 的插槽里;
  3. 在路由 onBeforeUnmount 时把 { conversationId, messages } 写入 sessionStorage
  4. 下次进入任意页面、首次打开抽屉时 session.restore(),回填消息与会话 ID。

持久化时我们对消息做了几层压缩 / 保护:

  • 只保留最近 N 条(避免历史无限增长);
  • 每条正文 / 思考做字符数截断,防止单条超大 payload;
  • status: 'streaming' 的消息落盘时改写为 'done',避免下次加载还卡在流式态;
  • 如果序列化后的 payload 超过 4MB(sessionStorage 的常见软上限),再次截断。

六、真实踩过的坑与解法

下面这部分可能是本文最有价值的部分。

6.1 并发发送导致两条流穿插

现象:用户在流式中途再发一条,两条答案的文本块会交替出现在同一个气泡里。

解法

  1. 每次 run() 分配一个自增的 runId
  2. 所有 SSE 回调里用闭包变量 thisRunId === runId 做过滤;
  3. 如果 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 // 过期消息直接丢
      ...
    }
  })
}

6.2 错误消息里的 JSON「套娃」

现象:部分错误走 HTTP 4xx(如被安全策略拦截),返回体是网关包裹的 JSON,里面又嵌着一层服务返回的 JSON,里面才是真正的业务错误文案。如果直接展示最外层,用户看到的是一串乱码。

解法:写了一个简单的「JSON 剥洋葱」工具 extractNestedErrorMessage

  1. | 分段、按 Response body: 前缀、按花括号平衡分别切片;
  2. 每一段尝试 JSON.parse
  3. 递归从 message / error.message / error(如果还是 JSON 字符串)里取最深的一层;
  4. 如果命中到常见的 choices[0].message.content 结构,也把它提出来作为展示文案。

错误渲染的时候,我们同时暴露了两种入口:

  • 气泡主体:展示用户能看懂的那一条;
  • 错误图标 tooltip:悬浮显示原始错误串,方便排查。

6.3 跨页面共享会话 or 隔离?

最初的想法是每个分析页一个 storageKey,严格隔离。上线灰度后用户反馈:

「我在 A 页和助手聊了一半,切到 B 页想继续聊 —— 怎么又重置了?」

所以我们把同一产品域内的页面统一到同一个 storageKey,表现为跨页面共享同一会话。实现代价只是一行默认参数,但用户体验好得多。

6.4 抽屉宽度要「用户可调 + 记忆」

600px 对一部分数据密集的回答来说还是太窄。我们在抽屉左缘加了一个 6px 宽的 resize-handle,鼠标按下后监听 documentmousemove / mouseup,按 RTL 方向(向左拖 = 加宽)更新 sizeclamp 在 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 周。回过头看,有几个关键动作是值得沉淀的:

  1. 动手前写 proposal.md / design.md / tasks.md:让「模糊的产品需求」与「模糊的技术方案」都显性化,避免在 PR 阶段反复 rebase;
  2. 把 SSE 消费和 UI 分层:Hook 只关心协议、UI 只关心渲染,两者通过响应式数据通信。未来换智能体平台,UI 几乎不用改;
  3. 早做状态机streaming / done / aborted / error 四种状态必须在一开始就想清楚——每一次「加一个分支」都要回头检查所有下游的兜底逻辑,否则很容易在「中止 / 空响应 / 错误」边界处漏判一个状态;
  4. 用户视角的「细节」才是 AI 产品的生死线:中止体验、错误文案、收起/打开保留、会话恢复,这些没有一条是 demo 里能看出来的,但少一条用户就会用得难受;
  5. 交付即沉淀 Skill:把这次解决的「协议对接 + 踩坑清单 + 可复用脚本」打包成 Skill 提交到仓库。下一次同类需求——不管是自己做、还是新同学做——都能直接从已经踩过坑的基线开始,而不是从零开始重走一遍。

希望这篇复盘能帮到正在接入 AI 对话能力的同行。我们未来还会继续迭代:

  • 支持附件(图片 / 表格截图)上传;
  • 对话历史的服务端漫游;
  • 基于用户行为的主动提醒。

如果你的团队也在用类似的技术栈(Vue 3 + SSE + Markdown 渲染),欢迎在评论区交流。