Widget Channel
當你希望使用者直接在你自己的網站或產品裡和 Agent 對話,而不是跳到一個獨立的託管頁面時,就用 widget channel。
這一頁主要講的是可嵌入的 Widget 流程。如果你要用 Codeer 提供的託管頁面,請改看 Web Channel。如果你要連前端與後端都自己掌管,請改看 API Access。
對 Consultation Desk 來說,最穩的 rollout 順序通常是:
- 先發布 Agent 版本
- 建立或更新 widget channel
- 設定 widget 的基本內容與存取規則
- 發布 widget channel
- 把 embed snippet 貼到 host page
- 自己先在 live 頁面跑完一輪測試再對外分享
開始前要先有什麼
- 一個至少發布過一次的 Agent
- workspace 的 channel 編輯權限
- 一個可放入 embed snippet 的網站或產品頁面
- 如果你要讓已登入使用者直接帶著身分進入 widget,團隊內要有人可以調整 host backend
完整設定流程
-
先發布 Agent
- 到
Editor打開目標 Agent,按下Publish。 - 發布 widget channel 並不會幫你一起發布 Agent。
- 如果 Agent 已發布版本裡有
suggested questions,widget 發布後會直接沿用它們作為面板預設問題。
- 到
-
到
Channels建立 widget channel- 打開
Channels。 - 按
New Channel。 - 選擇
Widget。 - 先填好穩定的
Name與Slug。
把 slug 當成嵌入合約的一部分
對 widget channel 來說,
widgetKey就是 channel slug。之後如果修改 slug,不只要重新 publish,也要同步更新所有已貼出的 embed snippet,以及任何依賴舊 slug 的 signed login 流程。 - 打開
-
設定 widget 的基本內容
先檢查這幾個欄位:
Agent:選擇這個 widget 要綁定的已發布 AgentLauncher Label與Launcher Icon:決定頁面上關閉狀態的入口長什麼樣子Widget Title:顯示在面板標題列Widget Language:選擇English、繁體中文或日本語Widget Position:通常會放在bottom-right或bottom-leftGreeting:使用者打開面板後先看到的問候語
Suggested Questions不是在 widget 設定裡另外編輯的。它會直接沿用你目前綁定的已發布 Agent 版本。 -
設定存取與安全性
這裡其實要分成兩個決策:
- 匿名存取
- 如果訪客應該能直接以 guest 身分開始對話,就打開它。
- 只有在你的 host page 一定會帶入已登入身分時,才把它關掉。
- 來源站限制
- 如果 widget 只該在特定網站上運作,就選
Configured origins only。 - 每列填一個精確 origin,例如
https://support.example.com。 - 這裡不要填頁面路徑,只填 origin。
- 如果 widget 只該在特定網站上運作,就選
如果你要讓 host page 把已登入身分帶進 widget,還要再選登入驗證模式:
Disabled:widget 一律以 guest 模式使用Assertion:正式環境建議用這個,由你的 host backend 簽發並驗證身分Direct User ID:安全性較低,只適合可信任環境
不要在沒有登入串接的情況下關掉 guest
如果匿名存取已關閉,但你的 host page 並沒有真的帶入有效的已登入身分,使用者就無法開始對話。
Direct login 需要先限制來源站
如果你要用 direct login,先把 origin policy 改成
Configured origins only,並至少加入一個 allowed origin。Assertion 的 shared secret 只會顯示一次
如果你使用
Assertion,產生 shared secret 後要立刻存進 host backend。它不會出現在config.json,而且離開頁面後也不會再次顯示完整內容。 - 匿名存取
-
發布 widget channel,並複製 embed snippet
- 設定完成後,再發布 widget channel。
- 到
Overview複製系統產生的 embed snippet。 Auto-init適合大多數情況,直接沿用已發布設定。Manual init只留給 host page 需要在 script 載入前覆寫執行時設定的情境。
-
先在真實頁面自己測一輪
正式分享前,先打開真正的 host page,確認:
- launcher 有出現在預期角落
- launcher 文字、圖示、標題與問候語都正確
- widget 綁定的是正確的 Agent
- guest 或已登入身分流程符合你的預期
- origin 限制沒有擋到真實頁面
- 對話有正常出現在
Histories
如何在 host app 使用 widget.js
最簡單的做法,是先在 Codeer 的 widget channel Overview 複製系統產生的 embed snippet,再貼到你的 host page。
最簡單的嵌入方式:Auto-init
如果你只想直接使用已發布的 channel 設定,通常用這種方式就夠了:
<script async src="https://app.codeer.ai/web-widget/widget.js?key=YOUR_WIDGET_KEY"></script>
重點只有一個:
YOUR_WIDGET_KEY就是這個 widget channel 的slug
這種方式會在 script 載入後自動初始化 widget,並使用目前已發布的 config.json。
進階嵌入方式:Manual init
如果你需要在 script 載入前覆寫部分執行時設定,例如標題、位置或登入行為,就用 manual init:
<script>
window.YourWidget =
window.YourWidget ||
function () {
(window.YourWidget.q = window.YourWidget.q || []).push(arguments)
}
window.YourWidget("init", {
widgetKey: "YOUR_WIDGET_KEY",
openOnInit: true,
appearance: {
title: "Support",
position: "bottom-left",
launcher: {
label: "Need help?",
},
},
})
</script>
<script async src="https://app.codeer.ai/web-widget/widget.js"></script>
這裡的重點是:
- 第一段 script 先把初始化指令排進 queue
- 第二段 script 載入真正的
widget.js widget.js載入完成後,才會執行先前排進 queue 的init- 如果在
widget.js準備好之前排了多次init,請只把最後一次視為真正生效的 widget 狀態與 callback 設定
不確定用哪一種時,先用 Auto-init
如果你不需要執行時覆寫,也不需要 host page 主動控制 widget,先從 Auto-init 開始最穩。
若需要覆蓋設定,該怎麼做
widget.js 可以在 init(...) 時覆寫部分已發布設定。這很適合拿來做 preview、A/B 測試或暫時調整 host page 上的顯示。
常見可覆寫欄位
configUrl- 改用另一份
config.json
- 改用另一份
apiRoot- 改用另一個 API 入口
agentId- 暫時改綁另一個 Agent
externalUserId- 指定 guest / session manager 使用的外部識別
appearance.titleappearance.positionappearance.launcher.labelappearance.launcher.iconUrlappearance.theme.baseColorappearance.offset.xappearance.offset.yappearance.zIndexthemeModeopenOnInit
覆寫範例
<script>
window.YourWidget =
window.YourWidget ||
function () {
(window.YourWidget.q = window.YourWidget.q || []).push(arguments)
}
window.YourWidget("init", {
widgetKey: "support-widget",
configUrl: "https://cdn.example.com/widget-preview/config.json",
apiRoot: "https://api.preview.example.com",
openOnInit: true,
themeMode: "light",
appearance: {
title: "VIP Support",
position: "bottom-left",
launcher: {
label: "Chat with us",
iconUrl: "https://cdn.example.com/widget-icon.png",
},
theme: {
baseColor: "green",
},
offset: {
x: 24,
y: 24,
},
zIndex: 9999,
},
})
</script>
<script async src="https://app.codeer.ai/web-widget/widget.js"></script>
覆寫優先順序
當同一個欄位同時出現在多個地方時,優先順序是:
init(...)裡的執行時覆寫- 已發布的
config.json - widget 的預設值
覆寫時要注意
appearance.offset與appearance.zIndex只能在init(...)裡設定,不能在 Console 發布themeMode是執行時選項,不屬於appearanceconfigUrl可用絕對或相對 URL;相對 URL 會以 host page 的 origin 為基準解析- 如果你要維持最穩定的正式環境設定,盡量把長期要用的值留在已發布 channel,執行時覆寫只留給少數必要情境
widget.js 可用 API 與範例
widget.js 載入後,host page 可以使用這些 API:
window.YourWidget.init(...)window.YourWidget.open()window.YourWidget.close()window.YourWidget.destroy()window.YourWidget.loginUser(...)window.YourWidget.logoutUser()
init(...)
初始化 widget。最少需要:
widgetKey
也可以加上:
configUrlapiRootagentIdexternalUserIdappearancethemeModeopenOnInitonConfigLoadedonErroronStateChange
範例:
<script>
async function startWidget() {
await window.YourWidget.init({
widgetKey: "support-widget",
openOnInit: true,
onConfigLoaded: (config) => {
console.log("Widget config loaded", config)
},
onError: (error) => {
console.error("Widget error", error)
},
onStateChange: (snapshot) => {
console.log("Widget state", snapshot)
},
})
}
</script>
重要行為:
- widget runtime 是 singleton,所以重複呼叫
init(...)不會建立多個 widget instance,而是重新設定同一個 instance - 如果多次
init(...)同時重疊,只有最後一次會決定 widget state,以及onConfigLoaded、onError這類 callback - 較早發出的
init(...)promise 之後仍可能 resolve,但不會再把舊狀態套回來,也不會觸發過期 callback
open() / close()
如果你想用自己的按鈕控制 widget 開關,可以直接呼叫:
<button onclick="window.YourWidget.open()">Open support</button>
<button onclick="window.YourWidget.close()">Close support</button>
destroy()
如果你的 host app 是單頁應用程式,頁面切換時不想保留既有 widget instance,可以呼叫:
<script>
window.YourWidget.destroy()
</script>
這通常用在:
- host app route 切換後要重新初始化
- widget 不應留在某些頁面
- 你要完全換掉目前的 widget 設定
User login 與注意事項
widget 的登入不是內建登入頁,而是由 host app 主動把已登入身分帶進 widget。
三種模式
Disabled- widget 不接受 host app 帶入登入身分
- 使用者只能以 guest 模式開始
Assertion- 正式環境建議模式
- host backend 產生短效 assertion,widget 再用它換取登入狀態
Direct User ID- host app 直接傳
externalUserId - 安全性較低,只建議在可信任環境使用
- host app 直接傳
Assertion 模式範例
Assertion 模式下,host app 應呼叫:
<script>
async function loginWidgetUser() {
await window.YourWidget.loginUser({
getAssertion: async () => {
const response = await fetch("/api/widget/assertion", {
method: "POST",
credentials: "include",
})
const data = await response.json()
return data.assertion
},
})
}
</script>
這裡的重點是:
- assertion 要由 host backend 產生,不是前端直接簽
- widget channel 的 shared secret 要存放在你自己的 backend
- 前端的
getAssertion只負責呼叫你自己的 backend API,拿回 assertion 後交給 widget - shared secret 不能放進瀏覽器程式碼
- 每次需要 assertion 時,都應該重新向 backend 取得新的短效值
Assertion 模式真正要做的事
如果你使用 Assertion,實際流程是這樣:
- 在 Codeer 的 widget channel 產生 shared secret
- 把這組 shared secret 存進你自己的 backend
- 在你自己的 backend 實作一個「產生 assertion」的 API
- host page 的
getAssertion()呼叫這個 API - backend 用 shared secret 簽出 assertion,回傳給前端
- 前端把 assertion 交給
window.YourWidget.loginUser(...)
也就是說:
- Codeer 負責驗證 assertion
- 你自己的 backend 負責保管 shared secret 並簽發 assertion
- 前端永遠不應該直接知道 shared secret
Assertion 的規則
Assertion 必須是用 HS256 簽出的短效 JWT,payload 至少要包含:
sub- 要登入 widget 的使用者識別
channel_slug- 必須和 widget channel 的 slug 完全一致
iat- 簽發時間,Unix seconds
exp- 到期時間,Unix seconds
jti- 每次都要是新的唯一值,用來避免重放
如果你的 channel 有設定 issuer,也要加上:
iss
另外要注意:
exp必須晚於iatexp最多只能比iat晚 5 分鐘- 每次簽發都要重新產生新的
jti - 如果你改了 widget channel 的 slug,
channel_slug也要一起改
Assertion payload 範例
{
"sub": "member@example.com",
"channel_slug": "support-widget",
"iat": 1760000000,
"exp": 1760000240,
"jti": "8f4d0e6d-2a63-4d17-a5a3-2be6f4a34862",
"iss": "https://customer.example.com"
}
Node.js 範例:在 backend 產生 assertion
下面這個範例示範如何在你自己的 Node.js backend 產生 assertion:
import crypto from "node:crypto"
import jwt from "jsonwebtoken"
export function buildWidgetAssertion({
externalUserId,
channelSlug,
sharedSecret,
issuer,
}) {
const now = Math.floor(Date.now() / 1000)
const payload = {
sub: externalUserId,
channel_slug: channelSlug,
iat: now,
exp: now + 240,
jti: crypto.randomUUID(),
}
if (issuer) {
payload.iss = issuer
}
return jwt.sign(payload, sharedSecret, {
algorithm: "HS256",
})
}
如果你要做成 backend API,回傳格式可以像這樣:
app.post("/api/widget/assertion", async (req, res) => {
const signedInUser = req.user
const assertion = buildWidgetAssertion({
externalUserId: signedInUser.email,
channelSlug: "support-widget",
sharedSecret: process.env.WIDGET_SHARED_SECRET,
issuer: "https://customer.example.com",
})
res.json({ assertion })
})
前端再透過 getAssertion() 去呼叫這個 API:
<script>
async function loginWidgetUser() {
await window.YourWidget.loginUser({
getAssertion: async () => {
const response = await fetch("/api/widget/assertion", {
method: "POST",
credentials: "include",
})
const data = await response.json()
return data.assertion
},
})
}
</script>
Assertion 模式的注意事項
- shared secret 只應存在你自己的 backend
- 不要把 shared secret 放進前端 bundle、HTML 或 public config
- 不要重複使用舊 assertion
channel_slug必須與目前 widget channel slug 相同- 若有設定
issuer,簽發時也要帶入相同的iss - 正式環境優先使用
Assertion,不要先用Direct User ID取代
Direct User ID 模式範例
只有在 channel 已明確設定 Direct User ID 時,才應該這樣用:
<script>
async function loginWidgetUser() {
await window.YourWidget.loginUser({
externalUserId: "member@example.com",
})
}
</script>
登出範例
<script>
async function logoutWidgetUser() {
await window.YourWidget.logoutUser()
}
</script>
登入相關注意事項
Assertion是正式環境建議做法Direct User ID屬於低安全性模式,因為 host page 上執行的 JavaScript 都可能冒用別人的externalUserId- 使用
Direct User ID前,應先設定Configured origins only,並至少填入一個 allowed origin widget.js不會幫你產生 assertion;這一定要由你的 backend 處理- 若 channel 設定的是
Assertion,就必須呼叫loginUser({ getAssertion }) - 若 channel 設定的是
Direct User ID,就必須呼叫loginUser({ externalUserId }) logoutUser()之後,如果你要讓使用者回到 guest 流程,最穩定的做法是關閉再重新打開 widget- 如果 guest session 在 widget 開啟期間過期,使用者通常需要關閉再重新打開 widget 才能繼續
上線前建議再確認一次
- host page 上貼的是
Overview複製出的最新 snippet widgetKey和目前 channel slug 一致- 如果你改過 slug,embed snippet 與登入流程都已同步更新
- 正式環境優先使用
Assertion allowed origins沒有漏掉實際會載入 widget 的網站- 先由內部成員或少數 stakeholder 實測一輪再擴大
受控 rollout 的做法
如果這是第一次 live rollout,先把入口收窄。
- 一開始先只把一個 staging 或內部站點加入
allowed origins - 先分享給少數 stakeholder,再決定是否擴大
- 如果還需要 workspace 層級的限制,搭配 Audience Access
- 除非你有非常明確的理由,否則不要在大範圍公開 rollout 時使用
Direct User ID