三阶段生成状态管理实操方法(Vue3 + Pinia 流式输出设计)

AI 爆款文章创作器的前端状态管理,用 Pinia 的 setup store 承载全部生成流程,核心是一个显式的生成状态机:idle → generating → streaming → done / failed / aborted。三阶段(大纲生成、正文扩写、成稿润色)共用同一个 store,用 stage 字段区分当前阶段,SSE 连接的创建、收流、中断全部收敛在 store 的 action 里,组件只读状态、不碰连接。这套设计解决了三个实际问题:多组件共享生成进度、流式数据写入不卡界面、页面刷新后生成状态可恢复。

一、状态模型:用状态机替代散落的布尔值

早期版本用 isGenerating、isStreaming、isDone 一组布尔值表达状态,三阶段叠加后组合爆炸:isGenerating && !isStreaming 到底代表”正在等待首字”还是”已结束”?排查起来全靠猜。改成单一状态枚举后,每个阶段可迁移的目标状态是明确列出来的,UI 层的按钮禁用、进度条、重试入口都能按状态推导。

方案 状态可推导性 非法组合防护 扩展新阶段成本 实际使用
散落布尔值 弱,靠 if 拼装 无 高 早期原型
事件总线广播 中,状态分散在各模块 无 中 已废弃
单状态枚举 + Pinia 强,迁移表可见 有(校验迁移合法性) 低 当前实现
export const GenStatus = {
  IDLE: 'idle',
  GENERATING: 'generating', // 已提交,等待首字
  STREAMING: 'streaming',   // 正在收流
  DONE: 'done',
  FAILED: 'failed',
  ABORTED: 'aborted',
}

const ALLOWED_NEXT = {
  [GenStatus.IDLE]: [GenStatus.GENERATING],
  [GenStatus.GENERATING]: [GenStatus.STREAMING, GenStatus.FAILED],
  [GenStatus.STREAMING]: [GenStatus.DONE, GenStatus.FAILED, GenStatus.ABORTED],
  [GenStatus.DONE]: [GenStatus.GENERATING],   // 重新生成
  [GenStatus.FAILED]: [GenStatus.GENERATING, GenStatus.IDLE],
  [GenStatus.ABORTED]: [GenStatus.GENERATING, GenStatus.IDLE],
}

transition(status) 里校验 ALLOWED_NEXT,非法迁移直接抛错。上线后这类错误基本没有出现过,但调试非法跳转时省了大量时间。

二、store 结构与 SSE 生命周期托管

2.1 setup store 的组织

生成状态、三阶段产出、SSE 连接引用放在同一个 store 里,用 setup 语法组织:ref 放响应式状态,普通变量放非响应式资源(AbortController、解码器、buffer),return 只暴露响应式部分。

import { defineStore } from 'pinia'
import { ref, shallowRef } from 'vue'

export const useGenerationStore = defineStore('generation', () => {
  const status = ref(GenStatus.IDLE)
  const stage = ref('outline')   // outline | draft | polish
  const stages = shallowRef({    // 各阶段完整产出,仅顶层引用变更
    outline: '',
    draft: '',
    polish: '',
  })
  const error = ref('')

  let controller = null
  let pendingBuf = ''
  let rafId = null

  async function startArticle(prompt) { /* 见 2.2 */ }
  function abort() {
    controller?.abort()
    status.value = GenStatus.ABORTED
  }
  return { status, stage, stages, error, startArticle, abort }
})

2.2 SSE 收流在 action 里闭环

连接不用 EventSource,因为生成接口需要 POST 带提示词和参数,EventSource 只支持 GET。前端用 fetch + ReadableStream,手动读流、解码、累积。

  1. 创建 AbortController,用户点击停止、组件卸载、切换到下一阶段时调用 abort();
  2. fetch('/api/ai/generate', { signal }) 发起 POST,服务端返回 text/event-stream;
  3. response.body.getReader() 配合 TextDecoder 逐块读取,按 SSE 格式解析 data: 行;
  4. 解析出的片段追加到 buffer,用 requestAnimationFrame 批量写入 stages,一帧只触发一次视图更新;
  5. 读流结束置 DONE,异常置 FAILED 并记录 error。
async function startArticle(prompt) {
  transition(GenStatus.GENERATING)
  controller = new AbortController()
  try {
    const resp = await fetch('/api/ai/generate', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ prompt, stage: stage.value }),
      signal: controller.signal,
    })
    if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
    transition(GenStatus.STREAMING)
    const reader = resp.body.getReader()
    const decoder = new TextDecoder()
    let chunk = ''
    while (true) {
      const { done, value } = await reader.read()
      if (done) break
      chunk += decoder.decode(value, { stream: true })
      pendingBuf += parseSseData(chunk)  // 按 \n\n 拆事件,返回 data 字段
      scheduleFlush()
    }
    transition(GenStatus.DONE)
  } catch (e) {
    if (e.name !== 'AbortError') {
      error.value = e.message
      transition(GenStatus.FAILED)
    }
  }
}

function scheduleFlush() {
  if (rafId) return
  rafId = requestAnimationFrame(() => {
    stages.value[stage.value] += pendingBuf
    pendingBuf = ''
    rafId = null
  })
}

三、三阶段流转与中间态

3.1 流转控制

三个阶段不是三个独立接口,而是同一生成链路上的三段推进,后端按阶段返回不同粒度的产物。前端在 store 里维护一个 stageOrder = ['outline', 'draft', 'polish'],完成当前阶段后自动进入下一阶段:

  1. 用户提交提示词,startArticle 以 stage=outline 发起第一次生成;
  2. 大纲流式输出结束,页面展示大纲并允许用户编辑;
  3. 用户确认大纲后,前端带大纲内容调用 startArticle,stage=draft,后端据此扩写正文;
  4. 正文完成后进入 stage=polish,后端做标题润色与文案打磨;
  5. 三个阶段的产物分别落在 stages.outline / draft / polish,互不覆盖,方便任意阶段重跑。

3.2 手动覆盖与重跑

用户编辑大纲后重跑正文,只重置 draft 和它之后的阶段,outline 保留。这一点靠”阶段产物隔离 + 迁移表允许 DONE → GENERATING“实现。UI 上每个阶段卡片有独立的”重新生成”按钮,点击时 stage 先切到目标阶段再发起请求,其他阶段的产出原样保留,页面不会整块刷新。

3.3 重试与错误边界

网络抖动、模型服务超时都会让 SSE 中断。FAILED 状态里保留当前阶段的已收内容,重试按钮直接把 pendingBuf 里没写完的内容作为”已产出”传回后端续写接口,而不是整篇重新生成。错误提示用 watch(status) 在 FAILED 时弹出,5 秒后自动清除,同时把 error 复位,避免下一次生成时残留旧错误信息。

四、组件订阅与派生状态

页面组件不直接操作连接,只消费 store。三个关键点:用 storeToRefs 解包状态保持响应性、用 computed 派生 UI 需要的中间值、用 store.$patch 避免多字段联动时多次触发渲染。

import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useGenerationStore } from '@/stores/generation'

const store = useGenerationStore()
const { status, stage, stages } = storeToRefs(store)

// 派生:当前阶段是否正在收流
const isStreaming = computed(() => status.value === GenStatus.STREAMING)
// 派生:进度条文案
const statusText = computed(() => STATUS_TEXT[stage.value][status.value])

statusText 这类派生值统一由 computed 提供,组件模板里不再写 v-if 长链。多个状态需要同时更新时(比如阶段切换同时改 stage 和 status),用 $patch 一次性提交,Vue 只会渲染一次。

五、流式写入的渲染性能

流式数据最怕”每来一个 chunk 就改一次响应式状态”。stages 用 shallowRef 而非深层 reactive,配合 requestAnimationFrame 合并写入,把高频小增量压成低频大写入:

  • 深层 reactive 的字符串字段每次 += 都会触发依赖它的组件重渲染,一次生成几万字符就是上万次渲染;
  • shallowRef 只在 stages.value = newObject 时触发更新,内部字符串的追加不会通知 Vue;
  • rAF 保证一帧最多渲染一次,字幕般的”打字机”效果下 FPS 稳定。

六、刷新恢复与持久化

SSE 生成中途刷新页面,连接必然断掉。项目用两条路兜底:生成完成时把三阶段产物写入本地持久化(IndexedDB),刷新后直接从库里恢复 stages 与 status,不必重新请求;生成中刷新则回落到 IDLE,页面给出”上次生成未完成”的提示条,用户可以一键续跑——续跑逻辑仍走 startArticle,只把 prompt 换成上次的大纲内容,保证链路单一。恢复逻辑里同样校验迁移表,已完成的阶段不允许再写回 stages,避免旧数据覆盖新生成的内容。

常见问题(FAQ)

Q1:三阶段为什么要共用同一个 store?

生成进度、产物、连接状态强耦合,拆多个 store 会在跨阶段切换时同步不一致。

Q2:EventSource 能直接用来收流吗?

不能。生成接口是 POST 带提示词,EventSource 只支持 GET,必须用 fetch 流式读取。

Q3:流式追加为什么用 shallowRef?

深层响应式每次追加都触发渲染,shallowRef 配合 rAF 把高频写入合并成一帧一次。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 qiqicto@qq.com 举报,一经查实,本站将立刻删除。
赞 (0)
拓扑大师的头像拓扑大师普通用户

相关推荐

返回顶部