追问在左、报告在右,一个文件看清整条请求;session_id 只接对话,不当凭据。
一直用 curl 讲 /v1/dual,开头总得先展开 JSON,解释哪个字段是追问、哪个是报告。内容还没开始读,时间先花在认字段上。所以这一阶段做个小页面,把两份输出直接放到对应位置。
追问在左,报告在右
左边放用户消息与 A 线追问,右边放 B 线的阶段性报告。输入区留在对话旁边,处理状态单独显示。报告里的引用跟着内容放,讲解时可以沿着一句话回头找资料,不用切回响应文本里翻字段。
┌──────────────┬────────────────┐│ 用户消息 │ 阶段性报告 ││ A 线追问 │ 引用与待补信息 ││ │ ││ 输入与发送 │ 处理状态 │└──────────────┴────────────────┘这是布局示意,不是当时截图。同步接口还要等 A/B 一起完成,这次接口不单独返回 A 线的结果。所以发送后得告诉用户正在处理,并挡住重复提交,否则一段没反馈的等待,很容易变成多点几次按钮。
“正在处理”和“挡住重复提交”各有讲究
“处理状态单独显示”还差一半:那块文字最好是一个 status 区域。WCAG 2.2 的 4.1.3 状态消息(AA 级)要求这类消息不拿焦点也能被辅助技术读出来,进行中、成功这类状态用 role="status" 就够。一个属性的事,决定读屏用户知不知道页面在忙。
挡重复提交最直接的是把按钮设成 disabled。按 MDN 的说明,禁用的控件收不到点击和焦点事件,也不随表单提交,所以 handler 里不用再判一次。代价是焦点也跟着没了;另外 Firefox 会跨页面加载保留 <button> 动态设置的禁用状态。
React 19 也有官方写法:useActionState 的第三个返回值 isPending,派发函数当 <form action> 用时会自动包进 Transition。但页面只有一个表单,手写一个 submitting 布尔值更短。
一个文件看清整条请求
技术上用 Vite + React。这个页面不需要 SEO 或复杂路由,状态先放在 App.tsx,CSS 也直接写。比起搭一套组件体系,我更想打开一个文件就看到:请求从哪发出,返回后哪一块更新,失败时输入还能不能继续。
React 官方文档也列了这条路。新建 React 项目那页说,从零开始更灵活,但路由、取数这些常见模式都得自己挑。官方默认推荐的仍是框架,这里有意选了例外,因为框架带的那些东西这个页面用不上。
状态放 App.tsx 也有判据。React 的 Thinking in React 给的规则是:找出所有用到这份状态的组件,放到它们最近的共同父级。左右两栏和输入区都挂在同一个父级下,App.tsx 正好就是那个位置。同一页还说,拆不拆组件更多是偏好问题。
工具链也不拦着。Vite 开发时按需用原生 ESM 提供源码,改哪个模块就热替换哪个,启动时间基本和项目大小无关。直接 import 一个 .css 会注入成 <style> 并带 HMR,不用配置;哪天真要隔离样式,把文件名改成 .module.css 就是 CSS Modules。
session_id 只是接上对话的键
刷新后能接着上一段对话,靠的是 localStorage 里的会话标识 session_id。页面拿它向后端读取会话,完整消息不只靠浏览器保存。
这里没有账号体系,这个 id 只负责把请求接到同一段对话,不是登录凭据,也不是病历身份。它跟着浏览器走:共用同一浏览器存储时,id 本身认不出实际操作者,换个人打开,接上的仍是同一段对话。localStorage 的行为也是按浏览器与来源保存的,不会自动替我们解决跨设备会话。
“跟着浏览器走”具体是什么意思
把 MDN 几页拼起来,这个 id 的命运大致是:
- 按来源分:localStorage 按文档来源保存,HTTP 和 HTTPS 各一份;无痕窗口里的数据在最后一个无痕标签页关掉时清空。
- 可能被清:浏览器存储默认是 best-effort,空间紧张时按 LRU 逐出整个来源的数据;Safari 更激进,某来源七天没有点击之类的交互,脚本写入的数据就会被删。
- 标签页之间互相看得见:同源的其他标签页改了 localStorage,会收到
storage事件,改动的那个窗口自己收不到。
所以完整消息放服务端是对的,“刷新还能接上”说准确点是“通常能”。想让一个标签页一段对话,可以换 sessionStorage,它按来源加标签页分区,刷新保留、关标签页即清;代价是关掉标签页,前端就接不回这段对话了。
不当凭据,也要防 XSS
OWASP 的 HTML5 安全备忘单写得很直接:
Do not store session identifiers in local storage as the data is always accessible by JavaScript.
一次 XSS 就能读走。会话管理备忘单还要求服务端绝不能只凭标识符就认定已认证。今天这条风险落不到实处,是因为服务端本来就不拿这个 id 认人。哪天加了登录,把同一个 key 换成真会话,存法就得一起换。
布局分开,状态也要分开
后面加流式输出和异步报告时,两边会有不同的完成时点。对话正在输出,不等于报告正在生成;报告失败,也不该把已有聊天全部清空。
两种完成时点,对应两种现成的交付方式:
| 方式 | 做法 | 代价 |
|---|---|---|
| 异步请求-应答 | Azure 架构模式:先回 202 和带 Retry-After 的状态端点,页面轮询进度 |
状态和结果要落存储、定保留策略;重试要靠幂等键防重复入队 |
| SSE 推送 | EventSource 连 text/event-stream,断线自动重连 |
单向;不走 HTTP/2 时每域名只有 6 条连接,两条线加多开标签页很快撞上 |
Azure 那页点出的一句话,让我觉得挡重复提交不只是防手抖:客户端分不清“响应丢了”和“请求根本没到”。界面禁用按钮只管住人手,网络层的重试同样会再触发一次 /v1/dual,那得靠服务端的幂等键。
九月整理时页面已经有这些后续状态,不能拿当前页面反推三月时每项都已具备。
留下几句话 读者来信
读到这里,有什么想法?欢迎留下你的看法。