演示如何构建 AI chat 应用让 LLM 实时调整应用自身界面,展示 AI 驱动交互的创新设计模式。
将原始 HTML 流式传输以动态改造 DOM
这个想法一直在我脑子里转。
每个 AI 聊天应用的工作方式都一样。你输入内容,模型返回文本或 markdown,UI 把它渲染成格式精美的段落。如果你只想要一个答案,这没什么问题。但如果你想真正构建什么东西,这就显得无聊透顶了。
如果 AI 能够响应一个你可以点击的游戏棋盘呢?如果说"做成芭比主题"能在你眼前实时改造整个界面呢?如果"在背景加个星空"能在你的聊天窗口后面立刻生成一个动画画布呢?
我花了几周时间构建了这一切。我叫它 FlowChat。
这是线上版本:https://flowchat-public.varshithvh.workers.dev
有个人立刻让它玩起了井字棋,然后要求它在游戏进行到一半时切换到奥本海默主题。我真是太自豪了。
普通 AI 聊天:模型返回 markdown,客户端把它渲染成文本。简单、可预测、无聊。
FlowChat:模型返回包含 CSS 和 JavaScript 的原始 HTML,客户端用一个基于浏览器原生 template 系统的流式协议将其直接注入 DOM。
仅仅这一个改变就让整个体验完全不同了。你不是在阅读关于游戏的描述。你在玩游戏。你不是在阅读芭比配色方案。你在坐在这样的配色里。
AI 不再只是回答问题。它从自己的响应中重建 UI。
在深入技术细节之前,我想让你对这意味着什么有个直观的感受,因为演示比任何架构图都更有意思。
游戏:请它构建井字棋。你得到一个可以玩的棋盘,点击移动,有 AI 对手,胜负检测。要求 Connect 4。要求贪吃蛇。游戏在聊天里渲染成一个代理气泡,里面包含一个表单。每一步都提交给 LLM,它处理后只更新改变的格子。
主题:说"换成芭比主题"。模型注入 CSS 覆盖,整个界面变成粉红色。消息、边框、按钮、提示框。说"奥本海默主题"。你会看到深棕褐色调和厚重的字体。侧边栏和顶栏始终保持不透明,这样外壳永远不会被破坏,但聊天内的一切都会转变。
背景:说"加个星空"。一个动画画布在你的消息后面渲染。说"DVD 弹跳动画"。那个标志在聊天视口里弹来弹去。说"用个太空图像"。一张图像填满背景。所有这些都在一个独立的图层里,所以永远不会覆盖实际的 UI。
完整界面接管:有一次我让它把页面做成维基百科的样子。它用链接替换了提示框。点击任何链接都会向 LLM 提交一个表单,后者生成一篇新文章替换聊天内容。我在一个我周日下午在三小时内构建的聊天应用里阅读关于罗马帝国的内容。
一切都运行在 Cloudflare 的边缘基础设施上。没有传统服务器。没有需要保活的 Node.js 进程。没有需要担心的托管数据库。
Cloudflare Workers 在每个请求上运行 TypeScript。全球冷启动时间低于 50ms。整个 worker 是一个文件,处理路由、认证、速率限制、WebSocket 升级和 LLM 流式传输。
Cloudflare Durable Objects 是让这一切成为可能的关键。每个聊天室都是一个单一的 Durable Object:一个有状态的 actor,拥有自己的 SQLite 数据库、自己的内存队列和自己的 WebSocket 连接。当你和朋友打开同一个聊天 URL 时,你们都连接到同一个 DO。同步不是你必须构建的东西。它就是这个架构的工作方式。
存储内容包括:
Hibernatable WebSockets 在不保活 DO 的情况下保持连接活跃。Cloudflare 自动处理 ping/pong。当消息到达时 DO 唤醒,在消息之间回到睡眠状态。
better-auth 处理可选的身份验证。如果你不配置它,应用对所有人开放。如果你配置了,你会得到 Google、GitHub 和电子邮件/密码,带有基于角色的访问控制(admin、dev、chat、view、blocked)。
Inception Labs Mercury-2 是驱动响应的模型。它是一个基于扩散的语言模型,而不是自回归的,这意味着它的生成方式不同于 GPT 或 Claude。在实践中,它感觉很快,并且似乎真正理解了我需要的 HTML 输出格式。
这是我最享受设计的部分,也是我最引以为豪的部分。
AI 不能只是把原始 HTML 倾倒到响应流中。一个响应可能需要独立更新页面的三个不同部分。井字棋的一步应该只更新一个格子,而不是重绘整个棋盘。背景动画不应该影响侧边栏。发给一个玩家的私人消息不应该出现在另一个玩家的聊天里。
所以我构建了一个基于分隔符的流式协议。模型用一个结构化的信封包装每个 DOM 更新:
PpqUtcLGQdYN4oqc:BODY_START
<template for="/chat/append-message">
<div class="message message-user" data-client-id="1">Lets play Tic Tac Toe</div>
<div class="message message-agent message-full-width" id="msg-1">
<!-- entire game board HTML -->
</div>
<?marker name="/chat/append-message">
</template>
PpqUtcLGQdYN4oqc:BODY_END
template 上的 for 属性指向 DOM 中的一个命名标记。客户端运行时在文档树中查找具有匹配名称的处理指令,并用模板内容替换它们。精确无误。不触及页面上的其他任何东西。
一个单一的 AI 响应可以包含多个消息,由分割分隔符分开:
PpqUtcLGQdYN4oqc:SPLIT_MESSAGE
所以模型可以向所有用户发送一个公开的聊天确认,同时通过包含 SERVER_PROPS 路由指令来只路由一个私人消息给一个玩家,服务器会在通过 WebSockets 转发前剥离这些指令。
整个系统建立在两个浏览器 polyfill 之上,这些 polyfill 实现了即将在 Chrome 中推出的动态部分更新规范。
让 AI 持续生成这个协议格式内的有效 HTML 需要大量迭代。最终的 system prompt 大约 300 行,说实话,它读起来更像是一份 API 合约而不是一个提示。
它涵盖了设计系统中每个 CSS 变量的确切十六进制值,这样模型就能正确地写出 var(--accent) 而不是猜测颜色。border-radius、shadow 值、动画时序的规则。Chart.js 和 d3 的异步 CDN 加载模式,因为模型老是在库加载前调用 new Chart()。
我追踪的最大 bug 是这个:模型老是把 append-message 标记放在应用容器 div 内部,而不是在它之后。后续的每条聊天消息都会注入到游戏棋盘里。我用 prompt 中一个错误对比正确的例子修复了它:
<!-- WRONG: marker inside app div, next message injects here forever -->
<div id="ttt-app-1">
...board...
<?marker name="/chat/append-message">
</div>
<!-- CORRECT: marker after ALL divs close -->
<div id="ttt-app-1">
...board...
</div>
<?marker name="/chat/append-message">
有显式注释的错误例子比仅仅的正确文档更有用。模型需要知道失败模式是什么样的,而不仅仅是成功路径。
我也发现扩散模型比如 Mercury-2 需要和自回归模型略有不同的 prompting。响应感觉不像打字机,更像内容在实现。它自然地配合这个用例。
每个聊天 URL 都是共享的。在两个浏览器标签页中打开同一个链接,两个都会通过 WebSockets 实时接收每个 AI 响应。每个客户端得到一个唯一的 ID。用户气泡按客户端以不同颜色标记。
LLM 知道每个客户端的 ID:
[1]: I want to guess a secret word
[2]: I want to give the hint
模型可以响应一个对两个玩家都可见的消息,以及第二条仅包含对客户端 1 可见的秘密的消息。在服务器端路由,在到达错误浏览器前从 WebSocket 有效载荷中剥离。
我没有添加任何特殊的多用户逻辑。Durable Object 架构只是让它自然地工作。每个客户端连接到同一个 DO 实例。DO 拥有 WebSocket 连接。当 LLM 响应时,DO 广播给所有客户端。
纯 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 和 MutationObservers 强制它,我最先试了这些并导致了一个无限循环,冻结了整个页面。学到的教训。
输入指示器、乐观用户气泡、消息上的弹簧进入动画。发送按钮有光晕。都是小东西但它们累积起来。
模型标记放置 bug 花了两天才真正修复,因为问题直到第二条消息到达前都是看不见的。第一条消息总是看起来是对的。
背景容纳战争花了大约一周的来回。我试了 CSS !important,然后是 MutationObserver enforcer,然后是 JS 级别的背景锁。所有这些都破坏了其他东西。正确答案是结构性的:把背景图层移到聊天视口内,这样物理上不可能让它逃脱。
CDN 脚本加载绊倒每个 AI 生成的应用。模型写出在库加载之前调用 Chart.js API 的代码。修复是教它轮询:
function init() {
if (typeof Chart === 'undefined') { setTimeout(init, 50); return; }
// safe to use Chart here
}
init();
那个模式现在已经烤进 system prompt 里,它工作得很可靠。
表单 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 key 之类的密钥通过 Wrangler 存储:
npx wrangler secret put INCEPTION_API_KEY --env public
它们永远不接触代码库或版本控制。
线上版本:https://flowchat-public.varshithvh.workers.dev
源码:https://github.com/Varshithvhegde/flowchat
打开一个新聊天。输入任何内容。要求它构建游戏、改变主题、添加背景,或让页面看起来完全不同的样子。它会做到的。
Varshithvhegde / flowchat
多用户 AI 聊天,生成实时 HTML UI——游戏、仪表板、应用——由 Cloudflare Workers + Durable Objects 驱动
一个多用户 AI 聊天,其中模型用实时 HTML 而不是 markdown 来响应。每个回复都可以是游戏、仪表板、动画背景、交互式表单,或完整的 UI 重新设计——直接在浏览器中运行。
线上演示:https://flowchat-public.varshithvh.workers.dev
特点
我一直回到的事情是这个过程中有多少只是移动了一个假设。不是"AI 返回文本,UI 渲染它",而是"AI 返回 HTML,浏览器运行它"。那一个改变打开了其他一切。
如果你对协议、Durable Objects 架构或 system prompt 工程有疑问,请在评论区提问。我在这三个方面都花了很多时间,很乐意深入讨论任何一个。
如果你用它构建了有趣的东西,或者 fork 了它并将它带到我没有想到的地方,我真的很想看到。
你也可以在 LinkedIn 和 Dev.to 上找到我。我写的是我实际正在构建的东西,而不是我认为我应该构建的东西。有区别。