14. 界面设计

把 Agent 的「内心戏」变成用户能懂的界面:流式、diff 与卡片

上一篇产品流程,主要讲的是「后台怎么运转」。可用户真正用手触碰的,从来不是流程,而是一个页面、一个输入框、一份稿子。这一篇,我们来看下怎么把后台那些精密又晦涩的 Agent 内心戏,翻译成大白话,让非技术背景的写作者,也能一眼看懂、轻松上手。这是项目的「最后一公里」

👆你在这里
👆你在这里

🪧 用户看不到 Agent,只看得到一个产品应用

前面十三篇讲的所有机制,老实说,用户一个都感知不到,也不该感知到

他打开应用,看到的是一个输入框、一份正在生成的稿子、几行提示。而后台那些东西呢?一条条流式的消息、一次次工具调用、时不时冒出来的权限审批、还有像 draft.md 这种纯粹的内部概念,这些「内心戏」如果原样端到用户面前,对一个写文章的人来说,可能是一堆看不懂的天书,甚至直接把他吓退

所以 UI 这一层的活,本质上是做翻译:把 Agent 的内心戏,翻译成写作者熟悉的语言和交互。而且这翻译,分寸要拿捏好,既要让用户感到「它在认真替我干活」(有掌控、可解释),又不能让他被底层细节淹没(不吓人、不添乱)。这一篇,会按「用户会遇到的几类内心戏」,分类来讲我是怎么翻译的

🎭 总纲:UI 是 Agent 内心戏的「翻译层」

后台的 Agent,此刻正在跑 turn、吐文本增量、调工具、等你审批,这些都是它的「内心戏」。可是用户想要的,是「我在跟一个懂写作的助手顺畅协作」的体感。UI 的全部职责,就是在这两者之间做翻译

一个从没听说过 Agent、Prompt、Token 的写作者,能不能仅凭直觉,就把这个产品顺手用起来? 这才是最大的标准,下面这七类翻译,都是奔着这同一个目标去的

📡 翻译一:两层信息流,让用户既看到「在写」,也看到「在干嘛」

Agent 干活时,后台其实在同时涌出两种性质完全不同的信息:一种是它正在辩证思考与琢磨的文本增量;另一种是它「开始调某个工具了」「工具返回了」「正在检索合集」「刚自动整理了一次上下文」这类结构化的进展节点

我把它们分两层呈现,各司其职:

支撑这两层的,是后台和前端之间一套严格对齐的事件协议(技术上走 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 需要「停下来、请你回应」。这种时刻有两种,性质不同:

这两种,一个是「安全闸」,一个是「意图确认」,底层机制完全不同。但站在用户的角度,它们是同一件事:「界面停住了,请你选一下」。所以我让它们共用同一套多选卡片 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 去验证,别等做深了、代码缠成一团了,才发现地基选错。 船大难掉头,小舟还好说

选完之后,还有大量集成工作要做,才能把写作体验磨得真正顺手。挑几个小案例说说:

这些细节用户可能都意识不到,但却不能懈怠。它们逐步累加起来,才成就了「这个编辑器真顺手」的整体手感。 到今天,我在高频的使用过程中,还能持续发现一些没被处理好的 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 一个 hydrate action 清空整个列表再重建,而 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」,而是一个真正长在写作业务里的东西?咱们继续聊