npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@hd-front-end/bridge

v0.0.13

Published

HaiChat Bridge SDK - 声明式嵌入 AI 助手,类微信 JS-SDK 风格

Downloads

282

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 件事(缺一不可):

  1. 在布局组件里放一个容器节点(如 <aside id="assistant-container">),并给它 确定宽度 + flex-shrink: 0 + 100% 高度。
  2. 容器的父级设为 display: flex,主体内容区设 flex: 1; min-width: 0; —— 这样打开助手时主体被推窄而不是被遮挡。
  3. 等容器渲染完成(Vue 里 await nextTick())再调用 init。
  4. auth / user 全部传 getter。
  5. context 传入 { pageName, pageDesc, pageParams };路由切换时 updateContext() 推送完整上下文。
  6. 容器显隐由 SDK 接管:不要对挂载容器使用 v-show / 样式 display:none,打开用 show()、关闭用 hide()。
  7. 卸载时 destroy()。

最容易踩的 5 个坑(详见 第十三章 FAQ):

  • 容器还没渲染就 init → Container not found 报错。
  • 对挂载容器使用 v-show / CSS display:none → 与 SDK 写入的 inline display 双重所有权,面板打不开或关不掉。
  • 容器宽度由外层祖先决定(flex/grid 撑出来的)→ 拖拽调宽无效,需要配 resize.target。
  • 容器父链上有 overflow: hidden 且用了 position: fixed 全屏 → 用 fullscreen.target 指定在工作区内全屏。
  • onError 只处理握手失败、不 catch init() 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 验证:

  1. 不传 launcher 初始化:确认 document.body 下只有一个 [data-hai-bridge-launcher],尺寸 80×80px,右/下偏移各 40px,iframe 初始隐藏。
  2. 传入自定义 width、height、right、bottom:确认尺寸和初始位置生效;传负偏移、非正尺寸或超出视口的位置,确认值被归一化且入口完整可见。
  3. 配置 launcher: { enabled: false }:确认不创建入口,面板仍初始隐藏,调用 show() 后才可见。
  4. 点击或使用 Tab 聚焦后按 Enter/Space:确认面板显示、入口隐藏、onOpen 触发。
  5. 使用鼠标和触控分别拖动超过 4px:确认入口跟随指针、四边不越界,释放后不展开助手;点击时正常展开。
  6. 将入口拖到右下角后缩小窗口:确认入口自动重新约束到新视口内。
  7. 位置基准回归:把入口拖到距右侧约 20px 处,然后放大/缩小窗口、并用 Ctrl/Cmd + +/- 调整浏览器缩放,确认入口与右下角的间距始终保持不变,不随视口变宽而离开右边缘。
  8. 左上角漂移回归:点击入口展开面板(此时入口 display:none),在面板展开状态下缩放窗口或调整浏览器 zoom,再关闭面板,确认入口回到原位置而不是跳到左上角。
  9. 触发 closeAssistant 的 hide 分支:确认退出全屏、面板与容器占位一并隐藏、入口在最近位置恢复;destroy 分支确认入口被移除。
  10. 调用 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」。当前实现里:

  1. iframe 侧已改为握手 origin 准入,不再读这份名单;
  2. 宿主若把 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' }}
    />
  );
}

十二、接入自检清单

上线前逐条验证:

  1. 容器:init 前容器已在 DOM 中;容器有确定宽度、flex-shrink: 0、完整高度链路。
  2. 推而不遮:打开助手时主体内容变窄而非被遮挡;表格右侧操作列仍可点击。
  3. 握手:onReady 触发;onError 同时覆盖拉地址失败(FETCH_IFRAME_URL_FAILED)与握手超时(HANDSHAKE_TIMEOUT)。init() 的 Promise reject 也要 catch。
  4. 首次打开:iframe 加载、消息可发送。
  5. 上下文:context 含 pageName / pageDesc / pageParams;路由切换后 updateContext 已调用且传的是完整上下文。
  6. token 刷新(触发 syncDynamicFields()):iframe 后续请求携带新 token。
  7. 用户切换组织/租户:iframe 侧 userCode / channelUserId / tenantId 已变。
  8. 关闭 + 再次打开:不重复 init、不产生二次 iframe、会话保留;hide() 后容器占位已收起,页面右侧无空白。
  9. 拖拽调宽:容器左边缘悬停出现手柄,拖动生效;默认最宽到视口 40%,继续拖不再变宽(需要更宽配 resize.maxWidthRatio);配了 storageKey 时刷新页面宽度被恢复。若拖动无效 → 检查是否需要 resize.target。
  10. 全屏 / 退出全屏:内置行为下容器铺满窗口(或 fullscreen.target);退出后容器几何、DOM 位置完全还原,无残留 inline style。
  11. 对外全屏目标节点方法:assistant.fullscreen('#target') 在目标节点内铺满、fullscreen() 挂载 document.body 铺满视口,exitFullscreen() 还原位置与 inline 样式;反复调用幂等。
  12. 悬浮球协议:聊天 UI 无智能体权限上报 uiState { floatingBall: 'hidden' } 后悬浮球被 SDK 隐藏,visible 后恢复;期间业务未直接调用任何悬浮球控制接口。
  13. 组件销毁:iframe DOM 被移除,拖拽手柄(挂在 document.body)一并移除,无残留 postMessage 监听器。
  14. 安全:伪造消息若不是来自助手 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 前判断实例是否已存在。