我一直有个挥之不去的想法。
每个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输出格式。
这是设计中最令人愉快的部分,也是我最自豪的部分。
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的动态局部更新规范。
让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向所有连接广播。
纯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来强制执行——我最初尝试过后者,但导致了整个页面冻结的无限循环。吸取教训了。
输入指示器、乐观用户气泡、消息的弹簧进入动画。发送按钮带有发光效果。都是些小细节,但积少成多。
模型标记放置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
源码:
——
一个热爱技术的程序员,喜欢分享前沿AI知识和开发经验。