跳轉到

Widget Channel

當你希望使用者直接在你自己的網站或產品裡和 Agent 對話,而不是跳到一個獨立的託管頁面時,就用 widget channel。

這一頁主要講的是可嵌入的 Widget 流程。如果你要用 Codeer 提供的託管頁面,請改看 Web Channel。如果你要連前端與後端都自己掌管,請改看 API Access

Consultation Desk 來說,最穩的 rollout 順序通常是:

  1. 先發布 Agent 版本
  2. 建立或更新 widget channel
  3. 設定 widget 的基本內容與存取規則
  4. 發布 widget channel
  5. 把 embed snippet 貼到 host page
  6. 自己先在 live 頁面跑完一輪測試再對外分享

開始前要先有什麼

  • 一個至少發布過一次的 Agent
  • workspace 的 channel 編輯權限
  • 一個可放入 embed snippet 的網站或產品頁面
  • 如果你要讓已登入使用者直接帶著身分進入 widget,團隊內要有人可以調整 host backend

完整設定流程

  1. 先發布 Agent

    • Editor 打開目標 Agent,按下 Publish
    • 發布 widget channel 並不會幫你一起發布 Agent。
    • 如果 Agent 已發布版本裡有 suggested questions,widget 發布後會直接沿用它們作為面板預設問題。
  2. Channels 建立 widget channel

    • 打開 Channels
    • New Channel
    • 選擇 Widget
    • 先填好穩定的 NameSlug

    把 slug 當成嵌入合約的一部分

    對 widget channel 來說,widgetKey 就是 channel slug。之後如果修改 slug,不只要重新 publish,也要同步更新所有已貼出的 embed snippet,以及任何依賴舊 slug 的 signed login 流程。

  3. 設定 widget 的基本內容

    先檢查這幾個欄位:

    • Agent:選擇這個 widget 要綁定的已發布 Agent
    • Launcher LabelLauncher Icon:決定頁面上關閉狀態的入口長什麼樣子
    • Widget Title:顯示在面板標題列
    • Widget Language:選擇 English繁體中文日本語
    • Widget Position:通常會放在 bottom-rightbottom-left
    • Greeting:使用者打開面板後先看到的問候語

    Suggested Questions 不是在 widget 設定裡另外編輯的。它會直接沿用你目前綁定的已發布 Agent 版本。

  4. 設定存取與安全性

    這裡其實要分成兩個決策:

    • 匿名存取
      • 如果訪客應該能直接以 guest 身分開始對話,就打開它。
      • 只有在你的 host page 一定會帶入已登入身分時,才把它關掉。
    • 來源站限制
      • 如果 widget 只該在特定網站上運作,就選 Configured origins only
      • 每列填一個精確 origin,例如 https://support.example.com
      • 這裡不要填頁面路徑,只填 origin。

    如果你要讓 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,而且離開頁面後也不會再次顯示完整內容。

  5. 發布 widget channel,並複製 embed snippet

    • 設定完成後,再發布 widget channel。
    • Overview 複製系統產生的 embed snippet。
    • Auto-init 適合大多數情況,直接沿用已發布設定。
    • Manual init 只留給 host page 需要在 script 載入前覆寫執行時設定的情境。
  6. 先在真實頁面自己測一輪

    正式分享前,先打開真正的 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.title
  • appearance.position
  • appearance.launcher.label
  • appearance.launcher.iconUrl
  • appearance.theme.baseColor
  • appearance.offset.x
  • appearance.offset.y
  • appearance.zIndex
  • themeMode
  • openOnInit

覆寫範例

<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>

覆寫優先順序

當同一個欄位同時出現在多個地方時,優先順序是:

  1. init(...) 裡的執行時覆寫
  2. 已發布的 config.json
  3. widget 的預設值

覆寫時要注意

  • appearance.offsetappearance.zIndex 只能在 init(...) 裡設定,不能在 Console 發布
  • themeMode 是執行時選項,不屬於 appearance
  • configUrl 可用絕對或相對 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

也可以加上:

  • configUrl
  • apiRoot
  • agentId
  • externalUserId
  • appearance
  • themeMode
  • openOnInit
  • onConfigLoaded
  • onError
  • onStateChange

範例:

<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,以及 onConfigLoadedonError 這類 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
    • 安全性較低,只建議在可信任環境使用

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,實際流程是這樣:

  1. 在 Codeer 的 widget channel 產生 shared secret
  2. 把這組 shared secret 存進你自己的 backend
  3. 在你自己的 backend 實作一個「產生 assertion」的 API
  4. host page 的 getAssertion() 呼叫這個 API
  5. backend 用 shared secret 簽出 assertion,回傳給前端
  6. 前端把 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 必須晚於 iat
  • exp 最多只能比 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

相關指南