# AI 交互与状态设计

## 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 的查询结果由测试提供；真正的鉴权和幂等仍在服务端。它的用途是把界面允许的动作固定下来，防止重构组件时重新引入矛盾状态。

```js 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 确认和执行失败。

<details>
<summary>参考思路</summary>

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

</details>

## 验收与自测

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

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

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

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

## 官方参考

- [Vue：组合式函数](https://cn.vuejs.org/guide/reusability/composables)
- [Vue：生命周期钩子](https://cn.vuejs.org/api/composition-api-lifecycle)
- [MDN：AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController)
- [MDN：ARIA live regions](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Guides/Live_regions)
