我原以为网页聊天是整个产品里最简单的入口。
Telegram、飞书、钉钉这些第三方集成才叫复杂。网页聊天不过是仪表盘里的一个功能,同浏览器、同服务器、同应用,能出什么问题?
结果问题大了。
从UI看,这个bug毫无规律。任务从网页聊天界面正常启动,运行时会话存在,对话存在,任务存在,日志看起来也健康。然后交付管道尝试往对话里发送后续更新时,却报错:conversation_not_found。
这说不通——对话明明存在,我刚用过。这种bug最耗时间,因为每个子系统单独看都半对半错。
真正的症结在于:我把网页聊天当成"页面"来设计,而非"渠道"。
CliGate的架构里早就有渠道模型。Telegram是渠道,飞书是渠道,钉钉是渠道。这些入站消息走同一套监管与交付机制:对话存储、调度器、交付发送器、助手编排、运行时会话绑定。但网页聊天却慢慢滑向了一条特殊路径。
起初这感觉无害。网页聊天活在同一个应用里,给它一点自定义状态、几个便利包装,很容易。这就是 mistake。
实际崩坏的是这里:旧版 chat-ui/conversation-store.js 导出了自己独立的 store 实例,而交付与编排路径用的是共享的渠道对话 store。两边都在读写"对话",但不是同一个内存数组。
结果是:聊天UI能创建对话,路由处理器能看见,运行时能绑定,但调度器之后就是找不到。修复注释比我讲得更直白——chat-ui 和 agent-channels 各持有一份独立的内存 conversations 数组,尽管两边都往磁盘同一个JSON文件写。服务器启动后,运行时创建的 chat-ui 对话对 chat-ui-route 可见,对 message-service 却不可见,于是调度器投递命中 conversation_not_found,通知被静默丢弃。
这就是"小UI捷径"悄悄分叉领域模型的典型后果。
修复并不复杂。我不需要新抽象,只需要单一真相源。生产环境不再导出专用的 chat-ui 对话 store,而是把聊天专用助手挂到渠道系统用的共享单例上:
installChatUiHelpers(agentChannelConversationStore);
export const chatUiConversationStore = agentChannelConversationStore;
这一改动比看起来更重要。现在网页聊天不再假装与渠道系统相邻,它就是渠道系统的一部分。
改变的不只是交付。一旦停止把网页聊天当作特殊页面,其他决策也清爽多了。chat-ui 对话现在表现得像真正的渠道对话:同样的持久化语义、同样的调度保证、同样的可观测性。之前为"网页聊天特例"写的防御代码可以删掉。测试覆盖不再需要两套断言。
最意外的收获:这个修复让多设备场景自然成立。同一个用户登录两台浏览器,现在看到的是同一份对话状态——因为状态不再困在某个UI组件的内存里,而是活在渠道系统的共享存储中。
回看这个bug,它暴露了一个设计惯性:我们太容易把"同一台服务器上的东西"当成"同一个系统"。网页聊天和渠道后端确实共享进程,却各用各的状态管理,等于在进程内部又造了一次分布式系统的经典难题。
单一真相源不是新道理,但"渠道"这个抽象迫使你认真对待它。Telegram、飞书、钉钉这些外部渠道没有给你"特殊处理"的选项,它们强迫你走统一管道。网页聊天之所以出问题,恰恰因为它看起来太"近",让人误以为可以偷懒。
现在网页聊天终于和其他渠道平起平坐。代码少了,行为对了,边界清了。有时候,把一个东西从"例外"还原为"规则",就是最好的架构决策。
热门跟贴