把聊天室做成一個套件:Clouda 的前端架構與運作方式
Clouda(雲寶寶)是我做的一個 AI 聊天室小工具,裝進各個專案之後,畫面右下角會多一顆按鈕,點開就能跟 AI 對話。後端由同事負責,我做的是前端,包括怎麼用 React 跟 Shadow DOM 做成一個不會跟宿主打架的元件、用 Vite 打包成套件發佈到私有的 npm registry、用 data 屬性當設定介面,還有怎麼用 fetch 接 SSE 串流把回覆一個字一個字印出來。
目錄
今年我參與做了一個 AI 助理,叫 Clouda,中文名字是雲寶寶。它不是一個獨立的網站,而是一個可以裝進各個專案的聊天室小工具,裝好之後畫面右下角會多一顆按鈕,點開就是一個聊天視窗,可以打字或用講的問問題,AI 會根據內部文件回答。
後端由另一位工程師負責,用 Django 跟 LangGraph 處理對話跟 AI 的工作流。我負責前端,也就是這個小工具本身,還有把它做成套件讓其他專案安裝。這篇想把整個架構跟運作方式整理一遍。
整體長什麼樣
從宿主專案(例如一套能源管理系統)的角度看,要用 Clouda 只要做兩件事。先在頁面上放一個容器:
<div id="clouda-container" data-token="…" data-env="dev" data-lang="zh-TW"></div>再安裝套件、呼叫一個函式:
import { mountClouda } from '@company/clouda'
mountClouda()剩下的事都在套件裡面發生。mountClouda 會在那個容器上建立 Shadow DOM,把整個 React 應用程式渲染進去,讀取容器上的 data 屬性決定要連哪個環境的後端、用什麼語言,之後跟後端用 REST API 管理對話、用 SSE 串流接收 AI 的回覆。
宿主頁面
└─ <div id="clouda-container" data-*>
└─ Shadow DOM
└─ React App(MUI)
├─ 浮動按鈕 → 聊天視窗
├─ apiClient ──REST──▶ 後端(對話、訊息、標題)
└─ sseClient ──SSE───▶ 後端(AI 回覆串流)為什麼做成套件,而不是 iframe 或各專案各寫一份
各專案各寫一份聊天室最直覺,但手上有好幾個專案,改一個 bug 要改好幾次,一開始就不考慮。用 iframe 隔離得最乾淨,但聊天視窗要跟宿主的畫面疊在一起、要拿到宿主的登入狀態、要能跟著宿主切換語言,iframe 處理這些都很彆扭。
最後選的是做成 npm 套件,宿主 npm install 之後呼叫一個函式掛載。程式碼只有一份,更新只要升版號、宿主重新安裝。至於樣式會不會跟宿主打架,交給 Shadow DOM 解決,後面會講。
打包與發佈
套件用 Vite 的 Library Mode 打包,入口就是 mountClouda 所在的檔案,一次輸出三種格式:
// vite.config.ts
export default defineConfig({
plugins: [react()],
build: {
lib: {
entry: 'src/main.tsx',
name: 'Clouda',
formats: ['es', 'cjs', 'iife'],
fileName: format => `clouda.${format}.js`,
},
},
})ES Module 給 React、Vue、Next.js 這類現代專案 import 用,IIFE 給只能放 <script> 標籤的頁面用,CommonJS 則是備著給 Node.js 環境。
IIFE 的版本會把 mountClouda 掛到 window.Clouda 上,所以就算是最傳統的 HTML 頁面也能用:
<div id="clouda-container" data-env="dev"></div>
<script src="./clouda.iife.js"></script>
<script>
Clouda.mountClouda()
</script>型別定義是用一個小腳本在建置時產生的 index.d.ts,只匯出 mountClouda 跟幾個公開的型別,套件對外的介面刻意維持很小。
發佈的地方是 GitLab 的 Package Registry,是私有的 npm registry。
宿主專案要安裝,得先有一份帶著 deploy token 的 .npmrc,本機開發是跟負責的人拿,CI 建置 Docker 映像檔的時候則是用 build argument 把 token 傳進去、在建置階段產生 .npmrc。
這段流程我寫在套件的 README 裡,因為「裝不起來」通常是宿主專案遇到的第一個問題。
掛載:Shadow DOM 跟 MUI
mountClouda 做的事情大概是這樣:
export function mountClouda(containerId = 'clouda-container') {
const container = document.querySelector<HTMLElement>(`#${containerId}`)
if (!container) throw new Error(`Container #${containerId} not found`)
const shadowRoot = container.attachShadow({ mode: 'open' })
const mountPoint = document.createElement('div')
shadowRoot.appendChild(mountPoint)
// 讓 Emotion 把樣式注入到 Shadow DOM 裡,而不是 document.head
const cache = createCache({ key: 'css', prepend: true, container: shadowRoot })
const theme = createAppTheme(mountPoint)
ReactDOM.createRoot(mountPoint).render(
<CacheProvider value={cache}>
<ThemeProvider theme={theme} defaultMode="dark">
<CssBaseline />
<App />
</ThemeProvider>
</CacheProvider>
)
}會用 Shadow DOM 主要是為了樣式隔離。宿主專案跟 Clouda 都用 MUI,如果兩邊的樣式都注入到同一個 document.head,class 名稱會撞、全域的 CSS reset 會互相覆蓋,而且宿主跟套件的 MUI 版本不一定一樣。
把 React 渲染在 Shadow DOM 裡,再把 Emotion 的樣式容器指到 shadow root,Clouda 的樣式就完全關在自己的範圍內,宿主的 CSS 也進不來。
實際做的時候有兩個地方要處理:MUI 的 Modal、Popover 這類元件預設會把內容渲染到 document.body,那就跑出 Shadow DOM 了,所以建立主題的時候要把掛載點傳進去,讓這些元件的 container 指向 Shadow DOM 內部。
字型也要想一下,Shadow DOM 隔離的是樣式規則不是字型資源,所以套件的主題直接沿用宿主會有的字型變數,再接微軟正黑體這類系統字型當備援,宿主不用額外載字。
設定介面:data 屬性加上 MutationObserver
套件需要知道三件事,用哪個 token、連哪個環境的後端、用什麼語言。我沒有讓 mountClouda 吃一堆參數,而是讓宿主把這些寫在容器的 data 屬性上,套件自己讀。
這樣的好處是宿主可以在掛載之後隨時改,例如使用者切換語言的時候,只要改 data-lang,套件用 MutationObserver 監看屬性變化,自己重新設定:
const observer = new MutationObserver(() => applyConfig(container))
observer.observe(container, {
attributes: true,
attributeFilter: ['data-token', 'data-env', 'data-lang'],
})data-env 對應到套件裡一張環境表(dev、sit、uat、prd 各自的 API 位址),宿主不需要知道後端的網址,只要說自己是哪個環境。
token 的設計是宿主把使用者的委派 token 交給 Clouda,由 Clouda 拿去跟後端換登入狀態,這樣使用者不用在聊天室裡再登入一次。
聊天室本體
打開之後是一個 MUI 的 Modal,裡面是一般聊天軟體的樣子。左邊的側欄列出過去的對話,可以新增、刪除、改標題。中間是訊息串,AI 的回覆用 react-markdown 加 remark-gfm 渲染,程式碼區塊有語法高亮跟一鍵複製。
等待回覆的時候有一個思考中的小動畫。空白狀態會列幾個常用問題讓使用者點。
輸入框旁邊有麥克風,用瀏覽器內建的 Web Speech API 做語音輸入,辨識的語言跟著 data-lang 走。AI 回答如果引用了內部文件,訊息下面會列出來源,可以點過去看。
這些都是一般的 React 元件,比較特別的是跟後端溝通的部分。
跟後端的溝通:REST 加 SSE
對話的管理走一般的 REST API,列出對話、建立、刪除、改標題。送出訊息跟接收回覆則走 SSE(Server-Sent Events),因為 AI 的回覆是一個字一個字生出來的,等全部生完再一次回傳,使用者會盯著空白畫面等很久。
一般接 SSE 會用瀏覽器內建的 EventSource,但它只能發 GET、不能自訂 header,而送訊息需要 POST 一段內容過去,所以我改用 fetch 拿到 ReadableStream 自己解析:
const response = await fetch(url, {
method: 'POST',
headers: { Accept: 'text/event-stream', 'Content-Type': 'application/json' },
body: JSON.stringify({ content }),
signal: abortController.signal,
})
const reader = response.body!.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
if (line.startsWith('data: ')) handlers.onMessage(JSON.parse(line.slice(6)))
}
}後端送來的事件有三種:start 表示開始回覆,content 帶著新生成的一小段文字,前端把它接到最後一則訊息的後面,畫面上就會看到回覆逐字出現。complete 帶著完整的回覆跟引用的文件,前端用它把整則訊息換成最終版本,順便把來源列出來。
串流連線比一般請求脆弱,所以 sseClient 裡有一些保護。後端每隔一段時間會送心跳,前端 15 秒沒收到就當作斷線。整個串流有 60 秒的逾時。
斷線會自動重連,最多五次,間隔從 3 秒開始每次乘 1.5。使用者關掉視窗的時候用 AbortController 把連線切掉,不然串流會在背景一直跑。
一般的 REST 請求也有類似的保護,15 秒逾時、失敗重試三次、間隔指數成長,碰到 401 就提示重新登入。
在宿主專案裡怎麼用
以一個 Next.js 專案為例,根 layout 裡放一個 client component 來渲染容器,語言從專案本身的 i18n 讀:
'use client'
const CloudaContainer = () => {
const { i18n } = useTranslation()
const lang = i18n.language.includes('zh') ? 'zh-TW' : 'en'
return <div id="clouda-container" data-env="dev" data-lang={lang} />
}然後在確認使用者已經登入的地方呼叫 mountClouda()。有一個要注意的是 mountClouda 只能在瀏覽器執行,它會碰 document,所以在 Next.js 裡要放在 useEffect 或其他確定在 client 端跑的地方。
宿主切換語言的時候只要 data-lang 跟著變,Clouda 的介面跟語音辨識就會一起換,不用重新掛載。
這樣的整合方式還有一個好處,宿主要不要用 Clouda 只是放不放那個容器、呼不呼叫 mountClouda 的差別,不牽涉其他程式碼,各專案可以依自己的需求隨時開關。
回顧
這是我第一次把一個完整的前端應用程式做成套件給別的專案用,跟平常寫頁面最大的差別是要一直想「別人會怎麼用」。
做完回頭看,對外的介面維持很小這件事很重要,一個函式加三個 data 屬性,宿主要知道的事情愈少愈好。Shadow DOM 很適合這種要嵌進別人頁面的元件,樣式隔離的問題一次解決,代價只是要多處理 Emotion 的樣式容器跟 MUI 的 portal。
環境切換做在套件裡也省了宿主不少事,宿主只要說自己是 dev 還是 prd。串流的部分,用 fetch 自己解析比 EventSource 彈性,重連、逾時、取消都能自己控制。
另外,文件也不能省,私有套件的安裝流程、三種引入方式、每個 data 屬性的意義,這些都寫在 README 裡,因為宿主專案的開發者不會來讀原始碼,他們只想知道怎麼裝、怎麼用。