所以我一直有個念頭,讓我怎麼也放不下。

每個 AI 聊天應用的運作方式都一樣。你輸入一些內容,模型回傳文字或 Markdown,介面再把它渲染成漂亮的段落。如果你只是想要答案,這很正常。但如果你是真的想要 做出 什麼東西,這就真的很無聊。

如果 AI 可以回傳一個你能點擊的可運作遊戲版面呢?如果你說「改成芭比風格」,整個介面真的就在你眼前變了呢?如果你說「背景加一個星空」,一個會動的畫布就即時掛在聊天視窗後面呢?

我花了幾個星期把這件事做出來。我把它叫做 FlowChat

這裡是線上版本:https://flowchat-public.varshithvh.workers.dev

而且沒錯,有人一上來就叫它玩井字遊戲,接著還要求它在遊戲中途切換成《奧本海默》風格。這件事讓我驕傲得不得了。


這個想法

一般的 AI 聊天:模型回傳 Markdown,客戶端把它渲染成文字。簡單、可預測、無聊。

FlowChat:模型回傳原始 HTML 與 CSS、JavaScript,客戶端透過建立在瀏覽器原生 template 系統上的串流協定,直接把它注入 DOM。

這一個改變,讓整個體驗完全不同。你不是在讀一個遊戲;你是在玩一個遊戲。你不是在讀芭比色系配色;你是直接坐在那個色系裡。

AI 不只是回答問題。它是 根據自己的回應重建 UI

Screenshot of Tic Tac Toe game running live inside a chat bubble


你實際可以拿它做什麼

在講技術細節之前,我先讓你感受一下這代表什麼,因為示範比任何架構圖都更有趣。

遊戲:叫它做井字遊戲。你會得到可玩的棋盤、點擊落子、AI 對手、勝負判定。叫它做四子棋。叫它做貪食蛇。遊戲會以聊天中的 agent 氣泡形式渲染,裡面包著一個表單。每一步操作都會送回 LLM,由它處理並且只更新有變動的格子。

主題風格:說「改成芭比主題」。模型會注入 CSS 覆寫,整個介面變成粉紅色。訊息、邊框、按鈕、輸入框都會變。說「奧本海默風格」。你會得到深褐色調與厚重字體。側邊欄與上方工具列會保持固定,不會把外殼弄壞,但聊天區內的一切都會轉變。

背景:說「加一個星空背景」。動畫 canvas 會出現在訊息後面。說「DVD 彈跳動畫」。Logo 會在聊天視窗裡四處彈跳。說「用太空圖片」。一張圖片會鋪滿背景。所有這些都在受控的圖層中,所以永遠不會蓋到真正的 UI。

整個介面接管:有一次我叫它把頁面做得像維基百科。它把輸入框換成連結。點任何連結都會把表單送回 LLM,由它生成一篇新文章,取代聊天內容。我是在一個星期天下午做出來的聊天 app 裡讀羅馬帝國的內容。

Barbie themed interface with pink gradients

Space background visible behind chat messages


技術棧

全部都跑在 Cloudflare 的邊緣基礎設施上。沒有傳統伺服器。沒有需要維持運作的 Node.js 行程。也沒有需要操心的代管資料庫。

Cloudflare Workers 在每次請求時執行 TypeScript。全球冷啟動不到 50ms。整個 worker 只有一個檔案,負責路由、驗證、速率限制、WebSocket 升級,以及 LLM 串流。

Cloudflare Durable Objects 是這套系統能運作的關鍵。每個聊天室都是一個 Durable Object:一個有狀態的 actor,擁有自己的 SQLite 資料庫、自己的記憶體佇列,以及自己的 WebSocket 連線。當你和朋友打開同一個聊天網址時,你們都連到同一個 DO。同步不是你要另外打造的功能,它就是這個架構本來的樣子。

每個 DO 會儲存:

  • SQLite 中完整的 LLM 訊息歷史
  • 用戶端工作階段紀錄
  • 待處理提示詞佇列(最多 5 個)
  • 每個瀏覽器 / IP 的速率限制狀態
  • 只讀快照的 fork 索引

可休眠的 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 輸出格式。

Wrangler terminal showing bindings after deploy


協定

這是設計起來最有趣的部分,也是我最得意的部分。

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

templatefor 屬性會對應到 DOM 中一個命名標記。用戶端執行階段會遍歷文件樹,尋找名稱相符的 processing instruction,然後以 template 內容取代它們。精準地替換。不會碰到頁面上的其他任何東西。

單一 AI 回應也可以包含多則訊息,並以分割分隔符號區隔:

PpqUtcLGQdYN4oqc:SPLIT_MESSAGE

因此模型可以同時傳送一則公開聊天確認給所有使用者,並且透過包含 SERVER_PROPS 路由指示,送出一則只給某位玩家看的私密訊息,而伺服器會在透過 WebSocket 轉送前將其移除。

整套系統建立在兩個瀏覽器 polyfill 之上,用來實作目前正在 Chrome 逐步導入的 Dynamic Partial Update 規格。

Debug view showing raw LLM message history with protocol delimiters visible


寫系統提示詞

讓 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 就廣播給所有人。

Two browser windows on the same chat URL receiving the same message


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 來強行限制更有效,而我一開始就是這樣做,結果造成無限迴圈,把整個頁面凍結了。教訓很深。

輸入中指示、樂觀式使用者氣泡、訊息的 spring 進場動畫。送出按鈕有發光效果。這些都是小細節,但加起來就很有感。

Default dark UI with welcome screen and suggestion cards


一些誠實的痛點

模型標記放置 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 找到我。我寫的是我真的正在做的東西,不是我覺得自己應該做的東西。這兩者是有差別的。

Your favorite screenshot from testing, whatever made you laugh or surprised you most


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


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝11   💬1   ❤️6
340
🥈
我愛JS
📝2   💬6   ❤️3
114
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登