本地 RAG 知识库
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

28 KiB

Chat SDK 第三方系统集成指南

本文档说明第三方系统如何接入 AI 智能客服的 Chat SDK,完成从创建 API Key 到嵌入对话组件的全流程。


目录

  1. 集成架构总览
  2. 第一步:创建 API Key
  3. 第二步:绑定客服角色(可选)
  4. 第三步:后端换取 SDK Token
  5. 第四步:前端嵌入 Chat SDK
  6. API 接口参考
  7. SDK 配置参数参考
  8. 错误码与排查
  9. 安全建议
  10. 验证 Token 自动刷新

1. 集成架构总览

┌─────────────────┐     ┌──────────────────┐     ┌──────────────────┐
│  第三方前端      │     │  第三方后端       │     │  AI 客服后端      │
│  (嵌入 SDK)     │     │  (你的服务)       │     │  (本系统)         │
│                 │     │                  │     │                  │
│  Chat SDK ──────┼─────┼──────────────────┼─────┼─► /ai/** 对话    │
│   ↑ token       │     │                  │     │                  │
│                 │     │  ① 携带 API Key   │     │  ② 返回 JWT Token│
│                 │     │  POST /open-api/  │─────┼─► /auth/token    │
│                 │     │  auth/token       │     │                  │
└─────────────────┘     └──────────────────┘     └──────────────────┘

两段式鉴权流程:

  1. 管理后台:管理员创建 API Key,可选绑定客服角色
  2. 第三方后端:用 API Key 换取短期 JWT Token + 可用角色列表
  3. 第三方前端:Chat SDK 携带 JWT Token 请求 /ai/** 接口进行对话

2. 第一步:创建 API Key

在管理后台「系统设置 → API Key 管理」页面创建。

管理后台操作

  1. 登录管理后台 http://localhost:9090/index.html
  2. 进入「系统设置 → API Key 管理」
  3. 点击「+ 新建 API Key」
  4. 填写名称、描述、频率限制等参数
  5. (可选) 在创建弹窗中勾选需要绑定的客服角色
  6. 创建后立即复制保存 Key,关闭后无法再次查看

API Key 格式

sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx(sk_ 前缀 + 32 位 UUID)

3. 第二步:绑定客服角色(可选)

客服角色决定 AI 的人设、知识库范围和 MCP 工具权限。

角色绑定规则

role_ids 状态 Token 换取行为
空数组 [](默认) 返回所有启用的客服角色(向后兼容)
非空 [1, 2, 3] 仅返回绑定的角色

通过管理后台绑定

在 API Key 列表中点击「绑定角色」按钮,勾选需要的角色后保存。

通过 API 绑定

PUT /api-key/{keyId}/roles
Content-Type: application/json
Authorization: Bearer {管理后台 JWT Token}

{
  "roleIds": [1, 2, 3]
}

响应:

{
  "success": true,
  "message": "角色绑定更新成功"
}

4. 第三步:后端换取 SDK Token

⚠️ 重要:Token 换取必须在第三方后端完成,不可在前端暴露 API Key。

请求

POST /open-api/auth/token
Content-Type: application/json
X-API-Key: sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

{
  "ttl": 7200
}
参数 类型 必填 说明
X-API-Key Header 是 创建的 API Key
ttl Body 否 Token 有效期(秒),默认 7200(2 小时),范围 [300, 86400]

成功响应

{
  "success": true,
  "token": "eyJhbGciOiJIUzI1NiJ9...",
  "expiresIn": 7200,
  "roles": [
    { "id": "1", "key": "general", "name": "通用客服" },
    { "id": "2", "key": "finance", "name": "财务顾问" }
  ]
}
字段 说明
token SDK JWT Token,传给前端使用
expiresIn 过期时间(秒)
roles 可用客服角色列表,前端用于展示角色选择器

错误响应

HTTP 状态码 说明
401 API Key 无效或已禁用
403 无可用客服角色

5. 第四步:前端嵌入 Chat SDK

使用测试面板快速验证

访问 http://localhost:9090/sdk/test.html 打开测试面板:

  1. 在「🔐 SDK Token 鉴权」区域输入 API Key
  2. 点击「获取 Token」按钮自动换取(或手动粘贴已有的 Token)
  3. Token 获取成功后,integrateId 会自动填充为第一个可用角色
  4. 点击「🚀 初始化 SDK」即可测试对话功能

测试面板支持所有 P0/P1/P2 测试用例的自动化运行,包括 API 连通性验证。

5.1 引入 SDK

<script src="https://your-domain.com/sdk/chatbot-sdk.js"></script>

5.2 初始化

ChatbotSDK.init({
  // ========== 必填参数 ==========
  integrateId: '1',                              // 客服角色 ID(从 Token 换取接口的 roles 中选择)
  requestDomain: 'https://your-domain.com',       // AI 客服后端域名

  // ========== 鉴权参数(推荐) ==========
  token: 'eyJhbGciOiJIUzI1NiJ9...',               // 后端换取的 SDK Token
  expiresIn: 7200,                                // 该 Token 的有效期(秒,与换取接口返回的 expiresIn 一致)
  tokenStrategy: 'expiry',                        // 'expiry'(默认)临期/401 才取;'always' 每个请求前都取
  tokenUrl: '/my-backend/sdk-token',              // 取 Token 的地址(与下面的 getToken 二选一,回调优先)
  getToken: async function() {                    // 取 Token 回调:SDK 在临期 / 每请求前(always)/ 收到 401 时调用
    // 注意:这里应调「你自己的后端」,由它持 API Key 去调 /open-api/auth/token
    const res = await fetch('/my-backend/sdk-token');
    const data = await res.json();
    return { token: data.token, expiresIn: data.expiresIn };
  },
  roles: [                                        // 可用角色列表(传多个时 SDK header 显示角色切换下拉框)
    { id: '1', key: 'general', name: '通用客服' },
    { id: '2', key: 'finance', name: '财务顾问' }
  ],

  // ========== 可选参数 ==========
  userId: 'user_12345',                           // 用户标识(用于会话隔离)
  title: 'AI 智能助手',                            // 对话窗口标题
  theme: 'light',                                 // 主题:light / dark
  primaryColor: '#4F46E5',                         // 主题色(十六进制)
  streaming: true,                                // 启用流式回复(默认 true)
  enableRag: true,                                 // 启用 RAG 知识库检索(默认 true)
  quickReplies: ['如何退款?', '联系人工客服'],       // 快捷问题
  position: 'right-bottom',                        // 悬浮按钮位置:right-bottom / left-bottom
  width: 500,                                      // 窗口宽度(px)
  height: 520,                                     // 窗口高度(px)
  debug: true,                                     // 调试日志

  // ========== 回调函数 ==========
  onReady: function() { console.log('SDK 就绪'); },
  onMessage: function(msg) { console.log('收到消息', msg); },
  onError: function(err) {
    // code: auth_expired / auth_refresh_failed / config_invalid / 通用错误码
    console.error('SDK 错误', err.code, err.message);
  }
});

5.3 SDK 方法

// 运行时更新 Token(宿主自行换好后推送)—— 无需 destroy + init 重建 DOM,下次请求即生效
ChatbotSDK.setToken(newToken, 7200);              // 第二个参数为有效期(秒),可省略

// 让 SDK 调 getToken 回调立即换新 Token,返回是否换到了不同的 Token
const refreshed = await ChatbotSDK.refreshToken();

// 销毁实例(移除 DOM 和事件监听)
ChatbotSDK.destroy();

其余可用方法:open() / close() / toggle() / clearHistory()。

5.4 不使用 Token 的兼容模式

如果暂时不接入后端 Token 换取,可直接使用兼容模式(不推荐用于生产环境):

ChatbotSDK.init({
  integrateId: '1',                    // 直接指定角色 ID
  requestDomain: 'https://your-domain.com'
  // 不传 token 和 roles
});

兼容模式下,integrateId 直接作为 roleId 传递给后端,无需 Token 换取步骤。由于不传 roles,SDK 不会显示角色选择器,用户只能使用初始指定的单一角色。

注意:此时 /ai/**、/feedback、/attachment/upload 等受 SdkAuthFilter 保护的接口仍会返回 401(对话不可用)。若只想免去自己管理过期时间、但仍要能对话,可以只配 getToken 不配 token: SDK 会在首个受保护请求发出之前自动换取 Token("自举"),因此不会白跑一次 401。之后按正常刷新逻辑续期。 拿不到 expiresIn 时还可以配 tokenStrategy: 'always',让 SDK 每次请求前都取一次(见第 8 节常见问题)。

5.5 角色切换

当 roles 数组长度 > 1 时,SDK 会在聊天窗口头部自动显示角色选择下拉框,用户可随时切换当前使用的客服角色。

触发条件:

  • ChatbotSDK.init() 时传入的 roles 数组包含 2 个及以上角色

切换行为:

  1. 自动保存当前对话到 localStorage(与旧角色关联)
  2. 清空当前消息列表
  3. 生成新的 chatId(与新角色关联)
  4. 尝试从 localStorage 恢复新角色的缓存消息
  5. 异步从后端加载新角色的历史会话

示例:

// 1. 后端换取 Token,获取可用角色列表
const data = await fetch('/api/chatbot/token').then(r => r.json());

// 2. 初始化 SDK,传入角色列表
ChatbotSDK.init({
  integrateId: data.roles[0].id,        // 默认使用第一个角色
  requestDomain: 'https://your-domain.com',
  token: data.token,
  roles: data.roles,                     // 传入多个角色 → header 显示角色选择器
  userId: 'current_user_id'
});

安全说明:

  • 角色切换由 SDK 前端发起,后端 SdkAuthFilter 会校验 JWT Token 中的 rids claim,确保用户只能切换到被授权的角色
  • 切换角色不需要重新换取 Token,JWT 中已包含允许的角色 ID 列表

6. API 接口参考

6.1 Token 换取

POST /open-api/auth/token

详见 第三步:后端换取 SDK Token。

6.2 对话接口(SDK 内部调用,无需手动对接)

接口 方法 说明
/ai/chat GET 同步对话(已统一为 ChatPipeline,支持普通/RAG 自动判断)
/ai/chat/stream GET SSE 流式对话(OpenAI Chat Completions 兼容格式,已统一为 ChatPipeline)
/ai/chat/sources GET 获取 RAG 引用来源
/ai/sdk/conversation/list GET 会话列表
/ai/sdk/conversation/{id}/messages GET 会话消息
/ai/sdk/conversation/{id} DELETE 删除会话
/ai/sdk/conversation/{id}/export GET 导出会话
/category/tree GET 知识库分类树
/feedback POST 消息反馈

已废弃:/ai/assistant_app/chat/server_sent_event 和 /ai/assistant_app/chat/sse_emitter 已移除。旧路径 /ai/assistant_app/chat/sync、/ai/assistant_app/chat/sse、/ai/assistant_app/chat/rag/sse、/ai/assistant_app/rag/sources 仍保留(向后兼容)但已废弃,请统一使用 /ai/chat、/ai/chat/stream、/ai/chat/sources。

6.3 Open API 对话接口(第三方系统直接调用)

接口 方法 说明
/open-api/chat POST 同步对话(已补齐角色/RAG/FAQ/MCP/分类隔离)
/open-api/chat/stream GET SSE 流式对话(同上)
/open-api/rag/search POST 知识库检索

Open API 对话新增参数:

参数 类型 必填 说明
categoryIds Query 否 知识库分类 ID,逗号分隔(非角色场景用)
rewriteStrategy Query 否 RAG 查询重写策略:REWRITE / TRANSLATION / COMPRESSION / MULTI_QUERY(默认)
enableRag Query 否 是否启用 RAG 检索(默认 true),false 时走普通对话

认证方式: 所有 /open-api/** 和 /ai/** 请求通过 X-API-Key Header 或 Bearer Token 鉴权。

6.3 管理接口(需要管理后台 JWT)

接口 方法 说明
/api-key/list GET API Key 列表(admin)
/api-key POST 创建 API Key(admin)
/api-key/{id}/revoke PUT 吊销 API Key(admin)
/api-key/{id}/enable PUT 启用 API Key(admin)
/api-key/{id} DELETE 删除 API Key(admin)
/api-key/{id}/roles PUT 更新角色绑定(admin)
/role/list GET 客服角色列表(admin)
/role/all GET 全部角色含停用(admin)

7. SDK 配置参数参考

参数 类型 默认值 说明
integrateId String/Number 必填 客服角色 ID
requestDomain String 必填 后端域名
token String - SDK JWT Token(推荐)
expiresIn Number - token 有效期(秒,与换取接口返回的 expiresIn 同单位)。传入后 SDK 会在临期前主动刷新
getToken Function - 取 Token 回调,SDK 在临期 / 每请求前(always 模式)/ 收到 401 时调用。推荐返回 { token, expiresIn };只返回字符串则仅能靠 401 驱动
tokenStrategy String 'expiry' 取 Token 时机:'expiry' 临期 / 401 才取;'always' 每个受保护请求前都取一次(不依赖 expiresIn,代价是每请求多一次宿主后端往返)
tokenUrl String - 取 Token 的地址(与 getToken 二选一,同时配置时 getToken 优先)。SDK 会 GET 它并解析 {token,expiresIn} 或 {success,token,expiresIn,roles};请求用 credentials: 'same-origin',跨域不带 Cookie
roles Array - 角色列表 [{id, key, name}],长度 > 1 时 header 显示切换下拉框
userId String - 用户标识(会话隔离)
categoryId Number - 默认知识库分类 ID
showCategorySwitch Boolean false 显示分类切换器
title String 'AI 智能助手' 窗口标题
width Number 500 窗口宽度(px)
height Number 520 窗口高度(px,最小 400)
position String 'right-bottom' 悬浮按钮位置
primaryColor String '#4F46E5' 主题色
launcherIcon String (内置) 自定义悬浮按钮图标(URL 或 SVG 字符串)
launcherSize Number/String 80 悬浮按钮尺寸(px 或 CSS 长度字符串)
theme String 'light' 界面主题:light / dark
streaming Boolean true 流式回复
enableRag Boolean true RAG 知识库检索
rewriteStrategy String 'REWRITE' 查询重写策略
quickReplies String[] [] 快捷问题列表
showClear Boolean true 显示清空按钮
showTeaser Boolean true 显示提示气泡
teaserText String '' 提示气泡文本
sound Boolean false 消息提示音
notification Boolean false 浏览器通知
allowImageUpload Boolean true 允许上传图片参与对话
suggestions Boolean true AI 回复后展示推荐问题
resizable Boolean true 允许拖拽缩放窗口
watermark String - 聊天窗口水印文字
locale String 'zh-CN' 语言:zh-CN / en
debug Boolean true 调试日志
onReady Function - SDK 就绪回调
onMessage Function - 消息回调
onError Function - 错误回调(code 见第 8 节)

8. 错误码与排查

Token 换取阶段

错误 原因 解决方案
401 API Key 鉴权失败 X-API-Key 无效或缺失 检查 Header 是否正确携带
401 API Key 已被禁用 Key 被吊销 在管理后台重新启用或创建新 Key
401 API Key 已过期 Key 超过有效期 创建新的 API Key
403 无可用客服角色 未绑定角色且系统无启用角色 在管理后台创建并启用客服角色

SDK 对话阶段

HTTP 状态码 SDK 提示 原因
401 鉴权失败 Token 过期或无效。SDK 会自动换一次 Token 并重试;若仍 401,则通过 onError 上报 auth_expired
403 无访问权限 roleId 不在 Token 允许范围内
429 请求过于频繁 超过 API Key 频率限制
500 服务器异常 后端错误,查看服务端日志
502/503 服务暂不可用 后端未启动或正在部署

onError 回调中的 code(用于程序化识别,比看中文文案可靠):

code 含义 解决方案
auth_expired 自动刷新后仍鉴权失败,Token 彻底失效 检查 API Key 是否被吊销/过期、是否绑定了启用中的客服角色;修好后调 ChatbotSDK.setToken()
auth_refresh_failed 换 Token 的 getToken 回调失败 检查宿主换 Token 接口与网络(同一 code 30 秒内只上报一次,不会刷屏)
config_invalid init() 传入的参数非法(如 token 非字符串、expiresIn 非正数),该参数被忽略 按提示修正配置
network / timeout / cors 网络层错误 检查网络、CORS 白名单、后端可用性

常见问题

Q: Token 过期后怎么办?SDK 会自动刷新吗? A: 会。 SDK 内置了自动刷新,第三方无需自己轮询换 Token(Token 默认 2 小时过期)。两种触发方式:

  1. 临期主动刷新(推荐):init() 时传入 expiresIn + getToken,SDK 会在 Token 剩余寿命不足 「总寿命 10% 与 5 分钟中的较小值」时主动换取新 Token,用户完全无感。
  2. 401 兜底重试:任何受保护请求收到 401 时,SDK 会换一次 Token 并自动重试原请求, 因此 UI 上不会闪出「鉴权失败」。(安全性说明:SdkAuthFilter 的 401 都发生在业务逻辑执行之前, 所以重试写请求不会产生重复副作用。)

如果不想给 getToken 回调,也可以由第三方后端自己盯过期时间,换好后调 ChatbotSDK.setToken(token, expiresIn) 推送给 SDK —— 同样是即时生效,不需要 destroy + init。

Q: 只配 getToken 不配 token 可以吗? A: 可以。SDK 在首个受保护请求发出之前会自动换取 Token("自举"),不会白跑一次 401。

Q: 拿不到 expiresIn(或不想让 SDK 推算过期时间)怎么办? A: 用 tokenStrategy: 'always'。此时 SDK 每个受保护请求发出前都会向你的后端取一次 Token, 完全不做过期时间推算,由你的后端决定何时真的重新换取(它自己的缓存命中就直接返回缓存值)。 代价是每个请求多一次宿主后端往返(含每次对话的首 token 之前)。若你的后端能给出可信的 expiresIn, 用默认的 'expiry' 更省往返。两者都建议配上取 Token 方式:

ChatbotSDK.init({
  // ...
  tokenStrategy: 'always',
  tokenUrl: '/my-backend/sdk-token',   // 或 getToken 回调
});

Q: 取 Token 接口需要带 Cookie 怎么办? A: tokenUrl 用的是 credentials: 'same-origin':同源请求会自动带上你站点的 Cookie(宿主页面调 自己后端的最常见场景),跨域则不带。若你的取 Token 接口在另一个域上且需要 Cookie, 请改用 getToken 回调,用你自己的 fetch 控制 credentials。

Q: 页面刷新后 Token 还在吗? A: 不在。Token 只存内存(不写 localStorage,避免被 XSS 读取)。页面刷新后请重新 init() 传入, 或只配 getToken 让 SDK 自动重取。

Q: 如何让用户只能使用特定角色? A: 在 API Key 上绑定角色(PUT /api-key/{id}/roles),SDK 初始化时只传入绑定的角色 ID。

Q: 多个第三方系统可以用同一个 API Key 吗? A: 可以,但建议每个系统使用独立的 API Key,便于独立管控频率限制和角色权限。


9. 安全建议

  1. API Key 仅在后端使用:永远不要将 API Key 暴露到前端代码中(getToken 回调应指向你自己的后端,由它持 Key 去换 Token)
  2. Token 短有效期:生产环境建议 TTL 设为 1-2 小时。SDK 已内置自动刷新(临期 + 401 双触发),短有效期不再影响长会话体验
  3. Token 不落盘:SDK 只在内存中持有 Token,不写 localStorage/sessionStorage;页面刷新后由宿主重新提供
  4. 角色最小权限:只绑定必要的客服角色,避免授予过多权限
  5. 频率限制:根据业务量设置合理的 rateLimit(默认 60 次/分钟)
  6. HTTPS:生产环境必须使用 HTTPS,防止 Token 被中间人截获
  7. 定期轮换 Key:定期吊销旧 Key 并创建新 Key
  8. 接好 onError:auth_expired / auth_refresh_failed 是排查鉴权问题的第一手线索(控制台日志已关闭,不接回调等于静默失败)

10. 验证 Token 自动刷新

在浏览器控制台(或测试页 http://localhost:9090/sdk/test.html)按以下步骤可验证四种路径。 全程打开 DevTools 的 Network 面板,过滤 Authorization 请求头与 /open-api/auth/token。

// 公共:一个真实的换 Token 实现(把 sk_xxx 换成你的 API Key)
let providerCalls = 0;
const provider = async () => {
  providerCalls++;
  const r = await fetch('/open-api/auth/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-API-Key': 'sk_xxx' },
    body: JSON.stringify({ ttl: 7200 }),
  });
  const d = await r.json();
  return { token: d.token, expiresIn: d.expiresIn };
};
# 验证项 操作 预期
1 setToken 生效 init({ token: 'invalid.jwt', ... }) 发一条消息;再 ChatbotSDK.setToken('<真实 token>', 7200) 发一条 第一条弹「鉴权失败」气泡;第二条正常回复;Network 里 Authorization 头变为真实 token
2 401 自动重试 init({ token: 'invalid.jwt', getToken: provider, ... }) 发一条消息 Network 上「一条 401 → 一条 200」;providerCalls === 1;无错误气泡;onError 未触发
3 防死循环 init({ token: 'invalid.jwt', getToken: async () => 'invalid.jwt', ... }) 反复发消息 只发 1 个请求、不重试;onError 收到 code === 'auth_expired';30 秒内不重复上报
4 临期主动刷新 init({ token: '<真实 token>', expiresIn: 20, getToken: provider, ... }) 后不做任何操作 约 18 秒后 Network 出现一条新的 /open-api/auth/token(expiresIn: 20 时临期阈值为 2 秒)
5 destroy 清理 承上,等 providerCalls 增长后立刻 ChatbotSDK.destroy(),等 1 分钟 providerCalls 不再增长;再 init() 一次功能恢复正常
6 非受保护路径不受影响 观察 /category/tree、/ai/system-config/disclaimer 请求 不带 Authorization 头,也不会因临期检查而变慢
7 always 模式每请求前取 init({ tokenStrategy: 'always', tokenUrl: '/api/sdk-token', ... }),连发两条消息 每条消息的 /ai/chat/stream 之前都各有一条取 Token 请求(Token 未过期时也照取)
8 熔断不拖慢请求 把 tokenUrl 指向一个 404 地址后发消息 首次失败 onError 收到 auth_refresh_failed;随后 60 秒内不再出现取 Token 请求,消息仍照常发出(走 401 兜底)

若 getToken 返回裸字符串(不带 expiresIn),第 4 项不会发生 —— 这是预期行为: 没有过期信息时只能靠 401 驱动(第 2 项)。生产环境建议始终返回 { token, expiresIn }。


附录:完整对接示例(Node.js)

// ==================== 第三方后端示例 ====================
const express = require('express');
const app = express();

const AI_DOMAIN = 'https://your-ai-domain.com';
const API_KEY = 'sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx';

// Token 缓存
let cachedToken = null;
let tokenExpireAt = 0;
let cachedRoles = [];

// 换取 Token(带缓存)
// 说明:SDK 侧已有自动刷新,这里的缓存是为了避免同一时刻多个用户各换一次 Token。
// 缓存窗口设为「有效期 - 5 分钟」,与 SDK 的临期阈值对齐,可保证 SDK 来取时拿到的是新 Token。
async function getSdkToken() {
  if (cachedToken && Date.now() < tokenExpireAt) {
    return { token: cachedToken, expiresIn: Math.round((tokenExpireAt - Date.now()) / 1000) };
  }

  const res = await fetch(`${AI_DOMAIN}/open-api/auth/token`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-API-Key': API_KEY
    },
    body: JSON.stringify({ ttl: 7200 })
  });

  const data = await res.json();
  if (!data.success) throw new Error(data.message);

  cachedToken = data.token;
  tokenExpireAt = Date.now() + (data.expiresIn - 300) * 1000; // 提前 5 分钟刷新
  cachedRoles = data.roles;
  return { token: data.token, expiresIn: data.expiresIn };
}

// 给前端提供 Token(前端在 init 时取一次,之后由 SDK 自动调用续期)
app.get('/api/chatbot/token', async (req, res) => {
  try {
    const data = await getSdkToken();
    res.json({ success: true, ...data, roles: cachedRoles });
  } catch (e) {
    res.status(500).json({ success: false, message: e.message });
  }
});

app.listen(3000);
<!-- ==================== 第三方前端示例 ==================== -->
<script src="https://your-ai-domain.com/sdk/chatbot-sdk.js"></script>
<script>
  // 从自己后端换取 Token(后端持 API Key,API Key 永远不进浏览器)
  async function fetchToken() {
    const res = await fetch('/api/chatbot/token');
    const data = await res.json();
    if (!data.success) throw new Error(data.message || 'Token 换取失败');
    return data;
  }

  (async () => {
    const first = await fetchToken();
    ChatbotSDK.init({
      integrateId: first.roles[0].id,    // 默认使用第一个角色
      requestDomain: 'https://your-ai-domain.com',
      token: first.token,                // 首次 Token
      expiresIn: first.expiresIn,        // 有效期(秒)→ SDK 据此在临期前主动刷新
      getToken: async () => {            // SDK 在临期 / 收到 401 时自动调用它续期
        const next = await fetchToken();
        return { token: next.token, expiresIn: next.expiresIn };
      },
      roles: first.roles,                // 传入多个角色时自动显示角色选择器
      userId: 'current_user_id',
      title: '在线客服',
      theme: 'light',
      quickReplies: ['如何退款?', '查看订单', '联系人工'],
      onError: (e) => {
        // 鉴权类问题看这两个 code;其余错误可忽略或上报监控
        if (String(e.code).startsWith('auth_')) console.error('鉴权异常', e.code, e.message);
      }
    });
  })();
</script>