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,手动读流、解码、累积。
- 创建
AbortController,用户点击停止、组件卸载、切换到下一阶段时调用abort(); fetch('/api/ai/generate', { signal })发起 POST,服务端返回text/event-stream;response.body.getReader()配合TextDecoder逐块读取,按 SSE 格式解析data:行;- 解析出的片段追加到 buffer,用
requestAnimationFrame批量写入stages,一帧只触发一次视图更新; - 读流结束置
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'],完成当前阶段后自动进入下一阶段:
- 用户提交提示词,
startArticle以stage=outline发起第一次生成; - 大纲流式输出结束,页面展示大纲并允许用户编辑;
- 用户确认大纲后,前端带大纲内容调用
startArticle,stage=draft,后端据此扩写正文; - 正文完成后进入
stage=polish,后端做标题润色与文案打磨; - 三个阶段的产物分别落在
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 把高频写入合并成一帧一次。