Skip to content

开发一个扩展页面 ​

让你的业务页面嵌进客服工作台,自动知道客服当前在接待谁。

这能让你做什么:把订单系统、工单系统嵌入对话右栏,页面自动拿到当前客户的身份,客服切换对话时自动更新。页面还可以把查到的内容回填到客服输入框,或直接以客服身份发送文本与订单、物流等卡片。

做出来是什么样 ​

对话右栏运行中的扩展页面,自动显示当前客户信息,并提供回填与发送两个操作

右栏就是你的页面,跑在一个 iframe 里。图中的客户信息是工作台推送过来的,客服切换对话时会重新推、页面不刷新。两个按钮分别演示了回填输入框和直接发消息。

图里是官方示例页,本文的协议它全都实现了一遍,可以直接查看源码对照。

开始之前 ​

这几条不满足,页面会加载不出来或收不到消息,先确认再动手写代码。

页面本身 ​

  • 必须是公网可访问的 https 域名。http 和 localhost 都填不进工作台。
  • 页面要始终停留在这个域名下。工作台按你填写网址的 origin 校验消息,跳到别的域名后会被静默拒收。登录跳转要保证最终跳回来。
  • 不需要引入任何 SDK,通信基于浏览器的 postMessage。

服务器需要配什么 ​

允许被工作台嵌入,否则浏览器直接拦掉:

Content-Security-Policy: frame-ancestors https://workpro.aihecong.com

同时移除 X-Frame-Options: DENY 或 SAMEORIGIN。

被拦掉时不会有友好提示

浏览器因为 CSP 或 X-Frame-Options 拒绝嵌入时,多数情况下仍然会触发 iframe 的 load 事件,工作台无法可靠识别这种失败。客服看到的会是浏览器自己的错误页,而不是「加载失败 + 重试」的界面。

上线前务必实际嵌一次验证,别只测网址能不能直接打开。

登录态要单独处理 ​

页面运行在第三方 iframe 里(你的域名嵌在工作台域名下),浏览器对第三方 Cookie 有隔离策略:Chrome 的存储分区会给这个 iframe 一份独立的 Cookie 和 localStorage,Safari 和 Firefox 默认拦截第三方 Cookie。客服在新标签页里登录过你的系统,不代表 iframe 里也是登录态。

可行的方向包括让 Cookie 带上 SameSite=None; Secure,或者在页面内做一次轻量登录。注意上面那条 origin 约束,跳到别的域名做 SSO 再跳回来会让页面失去通信能力。

本地怎么调试 ​

工作台只接受 https 公网地址,本地起的 http://localhost:3000 填不进去。两种做法:

  • 用内网穿透工具把本地端口映射成临时 https 域名(cloudflared、ngrok 等),填进工作台。改代码即时生效,适合联调。临时域名重启后会变,变了要回工作台同步改,否则消息会被丢弃。
  • 部署到测试域名,单独加一条扩展页面指向它,验证完删掉。

页面不嵌在工作台里直接打开时 window.parent === window,此时收不到任何上下文,可以据此显示一句"请在合从工作台中打开"。

工作方式 ​

工作台把你的页面放在一个 iframe 里。iframe 常驻,客服切换对话时不会重新加载,工作台只是重新推一次上下文。所以页面要把"更新数据"写在消息回调里,不能依赖页面加载时机。

协议只有两个方向:

  • 工作台 → 你的页面:推送 context(上下文)和 ack(命令回执)
  • 你的页面 → 工作台:发送 insertText 和 sendMessage 两种命令

所有消息都带 channel: "hecong-extension",收到时先校验它,避免和页面上其他 postMessage 通信串台。出向消息还带 version,当前恒为 1,只有破坏性变更才会 +1。

同一时刻只有一个扩展页面在工作 ​

一个团队可以配置多个扩展页面(订单系统、工单系统等),但客服的右栏一次只显示一个。只有当前显示的那个页面能收到上下文,也只有它发的命令会被受理。

客服切到别的扩展页面后,你的页面收不到新的 context,发出的命令被静默丢弃;切回来时 iframe 重新加载,上下文重新推一遍。所以不要设计"后台持续同步"的逻辑。

第一步:接收当前对话上下文 ​

页面加载完成后工作台推一次,之后每次切换对话或客户资料变化时再推。

监听器必须在脚本最外层同步注册。 协议没有握手环节,页面也无法主动索要上下文:工作台在 iframe 加载完成时立刻推第一条,监听器注册晚了这条就丢了,只能等下次切换对话才补推。

完整写法见完整骨架。

上下文字段 ​

字段类型说明
conversationIdstring当前对话 ID,发命令时要带上它
channelIdstring对话所属渠道 ID
channelPlatformstring渠道平台类型,如 web / link / app
capabilities.cardsboolean本渠道能否发卡片
capabilities.cardTypesstring[]本渠道实际可用的卡型,cards 为 false 时是空数组
agent.idstring | null当前客服 ID
agent.namestring | null当前客服名称
customer.idstring | null合从的客户 ID
customer.namestring | null客户展示名
customer.phonestring | null手机号
customer.emailstring | null邮箱
customer.customerNostring | null合从的客户编号
customer.externalUserIdstring | null你自己系统里的用户 ID

第一条 context 里 phone、email、customerNo 必为 null —— 这三项来自客户详情,详情加载完成后会补推第二条。不要用第一条判断"这个客户没留手机号",会稳定误判。

上下文本来就会重复推送(切换对话、客户资料更新、客服身份变化都会触发),每收到一条就重新渲染,把处理逻辑写成幂等的。

三个 ID 别用错 ​

字段是谁的拿来做什么
id合从的内部主键,一般用不到
customerNo合从的按渠道分配的递增编号,与你的系统没有任何对应关系,不能用来查数据,仅供客服口头称呼「#1803 这位客户」
externalUserId你的来自你调用的 hc.identify({ id: 'vip_10086' }),查订单、查会员的唯一依据;为 null 表示访客未登录,属正常情况

要让 externalUserId 有值,需要你的网站在加载聊天窗时传入用户身份,见传入已登录用户的身份。

先看 capabilities,再决定能做什么 ​

不同渠道能承载的消息形态不一样,能不能发卡片、能发哪些卡型一律以 capabilities 为准,不要按渠道名写死。

客服从一个渠道的对话切到另一个渠道时 capabilities 会变,所以每次收到 context 都要重读,只在第一条里读一次并缓存,切换后就会拿着过期的能力去发消息。

第二步:操作当前对话 ​

两种命令,都是向父窗口 postMessage:

命令作用频率限制
insertText把文本追加到客服输入框末尾,不发送无
sendMessage以当前客服身份向目标对话发送消息1 秒 1 条,且 10 秒 5 条
js
window.parent.postMessage({
  channel: 'hecong-extension',
  type: 'sendMessage',
  conversationId: currentContext.conversationId,   // 见下,强烈建议必传
  requestId: 'req-1',                              // 可选,回执会原样带回
  text: '您的订单预计明天送达。',
}, WORKSPACE_ORIGIN)

requestId 由你自己生成,工作台不校验内容,只在回执里原样带回;省略时回执中的 requestId 为 null。

conversationId 强烈建议必传 ​

不传会把 A 客户的信息发给 B

页面查订单往往要几百毫秒,这期间客服完全可能切到另一个客户。如果命令不带 conversationId,查询结果回来时会发进客服当时正打开的那个对话 —— A 客户的订单地址、手机号就发给了 B。

传了 conversationId,工作台发现与当前对话不一致时会拒绝执行并回 conversation_changed,你可以据此提示客服或丢弃这次结果。

取值用最近一条 context 里的 conversationId。

纯文本能发什么 ​

不带 blocks 时发的是纯文本,长度上限 4000 字符(按 JavaScript 的 text.length 计算,中文一个字算 1,多数 emoji 算 2)。

内容支持说明
换行、空格缩进支持原样保留,不会被折叠
Emoji支持按普通字符处理
链接、邮箱、电话自动识别网址可点,邮箱电话可复制或拨打
Markdown、HTML不解析原样显示为文字
图片、文件不支持需要发这类内容,让客服在输入框手动发送
引用回复某条消息不支持—

insertText 同样是纯文本、同样 4000 字符上限,且不能带卡片:回填的是客服输入框,卡片没法被编辑。

发送卡片 ​

卡片比一段文字清楚得多。下面是订单卡和物流卡在客服端的实际效果:

客服对话中的订单卡片与物流卡片,订单卡含状态徽章、商品行与合计金额,物流卡含运单号与轨迹时间轴

订单卡自动渲染出状态徽章、商品行和合计金额,物流卡把 timeline 渲染成轨迹时间轴,都是传字段进去自动生成的,不用自己拼样式。

sendMessage 加上 blocks 就是发卡片,text 这时作为摘要行,显示在会话列表和消息通知里。

js
send('sendMessage', {
  text: '您的订单 SO-1001 已发货',        // 摘要,会话列表里显示这行
  blocks: [{
    kind: 'card',
    card: {
      cardType: 'order',
      orderId: 'SO-1001',
      title: '订单 SO-1001',
      total: { amount: 29900, currency: 'CNY' },   // 299.00 元
      status: 'shipped',
      createdAt: 1753939200000,
      items: [
        { name: '示例商品 A', quantity: 1, price: { amount: 29900, currency: 'CNY' } }
      ],
      detailUrl: 'https://shop.example.com/orders/SO-1001'
    }
  }]
})

blocks 是数组,最多 50 项。每项的结构:

js
{
  kind: 'card',
  card: { cardType: '...', /* 按卡型填字段,见下 */ },
  actions: [                                  // 可选,卡片底部按钮
    { actionId: '...', label: '查看详情', style: 'primary' }
  ]
}

kind 只开放 text / card / carousel。传图片、视频、文件会被拒绝并回 block_kind_not_supported,需要发这类内容让客服手动发。

金额与时间的写法:金额一律 { amount, currency },amount 是最小货币单位的整数 —— 299 元写 { amount: 29900, currency: 'CNY' },0.99 美元写 { amount: 99, currency: 'USD' },不要传浮点数。时间一律毫秒时间戳。

四种卡片的字段 ​

扩展页面开放 order / product / article / shipment 四种。这是上限,具体到某个渠道还要看 capabilities.cardTypes,实际可用的是两者的交集。传其他 cardType 会被拒绝并回 card_type_not_supported。

标 ? 的是可选字段。

order 订单卡

字段类型说明
orderIdstring订单号,1–128 字符
titlestring卡片标题,≤200
totalMoney订单总额
statusenumpending / paid / shipped / delivered / refunded / cancelled
createdAtnumber下单时间,毫秒时间戳
itemsarray商品行,≤30 条,每条 { name, quantity, price, imageUrl? }
detailUrl?string订单详情链接

product 商品卡

字段类型说明
titlestring商品名,≤200
productId?string商品 ID
imageUrl?string商品图外链
price?Money现价
originalPrice?Money原价(划线价)
description?string描述,≤2000
detailUrl?string商品详情链接
layout?enumcompact 紧凑 / large 大图

article 文章卡

字段类型说明
titlestring标题,≤200
descriptionstring摘要,≤2000
urlstring文章链接
imageUrl?string封面图外链
source?string来源站点名,≤100

shipment 物流卡

字段类型说明
trackingNostring运单号,1–128 字符
carrierstring承运商,≤100
statusenumpicked_up / in_transit / out_for_delivery / delivered
currentLocation?string当前位置,≤100
eta?number预计送达,毫秒时间戳
timeline?array轨迹节点,≤50 条,每条 { time, location, description }

字段不符合上面的定义时会被拒绝并回 send_rejected,message 里会说明是哪个字段的问题。

网页接入的聊天窗发卡片是访客侧的能力,字段定义和限制都不一样,不要照搬那边的表。

第三步:处理回执 ​

每条命令工作台都会回一条 ack:

jsonc
{
  channel: 'hecong-extension',
  version: 1,
  type: 'ack',
  requestId: 'req-1',          // 你传的那个,没传则为 null
  ok: false,
  conversationId: '...',       // 命令实际作用在哪个对话,可据此核对没发错人
  reason: 'text_too_long',     // 失败原因码,成功时没有这个字段
  message: 'text 长度 4200,超出上限 4000',   // 人话说明,排障先看它
  serverErrorCode: '...'       // 服务端返回的原始错误码,报障时提供给合从
}

排障先看 message —— 它带具体数值和期望值。reason 是机器码,用来写分支判断。

错误码 ​

reason含义你该怎么办
unknown_commandtype 不是 insertText / sendMessage改代码
text_invalidtext 缺失、不是字符串、或去掉空白后为空改代码
text_too_longtext 超过 4000 字符改代码,自己先截断
conversation_id_invalidconversationId 传了但不是字符串改代码
blocks_invalidblocks 不是数组、为空数组、或超过 50 项改代码
block_kind_not_supportedkind 不是 text / card / carousel改代码
card_type_not_supported卡型不在上面四种之内换成开放的四种卡型
cards_not_supported_on_channel当前对话所在渠道不支持卡片降级发纯文本;发之前先看 capabilities.cards
no_conversation工作台当前没打开任何对话等收到 context 再发
conversation_changed目标对话与当前不一致,客服已经切走用最新的 context 重新发,或丢弃这次结果
rate_limited触发频控降低发送频率,或改用不受频控的 insertText
send_rejected服务端拒绝,看 serverErrorCode 和 message按返回的原因处理
network_error请求没送达服务器提示客服稍后重试

失败了怎么办 ​

命令失败后工作台不会自动重发,是否重试由你决定。其中 unknown_command、text_invalid、text_too_long、conversation_id_invalid、blocks_invalid、block_kind_not_supported、card_type_not_supported 属于参数写错,重试无效;cards_not_supported_on_channel 提前读 capabilities.cards 就能避免。

有三种情况收不到任何回执:命令的 type 字段完全缺失、消息里漏了 channel 字段、页面 origin 与配置网址不一致。这三种会被静默丢弃,表现是"什么都没发生,也没有报错",排查时先确认这三项。

频率限制 ​

sendMessage 是 1 秒 1 条且 10 秒 5 条,计数器在当前工作台标签页内共享:不按对话分开算,也不按扩展页面分开算,客服快速切换对话连续调用同样会触发。insertText 不受频控。

完整骨架 ​

把 WORKSPACE_ORIGIN 和业务逻辑换成你自己的,这份可以直接跑:

js
const CHANNEL = 'hecong-extension'
const WORKSPACE_ORIGIN = 'https://workpro.aihecong.com'

let currentContext = null
let caps = { cards: false, cardTypes: [] }
let seq = 0

// 监听器必须同步注册,晚了会丢掉首次推送
window.addEventListener('message', (e) => {
  if (e.origin !== WORKSPACE_ORIGIN || e.source !== window.parent) return
  const msg = e.data || {}
  if (msg.channel !== CHANNEL) return

  if (msg.type === 'context') {
    currentContext = msg.data
    caps = msg.data.capabilities      // 每次都要重读,切对话时渠道变、能力跟着变
    document.getElementById('send-card-btn').hidden = !caps.cards
    render(msg.data)                  // 每次收到都重新渲染,写成幂等的
  } else if (msg.type === 'ack') {
    if (!msg.ok) console.warn('命令失败', msg.requestId, msg.reason, msg.message)
  }
})

function send(type, payload) {
  if (!currentContext) return          // 还没收到上下文,发了也会被拒
  const requestId = `req-${++seq}`
  window.parent.postMessage({
    channel: CHANNEL,
    type,
    conversationId: currentContext.conversationId,   // 防止客服切走后发错人
    requestId,
    ...payload,
  }, WORKSPACE_ORIGIN)
  return requestId
}

function render(ctx) {
  // 首次推送时 phone / email 必为 null,随后会补推第二条 —— 这是正常路径
  const userId = ctx.customer.externalUserId
  if (!userId) return showEmpty('该访客尚未登录,无法关联账号')
  fetchOrders(userId).then(showOrders)
}

// 发卡片前先确认这个渠道支持
function sendOrderCard(card, summary) {
  if (!caps.cardTypes.includes('order')) {
    return send('sendMessage', { text: summary })      // 降级发纯文本
  }
  send('sendMessage', { text: summary, blocks: [{ kind: 'card', card }] })
}

完整示例 ​

官方示例页本身就是完整源码:

https://workpro.aihecong.com/extension-page-demo.html

管理员可以在 设置 > 扩展页面 的空白提示里选择 添加示例页面 一键添加,打开任意对话就能看到本文开头那张图的效果。用浏览器查看源代码可以看到本文所有协议的最小实现 —— 它是双语的,页头注释就是协议说明,页内实时显示收到的原始 JSON,还带一个事件日志面板,对照调试很方便。

常见问题 ​

切换对话后页面数据没更新 ​

症状:客服切到另一个对话,扩展页面还显示上一个客户的数据。

成因:iframe 常驻不重新加载,页面把渲染逻辑写在了加载时执行的代码里。

解决:把渲染逻辑放进 context 消息的回调,每次收到都重新渲染。

页面加载出来了,但一直收不到 context ​

症状:iframe 正常显示,message 事件回调没有被触发过。

成因:监听器注册得太晚,错过了工作台在 iframe 加载完成时推送的那一次;或者 origin 校验条件写错,把工作台的消息拦掉了。

解决:把 addEventListener 放在脚本最外层同步执行。先把校验条件临时放宽,打印出 e.origin 和 e.data 确认实际收到了什么。

改了网址,客服那边还是旧页面 ​

症状:管理员在设置里改了扩展页面的网址,客服刷新对话仍然打开旧地址。

成因:客服端的配置在打开面板时读取,改完不会自动刷新。

解决:让客服把右栏切到 客户信息 再切回 扩展页面。

参考 ​

TypeScript 类型定义 ​

ts
const CHANNEL = 'hecong-extension' as const

/** 金额:amount 是最小货币单位整数(分) */
interface Money {
  amount: number
  currency: string
}

type ExtensionCardType = 'order' | 'product' | 'article' | 'shipment'

interface ExtensionCapabilities {
  cards: boolean
  cardTypes: ExtensionCardType[]
}

interface ExtensionContextData {
  conversationId: string
  channelId: string
  channelPlatform: string
  capabilities: ExtensionCapabilities
  agent: {
    id: string | null
    name: string | null
  }
  customer: {
    id: string | null
    name: string | null
    phone: string | null
    email: string | null
    /** 合从内部编号,仅供客服口头称呼 */
    customerNo: string | null
    /** 你自己系统里的用户 ID,查业务数据用这个 */
    externalUserId: string | null
  }
}

type AckReason =
  // 参数写错了
  | 'unknown_command' | 'text_invalid' | 'text_too_long'
  | 'conversation_id_invalid' | 'blocks_invalid'
  | 'block_kind_not_supported' | 'card_type_not_supported'
  | 'cards_not_supported_on_channel'
  // 当下环境不满足
  | 'no_conversation' | 'conversation_changed' | 'rate_limited'
  // 服务端拒绝或网络问题
  | 'send_rejected' | 'network_error'

/** 工作台 → 页面 */
type InboundMessage =
  | { channel: typeof CHANNEL; version: 1; type: 'context'; data: ExtensionContextData }
  | {
      channel: typeof CHANNEL
      version: 1
      type: 'ack'
      requestId: string | null
      ok: boolean
      conversationId: string | null
      reason?: AckReason
      message?: string
      serverErrorCode?: string
    }

/** 页面 → 工作台 */
type OutboundMessage =
  | { channel: typeof CHANNEL; type: 'insertText'; text: string; conversationId?: string; requestId?: string }
  | {
      channel: typeof CHANNEL
      type: 'sendMessage'
      text: string
      blocks?: unknown[]        // 卡片,结构见「四种卡片的字段」
      conversationId?: string
      requestId?: string
    }

iframe 运行环境 ​

页面运行在带 sandbox 属性的 iframe 中:

allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads allow-modals
  • 可以执行脚本、读写自己域名下的存储、提交表单、打开新窗口、触发下载。
  • alert()、confirm()、window.print() 可以正常使用。
  • 不能跳转顶层窗口(没有 allow-top-navigation),target="_top" 的链接不会生效,需要跳出去用新窗口打开。
  • 没有设置 allow 属性,摄像头、麦克风、地理位置等能力拿不到。

安全责任边界 ​

合从不会给你的页面下发任何凭证,也不会代理你的页面内容。页面由浏览器直接加载,上下文通过浏览器消息机制推送。

协议不做签名,也不向你的服务端提供来源证明。请假设页面 URL 是可能泄露的,敏感数据必须靠你自己的登录态保护,不要仅凭"能打开这个页面"就放行数据。

两端都要校验来源:

  • 工作台会校验你:只接受来自你那个 iframe、且 origin 等于你填写网址的消息;推给你的消息也用精确的 targetOrigin,不用 *。
  • 你要校验工作台:确认 event.origin 是工作台域名,拒绝其他来源。骨架代码里的 WORKSPACE_ORIGIN 校验不要省略。

下一步 ​