# 管理后台集成 Web Embed SDK(浮窗对话)技术方案 - 日期:2026-10-16 - 状态:已实施(2026-10-16 开发完成并入 main;评审修订见 §4.2.1 / §4.3.1 / §5) - 范围:`admin`(管理后台)、`backend-ts`(新增 1 个配置项 + 1 个只读接口)、部署接线与文档。**不改 `sdk/embed` 源码**(SDK 现有能力已满足本需求,全部通过既有公开 API 消费)。 ## 1. 需求背景 Mao 已有 Web Embed SDK(`sdk/embed`,产物 `desktop/public/embed/mao-chat.js`,gzip 约 106KB),供内部 Web 业务系统以「一个脚本 + 一次 `MaoChat.init()`」嵌入 agent 对话浮窗,已具备流式对话、思考过程、工具执行状态、追问作答、手动停止、历史会话、页面操作(`page_*`)、上下文注入、公司 SSO 换票等完整能力,并已在外部宿主验证。 但 **Mao 自己的管理后台(`admin`)尚未接入这个 SDK**:管理员在后台做运维(看会话、配 Agent、查用量)时,想问助手「这个页面是干什么的」「帮我把这个 Agent 的提示词改一下」只能切到桌面端,链路割裂。 本需求把管理后台变成 Embed SDK 的第一个「自家宿主」:在 admin 引入 SDK 实现浮窗对话,验证 SDK 作为内部标准接入方案的可复用性,同时给管理员一个随时可用的页面助手。 ### 为什么不是其他方案 | 备选方案 | 结论 | 原因 | |---|---|---| | 后台里再写一套聊天 UI | 不做 | 重复实现消息流 / WS / 重连 / 工具渲染,与 SDK 双份维护 | | iframe 嵌 desktop 对话页 | 不做 | 已被 `docs/plan/2026-09-04-embed-sdk-technical-design.md` 明确否决(无法沉淀独立契约),且拿不到页面上下文 | | 后台顶栏自绘入口 | 本期不做 | 保留 SDK 自带浮动按钮,与外部宿主行为一致、零额外 UI 开发;后续如需融合 admin 视觉可另立方案 | | 走公司 SSO 换票 | 不做 | admin 用户本身就是 Mao 用户、已持有 Mao access token,绕 SSO 换票是绕远路 | ## 2. 需求描述 ### 2.1 目标 管理员登录管理后台后,页面右下角出现 Mao 助手浮动入口;点击展开浮窗,可就当前后台页面与配置的 Agent 对话(流式输出、思考过程、工具状态、追问作答、手动停止、历史会话、粘贴附件)。浮窗接入 Mao 现有账号体系与权限体系,不新增用户侧鉴权链路。 ### 2.2 本期功能边界(要做) 1. **接入**:`admin/index.html` 以 ` ``` - 放在 `` 前、模块脚本之后。同源相对路径:生产由 Nginx `location /` 直接命中 `desktop/dist/embed/mao-chat.js`(desktop prebuild `scripts/build-embed-sdk.cjs` 会把 sdk/embed 产物拷入 `desktop/public/embed/` 并附版本化副本,deploy-desktop.sh 构建后生效)。**admin 不复制、不打包该产物**,避免两份产物版本漂移。 - dev 需补两处代理(评审补充,否则验收 #1 在 dev 下无法执行):admin vite `server.proxy` 目前只有 `/api`(`admin/vite.config.ts`),① `/embed/mao-chat.js` 会 404 → `window.MaoChat` 为 undefined → `useEmbedChat` 静默不初始化;② `/api` 代理未开 `ws: true`,SDK 的 `ws://…/api/ws/stream` 升级请求不通。修正:proxy 增加 `'/embed': { target: 'http://localhost:5201', changeOrigin: true }`(desktop dev server 端口 5201,由 `public/` 目录提供该文件)+ `/api` 代理补 `ws: true`。备选:dev 下把 `serverUrl` 直连 `http://localhost:9080`(后端 CORS 对非 exchange 路径为 `origin: true` + `credentials: true`,WS 不校验 Origin,外部宿主已验证可行),但同源代理拓扑与生产一致,优先前者。注意 dev 下需同时跑 desktop dev server(:5201)供 `/embed` 代理。 #### 4.3.2 `admin/src/composables/useEmbedChat.ts` 职责(单文件,约 120 行): 1. **初始化时机**:`Layout.vue` 挂载后、确认已登录(`authStore.token` 存在)时调用;先 `GET /embed/admin-config`,`agentId` 非空才 `MaoChat.init`。 2. **init 参数**: ```ts MaoChat.init({ serverUrl: window.location.origin, // 同源:/api/v1 与 /api/ws/stream 都由 Nginx 反代 agentId, getToken: () => Promise.resolve(localStorage.getItem('token') ?? ''), context: () => collectEmbedContext(), // 见 4.3.3 page: { initialLevel: 'per_action' }, // 显式声明默认每次确认 onEvent: (e) => { /* phase/auth/error 仅 console.warn,不弹 toast,避免打扰后台操作 */ }, }); ``` 3. **token 供给细节**:admin 的 axios 拦截器在 401 时用 refreshToken 换新 token 并写回 `localStorage.token`,SDK 的 `getToken()` 回调每次重新读 localStorage,因此 refresh 后 SDK 下一次请求自然拿到新 token;SDK 内部 401 会主动 `invalidate()` 并重取一次,与 admin 的刷新链路叠加仍安全(最坏多刷一次)。**不传 refreshToken 给 SDK**(SDK 也不需要)。 4. **登出/失效联动(双保险)**: - `Layout.vue` 只在登录后的路由树下挂载,跳 `/login` 会被卸载 → `useEmbedChat` 的 `onScopeDispose` 自动 `destroy()`(`authStore.logout()` 后 `router.push('/login')` 即走这条)。 - 另加 `watch(() => authStore.token)`,token 变空时也 `destroy()`。实际覆盖路径:路由守卫拉取用户信息遇 401/403 时 `authStore.clearAuth()` + `next('/login')`(`router/index.ts:207/213`)。注意 `api/index.ts` 的 `forceLogout()` 是 `window.location.href` 硬跳转登录页(整页重载,SDK host 随之消失),**并不经过 authStore**,无需也不走这条 watch——原稿「forceLogout 只清存储不跳路由」的描述与代码不符,以此为准。 - refresh 成功只写 `localStorage`、不更新 `authStore.token` ref,watch 不会误触发 destroy;SDK 的 `getToken` 每次重读 localStorage,自然拿到新 token。 - `destroy()` 幂等(SDK 内部 `window.__maoChatInstance` 单实例约束 + 重复 init 先销毁旧实例),重复调用无副作用。 5. **单实例保护**:admin 是 SPA,`Layout.vue` 在子路由间切换时保持挂载(`keep-alive` 只作用于内部 `router-view`,不作用于 Layout),实例不重建;不做路由级 init。 6. **错误兜底**:`/embed/mao-chat.js` 加载失败(脚本 404 / 被 CSP 拦)时 `window.MaoChat` 为 undefined,`useEmbedChat` 静默返回,不影响后台任何功能;控制台留一条 warn。 #### 4.3.3 `admin/src/utils/embedContext.ts` 纯函数 + 单测: ```ts export interface EmbedPageContext { route: string; // route.name ?? route.path path: string; // vue-router 的 params/query 值可能是 string[](重复 query 参数),勿窄化为 string params: Record; query: Record; } export function collectEmbedContext(route: RouteLocationNormalized): EmbedPageContext ``` - 只取 `route.name` / `route.path` / `route.params` / `route.query`(vue-router 的值均为 string 或 string[],`SessionDetail` 的 `:id` 是路由参数,属结构性信息,允许)。 - **禁止**读取任何 store 的表格数据、表单值、用户资料;**禁止**把 `query` 之外的服务端响应体带入上下文。 - 空路由参数/查询不写入对象(`{}` 而非 `{ id: undefined }`),减少无意义的上下文变化导致的重复注入。 - 调用方在 `context()` 里包一层:`() => ({ ...collectEmbedContext(router.currentRoute.value), title: document.title })`。 SDK 侧行为(既有,无需改):`context()` 结果连同 `url`/`title` 做 hash 变更检测,仅变化时以引用块拼入下一条用户消息,总前缀上限 8KB,超限截断。 #### 4.3.4 挂载点 在 `Layout.vue` 的 `