首页 / 文章 / 我构建了一个实时重写自身UI的聊天应用
← 返回
IT技术

我构建了一个实时重写自身UI的聊天应用

✍️ zhirenhun 📅 2026/7/29 👁 207 阅读 ⏱ 19 分钟
我构建了一个实时重写自身UI的聊天应用

我一直有个挥之不去的想法。

每个AI聊天应用的工作方式都一样。你输入内容,模型返回文本或markdown,UI将其渲染为格式整齐的段落。如果你想得到一个答案,这没问题。但如果你想真正构建点什么,这实在无聊透顶。

如果AI能回复一个可点击的、可用的游戏棋盘呢?如果说"改成芭比主题",整个界面就实时变换了?如果说"在背景加个星空",聊天背后就实时出现一个带动画的画布?

我花了几个星期来构建这个功能。我称之为 FlowChat

这是在线版本:https://flowchat-public.varshithvh.workers.dev

没错,有人立刻让它玩井字棋,然后又在游戏中途要求切换成奥本海默主题。我无比自豪。


创意

普通的AI聊天:模型返回markdown,客户端渲染成文本。简单、可预测、无聊。

FlowChat:模型返回包含CSS和JavaScript的原始HTML,客户端利用基于浏览器原生模板系统构建的流式协议,将其直接注入DOM。

这一改变让整个体验截然不同。你不是在阅读一个游戏,而是在玩它。你不是在阅读芭比配色的描述,而是置身其中。

AI不仅回答问题,它用响应重构UI

对话气泡内实时运行的井字棋游戏截图


实际能做什么

在深入技术细节之前,我想让你感受一下这在实际中意味着什么,因为演示比任何架构图都更有趣。

游戏:让它构建一个井字棋。你会得到一个可玩的棋盘、点击移动、AI对手、胜负判定。要求玩四子棋或贪吃蛇。游戏会在聊天中以一个包含表单的智能体气泡形式呈现。每一步都会提交给LLM,LLM处理后只更新变化的部分。

主题:说"改成芭比主题"。模型会注入CSS覆盖,整个界面变成粉色。消息、边框、按钮、输入框都变了。说"奥本海默主题"。你会得到深褐色调与厚重字体。侧边栏和顶部栏保持锁定,外壳不会损坏,但聊天内部完全变换。

背景:说"添加星空"。一个动画画布会在你的消息后面渲染。说"DVD弹跳动画"。Logo会在聊天视口中弹跳。说"用一张太空图片"。背景会填充一张图片。所有这些都处于一个容器层中,不会覆盖实际UI。

全界面接管:有一次我让它让页面看起来像维基百科。它用链接替换了输入框。点击任何链接都会向LLM提交一个表单,LLM生成一篇新文章,替换聊天内容。我在一个周日下午构建的聊天应用里阅读关于罗马帝国的文章。

带有粉红渐变的芭比主题界面

聊天消息背后可见的太空背景


技术栈

一切都在Cloudflare的边缘基础设施上运行。没有传统服务器。没有需要保持活跃的Node.js进程。没有需要担心的托管数据库。

Cloudflare Workers 在每个请求上运行TypeScript。全球冷启动低于50ms。整个worker是一个文件,处理路由、认证、速率限制、WebSocket升级和LLM流式传输。

Cloudflare Durable Objects 是实现这一功能的关键部分。每个聊天室都是一个独立的Durable Object:一个有状态的actor,拥有自己的SQLite数据库、自己的内存队列和自己的WebSocket连接。当你和朋友打开同一个聊天URL时,你们都连接到同一个DO。同步不需要额外构建,这就是架构的工作方式。

每个DO存储:

Hibernatable WebSockets 保持连接活跃而不让DO保持活跃。Cloudflare自动处理ping/pong。DO在收到消息时唤醒,在消息间隔期间进入休眠。

better-auth 处理可选的认证。如果不配置,应用对所有人开放。如果配置,则支持Google、GitHub、邮箱/密码以及基于角色的访问权限(admin、dev、chat、view、blocked)。

Inception Labs Mercury-2 是驱动响应的模型。它是一个基于扩散的语言模型,而非自回归模型,这意味着它的生成方式与GPT或Claude不同。实际使用中感觉很快,而且似乎能真正理解我需要的HTML输出格式。

Wrangler终端显示部署后的绑定信息


协议

这是设计中最令人愉快的部分,也是我最自豪的部分。

AI不能只是把原始HTML丢进响应流中。一个响应可能需要独立更新页面的三个不同部分。井字棋的移动应该只更新一个格子,而不是重绘整个棋盘。背景动画不应该影响侧边栏。给一个玩家的私密消息不应出现在另一个玩家的聊天中。

所以我构建了一个基于分隔符的流式协议。模型将每个DOM更新包装在一个结构化信封中:

PpqUtcLGQdYN4oqc:BODY_START
<template for="/chat/append-message">
  <div class="message message-user" data-client-id="1">我们来玩井字棋</div>
  <div class="message message-agent message-full-width" id="msg-1">
    <!-- 整个游戏棋盘的HTML -->
  </div>
  <?marker name="/chat/append-message">
</template>
PpqUtcLGQdYN4oqc:BODY_END
进入全屏模式 退出全屏模式

模板上的for属性指向DOM中的一个具名标记。客户端运行时遍历文档树,查找具有匹配名称的处理指令,并用模板内容替换它们。精确无误,不影响页面上其他任何内容。

单个AI响应可以包含多个消息,由拆分分隔符分隔:

PpqUtcLGQdYN4oqc:SPLIT_MESSAGE
进入全屏模式 退出全屏模式

因此,模型可以向所有用户发送公开的聊天确认,同时通过包含SERVER_PROPS路由指令,将私密消息仅路由给一个玩家——服务器在通过WebSocket转发前会剥离这些指令。

整个方案基于两个浏览器polyfill,它们实现了即将登陆Chrome的动态局部更新规范。

显示原始LLM消息历史及可见协议分隔符的调试视图


编写系统提示词

让AI始终如一地在协议格式内生成有效的HTML,需要大量迭代。最后的系统提示词大约有300行,老实说读起来更像API契约而不是提示词。

它涵盖了设计系统中每个CSS变量的确切十六进制值,以便模型正确写出var(--accent)而不是猜测颜色。还包括border-radius规则、阴影值、动画时序。以及Chart.js和d3的异步CDN加载模式,因为模型总是在库加载之前调用new Chart()

我追踪的最大bug是这样的:模型总是把append-message标记放在app容器div内部,而不是后面。导致后续每条聊天消息都会注入到游戏棋盘内。我在提示词中用一个错误与正确的示例修复了它:

<!-- 错误:标记在app div内部,导致后续消息永远注入到此处 -->
<div id="ttt-app-1">
  ...棋盘...
  <?marker name="/chat/append-message">
</div>

<!-- 正确:标记在所有div关闭之后 -->
<div id="ttt-app-1">
  ...棋盘...
</div>
<?marker name="/chat/append-message">
进入全屏模式 退出全屏模式

带有明确注释的错误示例比只有正确的文档更有用。模型需要知道失败模式是什么样子,而不仅仅是理想路径。

我还学到,像Mercury-2这样的扩散模型需要的提示方式与自回归模型略有不同。响应感觉不像打字机,而更像内容实体化。这与这种用例天然匹配。


默认支持多用户

每个聊天URL都是共享的。在两个浏览器标签页中打开同一个链接,两者都能实时通过WebSocket收到每条AI响应。每个客户端获得唯一ID。用户气泡按客户端颜色编码。

LLM知道每个客户端的ID:

[1]: 我想猜一个秘密单词
[2]: 我想给提示
进入全屏模式 退出全屏模式

模型可以回复一条双方可见的消息,以及第二条仅对客户端1可见的包含秘密的消息。在服务器端路由,在到达错误浏览器之前从WebSocket负载中剥离。

我没有添加任何特殊的多用户逻辑。Durable Object架构自然实现了这一点。每个客户端连接到同一个DO实例。DO拥有WebSocket连接。当LLM响应时,DO向所有连接广播。

两个浏览器窗口在同一个聊天URL上接收到相同消息


UI

纯CSS。没有框架,没有Tailwind,没有组件库。Inter字体非阻塞加载,深海军蓝调色板(#06091a#101630),紫丁香靛蓝强调色(#5b6ef5)。

应用外壳是一个侧边栏加一个带有顶部栏的主区域。侧边栏和顶部栏始终物理不透明。聊天视口是唯一允许主题和背景渲染的区域。这防止了AI意外用太空照片覆盖导航——否则它绝对会这么做。我知道,因为在我修复包含问题之前,它已经这么做了很多次。

.chat-viewport {
  position: relative;
  isolation: isolate;
}

#fc-bg-layer {
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none !important;
}

.chat {
  position: relative;
  z-index: 2;
}
进入全屏模式 退出全屏模式

背景层位于z-index 0。聊天消息位于z-index 2。侧边栏和顶部栏是完全独立的元素,位于视口之外。结构性包含优于试图用!important和MutationObserver来强制执行——我最初尝试过后者,但导致了整个页面冻结的无限循环。吸取教训了。

输入指示器、乐观用户气泡、消息的弹簧进入动画。发送按钮带有发光效果。都是些小细节,但积少成多。

带有欢迎界面和建议卡片的默认暗色UI


一些诚实的痛点

模型标记放置bug 花了两天才正确修复,因为问题直到第二条消息到达时才显现。第一条消息看起来总是正确的。

背景包含之战 来回折腾了大约一周。我试过CSS !important,然后是MutationObserver强制执行器,然后是JS级别的背景锁。每种方法都破坏了其他东西。正确的答案是结构性的:将背景层移动到聊天视口内部,这样它物理上就不可能逃逸。

CDN脚本加载 总是让AI生成的应用出错。模型编写的代码在库加载之前就调用了Chart.js API。修复方法是教它进行轮询:

function init() {
  if (typeof Chart === 'undefined') { setTimeout(init, 50); return; }
  // 这里可以安全使用Chart
}
init();
进入全屏模式 退出全屏模式

这种模式现在已经内置于系统提示词中,运行可靠。

表单action URL bug 很尴尬。在app.html中,表单action是c/CHAT_ID/prompt(相对路径),而不是/c/CHAT_ID/prompt(绝对路径)。在首次加载时路径解析正确,但在重定向后就不对了。每个提示都提交到/c/c/CHAT_ID/prompt并返回404。我从服务器日志中发现了它,并添加了一个全局表单提交拦截器,在提交前规范化任何相对action URL,同时也作为AI生成表单的安全网。


部署

整个项目在Cloudflare的免费层上运行。一条命令:

npx wrangler deploy --env public
进入全屏模式 退出全屏模式

没有Docker。无需配置服务器。没有要配置的数据库UI。Cloudflare自动处理扩展、WebSocket休眠、全局分发以及每个Durable Object内的SQLite存储。

像API密钥这样的秘密通过Wrangler存储:

npx wrangler secret put INCEPTION_API_KEY --env public
进入全屏模式 退出全屏模式

它们永远不会接触代码库或版本控制。


试试看

在线版本:https://flowchat-public.varshithvh.workers.dev

源码:

——

🧑‍💻

zhirenhun

一个热爱技术的程序员,喜欢分享前沿AI知识和开发经验。

AI
← 上一篇
Cursor + BrowserAct 如何在不使用脆弱选择器的情况下处理动态页面
下一篇 →
你的AI代理需要有限状态机(FSMs)

📌 相关推荐

GraphRAG 是推理问题,而非数据库问题
2026/8/30
构建市场时光机:使用 Python 和 WebSocket 重放交易会话
2026/8/30
如何自行基准测试LLM推理:值得信赖的数字设计标准
2026/8/30
← 返回文章列表