所以我一直有個念頭,讓我怎麼也放不下。
每個 AI 聊天應用的運作方式都一樣。你輸入一些內容,模型回傳文字或 Markdown,介面再把它渲染成漂亮的段落。如果你只是想要答案,這很正常。但如果你是真的想要 做出 什麼東西,這就真的很無聊。
如果 AI 可以回傳一個你能點擊的可運作遊戲版面呢?如果你說「改成芭比風格」,整個介面真的就在你眼前變了呢?如果你說「背景加一個星空」,一個會動的畫布就即時掛在聊天視窗後面呢?
我花了幾個星期把這件事做出來。我把它叫做 FlowChat。
這裡是線上版本:https://flowchat-public.varshithvh.workers.dev
而且沒錯,有人一上來就叫它玩井字遊戲,接著還要求它在遊戲中途切換成《奧本海默》風格。這件事讓我驕傲得不得了。
一般的 AI 聊天:模型回傳 Markdown,客戶端把它渲染成文字。簡單、可預測、無聊。
FlowChat:模型回傳原始 HTML 與 CSS、JavaScript,客戶端透過建立在瀏覽器原生 template 系統上的串流協定,直接把它注入 DOM。
這一個改變,讓整個體驗完全不同。你不是在讀一個遊戲;你是在玩一個遊戲。你不是在讀芭比色系配色;你是直接坐在那個色系裡。
AI 不只是回答問題。它是 根據自己的回應重建 UI。

在講技術細節之前,我先讓你感受一下這代表什麼,因為示範比任何架構圖都更有趣。
遊戲:叫它做井字遊戲。你會得到可玩的棋盤、點擊落子、AI 對手、勝負判定。叫它做四子棋。叫它做貪食蛇。遊戲會以聊天中的 agent 氣泡形式渲染,裡面包著一個表單。每一步操作都會送回 LLM,由它處理並且只更新有變動的格子。
主題風格:說「改成芭比主題」。模型會注入 CSS 覆寫,整個介面變成粉紅色。訊息、邊框、按鈕、輸入框都會變。說「奧本海默風格」。你會得到深褐色調與厚重字體。側邊欄與上方工具列會保持固定,不會把外殼弄壞,但聊天區內的一切都會轉變。
背景:說「加一個星空背景」。動畫 canvas 會出現在訊息後面。說「DVD 彈跳動畫」。Logo 會在聊天視窗裡四處彈跳。說「用太空圖片」。一張圖片會鋪滿背景。所有這些都在受控的圖層中,所以永遠不會蓋到真正的 UI。
整個介面接管:有一次我叫它把頁面做得像維基百科。它把輸入框換成連結。點任何連結都會把表單送回 LLM,由它生成一篇新文章,取代聊天內容。我是在一個星期天下午做出來的聊天 app 裡讀羅馬帝國的內容。


全部都跑在 Cloudflare 的邊緣基礎設施上。沒有傳統伺服器。沒有需要維持運作的 Node.js 行程。也沒有需要操心的代管資料庫。
Cloudflare Workers 在每次請求時執行 TypeScript。全球冷啟動不到 50ms。整個 worker 只有一個檔案,負責路由、驗證、速率限制、WebSocket 升級,以及 LLM 串流。
Cloudflare Durable Objects 是這套系統能運作的關鍵。每個聊天室都是一個 Durable Object:一個有狀態的 actor,擁有自己的 SQLite 資料庫、自己的記憶體佇列,以及自己的 WebSocket 連線。當你和朋友打開同一個聊天網址時,你們都連到同一個 DO。同步不是你要另外打造的功能,它就是這個架構本來的樣子。
每個 DO 會儲存:
可休眠的 WebSocket 能在不讓 DO 持續運作的情況下維持連線。Cloudflare 會自動處理 ping/pong。DO 在訊息到來時喚醒,在訊息之間再回去休眠。
better-auth 負責可選的驗證。如果你不設定,它就是對所有人開放;如果你有設定,就會有 Google、GitHub、email/password,以及基於角色的存取權限(admin、dev、chat、view、blocked)。
Inception Labs Mercury-2 是提供回應的模型。它是基於 diffusion 的語言模型,而不是 autoregressive,所以生成方式和 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 中一個命名標記。用戶端執行階段會遍歷文件樹,尋找名稱相符的 processing instruction,然後以 template 內容取代它們。精準地替換。不會碰到頁面上的其他任何東西。
單一 AI 回應也可以包含多則訊息,並以分割分隔符號區隔:
PpqUtcLGQdYN4oqc:SPLIT_MESSAGE
因此模型可以同時傳送一則公開聊天確認給所有使用者,並且透過包含 SERVER_PROPS 路由指示,送出一則只給某位玩家看的私密訊息,而伺服器會在透過 WebSocket 轉送前將其移除。
整套系統建立在兩個瀏覽器 polyfill 之上,用來實作目前正在 Chrome 逐步導入的 Dynamic Partial Update 規格。

讓 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">
...board...
<?marker name="/chat/append-message">
</div>
<!-- 正確:標記放在所有 div 都關閉之後 -->
<div id="ttt-app-1">
...board...
</div>
<?marker name="/chat/append-message">
帶有明確註解的錯誤範例,比只有正確答案的文件更有用。模型需要知道失敗模式長什麼樣子,而不只是成功路徑。
我也學到,像 Mercury-2 這類 diffusion 模型,需要和 autoregressive 模型稍微不同的提示方式。它們的回應感覺不像打字機,而更像內容正在浮現。這和這個用途天然契合。
每個聊天網址都是共用的。在兩個瀏覽器分頁打開同一個連結,兩邊都會透過 WebSocket 即時收到每一則 AI 回應。每個客戶端都有唯一 ID。使用者氣泡會依客戶端 ID 做顏色區分。
LLM 知道每個客戶端的 ID:
[1]: I want to guess a secret word
[2]: I want to give the hint
模型可以回應一則所有玩家都看得到的訊息,並再附上一則只有 client 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 來強行限制更有效,而我一開始就是這樣做,結果造成無限迴圈,把整個頁面凍結了。教訓很深。
輸入中指示、樂觀式使用者氣泡、訊息的 spring 進場動畫。送出按鈕有發光效果。這些都是小細節,但加起來就很有感。

模型標記放置 bug 花了兩天才真正修好,因為問題在第二則訊息到來前都看不出來。第一則訊息永遠看起來是對的。
背景封裝戰爭 大約花了一週反覆拉扯。我先試 CSS !important,再試 MutationObserver 強制器,接著試 JS 層級的背景鎖。每一個都會壞掉別的東西。正確答案是結構性的:把背景圖層移到聊天視窗內部,讓它在物理上不可能逃出去。
CDN 腳本載入 幾乎每個 AI 生成的 app 都會踩到。模型會寫出在函式庫還沒載入前就呼叫 Chart.js API 的程式。修法是教它輪詢:
function init() {
if (typeof Chart === 'undefined') { setTimeout(init, 50); return; }
// safe to use Chart here
}
init();
這個模式現在已經寫進系統提示詞,且穩定可用。
表單 action URL 的 bug 很尷尬。app.html 裡的 form action 寫成了 c/CHAT_ID/prompt(相對路徑),而不是 /c/CHAT_ID/prompt(絕對路徑)。在剛載入時路徑會正確解析,但經過重新導向後就不會了。每個提示詞都被送到 /c/c/CHAT_ID/prompt,然後得到 404。我是從伺服器 log 才抓到這個問題,並加了一個全域 form submit 攔截器,在送出前先把任何相對 action URL 標準化,順便也替 AI 生成的表單多一道保險。
整套東西跑在 Cloudflare 免費方案上。只要一個指令:
npx wrangler deploy --env public
不用 Docker。不用配置伺服器。不用設定資料庫介面。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
開一個新聊天。輸入任何內容。叫它做遊戲、改主題、加背景,或把頁面變成完全不同的樣子。它都做得到。
{% github Varshithvhegde/flowchat
我一直反覆想到的一件事是,這一切其實只是移動了一個假設。不是「AI 回傳文字,UI 來渲染」,而是「AI 回傳 HTML,瀏覽器直接執行」。這一個改變,打開了其他所有可能。
如果你對協定、Durable Objects 架構,或系統提示詞工程有問題,歡迎在留言問我。我在這三方面都花了很多時間,也很樂意深入聊任何一部分。
如果你用它做出什麼有趣的東西,或者 fork 之後把它帶到我沒想到的地方,我真的很想看看。
你也可以在 LinkedIn 和 Dev.to 找到我。我寫的是我真的正在做的東西,不是我覺得自己應該做的東西。這兩者是有差別的。

原文出處:https://dev.to/varshithvhegde/i-built-a-chat-app-that-rewrites-its-own-ui-in-real-time-21m5