把聊天室做成一個套件:Clouda 的前端架構與運作方式

約 7 分鐘

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 裡,因為宿主專案的開發者不會來讀原始碼,他們只想知道怎麼裝、怎麼用。

References

留言

使用 GitHub 帳號登入即可留言,內容會存放在本站 repo 的 Discussions。