14. 界面设计
把 Agent 的「内心戏」变成用户能懂的界面:流式、diff 与卡片
上一篇产品流程,主要讲的是「后台怎么运转」。可用户真正用手触碰的,从来不是流程,而是一个页面、一个输入框、一份稿子。这一篇,我们来看下怎么把后台那些精密又晦涩的 Agent 内心戏,翻译成大白话,让非技术背景的写作者,也能一眼看懂、轻松上手。这是项目的「最后一公里」
🪧 用户看不到 Agent,只看得到一个产品应用
前面十三篇讲的所有机制,老实说,用户一个都感知不到,也不该感知到
他打开应用,看到的是一个输入框、一份正在生成的稿子、几行提示。而后台那些东西呢?一条条流式的消息、一次次工具调用、时不时冒出来的权限审批、还有像 draft.md 这种纯粹的内部概念,这些「内心戏」如果原样端到用户面前,对一个写文章的人来说,可能是一堆看不懂的天书,甚至直接把他吓退
所以 UI 这一层的活,本质上是做翻译:把 Agent 的内心戏,翻译成写作者熟悉的语言和交互。而且这翻译,分寸要拿捏好,既要让用户感到「它在认真替我干活」(有掌控、可解释),又不能让他被底层细节淹没(不吓人、不添乱)。这一篇,会按「用户会遇到的几类内心戏」,分类来讲我是怎么翻译的
🎭 总纲:UI 是 Agent 内心戏的「翻译层」
后台的 Agent,此刻正在跑 turn、吐文本增量、调工具、等你审批,这些都是它的「内心戏」。可是用户想要的,是「我在跟一个懂写作的助手顺畅协作」的体感。UI 的全部职责,就是在这两者之间做翻译
一个从没听说过 Agent、Prompt、Token 的写作者,能不能仅凭直觉,就把这个产品顺手用起来? 这才是最大的标准,下面这七类翻译,都是奔着这同一个目标去的
📡 翻译一:两层信息流,让用户既看到「在写」,也看到「在干嘛」
Agent 干活时,后台其实在同时涌出两种性质完全不同的信息:一种是它正在辩证思考与琢磨的文本增量;另一种是它「开始调某个工具了」「工具返回了」「正在检索合集」「刚自动整理了一次上下文」这类结构化的进展节点
我把它们分两层呈现,各司其职:
- 层一,逐字打字。 把思考过程逐字渲染出来,给用户「有个人正在替我想」的即时温度感。这一层的价值在于临场感,一个冷冰冰的 loading 转圈,和一行行文字实时冒出来,体感天差地别,后者才是「协作」该有的温度
- 层二,进展提示。 把那些结构化节点,翻译成人话的状态条,比如「正在检索合集资料……」「已自动帮你整理了对话」。这一层给的是「可解释性」,让用户知道它此刻在忙什么,而不是对着一段沉默的等待瞎猜
支撑这两层的,是后台和前端之间一套严格对齐的事件协议(技术上走 SSE)。后台把发生的事,翻译成一个个约定好的事件推给前端:text_delta(文本增量)、tool_start / tool_result(工具起止)、status(状态)、todo_update(待办更新)、ask_question(澄清)、approval_request(审批)、done(结束)。这套协议,是整个交互的骨架,前后端谁改了不告诉对方,界面就会错乱
这里有个分寸要拿捏:第二层的进展,最好不要把原始的工具名、参数直接甩给用户。后台那个工具真名叫 mcp__publish__to_mowen,用户看到只会一脸懵;得把它翻译成「正在发布到墨问……」。翻译层的价值,就在这一进一出的「讲人话」里
📝 翻译二:AI 产出了新内容,怎么让用户保有掌控感
第二类内心戏,关于「AI 产出了新内容」。这里我定了一条交互原则:AI 的产出直接更新右栏的稿子,但用户随时能点「编辑」进手动改稿模式,自己动手改。 而且把「编辑」和「对话」做成互斥:你在改稿时,AI 不会插嘴;AI 在写时,你也不会误触改乱它的产出。这跟第 13 篇 EditSession 的设计其实是一脉相承,编辑模式开启时聊天框禁用,退出编辑时才算结算一次 diff
为什么不让 AI 的产出走「diff 预览、逐条采纳」?这条路我想过,但放弃了:写作场景比较特殊,AI 产出的往往是一大段完整的新内容,不是对现有文字的零散小改。逐条 diff 审批放在这里,只会让用户陷入「每一句都要点一下采纳」的疲劳。不如让它直接写出来,用户读完觉得哪不顺,自己进编辑模式改,这更贴近现实中一个老手跟实习生协作的自然节奏:Agent 写完一稿,你拿过来调整
不过,diff 这个能力还是有用武之地的。它被用在了另一个更合适的地方:画像更新审阅。后台每次分析完你的编辑习惯,产出一份「建议修改画像」时,那才是 diff 真正发挥价值的场景。每一条建议都是对画像的精确小改(加一条偏好、改一个表述),逐条 +/- diff 展示、逐条采纳或拒绝,恰到好处。稿件的产出要的是流畅,画像的更新要的是精确,两种场景两种处理
这里还有个信息架构上的关键决定,是「作品」和「原材料」的分野:右栏那块稿子,是 AI 为这次任务产出的「作品」;而你上传的附件、粘贴的长文草稿、敲的指令,都是「上下文原材料」,它们会进 Agent 的脑子,但不会直接落到右栏那份稿子上。
🃏 翻译三:审批卡 + 澄清卡,把两种「打断」,收进同一张卡
第三类内心戏,是 Agent 需要「停下来、请你回应」。这种时刻有两种,性质不同:
- 审批(来自第 9 篇那个
canUseTool):它要做预计烧 token 的高频联网,按规矩得先弹卡问你,你选「这次允许 / 一直允许 / 拒绝」 - 澄清(来自第 5 篇那个
AskUserQuestion):它拿不准你的意图,想问你个多选题,比如「这篇你想要犀利点,还是温和点」
这两种,一个是「安全闸」,一个是「意图确认」,底层机制完全不同。但站在用户的角度,它们是同一件事:「界面停住了,请你选一下」。所以我让它们共用同一套多选卡片 UI,用户只需要理解「看卡、点选」这一个动作,两处都通用,尽量降低认知负担。这是「做减法」在交互上的体现,能合并的心智,就别让用户学两遍了
关于澄清,还额外做了一处专门的定制:
🔧 避坑 · 模型太爱「问」了,我加了一道过滤闸
那个澄清工具很好用,但也很容易被模型滥用。实测里,它偶尔会拿一些根本不该问用户的事来打断你,比如问「查证结果要写进你的作品吗」(这在产品流程里早有默认答案)、问一些很基础的写作流程(用户关注点在结果,而不是过程中的 1、2、3)。用户持续被这种蠢问题打断,体验很差
为此我在后端加了一道澄清问题的过滤闸:模型抛出的每个澄清,先过一遍质量判断,把那些「本该有默认答案的」「问基础流程的」低质问题拦下来,不往用户面前弹。这道闸,跟第 5 篇 System Prompt 里那条「Default-then-Clarify」(先按默认干、只在真必要时才问)、第 7 篇的产出契约,是里应外合的一套组合拳:System Prompt 从源头劝模型少问不必要的,过滤闸在末端再兜一道,双保险,避免让用户被没营养的问题烦到
🛡 翻译四:内部词不外露,用户永远不该看到「draft.md」
第四类不算「翻译」,更像「消音」:把那些不该传到用户耳朵里的内部黑话,彻底消掉
前面第 7、11、13 篇都提过这条产品洁癖,到 UI 这层算是它的终点站:对话里、界面上,不应出现 draft.md、draft panel、CLAUDE.md、「画像 block」这类技术词,统一说「你的作品」「你的写作风格」
因为内部概念一旦泄露,坏处很直接:一是吓到小白,一个写作者看到「draft.md」,第一反应是「这是啥?我是不是用错软件了?」;二是显得糙,用户本该只面对「作品」「风格」这些他熟悉的写作概念,而不是我后台的文件系统
而且这件事,不只是 UI 文案层面能搞定的。因为对话内容由模型生成,你光在界面上藏好没用,模型要是在回话里冒一句「我已经更新了你的 draft.md」,照样穿帮。所以这条约束,得从第 5 篇 System Prompt 的第 8 块(Tone & Output Format)就开始管,引导模型别在对话里吐这些内部词,前后两端一致,才能真正不穿帮。「不露技术词」这样一个小小的体验,看似理所应当,背后是从 System Prompt 到 UI 文案的一整条链路
⌨️ 翻译五:那些「藏在细节里」的用心良苦
有一些体验上的小细节,单拎出来每个都不怎么起眼,但少考虑一步,用户用起来就会有种「被绊了一下」的不爽。挑几个说说
IME 防误触,一个中文场景特有的坑。 一个中文用户,在输入框里敲英文(比如敲「admin」),敲完按 Enter 想确认候选词,结果呢?这条消息被误发出去了。因为浏览器在你「确认候选词」时派发的那个 Enter,也是一次 keydown,发送逻辑一看是 Enter,就把消息发出去了
📐 深挖 · 为什么这个 Enter 会「误伤」,以及怎么根治
根源在输入法(IME)的合成机制。你敲拼音、选候选词的这个过程,叫「合成中」。按 W3C 的规范,你确认候选词时那次
keydown,发生在「合成结束」事件之前,而且这次事件带着一个标志:isComposing === true,意思是「我这个键,是拿来确认输入法候选的,不是拿来触发你的业务的」所以根治办法,是在所有「Enter 提交」的键盘处理最开头,先判断一句:如果这次按键还在输入法合成态里(
isComposing),就直接返回、什么都不做。 旧浏览器没有这个标志,还得用一个古老的keyCode === 229兜底。而且必须在keydown这个阶段拦(早了没有信息,晚了消息已经发出去了)我把这套判断,收成了一个统一的小工具,所有输入框(登录框、写作框、底稿框)都接同一个范式:处理键盘事件的第一行,先拦 IME 合成态。这种「中文用户天天遇到、但外部开源组件没有兼顾」的坑,是做本土化产品时必须自己趟平的
inline banner,而不是右下角 toast。 表单类操作的成功、失败反馈(改了昵称、存了配置),我习惯用紧贴操作按钮的 inline 提示条(几秒后自动消失),而不是弹到屏幕右下角的 toast。因为用户此刻的眼睛,正盯着他刚点的那个按钮,反馈就该出现在他眼睛正看着的地方。 一个飞到右下角的 toast,新用户可能都注意不到。toast 我只留给那些跟具体操作位置无关的全局反馈(比如登录成功的欢迎语)
不在「disabled 的按钮」上挂提示逻辑。 一个 disabled 的按钮,它的点击事件是不会触发的。我曾经把「你还没选题材哦」这个提示,挂在一个「下一步」按钮的点击事件上,同时又因为「没选题材」把这个按钮设成了 disabled。结果就是两者冲突了,用户没选题材时,按钮是灰的、点不动,那句提示永远也弹不出来。这背后是一条更普适的自检:每一个交互入口,在它所有可能的前置状态下,都得有正确的行为,别留死角
✍️ 翻译六:一个真正好用的所见即所得编辑器
对于一个写作产品来说,编辑器很重要。这里其实也有个人喜好的成分,我自己比较偏好像 Typora 那种「所见即所得、边写边渲染」的丝滑体验,不是「左边写 Markdown 源码、右边看预览」的分屏,更不是一个光秃秃的文本框。
为此,其实也花费了不少力气作开源工具的整合:
🔧 避坑 · 编辑器选型,走了一些弯路
最初选的是一个叫 Milkdown 的编辑器(基于 ProseMirror)。选的时候看着挺美,等真上手做了个最小验证版,才发现它的架构,做不到我要的那种「真正的即时渲染」,体验上差点意思
这时候摆在面前的是个艰难决定:是将就着用,还是推倒重来?我选了后者,果断迁移到另一个真正 IR(即时渲染)模式的编辑器 Vditor,卸掉了 6 个 Milkdown 的包,把写作面板、底稿弹窗全部迁了过去
这里有个小小的教训,对一个产品的「核心体验假设」(比如「编辑器能不能所见即所得」),一定要趁早用最小的 spike 去验证,别等做深了、代码缠成一团了,才发现地基选错。 船大难掉头,小舟还好说
选完之后,还有大量集成工作要做,才能把写作体验磨得真正顺手。挑几个小案例说说:
- 序列化,内外分离。 编辑器内部,用它自己的一套 DOM 来渲染「所见即所得」;但落到磁盘时,我只存干净的 Markdown,靠一个转换引擎在两者之间双向翻译。这样作品文件永远是可移植、可读的纯 Markdown,不被编辑器的私有格式绑架
- 自动保存静默、退出时反馈。 编辑时防抖着自动存(配合第 13 篇的 EditSession),不打扰你,autosave 是静默执行的,不会弹「保存中」打断思路。但当你手动点「退出编辑」时,会明确反馈成功或失败,让心里有底。切换标签页时也立刻强制确认
- 图片的三个入口,一个坑。 粘贴、拖拽、点按钮,三种插图方式我统一到一处处理。这里有个坑:粘贴图片时,必须拦住编辑器自己的粘贴处理,否则它会把图片转成一长串 base64 塞进你的 Markdown,把稿子污染得一塌糊涂
- 连图片说明(caption)都要能改。 我把图片说明存在图片的
alt字段里(零侵入,不破坏 Markdown 标准),编辑时动态注入一个可点击的说明编辑区
这些细节用户可能都意识不到,但却不能懈怠。它们逐步累加起来,才成就了「这个编辑器真顺手」的整体手感。 到今天,我在高频的使用过程中,还能持续发现一些没被处理好的 edge case,唯有一边 dog-fooding、一边快速迭代
最后那条「图片说明也能改」其实很典型,前后改了十几轮。值得单独拆一拆,因为它特别能说明「跟一个强势的第三方库怎么相处」:
📐 深挖 · 一个图片说明框,为什么要改十来轮
难点不在功能,在于我选的这个所见即所得编辑器(Vditor 的即时渲染模式),它对「往它渲染的内容里,插一个我自己的输入框」这件事,仿佛充满"敌意"。前后缠斗了十来招,最后才摸清它的脾气
第一层敌意:它不给我留插手的钩子。 我想在图片底下显示一个可点击的说明编辑区,正常思路是用编辑器提供的「渲染钩子」,可它的即时渲染模式压根没有这东西。只能退而求其次,挂一个「DOM 变化监听器」(MutationObserver),趁它每次渲染完的空隙,把我的说明框硬「补」进去
第二层敌意,也最坑:它会把我插进去的东西,一遍遍毁掉。 它盯着你的输入,你每敲一个字,它就把整块内容的 DOM 推倒、重新解析重建一次。我辛辛苦苦注入的那个说明框,恰恰插在它管辖的地盘里,于是每敲一下就被它连锅端掉。硬碰硬试了好几轮拦截都不成,直到想通一件事:别跟它对抗,让它「感知不到」我。 我给自己的输入框标上「这不是可编辑内容」,再把它冒泡上去的输入事件在第一站就掐断,编辑器就不再把它当自己的一部分去重建了
而最终定稿那版,有点返璞归真的意思。 原本一直在用 JS 计算那个输入框该浮在哪、怎么跟着图片一起滚动,算得焦头烂额。后来干脆把它做成图片说明文字的一个 DOM 子元素,子元素天然跟着父元素走,滚动、定位一行 JS 都不用写
这一顿折腾下来,我自己也长进了。一是 「从对抗到顺应」:面对一个强势的第三方库,与其处处拦着它、跟它较劲,不如把自己的元素藏到它的感知之外,井水不犯河水。二是 别轻信自动化测试的所谓「通过」:这种
contenteditable里的细活,浏览器模拟出来的键盘输入,和真人一个字一个字敲,走的是两条不同的代码路径,我就被「自动化测过了」骗过好几回,最后只能老老实实用真键盘手动复验,才真的算数
🔄 翻译七:多任务并发,切换的是标签页,不是进程
最后这类内心戏,藏得最深。它不在单次写作里冒头,而是在你同时开两个写作任务、来回切换时,才冷不丁给你一个"意外惊喜"
事情是这样的。产品在设计时,就支持用户同时开多个写作任务。譬如,你写着一篇长文,突然想起另一篇要补个结尾,切过去改两笔再切回来,这也很合理。后端其实早就支持多任务并发了,每个任务有独立的 SDK client、独立的工作目录锁、独立的 SSE 流,两个 agent 子进程可以真正并行地跑
可是偏偏却漏了前端。早期前端只是「单一当前 session」模型,所有状态(聊天记录、审批卡片、待办、草稿编辑态……二十多个)全挂在一个组件实例上。每当任务一切换,这段代码就把所有状态一键清空,然后直接掐断 SSE 连接。接下来的连锁反应是,后端那头,连接一断,正在跑的 agent 子进程被杀掉;你切回来时,历史拉不回来(因为进程被杀时这一轮还没落盘),草稿也没了。用户只是切了个标签页,后台却经历了一场「进程屠杀」
这还没完。我还踩过一个更阴的数据损坏 bug:切换任务时,编辑器要结算上一个任务的编辑会话,但 React 的闭包捕获的是新任务的 ID,导致旧任务的编辑内容被写到了新任务的稿子里,覆盖了新任务已保存的底稿。用户切回来一看,辛辛苦苦写的稿子,变成了另一篇的内容
根因说来也简单:后端已经是多任务并发架构,前端却还停在「单任务、切换即重置」的模型上。 这道断层,是 Agent 产品从「能跑」到「能用」之间一道隐蔽的鸿沟。通用 Agent 的 demo 往往只演示单任务,这个问题可能不会被注意到;但做成产品,用户必然多开,一多开就崩
修法分两层。前端:引入 per-session 状态缓存,每个任务的状态独立保留,切换是「切视图」而非「清空重建」,你在 A 任务输入框里敲了半句话,切到 B 再切回 A,那半句话还在。后端:把 SSE 流和 HTTP 连接解耦,agent turn 在后台独立跑,事件写入 per-task 的事件缓冲;前端断开只是断开订阅,不杀进程,切回来用 stream_id 续传切走期间错过的事件。两层合起来,才真正做到「切换的是标签页,而不是进程」
这个续传机制,前前后后也磨了两轮 bug fix:
📐 深挖 · React state 在异步竞态下不可信,跨渲染的状态恢复得用 ref
续传的核心难点是:切回一个正在后台跑的任务时,怎么把续传的事件路由到正确的 UI 位置。我的 reducer 靠一个
currentTurnId来定位「当前该往哪个 turn 追加事件」,这个 ID 存在 React state 里第一轮修复,让切回时从
state.items(消息列表)里搜索正在 streaming 的 turn 来恢复currentTurnId。结果时灵时不灵,因为切回时还要异步拉历史消息,拉回来后会 dispatch 一个hydrateaction 清空整个列表再重建,而hydrate会顺手把currentTurnId清成 null。于是续传事件正好在hydrate前后到达,currentTurnId是 null,事件被静默丢弃,界面卡死第二轮才找到根因:React state 在异步竞态下不可信。
hydrate是异步的,你没法保证它和续传事件的先后顺序;而state.items在 hydrate 清空的那一瞬间就是空的,你从空列表里搜,当然搜不到。正解是用useRef(一个Map<taskId, turnId>),ref 跨渲染稳定,不受 hydrate 竞态影响。发送消息时,先同步地把 turn ID 存进 ref,再 dispatch 到 state;续传时优先从 ref 取,state 只作 fallback需要跨渲染、跨异步操作恢复的状态,尽量别依赖 React state(它会被 hydrate / 竞态清掉),用 ref 持久化。 state 是给渲染用的,ref 才是给「逻辑」用的
这个翻译做好之后,多任务并发才真正可用。用户开三五个写作任务来回切,每个任务的后台 agent 各跑各的,切回来续传一下,什么都没丢
⚖️ 垂直落点:最后一公里,是「垂直场景」要大施拳脚的地方
收个尾,落到一个完整的对照表格:
⚖️ 取舍现场 · 面对 Agent 的内心戏,能用和好用是两种态度
Agent 的内心戏 通用做法(开发者视角) SmartWriter(用户视角) 消息流 把原始 stream 甩给用户看 分两层:逐字打字 + 进展说人话 AI 产出 直接覆盖,用户被动接受 直接写入但随时可编辑,编辑与对话互斥 打断 各种弹窗各长各样 审批 / 澄清共用一张卡 + 过滤蠢问题 内部概念 原样露出(draft.md…) 全程消音,只说「你的作品」 本土细节 常被忽略 IME 防误触、inline banner、状态机无死角 多任务并发 单任务 demo,多开即崩 per-session 缓存 + 流与 HTTP 解耦,切换不杀进程
一个能用 Agent 的界面,往往是「开发者视角」的,它把后台发生的一切,或多或少原样地摆出来,因为对开发者来说,那些信息是有用的。而一个好用 Agent 的界面,需要切换到 用户视角,用户不关心 stream、不关心 tool call、更不认识 draft.md,他只想顺顺当当地把文章写好。这一篇讲的每一处翻译,两层信息流、编辑与对话互斥、共用卡片、内部词消音、IME 防误触、所见即所得编辑器、多任务并发不丢状态,没有一样是 SDK 开箱给你的,全都需要在「最后一公里」上,一寸一寸地铺出来
写到这,要 callback 一下序章里,朋友问我的那句「这不就是套了个壳吗」。走到这最后一篇,才大概能讲清楚:那层「壳」,从最里头的发动机,到最外面的方向盘和仪表盘,每一处,都是实打实的功夫。「套壳」这个词,也许没有错,但在它前面,要加上「精心」两个字,才对得起这一路上的思考斟酌与反复打磨
十四篇正文,到这里就走完了。我们从 Agent 的心跳出发,一路拆过它的记忆、人设、工具、权限、编排,最后落在用户指尖所触碰到的页面。十二个机制、两处垂直落地,我们都完整地拆了一遍
下一篇是终章,我想退后一步,把这台机器组装起来,以整体的视角打量:到底是什么,让它不是「又一个通用 Agent」,而是一个真正长在写作业务里的东西?咱们继续聊