Appearance
开发一个扩展页面
让你的业务页面嵌进客服工作台,自动知道客服当前在接待谁。
这能让你做什么:把订单系统、工单系统嵌入对话右栏,页面自动拿到当前客户的身份,客服切换对话时自动更新。页面还可以把查到的内容回填到客服输入框,或直接以客服身份发送文本与订单、物流等卡片。
做出来是什么样

右栏就是你的页面,跑在一个 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 加载完成时立刻推第一条,监听器注册晚了这条就丢了,只能等下次切换对话才补推。
完整写法见完整骨架。
上下文字段
| 字段 | 类型 | 说明 |
|---|---|---|
conversationId | string | 当前对话 ID,发命令时要带上它 |
channelId | string | 对话所属渠道 ID |
channelPlatform | string | 渠道平台类型,如 web / link / app |
capabilities.cards | boolean | 本渠道能否发卡片 |
capabilities.cardTypes | string[] | 本渠道实际可用的卡型,cards 为 false 时是空数组 |
agent.id | string | null | 当前客服 ID |
agent.name | string | null | 当前客服名称 |
customer.id | string | null | 合从的客户 ID |
customer.name | string | null | 客户展示名 |
customer.phone | string | null | 手机号 |
customer.email | string | null | 邮箱 |
customer.customerNo | string | null | 合从的客户编号 |
customer.externalUserId | string | 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 订单卡
| 字段 | 类型 | 说明 |
|---|---|---|
orderId | string | 订单号,1–128 字符 |
title | string | 卡片标题,≤200 |
total | Money | 订单总额 |
status | enum | pending / paid / shipped / delivered / refunded / cancelled |
createdAt | number | 下单时间,毫秒时间戳 |
items | array | 商品行,≤30 条,每条 { name, quantity, price, imageUrl? } |
detailUrl? | string | 订单详情链接 |
product 商品卡
| 字段 | 类型 | 说明 |
|---|---|---|
title | string | 商品名,≤200 |
productId? | string | 商品 ID |
imageUrl? | string | 商品图外链 |
price? | Money | 现价 |
originalPrice? | Money | 原价(划线价) |
description? | string | 描述,≤2000 |
detailUrl? | string | 商品详情链接 |
layout? | enum | compact 紧凑 / large 大图 |
article 文章卡
| 字段 | 类型 | 说明 |
|---|---|---|
title | string | 标题,≤200 |
description | string | 摘要,≤2000 |
url | string | 文章链接 |
imageUrl? | string | 封面图外链 |
source? | string | 来源站点名,≤100 |
shipment 物流卡
| 字段 | 类型 | 说明 |
|---|---|---|
trackingNo | string | 运单号,1–128 字符 |
carrier | string | 承运商,≤100 |
status | enum | picked_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_command | type 不是 insertText / sendMessage | 改代码 |
text_invalid | text 缺失、不是字符串、或去掉空白后为空 | 改代码 |
text_too_long | text 超过 4000 字符 | 改代码,自己先截断 |
conversation_id_invalid | conversationId 传了但不是字符串 | 改代码 |
blocks_invalid | blocks 不是数组、为空数组、或超过 50 项 | 改代码 |
block_kind_not_supported | kind 不是 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校验不要省略。
下一步
- 把自己的业务系统嵌进对话工作台 —— 管理员侧的配置步骤
- 传入已登录用户的身份 —— 让
externalUserId有值