@hd-front-end/bridge
v0.0.13
Published
HaiChat Bridge SDK - 声明式嵌入 AI 助手,类微信 JS-SDK 风格
Downloads
282
Keywords
Readme
AIAssistant Bridge SDK 接入说明
面向业务前端,将 CHATBOX(AI 助手)以 iframe 方式嵌入自有页面。
包名:
@hd-front-end/bridge| SDK 版本:0.0.13
零、给 AI 工具 / 快速接入者的速查表
本节是全文的结构化摘要,供 AI 编码工具一次性读取后生成接入代码。细节见后续章节。
SDK 的一句话模型:宿主页面预留一个占位 DOM 容器 → AIAssistant.init(config)(异步)→ SDK 向后端换取 iframe 地址并把 iframe 注入该容器 → 宿主通过实例方法(send / updateContext / syncDynamicFields / show / hide)与 iframe 通信。
| 事实 | 值 |
|---|---|
| 入口 | AIAssistant.init(config),返回 Promise,必须 await / .then() |
| 容器 | config.container,init 之前必须已存在于 DOM,否则抛错 |
| iframe 注入方式 | container.appendChild(iframe),iframe 默认 width/height: 100% → 容器必须有确定的宽高 |
| 挂载位置语义 | 页面布局右侧的一个 flex 兄弟节点(打开时把主体内容"推"窄),不是 position: fixed 悬浮层 |
| 鉴权模式 | 二选一:非 Portal(必传 auth.apiKey)/ Portal(走 auth.accessToken+refreshToken) |
| 用户身份 | user.userCode 与 user.channelUserId 二选一必填;user.userName 必填 |
| 可变字段 | auth.* / user.* 一律传 getter 函数,不要传字面量 |
| 业务上下文 | config.context,推荐结构 { pageName, pageDesc, pageParams },运行时用 updateContext() 全量替换 |
| 上下文下发 | 整个 context 对象 JSON.stringify 后作为对话接口 variables.pageContext |
| 悬浮入口 | 默认挂载到 document.body,40×40px、距右下各 40px;可拖动,点击后显示助手并隐藏入口;创建/拖动/贴边/显隐完全由 SDK 接管,聊天 UI 可用 uiState ✓ 消息让其随权限自动隐藏 |
| 关闭/全屏/拖拽调宽 | SDK 默认全部内置,业务方零配置;需要接管时置 enabled: false |
| origin 校验 | 宿主 SDK 只接受来自助手 iframe contentWindow 且 origin 匹配 iframe 地址的消息;iframe 侧只信任完成握手的宿主 origin。allowedOrigins 不是 401 开关,可不传 |
| 销毁 | 组件卸载必须 assistant.destroy() |
生成接入代码时必须做的 7 件事(缺一不可):
- 在布局组件里放一个容器节点(如
<aside id="assistant-container">),并给它 确定宽度 +flex-shrink: 0+ 100% 高度。 - 容器的父级设为
display: flex,主体内容区设flex: 1; min-width: 0;—— 这样打开助手时主体被推窄而不是被遮挡。 - 等容器渲染完成(Vue 里
await nextTick())再调用init。 auth/user全部传 getter。context传入{ pageName, pageDesc, pageParams };路由切换时updateContext()推送完整上下文。- 容器显隐由 SDK 接管:不要对挂载容器使用
v-show/ 样式display:none,打开用show()、关闭用hide()。 - 卸载时
destroy()。
最容易踩的 5 个坑(详见 第十三章 FAQ):
- 容器还没渲染就
init→Container not found报错。 - 对挂载容器使用
v-show/ CSSdisplay:none→ 与 SDK 写入的 inlinedisplay双重所有权,面板打不开或关不掉。 - 容器宽度由外层祖先决定(flex/grid 撑出来的)→ 拖拽调宽无效,需要配
resize.target。 - 容器父链上有
overflow: hidden且用了position: fixed全屏 → 用fullscreen.target指定在工作区内全屏。 onError只处理握手失败、不 catchinit()Promise → 漏掉拉取 iframe 地址失败(网络超时 / HTTP 错误)。
一、引入方式(二选一)
方式一:UMD script(注入全局 window.AIAssistant)
<script src="/haichat-web-ui/int/ai-assistant-bridge.umd.js"></script>
<script>
// init 为异步:先向后端拉取真实 iframe 地址,再创建实例
AIAssistant.init({ /* config */ }).then(function (assistant) {
assistant.send('你好');
});
</script>- 生产可用压缩版:
ai-assistant-bridge.min.js - 无需构建工具,适合传统多页应用 / 直接内嵌页面。
方式二:ESM import
import { AIAssistant } from '@hd-front-end/bridge';
// init 为异步:先向后端拉取真实 iframe 地址,再创建实例
const assistant = await AIAssistant.init({ /* config */ });- 适合 Vite / Webpack 等现代工程;自带 TypeScript 类型(
dist/types/index.d.ts)。
两种方式 API 完全一致,下文示例通用。
二、快速开始
<!-- 1. 预留 iframe 挂载容器(必须先于 init 存在于 DOM) -->
<div id="assistant-container"></div>
<script src="/haichat-web-ui/int/ai-assistant-bridge.umd.js"></script>
<script>
// init 为异步:SDK 会先调用 `${serverUrl}/athena-service/v1/app-info/get`
// 拿到响应里的 data.webHomeUrl 作为真正的 iframe 地址
AIAssistant.init({
// serverUrl 为后端服务地址(不是 iframe 地址)
serverUrl: 'https://xxxx',
container: '#assistant-container',
// 关键:可变字段传 getter,token 刷新 / 用户切换后 SDK 自动重读
auth: {
accessToken: function () { return AppContext.getToken(); },
apiKey: function () { return AppContext.getApiKey(); },
},
user: {
// 非 Portal 接入:userCode 传 employeeCode;也可改用 channelUserId(二选一)
userCode: function () { return userInfo.employeeCode; },
userName: function () { return userInfo.employeeName; },
tenantId: 'test',
tenantName: '测试租户',
},
// 业务上下文:推荐 pageName / pageDesc / pageParams 三段式
context: {
pageName: '指标模型列表',
pageDesc: '展示当前租户下所有指标模型,可筛选、查看明细',
pageParams: { metricName: '日活跃用户数' },
},
bot: { botId: '' },
channel: 'athena',
onReady: function () { console.log('握手完成,通信已建立'); },
onMessage: function (msg) { handleAssistantMessage(msg); },
onError: function (err) { console.error(err.code, err.message); },
}).then(function (assistant) {
// assistant 已就绪,可调用 send / updateContext 等
});
</script>跨域排障:
init会先请求${serverUrl}/athena-service/v1/app-info/get拉取真实 iframe 地址。若该请求报 CORS 跨域错误(表现为悬浮球不展示 / 控制台提示「尚未初始化」),说明业务系统域名未在网关白名单中——请找相关人员把业务系统域名添加到网关请求白名单,无需在前端改代码或加代理绕过。
三、挂载点 #assistant-container:定位、布局与初始 CSS
这一章解决"在一个全新系统里,AI 助手到底挂在哪、CSS 怎么写"。
3.1 交互形态:推开,而不是浮在上面
产品要求的形态是右侧侧栏(side panel):
关闭时 打开时
┌─────────────────────────┐ ┌──────────────────┬──────┐
│ │ │ │ AI │
│ 业务主体内容 │ → │ 业务主体内容 │ 助手 │
│ │ │ (被推窄) │ │
└─────────────────────────┘ └──────────────────┴──────┘- ✅ 正确:容器是主体内容区的 flex 兄弟节点,打开时占据布局宽度,主体内容自动变窄(内容不被遮挡,表格/表单仍可完整操作)。
- ❌ 错误:容器用
position: fixed; right: 0悬浮 —— 会盖住右侧的操作列、分页器、右侧抽屉,且与业务系统已有的z-index体系打架。
position: fixed只在 SDK 内置全屏时由 SDK 临时写入,退出全屏会逐字段还原。业务方自己的初始 CSS 不要用 fixed。
3.2 新系统如何确定挂载点:三步定位法
第 1 步 —— 找"主体内容区"的直接父级。
在业务系统的根布局组件(通常叫 Layout.vue / Main.vue / BasicLayout.vue)里,找到包裹 <router-view> 的那个节点。它的父级就是候选挂载父级。
常见后台布局:
<el-container> ← 整页
<el-aside>菜单</el-aside>
<el-container>
<el-header>顶栏</el-header>
<el-main class="content-wrap"> ← ✅ 这里就是"挂载父级"
<div class="content">
<router-view /> ← 主体内容区
</div>
<aside id="assistant-container" /> ← ✅ 容器作为兄弟节点插在这里
</el-main>
</el-container>
</el-container>第 2 步 —— 判断挂载父级是否满足三个条件。 不满足就改造它:
| 条件 | 为什么 | 不满足时怎么改 |
|---|---|---|
| 父级 display: flex(横向) | 容器才能与主体并排、把主体推窄 | 给父级加 display: flex |
| 父级有确定高度(height: 100% 链路完整) | iframe 高度 100% 需要祖先有高度 | 从 html, body, #app 起逐级补 height: 100% |
| 主体内容区 flex: 1; min-width: 0 | 否则主体不会让位,或内容溢出撑破布局 | 给主体加上,min-width: 0 不可省 |
第 3 步 —— 选择放置位置。 容器要放在主体内容区之后(DOM 顺序 = 视觉右侧),并且在整页布局组件里,而不是某个业务页面里 —— 这样路由切换时助手不会被卸载重建、会话不中断。
特殊场景速判:
| 系统形态 | 挂载点建议 |
|---|---|
| 经典后台(顶栏 + 左菜单 + 主体) | 挂在包裹 <router-view> 的 el-main 内,作为最后一个子节点 |
| 单页无框架(一个全屏内容区) | 在 #app 下建一层 flex wrapper:<div class="app-shell"><main/><aside id="assistant-container"/></div> |
| 已有右侧抽屉/侧栏体系 | 复用现有侧栏容器,把 #assistant-container 作为其内容节点;此时容器宽度往往由外层侧栏决定 → 必须配 resize.target 指向外层侧栏 |
| 微前端子应用 | 挂在主应用的布局里,子应用切换不影响助手;不要挂在子应用内部 |
3.3 初始 CSS 模板(可直接复制)
<div class="app-shell">
<main class="app-shell__content">
<!-- 业务主体 / router-view -->
</main>
<!-- AI 助手挂载点:SDK 会把 iframe 注入这里 -->
<aside id="assistant-container" class="ai-assistant-panel"></aside>
</div>/* 1) 高度链路:从 html 一路到挂载父级都要有高度 */
html, body, #app {
height: 100%;
margin: 0;
}
/* 2) 挂载父级:横向 flex,隐藏溢出 */
.app-shell {
display: flex;
height: 100%;
gap: 16px; /* 可选:主体与助手之间的间距 */
padding: 0 24px 24px;
overflow: hidden;
}
/* 3) 主体内容区:占满剩余空间,min-width: 0 防止被内容撑破 */
.app-shell__content {
flex: 1;
min-width: 0; /* ⚠️ 必须,否则表格等宽内容会顶开布局,助手被挤没 */
height: 100%;
overflow: auto;
}
/* 4) AI 助手容器:固定初始宽度 + 不参与压缩 */
.ai-assistant-panel {
width: 420px; /* 初始宽度,用户可拖拽调整 */
flex-shrink: 0; /* ⚠️ 必须,否则主体内容会把助手压扁 */
height: 100%;
position: relative; /* SDK 的拖拽手柄需要非 static 定位(不写 SDK 也会自动补) */
overflow: hidden;
/* 以下为视觉样式,按业务设计体系调整 */
background: #fff;
border: 1px solid #e1e6ef;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(31, 45, 61, 0.12);
}
/* 5) SDK 注入的 iframe 铺满容器 */
.ai-assistant-panel iframe {
display: block;
width: 100%;
height: 100%;
border: 0;
background: #fff;
}关键点解释:
| CSS | 不写会怎样 |
|---|---|
| .ai-assistant-panel { width: 420px } | iframe 是 width:100%,容器无宽度 → 助手不可见(宽度塌陷为 0) |
| flex-shrink: 0 | 主体内容多时助手被压成几十像素 |
| height: 100% + 完整高度链路 | iframe 高度塌陷为 0,看似"没加载出来" |
| min-width: 0(主体侧) | 宽表格把主体撑开,助手被挤出可视区 |
| position: relative(容器) | SDK 会自动补写;显式声明可避免与宿主样式冲突 |
3.4 显隐控制:由 SDK 接管容器 display
assistant.show() / hide() 同时切换 iframe 与挂载容器的 display(hide() 会把容器写成 display:none,flex 侧栏占位一并消失)。宿主不要再对容器使用 v-show、不要用 CSS 把容器默认写成 display:none——那会和 SDK 的 inline display 双重所有权,出现「点了打不开 / 关了收不掉」。
assistant.show(); // 拉起面板(容器 display 还原为样式表值)
assistant.hide(); // 收起面板 + 收起容器占位⚠️ 用常驻 DOM(不要
v-if):v-if会销毁容器,iframe 随之被移除,再次显示会重新加载 iframe、丢失会话。⚠️ 首次
init前容器必须已渲染。默认收起由 SDK 负责(create()即把容器置display:none);若确实要延迟创建,把init推迟到容器出现之后(见 11.1 Vue 3 示例)。⚠️
onOpen/onClose只是通知(工具条高亮、埋点),不要再在回调里改容器display。
3.5 展开/收起动画(可选)
SDK 拖拽调宽时会逐帧写入 inline width,若容器上有 transition: width,拖拽会有明显滞后。推荐做法:只在 toggle 时临时开启过渡。
.ai-assistant-panel {
width: 420px;
/* 默认不加 width 过渡,保证拖拽跟手 */
}
/* 仅在展开/收起的那一瞬间加类名 */
.ai-assistant-panel.is-animating {
transition: width 0.2s ease;
}若不想处理这个细节,直接靠 SDK 的 show() / hide() 硬切换即可,体验完全可接受。
3.6 全屏时的挂载点:fullscreen.target
SDK 内置全屏默认把容器改为 position: fixed; inset: 0,铺满整个浏览器窗口(盖住顶栏和左菜单)。若业务希望"只在工作区内全屏,保留导航",传 fullscreen.target:
AIAssistant.init({
container: '#assistant-container',
fullscreen: { target: '#workspace-root' }, // 在工作区节点内全屏
/* 其他配置 */
});此时 SDK 会把容器 DOM 临时移动到 #workspace-root 内并铺满它,退出全屏时移回原位并逐字段还原 inline style。要求 #workspace-root 自身是 position: relative(或其他非 static)。
⚠️ 两条全屏入口的目标来源不同,务必收敛到配置。 iframe 内 Header 的「全屏」按钮走
resizeAssistant事件 → 宿主enterFullscreen(),读的是fullscreen.target配置;而宿主侧的assistant.fullscreen('#xxx')读的是显式参数。当业务同时使用这两种入口时,必须把挂载点写进fullscreen.target配置作为唯一事实源,否则 iframe 内 Header 点全屏会回落到「整窗铺满」,与宿主按钮「指定区域内全屏」不一致。详见 9.2。
3.7 拖拽调宽时的挂载点:resize.target
SDK 默认改的是 config.container 自身的 inline width。当容器宽度实际由祖先节点决定时(flex/grid 撑出来的、或宿主已有侧栏体系),改容器宽度是无效的 —— 需要把 resize.target 指向"真正决定宽度的那个祖先":
<!-- 宿主已有的侧栏体系 -->
<div class="portal-side-panel"> <!-- ← 宽度实际由这里决定 -->
<div class="portal-side-panel__body">
<div id="assistant-container"></div> <!-- ← SDK 挂载点 -->
</div>
</div>AIAssistant.init({
container: '#assistant-container',
resize: {
target: '.portal-side-panel', // 真正被改宽度的元素
minWidth: 360,
maxWidth: 960,
storageKey: 'hai-assistant-width',
},
/* 其他配置 */
});判断方法:在 devtools 里给容器手动写 style="width: 600px",如果面板没变宽 → 说明宽度由祖先决定,必须配 resize.target。
四、初始化参数 AIAssistant.init(config)
4.1 顶层配置 AIAssistantConfig
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| serverUrl | string | ✅ | 后端服务地址(不是 iframe 地址);SDK 会调用 ${serverUrl}/athena-service/v1/app-info/get 拿响应里的 data.webHomeUrl 作为真实 iframe 地址 |
| container | string \| HTMLElement | ✅ | iframe 挂载点;字符串按 querySelector 解析,节点必须已存在,见第三章 |
| auth | AuthConfig | ✅ | 鉴权信息,见 4.2 |
| user | UserConfig | ✅ | 用户/租户信息,见 4.3 |
| bot | BotConfig | ❌ | 指定默认智能体;不传由 CHATBOX 侧决定 |
| channel | string | ❌ | 渠道标识,默认 athena |
| context | ContextConfig | ❌ | 初始页面上下文,见 4.4 |
| autoCollectContext | boolean | ❌ | 是否自动采集页面标题/URL/主体文本并并入 context,默认 false |
| allowedOrigins | string[] | ❌ | 随握手下发给 iframe 的额外 origin(可选保留字段)。宿主 SDK 不再用它做 iframe→host 来源白名单,漏配不会 401。见 第十章 |
| style | StyleConfig | ❌ | iframe 样式,见 4.5 |
| fullscreen | FullscreenConfig | ❌ | 全屏行为,默认走 SDK 内置,见 4.6 |
| close | CloseConfig | ❌ | 关闭按钮行为,默认走 SDK 内置,见 4.7 |
| resize | ResizeConfig | ❌ | 侧边拖拽调宽,默认开启,见 4.8 |
| launcher | LauncherConfig | ❌ | 悬浮入口,默认启用、80×80px、以右下角为基准偏移各 40px;设为 { enabled: false } 不创建悬浮球,面板仍初始隐藏,需显式 show() |
| enterprise | EnterpriseConfig | ❌ | 企业功能开关(预留) |
| onReady | () => void | ❌ | 握手完成回调,只触发一次 |
| onOpen | () => void | ❌ | show() 或悬浮入口点击显示助手后触发;容器显隐已由 SDK 接管,回调只做宿主其它 UI 同步 |
| onMessage | (msg: BridgeEvent) => void | ❌ | iframe → 宿主 事件回调,见第八章 |
| onResize | (mode, api) => void | ❌ | iframe 内点击全屏/退出全屏时触发;内置行为关闭时是唯一切换入口 |
| onClose | (api) => void | ❌ | iframe 内点击关闭时触发;内置行为关闭时是唯一关闭入口 |
| onError | (err: BridgeError) => void | ❌ | FETCH_IFRAME_URL_FAILED(拉地址失败,同时 init() reject)/ HANDSHAKE_TIMEOUT(握手超时或重试耗尽) |
4.2 auth(AuthConfig)
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| accessToken | string \| (() => string) | ⚠️ | 访问令牌;与 apiKey 至少有一个有值。强烈推荐传 getter |
| refreshToken | string \| (() => string) | ❌ | 刷新令牌 |
| apiKey | string \| (() => string) | ⚠️ | 渠道 apiKey,业务前端从后端获取,用于渠道鉴权。有值即视为"非 Portal 接入";推荐传 getter |
4.3 user(UserConfig)
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| userCode | string \| (() => string) | ⚠️ 二选一 | 渠道用户编码(原 userId,已重命名)。与 channelUserId 二选一,两者必须有一个有值。非 Portal 接入时值取业务系统 employeeCode;有值时请求头带 employeeCode |
| channelUserId | string \| (() => string) | ⚠️ 二选一 | 渠道用户 ID。仅非 Portal 接入使用;与 userCode 二选一。有值时请求头带 channelUserId |
| userName | string \| (() => string) | ✅ | 用户名(展示用) |
| tenantId | string \| (() => string) | ✅ | 租户 ID |
| tenantName | string \| (() => string) | ❌ | 租户名(展示用) |
userCode/channelUserId二选一: 两者必须有一个有值,不可同时为空(校验不通过会在init时抛错)。userName、tenantId缺失同样会在init时抛错。请求头下发规则(由 iframe 侧根据
apiKey是否有值判定接入形式):
- Portal 接入(未传
apiKey):userCode/userName不加到接口请求头。- 非 Portal 接入(传了
apiKey):userCode有值 → 请求头带employeeCode;channelUserId有值 → 请求头带channelUserId。
Dynamic<T> = T \| (() => T): 任何运行时可能变化的字段(accessToken、refreshToken、apiKey、userCode、channelUserId、userName、tenantId)都建议传 getter 而非字符串字面量。
- getter 只在宿主侧调用,跨 iframe 传的永远是求值后的字符串(函数无法经 postMessage 序列化)。
- 传字面量时,token 刷新 / 用户切换后 iframe 拿到的仍是初始值。
4.4 context(ContextConfig)— 页面上下文参数
上下文本质是一个 Map<string, any>:key 为字符串,value 为任意值(对象、数组、原始类型都行)。SDK 不对内部结构做任何约束,但强烈建议统一采用下面的三段式结构 —— 它与大模型的提示词模板对齐,能显著提升助手对"用户当前在看什么"的理解准确度。
4.4.1 推荐结构
{
"pageName": "页面名称",
"pageDesc": "页面描述",
"pageParams": {
"metricName": "指标模型名称",
"xxx": "xxx"
}
}| 字段 | 类型 | 建议 | 含义与写法 |
|---|---|---|---|
| pageName | string | ✅ 必填 | 页面名称。用户能看懂的中文菜单名/页面标题,如"指标模型列表"、"会员详情"。通常取路由 meta.title 或面包屑末级 |
| pageDesc | string | ✅ 推荐 | 页面描述。一句话说明这个页面能做什么、展示什么数据,供大模型判断可用能力,如"展示当前租户下所有指标模型,支持按名称/状态筛选,可查看指标口径与血缘" |
| pageParams | object | ✅ 推荐 | 页面参数。当前页面的业务标识与筛选态,key 用业务语义命名(metricName 而非 id2)。常见来源:路由 params/query、当前选中记录、筛选条件、分页信息 |
pageDesc建议写成静态描述(跟页面走,不随数据变),pageParams写成动态状态(跟用户操作走)。
4.4.2 真实示例
// 列表页
context: {
pageName: '指标模型列表',
pageDesc: '展示当前租户下所有指标模型,支持按名称、业务域、启用状态筛选,可进入详情查看指标口径与血缘关系',
pageParams: {
metricName: '日活跃用户数',
bizDomain: '用户增长',
status: 'enabled',
pageNo: 1,
pageSize: 20,
},
}
// 详情页
context: {
pageName: '会员详情',
pageDesc: '展示单个会员的基本信息、等级权益、积分流水与最近订单',
pageParams: {
memberId: '0001',
memberName: '张三',
memberLevel: 3,
tab: 'points',
},
}4.4.3 下发规则
发起对话时,若上下文非空,SDK 会把整个上下文对象序列化为 JSON 字符串,作为对话接口 variables 参数中 pageContext 这一项的值下发给后端:
// context 如上"列表页"示例,等价于:
// variables: {
// pageContext: '{"pageName":"指标模型列表","pageDesc":"展示当前租户下…","pageParams":{"metricName":"日活跃用户数",…}}'
// }上下文为空则不下发 pageContext。
补充:
autoCollectContext: true时,SDK 会额外采集pageTitle(document.title)、pageUrl(location.href)、domSummary(<main>内前 300 字纯文本)并并入 context(业务传入的同名 key 优先)。init 握手与每次updateContext()都会重新采集并入——iframe 侧 context 是全量替换语义,这样采集字段不会在首次updateContext后被抹掉。默认关闭 —— 已按上面的三段式传pageName/pageDesc时无需开启。
运行时更新见 第七章。
4.5 style(StyleConfig)
作用于 SDK 注入的 iframe 自身(不是容器)。容器的尺寸由宿主 CSS 决定,见第三章。
| 字段 | 类型 | 默认值 |
|---|---|---|
| width | string | 100% |
| height | string | 100% |
| borderRadius | string | 0 |
| zIndex | number | 1000 |
4.6 fullscreen(FullscreenConfig)
内嵌 H5 场景下用户点击对话框顶部的「全屏/退出全屏」图标时,iframe 会 emit event=resizeAssistant。SDK 默认接管样式切换,业务方不用写一行 CSS 即可完成整窗全屏。
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| enabled | boolean | true | 是否启用 SDK 内置全屏行为。false 时 SDK 不改任何样式,onResize 是唯一切换入口 |
| target | string \| HTMLElement | — | 指定"在某个节点内全屏"(例如业务系统的工作区容器);不传则铺满整个浏览器窗口。见 3.6 |
| zIndex | number | 9999 | 覆盖整窗时使用;target 场景不生效 |
| applyStyle | ({ container, iframe }) => void | — | 完全自定义"进入全屏"的样式应用;传入后 target/zIndex 失效 |
| restoreStyle | ({ container, iframe }) => void | — | 完全自定义"退出全屏"的还原逻辑,一般与 applyStyle 成对出现 |
内置行为的实际动作:
- 无
target:将 iframe 容器改为position:fixed; inset:0; z-index:<zIndex>; width:100vw; height:100vh;,iframe 自身宽高改为 100% - 传
target:将 iframe 容器 DOM 迁到target内部,样式改为position:absolute; inset:0; width:100%; height:100%; - 退出时逐字段还原 inline style,并把 DOM 迁回原位置——不会伤到宿主已有的 inline 样式
⚠️ 当业务同时使用宿主按钮
assistant.fullscreen('#xxx')与 iframe 内 Header 全屏按钮时,两条入口的目标来源不同:前者读显式参数,后者读fullscreen.target配置。务必把挂载点声明到fullscreen.target配置作为唯一事实源,避免两者分叉(iframe 内 Header 回落到整窗铺满)。见 9.2。
4.7 close(CloseConfig)
内嵌 H5 场景下用户点击对话框顶部的「关闭」图标时,iframe 会 emit event=closeAssistant。SDK 默认先退出全屏再调用 assistant.hide()——面板消失但会话不清空,下次 show() 可继续。
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| enabled | boolean | true | 是否启用 SDK 内置关闭行为。false 时 SDK 不做任何动作,onClose 是唯一关闭入口 |
| action | 'hide' \| 'destroy' | hide | 仅 enabled: true 生效。hide 隐藏但保留实例;destroy 销毁实例,下次需重新 AIAssistant.init |
⚠️ 内置
hide()会同时隐藏 iframe 和 容器占位,侧栏形态下无需再改容器display。见 3.4。
4.8 resize(ResizeConfig)— 侧边拖拽调宽
SDK 默认在 iframe 容器左边缘挂一个"拖动手柄"(图标为双向箭头)。鼠标移到容器左边缘时手柄淡入,按住向左/向右拖动即改变宽度。业务方无需写任何交互代码。
默认行为:
side: 'left'(默认):向左拖变宽、向右拖变窄- 最小宽度
360px,防止拖到 iframe 内容不可辨识 - 最大宽度默认为视口宽度的 40%(
maxWidthRatio: 0.4),避免对话面板把业务页面挤到不可用;需要更宽/更窄由业务自行配置 - 拖拽期间在 iframe 上盖一层透明覆盖层,让 pointermove 能穿过 iframe(跨 origin 也生效)
- 手柄以
position: fixed挂在document.body,"骑"在容器边框上,不会被宿主overflow: hidden裁切 - 全屏态自动隐藏手柄,退出全屏后恢复
- 配置
storageKey后,用户拖出的宽度会持久化到 localStorage,下次 init 自动恢复
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| enabled | boolean | true | 是否启用 SDK 内置调宽。false 时 SDK 不注入手柄 DOM |
| target | string \| HTMLElement | container 自身 | 真正被改宽度的元素。容器宽度由祖先决定时必须配置,见 3.7 |
| minWidth | number | 360 | 最小拖拽宽度(px)。优先级高于上限:窄视口下比例上限低于它时以它为准 |
| maxWidth | number | — | 最大拖拽宽度的绝对值(px)。不传则只受 maxWidthRatio 约束 |
| maxWidthRatio | number | 0.4 | 最大拖拽宽度占视口宽度的比例,取值 (0, 1]。传 1 即放开到整个视口宽度;非法值回落 0.4 |
| storageKey | string | — | 持久化 key。传入后拖动结果写入 localStorage,下次 init 自动恢复 |
| side | 'left' \| 'right' | 'left' | 手柄位置。iframe 内嵌在右侧抽屉用 left,反之用 right |
| zIndex | number | 10000 | 手柄 z-index。默认高于全屏 z-index (9999) 以确保可见 |
maxWidth与maxWidthRatio的关系:两者都配时取更小的那个(谁更严格听谁的)。 例如{ maxWidth: 960, maxWidthRatio: 0.4 }在 1920px 视口下实际上限是min(960, 768) = 768。 比例按拖拽当时的window.innerWidth实时计算,因此窗口变窄后上限自动收紧。 恢复storageKey持久化宽度时走同一套约束——上次拖出的宽度若超过当前上限,会被收敛后再应用。
// 关闭内置手柄,业务方自行实现
AIAssistant.init({
resize: { enabled: false },
/* 其他配置 */
});
// 自定义宽度上下限 + 持久化 + 指定真正被改宽的祖先
AIAssistant.init({
resize: {
target: '.portal-side-panel',
minWidth: 400,
maxWidth: 960,
storageKey: 'hai-assistant-width',
},
/* 其他配置 */
});
// 只调比例上限:最宽可拖到视口的 60%
AIAssistant.init({
resize: { maxWidthRatio: 0.6 },
/* 其他配置 */
});
// 放开比例限制,允许拖满整个视口宽度
AIAssistant.init({
resize: { maxWidthRatio: 1 },
/* 其他配置 */
});4.9 悬浮入口参数(LauncherConfig)
默认情况下 SDK 会把 logo.webm 以 80×80px 的悬浮入口挂载到父页面 document.body,初始位置距视口右侧、底部各 40px。入口支持鼠标、触控和触笔拖动,始终保持在父窗口可视范围内;拖动不会触发展开,点击或键盘激活才会调用 show()。
入口位置以视口右下角为基准:内部只记录右/下边缘到视口右/下边缘的距离,并以 right/bottom 样式定位(left/top 恒为 auto)。因此窗口缩放、浏览器 zoom、宿主容器变宽时,入口与右下角的间距保持不变——例如贴右侧 20px,视口变宽后仍是 20px。拖动结束时也按这一基准换算存储,视口变化后不会漂移。
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---:|---|
| enabled | boolean | true | 是否创建入口;设为 false 时不创建悬浮球,面板仍由 SDK 初始隐藏,需显式 show() |
| width / height | number | 80 | 入口尺寸,单位 CSS px;非正数使用默认值 |
| right / bottom | number | 40 | 右侧、底部偏移,单位 CSS px;也是视口变化后持续维持的基准间距。负数按 0 处理,超出视口时自动约束 |
| zIndex | number | 2147483000 | 入口层级 |
入口显示时 iframe 隐藏,点击入口后 iframe 显示且入口隐藏。调用 assistant.hide() 或内置关闭动作(close.action: 'hide')会恢复入口最近位置;close.action: 'destroy' 会同时清理入口。容器显隐由 SDK 接管(show()/hide() 会写容器 display),onOpen / onClose 只作通知:
const assistant = await AIAssistant.init({
// ...serverUrl / container / auth / user
launcher: { width: 48, height: 48, right: 24, bottom: 24 },
onOpen: () => { toolbarHighlight.value = true; },
onClose: () => { toolbarHighlight.value = false; },
});若业务不需要悬浮球,配置 launcher: { enabled: false },再在适当时机调用 assistant.show()。
4.10 接入形式参数速查
CHATBOX 有两种接入形式,二者在鉴权来源、用户身份来源上不同,其他字段(context、fullscreen、close、事件回调等)保持一致。
4.10.1 非 Portal 接入(业务系统直嵌)
业务前端在自己的页面里挂 iframe,token 来自宿主自己的登录态,渠道鉴权走 apiKey。
| 业务参数 | 对应 SDK 字段 | 必填 | 说明 |
|---|---|---|---|
| tenant | user.tenantId | ✅ | 租户 ID |
| channelId | channel | ✅ | 渠道标识 |
| botId | bot.botId | ❌ | 不传时 iframe 进入智能体选择列表 |
| employeeCode | user.userCode | ⚠️ 二选一 | 与 channelUserId 二选一。有值时请求头带 employeeCode |
| channelUserId | user.channelUserId | ⚠️ 二选一 | 与 userCode 二选一。有值时请求头带 channelUserId |
| channelUserName | user.userName | ✅ | 用户名(必填,仅展示,不下发请求头) |
| apiKey | auth.apiKey | ✅ | 渠道 apiKey;业务前端从后端换取。有值即视为"非 Portal 接入" |
最小接入示例:
AIAssistant.init({
serverUrl: 'https://baas.hd123.com',
container: '#assistant-container',
auth: {
// 非 Portal 模式:accessToken 用宿主自己的 token;apiKey 走渠道鉴权
accessToken: () => AppContext.getToken(),
apiKey: () => AppContext.getApiKey(), // 必填
},
user: {
tenantId: 'tenant-1234', // 必填 → tenant
userCode: () => userInfo.employeeCode, // 二选一 → 请求头 employeeCode
// channelUserId: () => userInfo.channelUserId, // 或改用 channelUserId(二选一)
userName: () => userInfo.employeeName, // 必填 → channelUserName
},
channel: 'athena', // 必填 → channelId
bot: { botId: 'xxxx' }, // 选填;缺省进入智能体选择列表
});4.10.2 Portal 接入(Portal 内挂载)
由 Portal 统一挂载 iframe,Portal 已有完整登录态。鉴权走 Portal 的 accessToken/refreshToken,业务方不需要 apiKey。
| 业务参数 | 对应 SDK 字段 | 必填 | 说明 |
|---|---|---|---|
| tenant | user.tenantId | ✅ | 租户 ID |
| channelId | channel | ✅ | 渠道标识 |
| channelUserName | user.userName | ✅ | 渠道展示名(必填;与 chatUserName 映射同一字段,传其一即可) |
| botId | bot.botId | ❌ | 不传时 iframe 进入智能体选择列表 |
| chatUser | user.userCode | ❌ | 聊天用户 ID(Portal 命名);Portal 接入下不加到请求头 |
| chatUserName | user.userName | ✅ | 聊天用户名(Portal 命名;与 channelUserName 映射同一字段) |
Portal 模式下
channelUserName与chatUserName都映射到同一个user.userName,二者传其一即可;同时传时以后传入者为准。 Portal 接入下userCode/userName不会加到接口请求头(由 iframe 侧按apiKey是否有值判定)。
最小接入示例:
AIAssistant.init({
serverUrl: 'https://baas.hd123.com',
container: '#assistant-container',
auth: {
// Portal 模式:不传 apiKey,只走 accessToken / refreshToken
accessToken: () => store.loginInfo.accessToken || '',
refreshToken: () => store.loginInfo.refreshToken || '',
},
user: {
tenantId: '8211', // 必填 → tenant
userName: () => userInfo.employeeName, // 必填 → channelUserName / chatUserName
userCode: () => userInfo.employeeCode, // 选填 → chatUser
},
channel: 'athena', // 必填 → channelId
bot: { botId: 'xxxx' }, // 选填
});4.10.3 两种形式的差异一览
| 维度 | 非 Portal | Portal |
|---|---|---|
| 鉴权来源 | 宿主自己的 accessToken + 渠道 apiKey | Portal 的 accessToken / refreshToken |
| apiKey | 必填 | 无需传 |
| 用户身份 (user.userCode / user.channelUserId) | 二选一必填;userCode→请求头 employeeCode,channelUserId→请求头 channelUserId | userCode 选填(对应 chatUser),不下发到请求头 |
| user.userName | 必填(对应 channelUserName,仅展示) | 必填(channelUserName 或 chatUserName 二选一),不下发到请求头 |
| tenant / channelId / botId | 一致 | 一致 |
五、实例方法 AIAssistantInstance
| 方法 | 签名 | 说明 |
|---|---|---|
| send | (text: string, options?: SendOptions) => void | 发送消息/指令;发送前自动同步动态字段 |
| syncDynamicFields | () => void | 重读所有 getter,与上次快照 diff,仅变化才推送 update |
| updateAuth | (auth: AuthConfig) => void | 整体替换鉴权并推送 |
| updateApiKey | (apiKey: string) => void | 便捷入口:仅更新渠道 apiKey;内部走 syncDynamicFields,值真变化才推送。传空串等同于清除 |
| updateUser | (user: Partial<UserConfig>) => void | 合并用户信息并推送(用户/租户切换入口) |
| updateContext | (ctx: Partial<ContextConfig>) => void | 推送页面上下文(iframe 侧全量替换);会写回内部 config,iframe 重载重握手时带上最新值 |
| updateBot | (botId: string) => void | 运行时切换默认智能体,写回 config.bot 并推送 |
| show / hide | () => void | 切换面板显隐(同时改容器 display,不销毁实例、会话保留) |
| enterFullscreen / exitFullscreen | () => void | 显式进入/退出全屏(幂等);等价于收到 resizeAssistant 后 SDK 内置的处理。exitFullscreen() 退出后一律回到右侧侧边栏可见态(面板保留、悬浮球隐藏);要彻底收起请用 hide() |
| fullscreen | (target?: string \| HTMLElement) => void | 对外全屏目标节点方法(独立于 iframe 的 resizeAssistant 事件流与 onResize)。传入目标节点时把 iframe 容器移动进该节点并铺满;未传则挂载到 document.body 铺满整个视口(body-mount)。退出时调用 exitFullscreen() 还原。已处于全屏时重复调用会被忽略,不能在全屏中切换 target(先 exitFullscreen() 再进入)。见 9.2 |
| toggleFullscreen | () => void | 切换全屏状态 |
| isFullscreen | () => boolean | 查询当前是否处于全屏 |
| destroy | () => void | 销毁 Messenger、iframe,清空监听 |
| on / off | (event, handler) => void | 事件监听 |
SendOptions:{ type?: 'message' \| 'command'; metadata?: Record<string, unknown> }
六、运行时同步:token 刷新 / 用户切换
SDK 会在下列时机自动「重读 getter → diff → 有差异才推 update」:
assistant.send(text)调用前assistant.updateContext(ctx)调用前- 握手
ack到达后(兜底 init 期间的漂移) - 宿主显式调用
assistant.syncDynamicFields()
因此推荐在宿主框架里挂 watcher,触发 SDK 主动同步(无需自己 diff):
// token 刷新链路
watch(() => store.accessToken, () => assistant.syncDynamicFields());
// 用户 / 租户切换
watch(() => userInfo, () => assistant.syncDynamicFields());
// 渠道 apiKey 变化(业务方主动切租户 / 切换渠道后重新拿 apiKey)
watch(() => store.apiKey, () => assistant.syncDynamicFields());或用显式方法:
assistant.updateAuth({ accessToken: () => AppContext.getToken() });
assistant.updateUser({ userCode: 'new-user', userName: '新用户' });
// apiKey 单独更新(内部同样走 diff,值真变化才 push;传空串代表清除)
assistant.updateApiKey('new-channel-api-key');
assistant.updateApiKey(''); // 清除apiKey 与 accessToken/refreshToken 共同参与
syncDynamicFields的 diff。 若auth.apiKey传的是 getter,getter 返回值变化时也会被自动感知,无需显式调用updateApiKey。
sequenceDiagram
participant HOST as 宿主业务
participant SDK as AIAssistant SDK
participant IFRAME as CHATBOX iframe
HOST->>HOST: token 刷新 / 用户切换
HOST->>SDK: syncDynamicFields()
SDK->>SDK: 重读 getter,与快照 diff
alt 有变化
SDK->>IFRAME: postMessage update(变化字段)
IFRAME-->>SDK: ack
Note over IFRAME: 写回 store,后续请求用新值
else 无变化
SDK->>SDK: no-op(不推送)
end七、更新上下文:updateContext
上下文变化时(页面切换、业务数据更新、用户在某条记录上操作等),调用 assistant.updateContext(ctx) 推送最新上下文,随下一次对话透传给 LLM。
⚠️ iframe 侧对
context是全量替换语义:以新传入的对象为准,旧 key 若不在新对象中则被删除。因此每次都要传完整的当前上下文,不要只传变化的那一个字段。
// 推荐:封装一个"由路由 + 业务状态生成完整上下文"的函数,任何变化都走它
function buildPageContext(route, state) {
return {
pageName: route.meta?.title ?? document.title,
pageDesc: route.meta?.desc ?? '',
pageParams: {
...route.params,
...route.query,
...state, // 当前选中记录、筛选条件等
},
};
}
// 1) 路由切换时同步
router.afterEach((to) => {
assistant?.updateContext(buildPageContext(to, currentState));
});
// 2) 页面内业务状态变化时同步(筛选、选中行、切 tab…)
watch([filters, selectedRow], () => {
assistant?.updateContext(buildPageContext(router.currentRoute.value, {
metricName: filters.metricName,
status: filters.status,
selectedId: selectedRow.value?.id,
}));
});对应下发到后端:
// variables: {
// pageContext: '{"pageName":"指标模型列表","pageDesc":"…","pageParams":{"metricName":"日活跃用户数","status":"enabled"}}'
// }
updateContext可随时调用,不改变对话框的展开/收起状态;上下文自动跟随,对话不中断。握手完成前调用会排队,完成后自动刷出(同时写回内部 config,iframe 因全屏移动重载后重握手仍是最新值)。- 上下文只在下一次发起对话时生效,不会打断正在进行的回答。
- 无论首次
init传入还是运行时updateContext推送,最终的整个上下文对象都会序列化为 JSON 字符串,作为对话接口variables.pageContext的值一起下发。
八、onMessage 事件与导航
CHATBOX 通过 onMessage 把大模型的结构化产物回传给宿主,宿主按 event 类型分发。典型是导航跳转 jumpPage:
onMessage(msg) {
if (msg.event === 'jumpPage') {
handleHaiJumpPage(msg.data); // 走宿主自己的路由跳转
}
// 内嵌 H5 场景:以下两个事件默认已被 SDK 内置接管,onMessage 里通常无需处理。
// 只有把对应 config 的 enabled 置为 false 时,才必须在这里(或 onClose/onResize)自己做。
// if (msg.event === 'closeAssistant') { /* 默认 SDK 已 hide() */ }
// if (msg.event === 'resizeAssistant') { /* 默认 SDK 已切换样式 */ }
}全屏配置的三种典型接入姿势:
// A. 零配置(默认)—— iframe 内点击全屏图标即铺满整个浏览器窗口
AIAssistant.init({ /* 其他配置 */ });
// B. 在业务系统「工作区」节点内全屏(不覆盖侧栏/导航)
AIAssistant.init({
fullscreen: { target: '#workspace-root' },
/* 其他配置 */
});
// C. 关闭内置行为,业务自己切换(例如需要触发路由跳转/自研全屏组件)
AIAssistant.init({
fullscreen: { enabled: false },
onResize(mode, api) {
if (mode === 'fullscreen') {
openMyOwnFullscreenLayer();
// 若中途还想让 SDK 接管:api.enter();
} else {
closeMyOwnFullscreenLayer();
// api.exit();
}
},
/* 其他配置 */
});关闭按钮的三种典型接入姿势:
// A. 零配置(侧栏形态推荐):SDK hide() 已收起容器占位
AIAssistant.init({
onClose() {
toolbarHighlight.value = false;
},
/* 其他配置 */
});
// B. 让关闭等于销毁(例如"关闭"就是彻底退出,下次重新初始化)
AIAssistant.init({
close: { action: 'destroy' },
/* 其他配置 */
});
// C. 关闭内置行为,业务完全接管(例如二次确认、埋点、动画)
AIAssistant.init({
close: { enabled: false },
onClose(api) {
confirm('确定关闭吗?').then((ok) => {
if (ok) api.hide(); // 想复用 SDK 的 hide 逻辑
// 或 api.destroy(); // 需要彻底销毁
// 或 myOwnCloseAnimation(); // 完全自定义
});
},
/* 其他配置 */
});BridgeEvent 结构:{ event: string; data?: unknown; timestamp: number }
jumpPage 载荷示例:
{
"event": "jumpPage",
"data": {
"name": "会员详情页",
"params": { "appId": "member-system", "path": "/#/member/member-details", "memberId": "0001" }
}
}closeAssistant 载荷示例:
{
"event": "closeAssistant",
"data": { "source": "header" }
}resizeAssistant 载荷示例:
{
"event": "resizeAssistant",
"data": { "mode": "fullscreen" }
}mode 取值:
"fullscreen"—— 用户点击全屏图标,宿主应让 iframe 容器覆盖整个窗口"sidebar"—— 用户点击退出全屏,宿主应恢复到侧边抽屉/侧栏原有尺寸
uiState —— 宿主 UI 元素显隐(无需业务处理,SDK 自动消费)
CHATBOX 聊天 UI 在探测到宿主 UI 元素应隐藏/显示时,会 emit event=uiState。SDK 已自动接管并据此驱动对应元素,业务方通常无需在 onMessage 里处理;只有把 SDK 内置行为关闭或需要额外联动时才关心它。
{
"event": "uiState",
"data": { "floatingBall": "hidden" }
}floatingBall 取值:
"hidden"—— 当前用户无任何智能体权限,SDK 自动隐藏右下角悬浮球(悬浮球创建/拖动/显隐完全由 bridge 接管)"visible"—— 恢复正常显示
锚定在页面右上角的固定 AI 图标属于业务系统自身元素,不在 SDK 元素清单内,
uiState不会驱动它。
也可用 on() 订阅具体事件:
assistant.on('jumpPage', (data) => { /* ... */ });
assistant.on('ready', () => { /* 握手完成 */ });
assistant.on('error', (err) => { /* ... */ });九、显隐、悬浮入口与销毁
默认启用悬浮入口时,init() 完成后的初始状态是“入口显示、面板隐藏”(容器 display:none)。入口点击或键盘激活会显示面板并隐藏入口;hide() 会收起容器占位并恢复入口最近的拖动位置。宿主不要再用 v-show 管容器:
const assistant = await AIAssistant.init({
// ...其他配置
onOpen: () => { toolbarHighlight.value = true; },
onClose: () => { toolbarHighlight.value = false; },
});
assistant.show(); // 显示面板、隐藏悬浮入口,并触发 onOpen
assistant.hide(); // 隐藏面板、收起容器占位、恢复悬浮入口(不销毁,会话保留)禁用入口时不创建悬浮球,面板同样初始隐藏,需显式 show():
const assistant = await AIAssistant.init({
// ...其他配置
launcher: { enabled: false },
});
assistant.show();// 组件卸载时无条件销毁
onBeforeUnmount(() => {
assistant?.destroy();
assistant = null;
});destroy() 会:退出全屏并还原容器 inline style → 拆除拖拽手柄、悬浮入口与全局监听 → 移除 iframe DOM → 销毁 Messenger 与事件监听。
9.1 悬浮入口人工验证清单
bridge 包当前未配置测试运行器,可在任一宿主测试页加载构建后的 UMD 或 ESM 产物,按以下步骤执行最小 DOM 验证:
- 不传
launcher初始化:确认document.body下只有一个[data-hai-bridge-launcher],尺寸 80×80px,右/下偏移各 40px,iframe 初始隐藏。 - 传入自定义
width、height、right、bottom:确认尺寸和初始位置生效;传负偏移、非正尺寸或超出视口的位置,确认值被归一化且入口完整可见。 - 配置
launcher: { enabled: false }:确认不创建入口,面板仍初始隐藏,调用show()后才可见。 - 点击或使用 Tab 聚焦后按 Enter/Space:确认面板显示、入口隐藏、
onOpen触发。 - 使用鼠标和触控分别拖动超过 4px:确认入口跟随指针、四边不越界,释放后不展开助手;点击时正常展开。
- 将入口拖到右下角后缩小窗口:确认入口自动重新约束到新视口内。
- 位置基准回归:把入口拖到距右侧约 20px 处,然后放大/缩小窗口、并用
Ctrl/Cmd++/-调整浏览器缩放,确认入口与右下角的间距始终保持不变,不随视口变宽而离开右边缘。 - 左上角漂移回归:点击入口展开面板(此时入口
display:none),在面板展开状态下缩放窗口或调整浏览器 zoom,再关闭面板,确认入口回到原位置而不是跳到左上角。 - 触发
closeAssistant的hide分支:确认退出全屏、面板与容器占位一并隐藏、入口在最近位置恢复;destroy分支确认入口被移除。 - 调用
destroy()后重新init():确认页面始终只有一个入口,旧指针和 resize 监听不再响应。
9.2 对外全屏目标节点方法:fullscreen(target?)
区别于 iframe 内点击「全屏/退出」触发的 resizeAssistant 事件流,assistant.fullscreen(target?) 是业务侧可随时显式调用的独立方法(工具栏按钮、快捷键、宿主自身的切换入口都能用),不依赖 onResize 联动。
// ① 挂载到业务系统指定的工作区节点内全屏(不覆盖导航)
assistant.fullscreen('#workspace-root');
// 或传 DOM 节点
assistant.fullscreen(document.getElementById('workspace-root'));
// ② 不传 target → 挂载到 document.body,铺满整个视口(body-mount)
assistant.fullscreen();
// ③ 退出:还原到调用前的位置与 inline 样式
assistant.exitFullscreen();
// 查询当前是否在全屏
assistant.isFullscreen();行为要点:
- 进入:把 iframe 容器 DOM 临时移动到目标节点(未传则
document.body)内并铺满(position:absolute; inset:0,body-mount 场景铺满视口),复用 SDK 现有 target-mount 逻辑——目标节点需是position: relative(或其他非 static)。 - 退出:调用
exitFullscreen(),把容器移回原位置并逐字段还原 inline style,不会伤到宿主已有样式。退出后面板一律停在右侧侧边栏可见态(不因「进入全屏前是隐藏态」而被顺带关掉);「关闭」是独立语义,走closeAssistant/hide()。 - 全屏布局与
appearance.mode === 'fullscreen'内嵌场景完全一致;全屏态自动隐藏侧边拖拽手柄,退出后恢复。 - 可幂等调用:已处于全屏时重复调用会被忽略。全屏中不能切换
target,需要先exitFullscreen()再以新 target 进入。
同时也暴露在 onResize 回调的 api 上(api.fullscreen(target?)),便于宿主在通知回调里反手完成样式切换。
⚠️ 与 iframe 内 Header 全屏按钮保持一致: iframe 内点「全屏」走
resizeAssistant→ 宿主enterFullscreen(),目标读的是fullscreen.target配置;而fullscreen(target?)读的是显式参数。若两种入口都要用且希望行为一致,请把挂载点声明到fullscreen.target配置里(唯一事实源),宿主按钮fullscreen('#xxx')与配置指向同一节点。否则 iframe 内 Header 全屏会整窗铺满,与宿主按钮指定区域全屏不一致。
十、安全模型与 allowedOrigins
10.1 双向校验(与旧版白名单不同)
Bridge 是宿主页面与 iframe 之间的 postMessage 通信,两侧校验方式不对称:
| 方向 | 校验方 | 实际规则 |
| --- | --- | --- |
| iframe → 宿主 | SDK(Messenger) | event.source === iframe.contentWindow 且 event.origin 等于 iframe 地址(由后端 data.webHomeUrl 解析)。同源其它窗口/iframe/弹窗注入的 closeAssistant / ready / uiState 会被丢弃 |
| 宿主 → iframe | CHATBOX 页面 | 只信任完成 init 握手的那个宿主 origin。首个 init 放行并记录 event.origin,之后的 init / update / command 必须同源 |
- 宿主向 iframe
postMessage的 target origin 使用 iframe 地址解析出的 origin,不使用'*'。 - iframe 向外发:
ready可广播(握手前、不含敏感信息);其余事件锁定到握手 origin。 - 若宿主站点配置了 CSP,需要把 iframe 地址加入
frame-src,并把serverUrl加入connect-src(SDK 用fetch请求 app-info 接口)。
10.2 allowedOrigins 不再是 401 开关
config.allowedOrigins 只作为可选字段随握手 payload 下发给 iframe(保留兼容)。漏配不会导致接口 401,也不再作为宿主 SDK 的来源白名单。
旧文档曾要求 allowedOrigins: [location.origin],并把漏配解释成「iframe 丢弃 init → 401」。当前实现里:
- iframe 侧已改为握手 origin 准入,不再读这份名单;
- 宿主若把
location.origin配进白名单、却只验 origin 不验event.source,同源业务页里其它 iframe / 弹窗就能伪造closeAssistant/ready。因此宿主侧已改为只认助手 iframe 的contentWindow。
存量接入里写着 allowedOrigins: [location.origin] 可以保留(无害),新接入可以不传。
接口 401 请查:auth.apiKey / accessToken 是否为空或过期、onError 是否报了 FETCH_IFRAME_URL_FAILED / HANDSHAKE_TIMEOUT、iframe 是否真正完成握手(onReady)。不要去查 CORS——后端 CORS 是放开的。
10.3 握手失败怎么感知
两条错误路径都会走到 onError,避免只 catch 其中一个:
| 场景 | onError.code | init() Promise |
| --- | --- | --- |
| 拉取 iframe 地址失败(HTTP 错误 / 缺 webHomeUrl / 10s 超时) | FETCH_IFRAME_URL_FAILED | reject(实例未创建) |
| iframe 未发 ready,或 init 已发出但 ack 迟迟不到(间隔重试耗尽 / 30s 超时) | HANDSHAKE_TIMEOUT | 已 resolve(实例已创建),靠回调/事件 |
AIAssistant.init({
/* ... */
onError(err) {
console.error(err.code, err.message);
},
}).catch((e) => {
// 拉 iframe 地址失败会同时 reject
console.error(e);
});十一、完整调用示例
11.1 Vue 3(组合式 API / Composable,推荐)
src/composables/useAIAssistant.ts —— 单例封装,全局只初始化一次:
import { ref, shallowRef, nextTick } from 'vue';
import type { Router, RouteLocationNormalized } from 'vue-router';
import {
AIAssistant,
type AIAssistantInstance,
type BridgeEvent,
type ContextConfig,
} from '@hd-front-end/bridge';
const CONTAINER = '#assistant-container';
/** iframe 实例(shallowRef:避免 Vue 深度代理 SDK 实例) */
const assistant = shallowRef<AIAssistantInstance | null>(null);
/** 面板是否打开(仅驱动宿主工具条,不要绑到容器 v-show) */
const visible = ref(false);
/** 页面内业务状态,参与 pageParams */
const extraParams = ref<Record<string, unknown>>({});
/** 统一生成完整上下文:pageName / pageDesc / pageParams 三段式 */
function buildContext(route: RouteLocationNormalized): ContextConfig {
return {
pageName: (route.meta?.title as string) || document.title,
pageDesc: (route.meta?.desc as string) || '',
pageParams: {
...route.params,
...route.query,
...extraParams.value,
},
};
}
export function useAIAssistant() {
/** 初始化:必须在容器已渲染之后调用(布局组件的 onMounted 里) */
async function init(router: Router) {
if (assistant.value) return assistant.value;
// 等容器进入 DOM,否则 SDK 抛 "Container not found"
await nextTick();
if (!document.querySelector(CONTAINER)) {
console.warn('[AIAssistant] 容器未渲染,跳过初始化');
return null;
}
assistant.value = await AIAssistant.init({
serverUrl: import.meta.env.VITE_HAI_SERVER_URL,
container: CONTAINER,
// 可变字段一律传 getter
auth: {
accessToken: () => userStore.accessToken,
apiKey: () => userStore.apiKey,
},
user: {
userCode: () => userStore.employeeCode,
userName: () => userStore.employeeName,
tenantId: () => userStore.tenantId,
tenantName: () => userStore.tenantName,
},
bot: { botId: import.meta.env.VITE_HAI_BOT_ID },
channel: 'athena',
// 初始上下文
context: buildContext(router.currentRoute.value),
// 拖拽调宽:持久化用户偏好
resize: { minWidth: 360, maxWidth: 960, storageKey: 'hai-assistant-width' },
// 在工作区内全屏,保留顶栏与左菜单
fullscreen: { target: '#workspace-root' },
onReady: () => console.log('[AIAssistant] 就绪'),
onMessage: (msg: BridgeEvent) => {
if (msg.event === 'jumpPage') {
const path = (msg.data as any)?.params?.path;
if (typeof path === 'string') router.push(path).catch(() => {});
}
},
onOpen: () => { visible.value = true; },
onClose: () => { visible.value = false; },
onError: (err) => console.error('[AIAssistant]', err.code, err.message),
});
// 路由切换 → 推送完整上下文
router.afterEach((to) => {
extraParams.value = {};
assistant.value?.updateContext(buildContext(to));
});
// 登录态变化 → 让 SDK 重读 getter
watch(
() => [userStore.accessToken, userStore.apiKey, userStore.tenantId],
() => assistant.value?.syncDynamicFields(),
);
return assistant.value;
}
/** 展开/收起:只调 SDK,不要再改容器 display */
function toggle() {
if (visible.value) {
assistant.value?.hide();
visible.value = false;
} else {
assistant.value?.show();
}
}
/** 页面内业务状态变化 → 刷新 pageParams */
function setPageParams(params: Record<string, unknown>, route: RouteLocationNormalized) {
extraParams.value = params;
assistant.value?.updateContext(buildContext(route));
}
function destroy() {
assistant.value?.destroy();
assistant.value = null;
}
return { assistant, visible, init, toggle, setPageParams, destroy };
}src/layouts/BasicLayout.vue —— 挂载点 + 布局:
<template>
<div class="app-shell">
<main id="workspace-root" class="app-shell__content">
<router-view />
</main>
<!-- AI 助手挂载点:DOM 常驻(不要 v-if / v-show),显隐由 SDK 接管 -->
<aside
id="assistant-container"
class="ai-assistant-panel"
></aside>
</div>
<el-button class="assistant-trigger" @click="toggle">AI 助手</el-button>
</template>
<script setup lang="ts">
import { onMounted, onBeforeUnmount } from 'vue';
import { useRouter } from 'vue-router';
import { useAIAssistant } from '@/composables/useAIAssistant';
const router = useRouter();
const { visible, init, toggle, destroy } = useAIAssistant();
onMounted(() => init(router));
onBeforeUnmount(() => destroy());
</script>
<style scoped lang="scss">
.app-shell {
display: flex;
height: 100%;
gap: 16px;
padding: 0 24px 24px;
overflow: hidden;
}
.app-shell__content {
flex: 1;
min-width: 0;
height: 100%;
position: relative; /* fullscreen.target 需要非 static */
overflow: auto;
}
.ai-assistant-panel {
width: 420px;
flex-shrink: 0;
height: 100%;
position: relative;
overflow: hidden;
background: #fff;
border: 1px solid #e1e6ef;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(31, 45, 61, 0.12);
:deep(iframe) {
display: block;
width: 100%;
height: 100%;
border: 0;
}
}
</style>在业务页面里补充 pageParams:
<script setup lang="ts">
import { watch } from 'vue';
import { useRoute } from 'vue-router';
import { useAIAssistant } from '@/composables/useAIAssistant';
const route = useRoute();
const { setPageParams } = useAIAssistant();
const filters = reactive({ metricName: '', status: 'enabled' });
// 筛选条件变化 → 让助手知道用户当前在看什么
watch(filters, () => {
setPageParams({ metricName: filters.metricName, status: filters.status }, route);
}, { deep: true });
</script>11.2 Vue 2(选项式 API / Class 组件)
// src/common/aiAssistant.ts
import { AIAssistant, type AIAssistantInstance, type BridgeEvent } from '@hd-front-end/bridge';
const CONTAINER = '#assistant-container';
let assistant: AIAssistantInstance | null = null;
function buildContext(route: any) {
return {
pageName: route?.meta?.title || document.title,
pageDesc: route?.meta?.desc || '',
pageParams: { ...(route?.params || {}), ...(route?.query || {}) },
};
}
export async function initAIAssistant(
router: any,
hooks?: { onOpen?: () => void; onClose?: () => void },
) {
if (assistant) return assistant;
if (!document.querySelector(CONTAINER)) return null; // 容器未渲染,跳过
assistant = await AIAssistant.init({
serverUrl: process.env.VUE_APP_HAI_SERVER_URL!,
container: CONTAINER,
auth: {
accessToken: () => store.state.token.accessToken || '',
apiKey: () => store.state.hai.apiKey || '',
},
user: {
userCode: () => store.state.loginInfo?.user?.employeeNo || '',
userName: () => store.state.loginInfo?.user?.employeeName || '',
tenantId: () => store.state.loginInfo?.tenant || '',
},
bot: { botId: process.env.VUE_APP_HAI_BOT_ID! },
channel: 'athena',
context: buildContext(router?.currentRoute),
resize: { storageKey: 'hai-assistant-width' },
onMessage: (msg: BridgeEvent) => {
if (msg.event === 'jumpPage') {
const url = (msg.data as any)?.params?.url || '';
const path = url.includes('#') ? url.slice(url.indexOf('#') + 1) : url;
if (path.startsWith('/')) router.push(path).catch(() => {});
}
},
onOpen: () => hooks?.onOpen?.(),
onClose: () => hooks?.onClose?.(),
onError: (err) => console.error('[AIAssistant]', err.code, err.message),
});
router.afterEach((to: any) => assistant?.updateContext(buildContext(to)));
return assistant;
}
export function getAIAssistant() { return assistant; }
export function destroyAIAssistant() {
assistant?.destroy();
assistant = null;
}<!-- Layout.vue -->
<template>
<el-main class="app-shell">
<div class="app-shell__content"><router-view /></div>
<aside id="assistant-container" class="ai-assistant-panel"></aside>
</el-main>
</template>
<script lang="ts">
import Vue from 'vue';
import { initAIAssistant, getAIAssistant, destroyAIAssistant } from '@/common/aiAssistant';
export default Vue.extend({
data() {
return { visible: false };
},
async mounted() {
await this.$nextTick(); // 保证容器已渲染
await initAIAssistant(this.$router, {
onOpen: () => { this.visible = true; },
onClose: () => { this.visible = false; },
});
},
beforeDestroy() {
destroyAIAssistant();
},
methods: {
toggle() {
if (this.visible) {
getAIAssistant()?.hide();
this.visible = false;
} else {
getAIAssistant()?.show();
}
},
},
});
</script>11.3 纯 JS / 无构建工具(UMD script)
适合 JSP、Thymeleaf、jQuery 老系统或任何不走打包的页面。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>业务系统</title>
<style>
html, body { height: 100%; margin: 0; }
/* 挂载父级:横向 flex */
.app-shell { display: flex; height: 100%; overflow: hidden; }
/* 主体内容:占满剩余宽度 */
.app-shell__content { flex: 1; min-width: 0; height: 100%; overflow: auto; padding: 24px; }
/* AI 助手容器:宽度由 CSS 决定,显隐由 SDK 写 inline display */
#assistant-container {
width: 420px;
flex-shrink: 0;
height: 100%;
position: relative;
overflow: hidden;
background: #fff;
border-left: 1px solid #e1e6ef;
}
#assistant-container iframe { display: block; width: 100%; height: 100%; border: 0; }
</style>
</head>
<body>
<div class="app-shell">
<main class="app-shell__content">
<h1>指标模型列表</h1>
<button id="toggle-assistant">AI 助手</button>
<!-- 业务内容 -->
</main>
<!-- AI 助手挂载点(DOM 常驻,显隐由 SDK 接管) -->
<aside id="assistant-container"></aside>
</div>
<script src="/haichat-web-ui/int/ai-assistant-bridge.umd.js"></script>
<script>
// ① 宿主侧可变状态(token 刷新 / 切租户时改这里即可,SDK 自动感知)
var hostState = {
accessToken: window.APP_TOKEN,
apiKey: window.APP_API_KEY,
employeeCode: window.APP_USER.employeeCode,
employeeName: window.APP_USER.employeeName,
tenantId: window.APP_USER.tenantId
};
// ② 生成完整上下文:pageName / pageDesc / pageParams
function buildContext() {
return {
pageName: '指标模型列表',
pageDesc: '展示当前租户下所有指标模型,支持按名称与状态筛选',
pageParams: {
metricName: document.getElementById('metric-name')
? document.getElementById('metric-name').value
: '',
status: 'enabled'
}
};
}
var assistant = null;
var panelOpen = false;
// ③ 初始化(异步)
AIAssistant.init({
serverUrl: 'https://baas.hd123.com',
container: '#assistant-container',
auth: {
accessToken: function () { return hostState.accessToken; },
apiKey: function () { return hostState.apiKey; }
},
user: {
userCode: function () { return hostState.employeeCode; },
userName: function () { return hostState.employeeName; },
tenantId: function () { return hostState.tenantId; }
},
bot: { botId: 'your-bot-id' },
channel: 'athena',
context: buildContext(),
resize: { minWidth: 360, storageKey: 'hai-assistant-width' },
onReady: function () { console.log('[AIAssistant] 就绪'); },
onOpen: function () { panelOpen = true; },
onClose: function () { panelOpen = false; },
onMessage: function (msg) {
if (msg.event === 'jumpPage') {
location.href = msg.data.params.path;
}
},
onError: function (err) { console.error('[AIAssistant]', err.code, err.message); }
}).then(function (inst) {
assistant = inst;
}).catch(function (e) {
console.error('[AIAssistant] 初始化失败', e);
});
// ④ 展开/收起:只调 SDK
document.getElementById('toggle-assistant').addEventListener('click', function () {
if (!assistant) return;
if (panelOpen) {
assistant.hide();
panelOpen = false;
} else {
assistant.show();
}
});
// ⑤ 业务状态变化 → 推送完整上下文
function onFilterChange() {
assistant && assistant.updateContext(buildContext());
}
// ⑥ token 刷新 → 改 hostState 后让 SDK 重读
function onTokenRefreshed(newToken) {
hostState.accessToken = newToken;
assistant && assistant.syncDynamicFields();
}
// ⑦ 页面卸载 → 销毁
window.addEventListener('beforeunload', function () {
assistant && assistant.destroy();
});
</script>
</body>
</html>11.4 React(参考)
import { useEffect, useRef, useState } from 'react';
import { AIAssistant, type AIAssistantInstance } from '@hd-front-end/bridge';
export function AssistantPanel() {
const ref = useRef<HTMLDivElement>(null);
const instRef = useRef<AIAssistantInstance | null>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
let disposed = false;
// ref.current 在 effect 执行时已挂载,可直接传节点,省去 querySelector
AIAssistant.init({
serverUrl: import.meta.env.VITE_HAI_SERVER_URL,
container: ref.current!,
auth: { accessToken: () => store.getToken(), apiKey: () => store.getApiKey() },
user: {
userCode: () => store.user.employeeCode,
userName: () => store.user.employeeName,
tenantId: () => store.user.tenantId,
},
channel: 'athena',
context: { pageName: document.title, pageDesc: '', pageParams: {} },
onOpen: () => setVisible(true),
onClose: () => setVisible(false),
}).then((inst) => {
if (disposed) { inst.destroy(); return; }
instRef.current = inst;
});
return () => {
disposed = true;
instRef.current?.destroy();
instRef.current = null;
};
}, []);
return (
<aside
ref={ref}
style={{ width: 420, flexShrink: 0, height: '100%', position: 'relative' }}
/>
);
}十二、接入自检清单
上线前逐条验证:
- 容器:
init前容器已在 DOM 中;容器有确定宽度、flex-shrink: 0、完整高度链路。 - 推而不遮:打开助手时主体内容变窄而非被遮挡;表格右侧操作列仍可点击。
- 握手:
onReady触发;onError同时覆盖拉地址失败(FETCH_IFRAME_URL_FAILED)与握手超时(HANDSHAKE_TIMEOUT)。init()的 Promise reject 也要 catch。 - 首次打开:iframe 加载、消息可发送。
- 上下文:
context含pageName/pageDesc/pageParams;路由切换后updateContext已调用且传的是完整上下文。 - token 刷新(触发
syncDynamicFields()):iframe 后续请求携带新 token。 - 用户切换组织/租户:iframe 侧
userCode/channelUserId/tenantId已变。 - 关闭 + 再次打开:不重复
init、不产生二次 iframe、会话保留;hide()后容器占位已收起,页面右侧无空白。 - 拖拽调宽:容器左边缘悬停出现手柄,拖动生效;默认最宽到视口 40%,继续拖不再变宽(需要更宽配
resize.maxWidthRatio);配了storageKey时刷新页面宽度被恢复。若拖动无效 → 检查是否需要resize.target。 - 全屏 / 退出全屏:内置行为下容器铺满窗口(或
fullscreen.target);退出后容器几何、DOM 位置完全还原,无残留 inline style。 - 对外全屏目标节点方法:
assistant.fullscreen('#target')在目标节点内铺满、fullscreen()挂载document.body铺满视口,exitFullscreen()还原位置与 inline 样式;反复调用幂等。 - 悬浮球协议:聊天 UI 无智能体权限上报
uiState { floatingBall: 'hidden' }后悬浮球被 SDK 隐藏,visible后恢复;期间业务未直接调用任何悬浮球控制接口。 - 组件销毁:iframe DOM 被移除,拖拽手柄(挂在
document.body)一并移除,无残留postMessage监听器。 - 安全:伪造消息若不是来自助手 iframe 的
contentWindow会被拒;CSP 已放行 iframe 地址与serverUrl。
十三、常见问题 FAQ
Q1. 报错 [AIAssistant] Container not found: "#assistant-container"
容器在 init 时还没进 DOM。Vue 3 里在 onMounted + await nextTick() 之后再 init;容器 DOM 常驻(不要 v-if),显隐交给 SDK;确认选择器与 DOM 上的 id/class 一致。
Q2. iframe 加载了但看不见 / 高度为 0
容器宽高塌陷。iframe 默认 100%,容器必须有确定尺寸:检查容器 width、height: 100%,以及 html, body, #app 到挂载父级的高度链路是否完整。
Q2.1 iframe 出来了但一直是加载态 / onReady 不触发 / 里面所有接口 401
先看 onError:FETCH_IFRAME_URL_FAILED 是拉 iframe 地址失败(网络/超时/缺 webHomeUrl),HANDSHAKE_TIMEOUT 是 iframe 没回 ready 或 init 没有 ack。鉴权 401 查 auth.apiKey / accessToken 是否为空或过期。
这与 allowedOrigins 无关——宿主 SDK 不再用它做来源白名单,iframe 侧只信任握手 origin,漏配不会丢 init。不要去查 CORS。详见 第十章。
Q3. 关闭助手后页面右侧留了一条空白
宿主仍在用 v-show / CSS display:none 管容器,和 SDK 的 inline display 打架。去掉容器上的 v-show,只调 assistant.hide(),见 3.4。
Q4. 打开助手时内容被遮挡,而不是被推窄
容器用了 position: fixed/absolute,脱离了文档流。改为 flex 兄弟节点 + flex-shrink: 0,见 3.1。
Q5. 拖动手柄能拖,但面板宽度不变
容器宽度由祖先决定。配 resize.target 指向真正决定宽度的祖先节点,见 3.7。
Q6. 拖动手柄看不见 / 被裁掉
手柄以 position: fixed 挂在 document.body,正常不会被裁。若被业务系统的高 z-index 浮层盖住,调大 resize.zIndex。
Q6.1 拖到一半就拖不动了,宽度卡住不再变大
到了上限。默认最大宽度是视口宽度的 40%(maxWidthRatio: 0.4),业务按需放宽:resize: { maxWidthRatio: 0.6 } 放到 60%,传 1 即允许拖满整个视口。若同时配了 maxWidth,实际上限取两者中更小的那个。
Q7. 全屏时盖住了顶栏和左菜单,业务希望只在工作区内全屏
传 fullscreen: { target: '#workspace-root' },并确保该节点是 position: relative。
Q7.1 宿主按钮 fullscreen('#xxx') 全屏正常,但 iframe 内 Header 点全屏却整窗铺满
两条入口的目标来源不同:宿主按钮 fullscreen('#xxx') 读的是显式参数,iframe 内 Header 的全屏按钮走 resizeAssistant → enterFullscreen(),读的是 fullscreen.target 配置(未配则整窗铺满)。把挂载点声明到 fullscreen.target 配置作为唯一事实源,两条入口即可一致。
Q8. 助手收到的上下文总是上一个页面的 / 丢字段
updateContext 是全量替换。每次都要传完整上下文对象,不要只传变化的字段;推荐用统一的 buildContext() 生成。
Q9. token 刷新后 iframe 仍用旧 token
auth.accessToken 传成了字符串字面量。改成 getter;必要时在刷新链路里调用 assistant.syncDynamicFields()。
Q10. 路由切换后助手重新加载、会话丢失
容器挂在了业务页面组件里,随路由被销毁重建。把容器和 init 上移到根布局组件。
Q11. 用户切换菜单时能否保持对话不中断?
可以。只要容器不被销毁,updateContext 只更新上下文、不影响会话与展开状态。
Q12. 一个页面能不能挂两个助手实例?
不建议。SDK 按容器注入 iframe,重复 init 会产生多个 iframe 与多套监听。请用单例封装(见 11.1),init 前判断实例是否已存在。
