AI 交互与状态设计
处理消息、尝试、工具状态、取消和重试,构建清楚可靠的 AI 产品体验。
建议先读: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。
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 的查询结果由测试提供;真正的鉴权和幂等仍在服务端。它的用途是把界面允许的动作固定下来,防止重构组件时重新引入矛盾状态。
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 都应播报吗? 答:通常不应,应提供克制的状态通知和可阅读的内容。