本页目录

AI 交互与状态设计

处理消息、尝试、工具状态、取消和重试,构建清楚可靠的 AI 产品体验。

L3 · 能负责约 16 分钟阅读含示例、练习与验收

建议先读:TypeScript 与接口契约

本页内容

AI 界面为什么需要更精细的状态模型#

普通表单常见的流程是提交、等待、成功或失败。AI 产品可能先检索,再生成部分回答,再调用工具,最后等待用户确认;用户还可以取消和重试。一个 isLoading 不能表达这些状态,也无法说明已经显示的文本是否完整、上次尝试是否失败。

本章目标是前端 L3:能把异步执行过程变成可理解、可恢复、可访问的界面。前置是组件状态、Promise 与接口契约。重点是业务状态设计,继续使用你熟悉的 Vue 即可,无需为了 AI 产品更换框架。

分开消息、执行尝试和工具操作#

消息是对话中的内容,有稳定的 messageId。尝试是一次生成过程,有 attemptId,记录开始时间、错误和完成状态。工具操作是可能产生业务副作用的动作,有自己的 operationId 与幂等键。用户重试一个回答时,可以保留原消息或创建新版本,但新的尝试必须可识别。

例如用户先提交问题 A,随后立即提交问题 B。即使 A 的网络响应晚到,它也不能覆盖 B 的当前状态。取消控制器帮助停止请求,请求 ID 帮助忽略失去所有权的响应;两者解决不同问题,通常需要一起使用。

建立最小可用状态表#

状态 用户看到什么 允许的操作
idle 输入区与说明 提交
connecting 正在连接 取消
streaming 部分回答与生成状态 取消、阅读已到内容
awaiting_confirmation 工具草稿和关键字段 确认、修改、放弃
completed 完整回答、引用和后续操作 复制、重新生成
cancelled 已停止与可保留的部分内容 重试
failed 错误说明与不完整内容标识 按错误类别重试或修改

状态名可以因项目调整,但要避免互相矛盾,例如 completed 还显示“生成中”。检索和工具调用的细分进度可以作为执行阶段字段,不一定把所有组合都展开成顶层状态。状态设计的目标是让允许的转移明确,避免无限增长的布尔变量。

用纯函数验证状态转移#

下面的完整离线示例保存为 message-state.mjs,使用 Node 22+ 运行 node message-state.mjs。它不涉及 Vue,目的是把状态逻辑从界面中分离,便于验证。返回新对象的方式可以直接用于 Vue ref 或 store。

js
import assert from 'node:assert/strict';

const initial = { phase: 'idle', attemptId: null, text: '', error: null };

function reduce(state, event) {
  if (event.type === 'start') {
    if (typeof event.attemptId !== 'string' || !event.attemptId) {
      throw new TypeError('start 需要非空 attemptId');
    }
    return { phase: 'connecting', attemptId: event.attemptId, text: '', error: null };
  }
  // 旧请求迟到时,不能影响当前显示的新请求。
  if (event.attemptId !== state.attemptId) return state;
  const running = ['connecting', 'streaming'].includes(state.phase);
  if (!running) return state; // 终态不再接受 delta 或重复完成。

  if (event.type === 'delta') {
    if (typeof event.text !== 'string') throw new TypeError('delta.text 必须为字符串');
    return { ...state, phase: 'streaming', text: state.text + event.text };
  }
  if (event.type === 'complete') return { ...state, phase: 'completed' };
  if (event.type === 'cancel') return { ...state, phase: 'cancelled' };
  if (event.type === 'error') return { ...state, phase: 'failed', error: event.message };
  return state;
}

let state = reduce(initial, { type: 'start', attemptId: 'try-1' });
state = reduce(state, { type: 'delta', attemptId: 'try-1', text: '你好' });
state = reduce(state, { type: 'start', attemptId: 'try-2' });
state = reduce(state, { type: 'delta', attemptId: 'try-1', text: '迟到的内容' });
assert.equal(state.text, ''); // 旧尝试被忽略。
state = reduce(state, { type: 'cancel', attemptId: 'try-2' });
state = reduce(state, { type: 'delta', attemptId: 'try-2', text: '取消后到达' });
assert.equal(state.phase, 'cancelled');
assert.equal(state.text, '');
console.log('通过:旧响应隔离与取消终态保护');

reduce(state,event) 接收当前状态和一个事件,返回新状态或原状态。它不发请求、不操作 DOM,因此可以很容易构造边界测试。start 创建新尝试;其余事件必须带相同 attemptId;终态之后的增量被忽略。实际项目再加上消息持久化、事件序号和多消息映射。

在 Vue 中让资源跟随组件生命周期#

网络请求、读流和定时器属于资源。组件卸载时要停止当前订阅和请求,防止旧页面继续更新状态。使用组合式函数封装请求生命周期,组件只消费状态和动作。不要在多个组件中各自维护一份“当前请求”,否则取消按钮很可能中止了错误的实例。

错误信息分两层:用户看到可理解的说明与恢复动作,开发日志记录错误类别和 requestId。不要把原始供应商错误堆栈直接塞进聊天气泡,也不要把所有失败都写成“网络不好”。权限错误需要重新确认身份,配额不足需要明确限制,输入不合法需要用户修改。

取消、重试和编辑的语义#

取消只保证应用不再继续消费当前尝试,能否停止远端生成取决于服务端和上游支持。已写入的任务不会因为前端取消而自动撤销。界面要区分“停止生成”和“撤销业务操作”,否则用户会对结果产生错误预期。

重试回答通常创建新 attempt;重试创建任务则应复用同一业务操作的幂等键,先确认是否已经执行成功。编辑先前问题还会影响后续上下文,产品需要决定创建分支还是截断后续消息。历史记录必须与实际传给模型的上下文一致,不能界面显示一个版本、服务端使用另一个版本。

自动滚动与可访问性#

只有用户原本接近底部时才自动滚动。用户向上阅读资料时,新增文字应显示“有新内容”入口,而不是把他强行拉回底部。长回答优先按段落更新,避免每个字符都引起布局与语音播报。

给按钮清晰名称,用真正的 button 承担动作。生成状态可以用 role="status"aria-live="polite",但不要给不断追加的整段文本设置过于激进的播报。键盘提交应考虑输入法组合状态,不能在中文候选词确认时误发送消息。

用一张时间线找出布尔状态的漏洞#

设想用户发送问题后,系统先检索资料,再生成文字,随后提出“创建一条任务”的建议。用户选择修改任务标题,这时模型回答已经完成,但业务任务尚未创建。一个 loading 无法表达“文字生成结束,同时操作草稿等待确认”。如果把它设成 false,用户可能以为任务已经成功;保持 true,又会让整个页面永远转圈。

正确拆法是分别管理内容生成和业务操作。消息有自己的生成状态,操作草稿有自己的执行状态,两者用 operationId 关联。顶层只组合成用户能理解的进度,不把任意状态的排列都做成一个巨大枚举。你可以把它理解成多个小状态机,通过明确事件沟通,而不是一个状态机包含所有产品细节。

时刻 消息状态 操作状态 用户看到的结果
提问 connecting 等待连接,可以停止
生成建议 streaming 部分答案,不可确认尚未完整的参数
草稿返回 completed draft 回答完成,显示具体任务字段
用户修改 completed draft 字段标记为用户编辑版本
点击确认 completed submitting 禁止重复点击,保留操作 ID
响应超时 completed unknown 正在核实结果,不能直接宣称失败
查回成功 completed succeeded 显示真实任务 ID 和访问入口

unknown 是本表最容易遗漏的状态。它表示客户端不知道服务器是否已经写成功,而不等于服务器执行失败。网络超时后直接显示“创建失败,再试一次”,可能诱导用户创建重复任务。正确恢复动作是用原 operationId 查询事实,服务端以幂等规则保证同一个操作不会重复落库。

完整练习:操作草稿、确认、未知结果与恢复#

保存为 operation-state.mjs,执行 node operation-state.mjs。代码只实现前端状态规则,mock 的查询结果由测试提供;真正的鉴权和幂等仍在服务端。它的用途是把界面允许的动作固定下来,防止重构组件时重新引入矛盾状态。

operation-state.mjs
import assert from 'node:assert/strict';

const initialOperation = { phase: 'none', operationId: null, draft: null, result: null, seenOperationIds: [] };
function reduceOperation(state, event) {
  if (event.type === 'draft') {
    if (typeof event.operationId !== 'string' || !event.operationId || typeof event.title !== 'string' || !event.title.trim()) {
      throw new TypeError('INVALID_DRAFT');
    }
    if (['submitting', 'unknown'].includes(state.phase)) return state;
    // 相同 ID 的迟到草稿不能重开旧操作,也不能覆盖用户已编辑的草稿。
    if (state.seenOperationIds.includes(event.operationId)) return state;
    return { phase: 'draft', operationId: event.operationId,
      draft: { title: event.title.trim() }, result: null,
      seenOperationIds: [...state.seenOperationIds, event.operationId] };
  }
  // 所有后续事件必须属于同一个业务操作。
  if (event.operationId !== state.operationId) return state;
  if (event.type === 'edit' && state.phase === 'draft') {
    if (typeof event.title !== 'string' || !event.title.trim()) throw new TypeError('TITLE');
    return { ...state, draft: { title: event.title.trim() } };
  }
  if (event.type === 'discard' && state.phase === 'draft') {
    return { ...state, phase: 'discarded' };
  }
  if (event.type === 'confirm' && state.phase === 'draft') {
    return { ...state, phase: 'submitting' };
  }
  if (event.type === 'transport_lost' && state.phase === 'submitting') {
    return { ...state, phase: 'unknown' };
  }
  if (event.type === 'resolved' && ['submitting', 'unknown'].includes(state.phase)) {
    if (event.status === 'succeeded') {
      if (typeof event.taskId !== 'string' || !event.taskId) throw new TypeError('TASK_ID');
      return { ...state, phase: 'succeeded', result: { taskId: event.taskId } };
    }
    if (event.status === 'rejected') return { ...state, phase: 'rejected', result: null };
    if (event.status === 'pending') return { ...state, phase: 'unknown' };
    throw new TypeError('UNKNOWN_SERVER_STATUS');
  }
  return state; // 重复确认、迟到回调、终态之后的动作均不改变当前事实。
}

let state = reduceOperation(initialOperation, { type: 'draft', operationId: 'op-1', title: '检查引用' });
state = reduceOperation(state, { type: 'edit', operationId: 'op-1', title: '检查文档权限' });
assert.equal(state.draft.title, '检查文档权限');
state = reduceOperation(state, { type: 'confirm', operationId: 'op-1' });
assert.equal(state.phase, 'submitting');
assert.equal(reduceOperation(state, { type: 'confirm', operationId: 'op-1' }), state);
state = reduceOperation(state, { type: 'transport_lost', operationId: 'op-1' });
assert.equal(state.phase, 'unknown');
assert.equal(reduceOperation(state, { type: 'discard', operationId: 'op-1' }), state);
assert.equal(reduceOperation(state, { type: 'resolved', operationId: 'old', status: 'succeeded', taskId: 'wrong' }), state);
state = reduceOperation(state, { type: 'resolved', operationId: 'op-1', status: 'succeeded', taskId: 'task-8' });
assert.equal(state.result.taskId, 'task-8');
assert.equal(reduceOperation(state, { type: 'transport_lost', operationId: 'op-1' }), state);
assert.equal(reduceOperation(state, { type: 'draft', operationId: 'op-1', title: '迟到草稿' }), state);
const next = reduceOperation(state, { type: 'draft', operationId: 'op-next', title: '另一项操作' });
assert.equal(next.phase, 'draft');
assert.equal(reduceOperation(next, { type: 'draft', operationId: 'op-1', title: '更晚到的旧草稿' }), next);
const submitting = reduceOperation(next, { type: 'confirm', operationId: 'op-next' });
assert.equal(reduceOperation(submitting, { type: 'resolved', operationId: 'op-next', status: 'rejected' }).phase, 'rejected');
let discarded = reduceOperation(initialOperation, { type: 'draft', operationId: 'op-2', title: '草稿' });
discarded = reduceOperation(discarded, { type: 'discard', operationId: 'op-2' });
assert.equal(reduceOperation(discarded, { type: 'confirm', operationId: 'op-2' }), discarded);
console.log('通过:草稿修改、重复确认、结果未知、迟到草稿、恢复成功、服务拒绝与放弃草稿');

reduceOperation 不执行 fetch,这是刻意的分工。组件或组合式函数先派发 confirm,再调用 API;得到服务端明确拒绝时派发 { type: 'resolved', operationId, status: 'rejected' },失去响应时派发 transport_lost。只有服务端确认成功后才派发 { type: 'resolved', operationId, status: 'succeeded', taskId }。rejected 与 succeeded 是服务结果和目标状态,不是本例独立的事件 type。测试纯规则时不需要启动 Vue,验证网络时则使用真实 HTTP 或符合契约的模拟器。

seenOperationIds 在当前演示生命周期内记录已接收的操作 ID,使迟到的同一草稿不能重开已完成操作,也不能覆盖用户修改。开始另一项操作必须使用新的 ID;这份前端内存历史不是服务端幂等记录,刷新后的真实状态仍需从服务端恢复。实际项目可按会话管理操作表与资源生命周期,不能无限积累全站历史,也不能依赖这份数组替代后端校验。

生产接口应该返回与草稿绑定的参数版本或摘要。用户确认的是当前可见的标题和字段,不能在等待确认期间被另一个模型增量静默替换。后端再次检查 operationId、参数版本、用户身份和资源权限,再执行写入。前端禁用按钮减少误触,数据库幂等约束防止真实重复,两者都要有,但责任不同。

在 Vue 中组织状态:组件、组合式函数与 Store#

页面内只有一个对话窗口时,可以让 useChat() 持有消息列表、当前尝试和取消控制器,组件负责输入与展示。多个页面都要看到任务执行状态时,才考虑把可序列化业务状态放入 Store。控制器、reader 和定时器属于运行资源,不应该直接序列化到持久化缓存中;刷新后它们已经失效。

消息记录保存 messageId、role、content、status、citations、createdAt;attempt 保存 attemptId、messageId、phase、errorCode、startedAt、finishedAt。多个尝试可能对应一条逻辑回答,但你需要明确界面是否展示旧版本。不要用数组下标当消息身份:插入系统提示、删除前文或分页加载后,下标会变化,组件复用和回调归属都可能错位。

派生信息尽量使用 computed,例如 busy 由 connecting 和 streaming 推导,而不是每条路径都手工设置 isBusy。否则 catch 改了 error,忘记改 isBusy,用户就会遇到一直不能发送的按钮。派生状态减少重复维护,原始业务事实仍应显式保存,不能从文本是否为空猜测成功或失败。

在组合式函数接口上,尽量暴露状态和动作,例如 messages、submit、cancel、retry、loadConversation。不要让所有子组件都可以随意写 phase,否则状态转移规则很快失去入口。模板可以只读状态,用户事件通过统一动作触发流程。测试也可以直接调用动作检查行为,而不是查询某个内部变量是否恰巧被设置。

刷新、路由切换与多标签页#

刷新之后,浏览器内存中的 streaming 不再是仍然活跃的请求。恢复流程应先读取服务端持久化状态:消息是否完整、attempt 是否结束、operation 是否成功。发现旧尝试仍标记运行中时,界面显示“正在核实”或依据服务端规则恢复订阅,不能永远显示打字动画。

如果后端支持恢复事件,需要持久化事件序号、定义重放范围、处理重复事件,并验证用户仍有访问权限。没有这些机制时,重新连接不是“从断点继续”,而可能是一次全新的生成。第一版完全可以明确只恢复历史内容,让用户主动重新生成;把限制解释清楚比假装支持续传更可靠。

路由从会话 A 切换到 B 时,应由产品决定 A 是否继续后台生成。若停止,就清理 A 的订阅并保存不完整状态;若继续,则请求资源属于会话管理器,而不属于已经卸载的消息组件。不能同时声称“切页面仍继续”却在组件卸载时无条件取消所有任务。资源的生命周期必须与承诺的用户体验一致。

多标签页会带来另一个问题:A 标签确认任务后,B 标签仍显示可确认的旧草稿。前端跨标签广播可以改善提示,但服务端仍须用相同操作 ID 检查已执行状态。把防重复建立在 localStorage 或一个禁用按钮上,不足以处理不同设备和并发请求。

自动滚动是阅读意图管理#

一个可用的策略是:用户在底部附近时跟随新增内容;用户向上滚动后暂停跟随;点击“回到最新”再恢复。接近底部可以用 scrollHeight - scrollTop - clientHeight 与阈值比较,阈值按界面字号和间距调节。不要把某个像素值当跨设备的正确常量,应在实际页面验证。

还要区分用户滚动与程序滚动。新增文字导致容器高度增加时,底部距离会突然变大;如果此时才判断用户是否靠近底部,就可能错误停止跟随。应保存更新前的跟随意图,在 DOM 更新后再滚到新底部。Vue 中 DOM 更新时机可以用 nextTick 配合,但不要在每一个 token 都触发强制布局测量。

历史分页从列表顶部插入旧消息时,要保持用户正在阅读的位置。可记录插入前后的高度差并补偿 scrollTop,或采用虚拟列表提供的锚点能力。字体加载、图片高度和代码展开仍可能改变尺寸,因此高级界面需要按消息 ID 锚定,不能只对一次高度差假设永远不变。

错误文案应该告诉用户下一步#

故障 不充分的文案 更明确的文案与操作
输入超过上限 请求失败 问题超过允许长度,请缩短后发送;保留原输入
身份失效 网络错误 登录已失效,重新登录后可继续;保留草稿
生成中断 回答完成 回答中断,当前内容不完整;允许重新生成
执行响应丢失 创建失败 正在核实执行结果;查询原操作
资料权限变化 找不到答案 部分引用当前不可访问;刷新资料或联系管理员
额度用完 请稍后重试 当前额度不足,展示下一次可用条件或配置入口

文案要与真实状态一致,不能为了让用户放心隐藏结果未知。错误详情可以按需展开 requestId 和错误码,但默认界面以恢复动作优先。保留用户已经输入的内容,除非内容本身违反明确的数据保留策略;一次网络故障不该让用户重新打完整的问题。

无障碍与交互验收要覆盖真实输入#

只用鼠标点按钮会遗漏很多问题。键盘 Tab 应能到达输入、发送、停止、引用和重试;打开确认对话框后焦点应进入其中,关闭后回到触发按钮。中文输入法确认候选词时不应提交。Shift+Enter 换行与 Enter 发送需要在界面可发现,不能把所有换行都吞掉。

生成状态适合简短的 polite 通知,整段答案不应每个字符重读。颜色之外加文字说明失败和部分完成。复制按钮需要复制当前可见内容或明确标识的完整版本,而不能复制隐藏的旧尝试。页面变窄时,停止按钮和错误恢复入口仍需可达,不能让代码块把整个页面撑出屏幕。

本章的 L3 验收是能够手工推演并重现:A 请求晚到、取消后迟到、确认后断网、刷新后恢复、多标签重复操作、向上阅读时新增内容。逐个写出预期状态和允许操作,再实现组件。你已经有多年组件经验,转型的新增难点在这些跨时间的业务语义,值得比“聊天气泡怎么布局”投入更多练习。

练习#

扩展示例,增加 awaiting_confirmation 状态。要求只有该状态接受确认事件,确认之后进入执行中;取消草稿不能产生副作用。再测试重复确认、旧 attempt 确认和执行失败。

参考思路

把“生成草稿”与“执行操作”分开:草稿状态保存 operationId,确认事件由服务端再次鉴权,前端进入 submitting。服务端幂等约束负责防止重复副作用,纯前端状态机只负责避免重复交互和错误展示。执行失败后保存操作标识,重试先查询状态,不能换新 ID 盲目重复执行。

验收与自测#

  • 连续提交两个请求,旧响应不会污染新响应。
  • 取消后保留不完整标识,不把部分答案视为完成。
  • 用户向上阅读时不会被自动滚动打断。
  • 键盘、输入法和错误恢复都有明确行为。

问:只调用 abort 就能防竞态吗? 答:不能保证,已排队的回调和不支持取消的任务仍可能返回,需要尝试标识校验。

问:重试回答与重试写操作相同吗? 答:不同,后者必须考虑已执行但响应丢失的情况。

问:每个 token 都应播报吗? 答:通常不应,应提供克制的状态通知和可阅读的内容。

官方参考#

原有课程整理于 2026-09-10;Node / Electron 扩充于 2026-09-11。示例环境与验证范围以正文为准。
原创中文学习手册,阅读结构参考 Vue 文档;非 Vue 官方教材。
下载本章 Markdown

支持中文和英文全文搜索 · ↑ ↓ 选择 · Enter 打开 · Esc 关闭