@csntgao/uni-base
v0.7.12
Published
用于把 Uniplat 登录、应用切换和数字员工聊天能力嵌入第三方网页的 TypeScript SDK。v0.1 包含与 UI 无关的浏览器客户端、一组标准 Web Component,以及原生 HTML 接入示例。
Readme
uni-base
用于把 Uniplat 登录、应用切换和数字员工聊天能力嵌入第三方网页的 TypeScript SDK。v0.1 包含与 UI 无关的浏览器客户端、一组标准 Web Component,以及原生 HTML 接入示例。
Workspace
packages/core @csntgao/ai-employee-client
packages/web-components @csntgao/ai-employee-web-components
examples/vanilla 无框架接入示例包 scope 已从
@uniplat迁移到@csntgao。调用方需要把依赖和 import 分别改为@csntgao/ai-employee-client、@csntgao/ai-employee-web-components;<uniplat-...>Custom Element 标签、公开类型和后端协议名称保持不变。
要求 Node.js 20+ 和 pnpm。常用命令:
pnpm install
pnpm lint
pnpm format:check
pnpm typecheck
pnpm test
pnpm build
pnpm devpnpm dev 会先构建两个 SDK 包,再启动 examples/vanilla。示例通过 workspace 包的 exports 加载 dist,不引用包内源码;宿主接口地址在 examples/vanilla/public/runtime-config.js 中配置。
Core client
宿主系统负责用自己的 HTTP API 适配会话、历史、发送和短期令牌。SDK 不调用 Kanban 内部 HMAC 接口,也不会持久化 JWT。
import {
AiEmployeeClient,
AiEmployeeTransportError,
type AiEmployeeTransport,
} from '@csntgao/ai-employee-client'
const transport: AiEmployeeTransport = {
async createSessionWithFirstMessage(request) {
const response = await hostApi.createSessionWithFirstMessage({
request_id: request.requestId,
ae_code: request.aeCode,
agent_code: request.agentCode,
content: request.content,
trusted_context: request.trustedContext,
attachments: request.attachments.map((attachment) => ({
transaction_id: attachment.transactionId,
fspath: attachment.fspath,
})),
})
return {
sessionId: response.session_id,
messageId: response.message_id,
executionId: response.execution_id,
status: response.status,
startedAt: response.started_at,
aeCode: response.ae_code,
agentCode: response.agent_code,
conversationType: response.conversation_type,
title: response.title,
createdAt: response.created_at,
}
},
async listMessages(sessionId) {
return hostApi.listMessages(sessionId)
},
async listSessions() {
return hostApi.listSessions()
},
async markSessionRead(sessionId, latestMessageId) {
await hostApi.markSessionRead(sessionId, latestMessageId)
},
async sendMessage(sessionId, content) {
const result = await hostApi.sendMessage(sessionId, content)
if (result.status === 429) {
throw new AiEmployeeTransportError('TOO_MANY_REQUESTS')
}
},
async getAccessToken() {
// 可以返回已登录用户 JWT,也可以返回 Host 为匿名访客申请的匿名 JWT。
return auth.getCurrentAccessToken()
},
}
const client = new AiEmployeeClient({
realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
aeCode: 'recruit',
agentCode: 'default',
transport,
})
client.on('message', ({ message, source }) => {
renderMessage(message, source)
})
client.on('connection-state-change', ({ current }) => {
renderConnectionState(current)
})
client.on('error', ({ code, message }) => {
renderSafeError(code, message)
})
await client.start() // 未传 sessionId 时只建立本地草稿,不创建后端空 Session
await client.sendMessage('帮我筛选候选人')
// 页面或业务容器销毁时:
client.destroy()也可以在构造时传入 sessionId 绑定已有会话,稍后调用 bindSession(sessionId) 切换,或调用 startNewConversation() 回到本地草稿。未绑定会话时,sendMessage() 会生成 UUID request_id 并调用 createSessionWithFirstMessage();Host 将字段映射到首条消息原子创建接口,成功后 SDK 才使用响应中的 sessionId 和 messageId 绑定会话。网络失败、组件重建或同一标签页刷新后,重试内容未变化的首条消息会复用原 request_id,防止重复会话和重复执行。默认恢复信息只在 sessionStorage 保存 UUID 和消息内容的摘要,不保存消息正文、附件内容、JWT 或后端响应;成功、切换既有会话或主动开启新对话后自动清除。测试或特殊 Host 可以通过 firstMessageRequestStore 注入等价存储,传 null 可关闭页面恢复。
AiEmployeeTransport 不再提供 getOrCreateSession() 或 createSession();Host 不得创建空 Session,也不得先创建会话再调用后续消息接口。已有会话的第二条及以后消息仍通过 sendMessage(sessionId, content) 提交。
getAccessToken() 的返回值是当前访问主体的连接凭证:它既可以是注册用户 JWT,也可以是 Host 通过匿名身份接口取得的匿名 JWT。SDK 不判断或持久化主体类型;每次连接和 4401 恢复都会重新调用该方法。匿名 JWT 与注册用户 JWT 遵守同一安全边界,只进入 HTTP Authorization header 和 WebSocket authenticate 首帧,不进入 URL、DOM、事件或日志。断线后客户端先重新拉取 HTTP 历史,再取得当前凭证并恢复订阅;历史拉取失败时会请求 WebSocket replay 兜底。
宿主 transport 遇到稳定后端错误时,可抛出 AiEmployeeTransportError,或抛出带有同名 code 的宿主错误。客户端只识别 UNAUTHORIZED、ACCESS_DENIED、AI_EMPLOYEE_NOT_AVAILABLE、APPLICATION_NOT_SUPPORTED、ORGANIZATION_CONTEXT_REQUIRED、INVALID_ARGUMENT、IDEMPOTENCY_CONFLICT、REALTIME_UNAVAILABLE 和 TOO_MANY_REQUESTS 白名单,并映射为主体中性的安全提示;其他错误码和后端正文不会透传。IDEMPOTENCY_CONFLICT 映射为不可自动重试的 FIRST_MESSAGE_CONFLICT,提示用户主动开启新对话;频率限制映射为 RATE_LIMITED。
Web Component
调用方只接入登录和统一账户工作台时,请先阅读 账户与登录组件动态接入说明。该文档定义了稳定的配置、事件与动态运行时加载方式;在公开契约不变的常规升级中,调用方无需改代码或重新构建。
安装并导入组件包后,通过 JavaScript property 配置。不要把 JWT 写入 attribute。
当前包提供十个 Web Component:
<uniplat-ai-employee-chat></uniplat-ai-employee-chat>
<uniplat-customer-support-chat></uniplat-customer-support-chat>
<uniplat-user-login></uniplat-user-login>
<uniplat-organization-selector></uniplat-organization-selector>
<uniplat-organization-multi-employee-selector></uniplat-organization-multi-employee-selector>
<uniplat-organization-single-employee-selector></uniplat-organization-single-employee-selector>
<uniplat-account-context></uniplat-account-context>
<uniplat-user-menu></uniplat-user-menu>
<uniplat-notification-center></uniplat-notification-center>
<uniplat-notification-popover></uniplat-notification-popover>浏览器运行时发布
pnpm --filter @csntgao/ai-employee-web-components build 除 npm 包的 dist/ 外,还会生成可由官网运行时加载的独立浏览器 ESM:
dist/runtime/current.json
dist/runtime/releases/<version>/index.js
dist/runtime/releases/<version>/index.js.map运行时版本从 Web Components 的 package.json 读取。构建会实际导入独立 ESM,校验 Host 必需导出、九个 Custom Element 注册结果,并拒绝任何残留的运行时 import;全部通过后才原子写入本地 current.json。独立 ESM 会完整打包 lit 和 @csntgao/ai-employee-client。
部署时仍须先上传并验证整个版本目录,再原子替换远端 current.json;清单使用 Cache-Control: no-store,版本目录使用 Cache-Control: public, max-age=31536000, immutable。同一版本的文件不得覆盖,回滚只切回先前清单。缓存头、匿名 CORS、CSP 与远端原子替换由静态发布平台负责,不是 JavaScript 构建产物能够代替的配置。
生产静态服务必须允许调用方 origin 以匿名 CORS 加载清单与模块。清单契约及官网校验规则见 /Users/gaostudio/.vibe-requirements/qqxbwebsite_UniBase运行时自动升级需求_v0_1.md。
Notification Center 与小型通知浮层
完整通知页使用 <uniplat-notification-center>;导航栏铃铛下方的 40 号官网原型小浮层使用 <uniplat-notification-popover>。两者复用同一个 NotificationCenterTransport 和 UniplatBase transport factory,小浮层不新增接口,也不创建第二条 WebSocket。
import {
createUniplatBaseNotificationCenterTransport,
type UniplatNotificationCenter,
type UniplatNotificationPopover,
} from '@csntgao/ai-employee-web-components'
const transport = createUniplatBaseNotificationCenterTransport({
serviceBaseUrl,
getAccessToken,
})
const popover = document.querySelector<UniplatNotificationPopover>('uniplat-notification-popover')!
const center = document.querySelector<UniplatNotificationCenter>('uniplat-notification-center')!
popover.configure({ applicationName, transport })
center.configure({ applicationName, transport })
notificationBell.addEventListener('click', () => popover.toggle())
popover.addEventListener('notification-center-requested', (event) => {
const { notificationId } = (event as CustomEvent<{ notificationId?: string }>).detail
openNotificationCenter({ notificationId })
})
// Host 已有 notification channel 收到原始事件后,同时投递给两个投影。
function applyNotificationRealtimeEvent(rawEvent: unknown) {
popover.applyRealtimeEvent(rawEvent)
center.applyRealtimeEvent(rawEvent)
}applicationName 传入非空应用标识时,只加载该应用的消息;显式传入空字符串 '' 时,表示加载当前用户、当前组织下所有应用的消息。UniplatBase transport 在全应用模式下不发送 application_name,不会把空字符串或推断的应用名写入请求。该模式需要 UniplatBase 实现全应用聚合契约;旧后端会回退到 JWT 当前应用,不能达到全量效果。
小浮层初始关闭,由 Host 的铃铛调用 open()、close() 或 toggle()。它只查询未读投影,固定展示最近 4 条未读;单条点击先异步标记已读,再关闭浮层并派发 notification-center-requested(仅含 notificationId);底部“查看全部消息”派发同一事件但 detail 为空。“全部已读”直接复用 transport 的 markAllRead()。未读数通过 unread-count-change 提供给 Host 渲染铃铛角标。Host 负责浮层锚点位置和打开完整通知页,组件负责原型面板、空态和安全错误提示。组件打开时会在下一宏任务于所属 document 注册外部点击监听,不会把调用 open()/toggle() 的当前铃铛点击误判为外部点击;因此即使 Host 存在 overflow、transform 或独立层叠上下文,后续点击组件外仍会关闭。关闭或移除组件后立即解除监听并取消待执行任务。
两个组件都不接收 JWT,不建立实时连接。Host 继续维护唯一 /api/user-realtime/ws 并订阅 notification channel;JWT 只存在于 transport 的 HTTP Authorization: Bearer header。小浮层使用原型指定的 Tabler Icons 2.47.0 原始 SVG 路径,不依赖 Host 图标字体。可通过 typeIcons 覆盖后端分类 code 的图标,但不得传入携带认证 query、fragment 或用户凭据的 URL。
User Login
<uniplat-user-login> 严格按亲亲企服 40.1 注册登录原型重构为 400px 登录卡片,顶部固定提供“密码登录 / 验证码登录 / 扫码登录”三个页签。密码登录与验证码登录共用“我已阅读并同意《用户服务协议》与《隐私政策》”勾选交互:未勾选时按钮不可提交,失焦后显示协议错误;密码页不再显示“7 天内免登录”和“忘记密码”。验证码登录只有手机号和短信验证码,不显示或请求图形验证码;它同时承担新手机号注册入口。字段失焦后就地显示原型错误态,服务端返回稳定的验证码错误码时,错误提示贴在短信字段下方。新用户注册成功后显示原型 1.3 过渡页,2 秒后自动请求 Host 进入入驻引导,也可点击“立即开始”。扫码的待扫码、已扫码和失效状态复用同一个二维码区域,不改变卡片布局。组件不依赖 Vue 或 Ant Design,所有样式位于 Shadow DOM 中。
短信接口返回稳定错误码 SMS_SEND_TOO_FREQUENT 时,组件明确提示“验证码发送过于频繁,请 1 分钟后重新获取。”;其他短信发送错误继续收敛为不暴露后端正文的安全提示。
对接现有 UniplatBase 登录接口时使用专用 transport。apiBaseUrl 是 Gateway 基础地址;setAccessToken() 由宿主负责原子保存登录 JWT,并可使用第二个参数中的无凭据登录元数据初始化用户和组织状态:
<uniplat-user-login id="user-login"></uniplat-user-login>
<script type="module">
import { createUniplatBaseUserLoginTransport } from '@csntgao/ai-employee-web-components'
const transport = createUniplatBaseUserLoginTransport({
apiBaseUrl: '/api',
async setAccessToken(nextToken, metadata) {
await authStore.completeLogin(nextToken, metadata)
},
})
document.querySelector('#user-login').configure({
transport,
applicationName: 'HrSaas',
headerTitle: '登录企业工作台',
headerSubtitle: '手机号验证后进入企业服务平台',
headerIconUrl: '/assets/login-shield.svg',
securityHint: '登录凭证仅用于企业工作台认证,请勿向他人透露验证码',
userAgreementUrl: 'https://website.hrs100.com/agreement/user-service-agreement.html',
privacyAgreementUrl: 'https://website.hrs100.com/agreement/qqxb-user-privacy.html',
initialMethod: 'verification-code',
})
document.querySelector('#user-login').addEventListener('onboarding-requested', (event) => {
document.querySelector('#account-context')?.startOrganizationOnboarding()
})
</script>applicationName 由 Host 通过 configure() 提供,组件在登录时把它交给 transport,用于请求目标应用身份;它不会进入 DOM。headerTitle、headerSubtitle、headerIconUrl 和 securityHint 同样由 Host 提供,组件负责渲染顶部品牌区和底部安全区。它们不应包含 JWT、验证码或其他认证凭据。headerIconUrl 仅接受不含认证参数的 HTTP(S) 或图片 Data URL。右上角关闭按钮仍由宿主弹窗负责,因为组件不管理宿主弹窗的打开和关闭状态。
UserLoginTransport 的短信发送和验证码登录方法是必需能力。短信发送只接收 { mobile };旧版可选 createCaptcha() 为源码兼容保留,但组件不会调用。自定义 Host transport 的密码和扫码能力仍为可选:只有 Host 同时提供对应方法时页签才可点击,未配置的页签保持显示但禁用。createUniplatBaseUserLoginTransport() 已对接后端统一应用登录接口并提供三种登录能力,因此直接使用该 factory 时三个页签均可用:
旧 forgotPasswordUrl、initiallyRememberSession 和 forgot-password-requested 仅为源码兼容保留;组件不保存这些配置、不渲染对应控件,也不会派发旧事件。密码 transport 请求中的 rememberSession 固定为 false。
const transport: UserLoginTransport = {
sendSmsCode,
login,
loginWithPassword: hostAuth.loginWithPassword,
createQrChallenge: hostAuth.createQrChallenge,
pollQrChallenge: hostAuth.pollQrChallenge,
}扫码 transport 应在确认成功前完成 JWT 的安全保存,只向组件返回 { status: 'confirmed', result: { applicationName } }。二维码 challenge ID 仅在 transport 与组件内部短暂传递,不进入 DOM、事件或日志;二维码图片只接受图片 Data URL,或不带 query/hash/用户凭据的 HTTP(S) URL。组件优先使用创建响应中的 pollIntervalMs,自定义 transport 未提供时才使用 configure() 的 qrPollIntervalMs。收到确认、拒绝或过期状态,以及切换页签、重新配置或移除时,组件都会停止二维码轮询。
专用 transport 使用以下 UniplatBase 接口:
POST /api/general/project/uniplat_base/service/anonymous/system.user/sendVerifyCode
POST /api/general/project/uniplat_base/service/anonymous/application.identity/login-application短信接口的请求体为 { "mobile": "..." },不携带图形验证码 seed 或 verifycode。login-application 通过 auth_method=sms|password|qr 复用相同的应用身份选择与 JWT 响应;专用 transport 自动将 applicationName 同时传为 application_name、client_id,并收集浏览器设备类型、名称和品牌。它在本地根据浏览器与设备特征计算 SHA-256 摘要,作为稳定的 device_id 上报;原始指纹特征不会上报。扫码创建和轮询分别使用 action=create|poll。调用方无需为此升级配置;如已有合规的稳定设备 ID,可选在 factory 的 deviceInfo.deviceId 中覆盖默认值,SDK 不写入 localStorage、Cookie 或其他宿主存储。完整后端契约见 /Users/gaostudio/.vibe-requirements/UniplatBase_应用登录密码验证码与扫码接口文档_v0_1.md。登录 JWT 只会从登录响应传给 setAccessToken(),不会进入 URL、DOM、日志或公开事件;密码、challenge ID 和后端原始错误同样不会进入这些渠道。login-success 返回 { applicationName, isNewUser? };onboarding-requested 返回 { applicationName, trigger },其中 trigger 为 automatic 或 manual;sms-sent 不返回手机号;agreement-requested 返回 { type: 'user' | 'privacy' };forgot-password-requested 不返回手机号。Host 收到入驻事件后负责进入原型 02 流程。错误事件只包含白名单错误码与安全提示。
组件支持以下 CSS Variables:--uniplat-user-login-width、--uniplat-user-login-primary、--uniplat-user-login-text、--uniplat-user-login-muted、--uniplat-user-login-placeholder、--uniplat-user-login-background、--uniplat-user-login-input-background、--uniplat-user-login-error 和 --uniplat-user-login-font-family。
公开 ::part:shell、header、header-icon、header-title、header-subtitle、form、title(兼容别名,现指页签栏)、tabs、tab、method-panel、field、input、sms-row、send-button、password-toggle、login-options、forgot-password、agreement、agreement-link、submit-button、qr-panel、qr-code、qr-overlay、qr-tip、registration-success、registration-success-icon、onboarding-button、field-error、status、error、security-footer 和 security-icon。
内置组织入驻
40.1 原型的入驻方式选择、跳过确认、企业/团队创建、校验与成功状态已内置到 <uniplat-account-context>。不再提供 <uniplat-organization-onboarding> 标签、类或独立 configure() 接口;账户菜单和无归属个人态点击“创建团队 / 企业”会直接打开内部流程。Host 只需在账户组件配置中注入创建 transport;邀请加入、企业认证、稍后认证、团队升级和邀请成员仍以业务事件交由 Host 打开对应流程。
生产 Host 应注入 OrganizationOnboardingTransport:
interface OrganizationOnboardingTransport {
createEnterprise(request: {
organizationName: string
enterpriseName: string
unifiedSocialCreditCode: string
}): Promise<{ id: string; name: string; type: 'enterprise' }>
createTeam(request: { teamName: string }): Promise<{ id: string; name: string; type: 'team' }>
}transport 可抛出 OrganizationOnboardingTransportError,稳定错误码为 ENTERPRISE_NAME_CONFLICT、CREDIT_CODE_CONFLICT、TEAM_NAME_CONFLICT 和 UNKNOWN。原始后端异常不会进入 DOM 或公共事件。
UniplatBase 已在现有 system.org 服务下提供真实创建能力。Host 使用当前 JWT 对接时,不需要自行拼请求体:
import { createUniplatBaseOrganizationOnboardingTransport } from '@csntgao/ai-employee-web-components'
accountContext.configure({
applicationName: 'OfficialWebsite',
organizationOnboarding: {
transport: createUniplatBaseOrganizationOnboardingTransport({
serviceBaseUrl: '/api/general/project/uniplat_base/service',
async getAccessToken() {
return authStore.getCurrentAccessToken()
},
}),
},
})该 transport 复用 POST system.org/create_organization。企业请求映射为 organization_type/team_name/customer_name/credit_code,团队请求只发送 organization_type/team_name;不会提交 uid/oid/eid/owner 等身份字段。它在每次创建前重新调用 getAccessToken(),JWT 只进入 Authorization: Bearer header,成功响应只保留组件需要的组织 ID、展示名称和类型。INVALID_ARGUMENT、ORGANIZATION_CREATE_FAILED、HTTP、解析及 token provider 异常均收敛为 UNKNOWN,不透传后端正文。
入驻流程一律以原生 dialog.showModal() 在视口居中呈现(top layer,不受 Host 导航 transform/overflow 影响),不再使用锚定账户胶囊的下拉浮层;账户菜单里点“创建团队 / 企业”打开的普通态同样居中,可由遮罩点击、Escape 或右上角“取消”关闭。“取消”只在当前用户已归属某个组织时渲染,无归属用户看不到该按钮;点击后直接收起流程,不再经过“确定暂不创建”确认页。
内部流程会经账户组件派发 onboarding-skipped、join-organization-requested、organization-created、enterprise-authentication-requested、enterprise-authentication-deferred、onboarding-finished、logout-requested 和安全的 error 事件。团队创建成功页只有一个“确认”,点击后派发 onboarding-finished({ organizationId });此前的 team-upgrade-requested 与 team-invite-members-requested 不再派发,升级与邀请成员留在组织管理里。Host 可通过账户组件的 onboarding-panel part 定制该浮层或对话框。
个人用户不允许以非员工身份留在系统里,所以无归属用户必须先入驻,没有跳过开关。账户投影返回零归属时,组件用原生 dialog.showModal() 自动打开强制态创建流程:
accountContext.configure({
applicationName: 'OfficialWebsite',
transport: accountTransport,
organizationOnboarding: { transport: organizationOnboardingTransport },
})强制态下不渲染“跳过”“暂不创建,先逛逛”和关闭按钮,遮罩点击、Escape 与原生 cancel 都不关闭。首屏出口只有创建企业、创建团队和“退出登录”(派发 logout-requested,由 Host 清理登录态):加入已有组织通过扫码或粘贴邀请链接进入系统完成,强制态不提供邀请码入口,Host 也无需为此实现页面。持有有效 JWT 但没有任何企业员工身份时,账户胶囊仍会渲染,但不显示工作台入口,即使配置了 workbench。
组织邀请页豁免强制入驻。用户扫码或粘贴 org-invite?code=... 进入时已有有效 JWT、还没有员工身份,此时不弹对话框,否则会盖住加入流程:
accountContext.configure({
applicationName: 'OfficialWebsite',
transport: accountTransport,
organizationOnboarding: {
transport: organizationOnboardingTransport,
invitePaths: ['org-invite'], // 默认值,可按 Host 路由覆盖
},
})匹配规则是「当前 URL 末段路径命中 invitePaths 且 code 非空」,pathname 与 hash 路由都参与匹配。组件只取一个布尔值,邀请码的值不进入组件状态、DOM、公共事件或日志。离开邀请页(popstate / hashchange)后如果仍没有员工身份,强制入驻会重新生效。企业/团队表单页提供“返回”回到创建方式选择,避免困在错误分支;创建成功页的“去认证 / 稍后认证 / 升级为企业 / 邀请成员”是关闭对话框的终点。对话框使用 top layer,不受 Host 导航 transform 或 overflow 影响。
Organization Selector
<uniplat-organization-selector> 实现登录后存在多个归属时的紧凑选择弹窗。Host 传入完整组织列表、默认项及“记住选择”的初始值;组件负责类型筛选、“最近进入 / 全部组织”分组与选中态,只在用户点击“进入”后派发选择,不自行修改 Host 登录态。弹窗不显示账户摘要、归属数量说明或搜索框。
const selector = document.querySelector('uniplat-organization-selector')
selector.configure({
presentation: 'modal',
selectedOrganizationId: 'enterprise-beijing',
rememberSelection: true,
organizations: [
{
id: 'enterprise-beijing',
name: '北京星河科技有限公司',
type: 'enterprise',
verificationStatus: 'verified',
role: '超管',
memberCount: 128,
lastEnteredLabel: '上次进入 2 小时前',
},
{ id: 'team-growth', name: '星河增长项目组', type: 'team', role: '成员' },
],
})presentation 默认 inline,组件只输出卡片,遮罩由 Host 负责。传 modal 时组件自行渲染 S.1 的强制态弹窗:固定深色遮罩、无关闭按钮,遮罩点击与 Escape 一律不关闭(Escape 也不会冒泡到 Host 的全局关闭逻辑),只能通过“退出登录”离开。遮罩颜色与层级用 --uniplat-organization-selector-overlay、--uniplat-organization-selector-modal-z-index 定制。
旧 displayName、maskedMobile 与条目 unifiedSocialCreditCode 仅为源码兼容保留:组件继续执行输入校验,但不会保存或渲染这些字段;完整手机号仍会抛 TypeError。未传 selectedOrganizationId 时默认选中“最近进入”分组的第一项,主按钮显示 进入「<组织名>」;列表超过约 5 行时只有列表区滚动,所有归属仍完整渲染。
组件派发 organization-enter-requested({ organizationId, rememberSelection })、organization-create-requested({})和 logout-requested({})。事件不含姓名、手机号、组织名称或统一社会信用代码。
S.0 的“弹不弹”判定由纯函数 resolveOrganizationEntryDecision() 提供,它不读写宿主存储:
import { resolveOrganizationEntryDecision } from '@csntgao/ai-employee-web-components'
const decision = resolveOrganizationEntryDecision({
organizations,
rememberedOrganizationId: hostReadRememberedOrganizationId(),
})
// { kind: 'personal' } 无归属:进入个人态并常驻创建 / 加入入口
// { kind: 'auto-enter', organizationId } 仅 1 个归属,或命中记住选择
// { kind: 'prompt', selectedOrganizationId } 2 个及以上且未记住:展示选择弹窗记住选择的偏好由 Host 自行持久化并在下次登录时回传;记住的组织已不在归属列表时函数会退回 prompt。
UniplatBase 的组织投影由 application.identity/list_available_organizations 返回。Host 应使用 team_name 作为选择器展示名,将 organization_type 原样映射为 enterprise | team,将企业的 verification_status 映射为 unverified | pending | rejected | verified | expired,并把 role/member_count/last_active_at 转为对应展示字段;团队的 verification_status=not_applicable 不传给组件。角色显示文案和最近进入时间仍由 Host 本地化,不应由组件解析后端文本。
Organization Multi Employee Selector
<uniplat-organization-multi-employee-selector> 是当前组织员工多选弹层。组件根据当前 JWT 读取组织架构与在职员工,负责搜索、分页、初始回显、去重、选择上限、受保护成员和最终选择事件;它不判断管理员权限,也不调用管理员或业务角色写入接口。完整接入契约见 组织员工多选组件接入说明。
import {
createUniplatBaseOrganizationMultiEmployeeSelectorTransport,
type UniplatOrganizationMultiEmployeeSelectorElement,
} from '@csntgao/ai-employee-web-components'
const selector = document.querySelector<UniplatOrganizationMultiEmployeeSelectorElement>(
'uniplat-organization-multi-employee-selector',
)!
selector.configure({
applicationName: 'OfficialWebsite',
transport: createUniplatBaseOrganizationMultiEmployeeSelectorTransport({
serviceBaseUrl: '/api/general/project/uniplat_base/service',
getAccessToken: () => authStore.getCurrentAccessToken(),
}),
})
await selector.open({
title: '选择组织管理员',
initialSelectedEmployeeIds: currentAdministrators.map(({ employeeId }) => employeeId),
disabledSelections: owner
? [{ employeeId: owner.employeeId, reason: '组织所有者不能在此处移除' }]
: [],
})
selector.addEventListener('selection-confirmed', (event) => {
const { employeeIds } = event.detail
void organizationApi.setAdministrators(employeeIds, expectedVersion)
})专用 transport 复用 system.org_directory/tree_full、node_contacts、contact_search 与 contact_get,每次请求重新读取 JWT,且 JWT 只进入 Authorization header。组织身份完全由 JWT 决定,组件和 Host 都不得提交 oid/xid/eid/uid/poid 等身份字段。v0.1 固定为员工多选且只选择在职员工;部门仅用于导航,尚不开放部门批量勾选。
Organization Single Employee Selector
<uniplat-organization-single-employee-selector> 提供与多选组件一致的组织架构、搜索、分页和在职员工过滤,但公开语义固定为单选。它使用独立标签、单个初始 ID 和标量确认结果,不接受 selectionMode、maxSelection 或多选 ID 数组。完整契约见 组织员工单选组件接入说明。
const selector = document.querySelector('uniplat-organization-single-employee-selector')
selector.configure({
applicationName: 'OfficialWebsite',
transport: createUniplatBaseOrganizationSingleEmployeeSelectorTransport({
serviceBaseUrl: '/api/general/project/uniplat_base/service',
getAccessToken: () => authStore.getCurrentAccessToken(),
}),
})
await selector.open({
title: '选择组织负责人',
initialSelectedEmployeeId: currentOwnerEmployeeId,
})
selector.addEventListener('selection-confirmed', (event) => {
void organizationApi.setOwner(event.detail.employeeId)
})Account Context
<uniplat-account-context> 是 40.1 原型导航右侧的统一账户工作台组件,不包含品牌、全局导航菜单或整条导航。它在一个 Host 元素内组合工作台入口、当前账户胶囊、应用入口浮层以及企业/团队切换浮层;两个浮层互斥且绝对定位,不改变导航布局。
账户区和组织菜单中的工作台、选择器、认证状态、选中、创建及退出图标使用 40.1 原型指定的 Tabler Icons 2.47.0 原始 SVG 路径;组织列表左侧使用原型中的企业建筑或团队成员图标。组件将这些原型资源打入独立运行时,不依赖 Host 的图标字体或全局样式。
账户胶囊宽度固定:胶囊内姓名超过 6 个字、当前组织名称超过 8 个字会截断为省略号,完整值只放在 title 上,长名称不会撑宽胶囊挤压导航左侧内容;展开后的菜单仍显示完整名称。
import {
createUniplatBaseAccountContextTransport,
createUniplatBaseApplicationSwitcherTransport,
createUniplatBaseOrganizationOnboardingTransport,
} from '@csntgao/ai-employee-web-components'
const applicationName = 'OfficialWebsite'
const accountContext = document.querySelector('uniplat-account-context')
const accountTransport = createUniplatBaseAccountContextTransport({
serviceBaseUrl,
getAccessToken: () => authStore.getAccessToken(),
setAccessToken: (nextToken) => authStore.setAccessToken(nextToken),
})
const workbenchTransport = createUniplatBaseApplicationSwitcherTransport({ serviceBaseUrl })
accountContext.configure({
applicationName,
transport: accountTransport,
workbench: {
getAccessToken: () => authStore.getAccessToken(),
transport: workbenchTransport,
},
organizationOnboarding: {
transport: createUniplatBaseOrganizationOnboardingTransport({
serviceBaseUrl,
getAccessToken: () => authStore.getAccessToken(),
}),
},
})
accountContext.addEventListener('organization-selected', async (event) => {
await accountContext.switchOrganization(event.detail.organizationId)
})有当前组织时默认显示“工作台”按钮和带组织数量的账户胶囊;账户胶囊及展开菜单资料区优先显示服务端当前组织投影的员工姓名与头像。未选组织或当前组织没有员工身份时,回退到 list_available_organizations 顶层返回的用户姓名与头像。组织列表左侧严格使用原型中的企业建筑或团队成员图标。企业认证状态收进组织名后的紧凑图标,团队显示“无需认证”的类型状态,认证未通过或过期时账户胶囊出现红点。服务端返回无当前组织时显示“创建团队 / 企业”和个人账户。showWorkbench、showVerificationStatus 仍可显式关闭对应视觉能力。
applicationName 是 Host 当前应用的稳定标识,必须显式传入。Host 不再提供姓名、头像、当前组织、可选组织或企业认证状态;组件通过账户 transport 自行调用 list_available_applications 与 list_available_organizations,后者同时提供用户的 display_name、avatar_url 与 mobile_masked,按服务端投影完整渲染,并可用 refresh() 主动刷新。应用接口只提供当前身份和可用应用;组织接口以 { application_name } 返回该应用可进入的组织和用户资料,两者不得互相替代。选择组织时组件仍先派发 organization-selected,由 Host 决定是否调用 accountContext.switchOrganization(id);方法内部完成 JWT 切换、Host setAccessToken() 回调和组件重绘,Host 不解析响应或重新配置展示数据。Open Application 切换请求使用服务端 xid + organization_oid。当前后端对组织型应用切换仍要求目标组织对应的 xid,但新的两个列表接口不返回该值;因此 SDK 会拒绝组织型应用切换到非当前组织,直到服务端提供可安全解析目标实例的既有切换能力。调用方不能提交身份字段。组件不直接接收或保存 JWT,专用 transport 每次从 Host 的 getAccessToken() 获取。旧的 displayName/organizations 等手工展示配置仅为兼容保留,不再推荐。
使用 UniplatBase 专用账户 transport 时,账户菜单的“个人账号设置”会由组件直接打开新窗口:它会先把 /personal 按当前页面解析为完整 HTTP(S) URL,再以当前 applicationName(官网为 OfficialWebsite)调用 switch_current_application,传入 { application_name: applicationName, response_type: 'ticket', entry_url },并对同一个完整入口 URL 追加 #uniplat_application_ticket=<ticket> 后跳转。这样目标页兑换到的是当前应用的 JWT;ticket 只在局部导航 URL 中短暂存在。独立 <uniplat-user-menu> 仍只派发 account-settings-requested 事件。
账户菜单内点击“我的员工信息”会直接打开组件内的原型弹窗,不需要 Host 再配置页面或手工拼装资料。所属企业、姓名和角色来自当前组织投影;弹窗打开后才通过账户 transport 按当前 JWT 请求员工主部门,避免给高频组织列表增加字段和查询成本。专用 UniplatBase transport 使用 system.employee_profile/get_current_employee_info;部门缺失或请求失败时显示“未设置”,状态按当前有效员工身份显示“在职”。employee-info-requested 事件仍会冒泡,供既有调用方做统计或附加业务,但不再需要用它打开该弹窗。
账户菜单底部和个人态的“创建企业 / 团队”会直接在账户组件内启动原型 02 流程,不会派发 organization-create-requested 或 create-organization-requested。如需从登录成功后进入入驻流程,Host 调用 accountContext.startOrganizationOnboarding()。
workbench 是内嵌 Application Switcher 的独立能力配置:其中 getAccessToken 为应用入口请求提供当前 JWT,transport 负责读取可见应用并在点击时签发短期 ticket。它只服务“工作台”九宫格,不参与账户姓名、头像、组织列表或认证状态的加载。统一账户区不会在工作台浮层中重复显示组织选择器。工作台应用按固定九宫格、新窗口和短期 ticket 规则运行,并优先以 application_scope=open 判断开放应用;仅在旧响应缺少该字段时临时回退 oid=0。当前服务端组织投影为团队时,内嵌工作台临时隐藏稳定 application_name 为 cross-regional-employment(异地用工)和 employee-benefits(员工福利)的入口;企业身份正常显示,组织切换后立即按新类型重算。组件不解析 JWT,也不按中文标题过滤。组件派发账户、组织切换、工作台、退出和内部入驻流程所需的净化事件;公共事件不包含应用名、显示名、手机号、组织名称、头像 URL、JWT 或 ticket。
专用 transport 对外只抛出 AUTH_REQUIRED、INVALID_RESPONSE、ORGANIZATION_UNAVAILABLE、ORGANIZATION_SWITCH_UNSUPPORTED、REQUEST_FAILED 和 TOKEN_PERSIST_FAILED 六类稳定错误码,不透传服务端消息或原始响应。切换成功响应中的 JWT 会先交给 Host setAccessToken(),返回给调用方的 projection 不含 JWT。
正常情况下 employee_avatar_url 应由服务端返回可匿名读取的安全 HTTP(S) URL。为兼容已部署服务曾返回的员工头像受管存储路径,专用 transport 只对严格匹配 <xid>/.root/.avatars/<eid>/avatar-<uuid>.<ext> 且 eid 与组织投影一致的值,转换为同一 serviceBaseUrl 下的匿名头像接口;内部存储路径不会进入组件 DOM。其他非 URL 值或身份不一致的路径按无效响应拒绝。
账户区公开 ::part:context、create-action、workbench-action、account-trigger、avatar、avatar-image、display-name、organization-name、verification-status、organization-count、workbench-panel、account-panel、onboarding-panel、employee-info-modal、employee-info-dialog、employee-info-close 和 employee-info-details。
User Menu
<uniplat-user-menu> 作为兼容入口保留,并与统一账户区内部的 40.1 组织切换菜单使用同一实现。它展示头像、显示名、脱敏手机号、“个人账号设置”、随当前组织变化的“我的员工信息”、带数量的企业/团队混合列表、企业认证状态、创建入口和退出登录。新接入应优先只使用 <uniplat-account-context>。
组件不请求身份接口,也不接收 JWT。Host 从自身登录态或已有身份响应中提取显示数据后,通过 JavaScript property 配置:
<uniplat-user-menu id="user-menu"></uniplat-user-menu>
<script type="module">
import '@csntgao/ai-employee-web-components'
const userMenu = document.querySelector('#user-menu')
userMenu.configure({
displayName: '张伟',
maskedMobile: '138****6688',
avatarUrl: 'https://static.example.com/employees/zhang-wei.png',
currentOrganizationId: 'company-beijing',
organizations: [
{
id: 'company-beijing',
name: '北京星河科技有限公司',
type: 'company',
avatarText: '星',
role: '超管',
memberCount: 128,
verificationStatus: 'verified',
},
{
id: 'team-growth',
name: '星河增长项目组',
type: 'team',
role: '团队负责人',
memberCount: 9,
},
],
})
userMenu.addEventListener('organization-selected', (event) => {
// Host 调用自己的组织切换流程,成功后重新 configure() 并关闭浮层。
switchOrganization(event.detail.organizationId)
})
</script>organizations 只用于展示和选择,不会被组件提交给任何服务。maskedMobile 只能传脱敏值,组件会拒绝连续 11 位的中国大陆手机号。组织点击是受控交互:组件派发事件但不自行改变选中项,Host 应在切换成功后使用新的 currentOrganizationId 重新配置。
组件派发以下 bubbles: true、composed: true 事件:
account-settings-requested detail: {}
employee-info-requested detail: { organizationId | null }
organization-selected detail: { organizationId }
organization-create-requested detail: {}
logout-requested detail: {}事件不会包含显示名、手机号、组织名称、JWT 或内部配置。组件支持 CSS Variables:--uniplat-user-menu-width、--uniplat-user-menu-primary、--uniplat-user-menu-text、--uniplat-user-menu-muted、--uniplat-user-menu-border、--uniplat-user-menu-surface、--uniplat-user-menu-hover、--uniplat-user-menu-selected、--uniplat-user-menu-list-max-height 和 --uniplat-user-menu-font-family。
公开 ::part:panel、profile、avatar、avatar-image、profile-name、profile-mobile、menu-actions、account-settings-action、employee-info-action、section-title、organization-list、organization、organization-icon、organization-name、organization-type、verification-status、organization-meta、organization-check、empty、footer、create-footer、logout-footer、create-action 和 logout-action。
工作台内置应用切换
不再提供或注册 <uniplat-application-switcher>。工作台应用面板仅作为 <uniplat-account-context> 的内部能力,通过其 workbench 配置启用;Host 不得创建、配置或监听独立的应用切换元素。内嵌面板保留固定 3 列网格、新窗口和短期 ticket 跳转。
破坏性变更(v0.8.0):独立应用切换标签、其类和标签常量已移除。调用方应将原
applicationSwitcher.configure(...)迁移至accountContext.configure({ workbench: ... });未迁移的页面不会注册旧标签,原有独立工作台入口将不再显示。
对接 UniplatBase 时使用专用 transport。Application Switcher Host 只提供当前 JWT,不保存目标应用 JWT:
import { createUniplatBaseApplicationSwitcherTransport } from '@csntgao/ai-employee-web-components'
const transport = createUniplatBaseApplicationSwitcherTransport({
serviceBaseUrl: '/api/general/project/uniplat_base/service',
})
accountContext.configure({
applicationName: 'OfficialWebsite',
transport: accountTransport,
workbench: { getAccessToken: () => authStore.getAccessToken(), transport },
})内嵌工作台使用当前 JWT 以 POST {} 请求 application.identity/list_available_applications,其中 available_applications 是应用网格唯一数据源。内嵌面板不提供组织选择器,因此不会请求 list_available_organizations;组织上下文由 Account Context 自身管理。响应中当前组织或 application_scope=open 的应用会按原顺序显示,不截断为前 9 个。团队身份的两个临时隐藏项在 Account Context 内部按 application_name 过滤,该标识不进入公开应用 entry、DOM 或公共事件。
点击应用时,transport 会重新读取当前 JWT,以 POST {} 调用 application.identity/issue_ticket。本地和外部入口都直接使用 list_available_applications 当前选中项的 entry_url,并追加 #uniplat_identity_ticket=<ticket>&source_system=<source_system>;source_system 必须取自签发响应,不能用列表中的目标系统代替。Fragment 不携带目标应用或 client_id,当前 Host JWT 不会被替换,Ticket 也不会进入公开事件或持久状态。已有 launch_target 字段继续用于列表兼容,但不进入新 Ticket 签发请求。
application-selected 是 Account Context 冒泡的可取消事件。事件只包含原始安全入口,不包含 ticket;宿主阻止默认导航时,预开的空白窗口会关闭,已签发 ticket 等待服务端自动过期:
accountContext.addEventListener('application-selected', (event) => {
if (!hostPolicy.allowsApplication(event.detail.entry.id)) event.preventDefault()
})目标应用需要在启动最早阶段登录 Ticket。目标应用只能取自该前端项目的初始化配置,不能由 URL 覆盖。helper 一旦发现 Ticket,会先从地址栏删除 uniplat_identity_ticket 与 source_system、保留其他 Fragment 参数,再请求登录接口,并只把校验后的 JWT 交给目标 Host:
import { loginWithUniplatBaseIdentityTicket } from '@csntgao/ai-employee-web-components'
await loginWithUniplatBaseIdentityTicket({
serviceBaseUrl: '/api/general/project/uniplat_base/service',
applicationName: 'HrSaas',
async setAccessToken(nextToken) {
await authStore.setAccessToken(nextToken)
},
})没有 Ticket 时 helper 返回 false,不会请求服务器;成功登录并完成 setAccessToken() 后返回 true。请求固定使用初始化的 applicationName,可将 Fragment 中非空的 source_system 透明传给 anonymous/application.identity/login_with_ticket,但不传 client_id。响应中的 application_name 必须与初始化配置完全一致,否则不会保存 JWT。JWT 与短期 Ticket 均不得进入 DOM、公开事件、持久状态、日志或错误消息。
旧的 exchangeUniplatBaseApplicationTicket()、uniplat_application_ticket Fragment 和后端 exchange_application_ticket 协议继续保留,供尚未迁移的显式调用方使用;Application Switcher 默认流程不再调用它们。
对接其他、已经提供扁平 Application Switcher 接口的服务时,可以使用通用 HTTP transport:
import { createHttpApplicationSwitcherTransport } from '@csntgao/ai-employee-web-components'
const transport = createHttpApplicationSwitcherTransport({
endpoint: 'https://hr-saas.example.com/api/application-entries',
organizationsEndpoint: 'https://hr-saas.example.com/api/organizations',
switchOrganizationEndpoint: 'https://hr-saas.example.com/api/current-organization',
})
accountContext.configure({
applicationName: 'OfficialWebsite',
transport: accountTransport,
workbench: { getAccessToken: () => authStore.getAccessToken(), transport },
})通用 HTTP transport 在内嵌工作台中只以 GET 请求应用入口,并只在 Authorization: Bearer <JWT> 请求头中携带令牌。应用入口响应格式:
{
"entries": [
{
"id": "enterprise-admin",
"name": "企业管理",
"url": "https://apps.example.com/enterprise-admin",
"iconUrl": "https://apps.example.com/icons/enterprise-admin.svg",
"description": "企业管理后台",
"openMode": "blank"
}
]
}应用入口的 id、name、url 必填;iconUrl 和 description 可选,所有应用统一在新窗口打开。需要切换应用身份时,内嵌工作台会在用户点击时先打开不含凭据的空白窗口,等待短期 Ticket 签发完成后再导航,避免覆盖 Host 当前页面。组件加载时及调用 refresh() 时重新取得 JWT。组织切换只由 Account Context 处理。UniplatBase 专用 transport 返回当前组织级应用和所有 application_scope=open 应用;只有旧响应缺少范围字段时才以 oid=0 兼容判断。当前工作台组织上下文来自 Account Context;issue_ticket 始终按点击时读取到的当前 JWT 签发,不接受目标应用、实例、组织或入口参数。工作台内部派发的事件不会暴露到公开组件契约,且不包含 JWT 或 Ticket。
AI Employee Chat
<uniplat-ai-employee-chat id="chat"></uniplat-ai-employee-chat>
<script type="module">
import '@csntgao/ai-employee-web-components'
const chat = document.querySelector('#chat')
chat.configure({
realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
aeCode: 'recruit',
agentCode: 'default',
transport,
})
document.querySelector('#open-chat').addEventListener('click', () => chat.open())
</script>组件初始关闭;Host 只负责配置并在需要时调用 open()。chat.open(initialMessage?) 可接收一个可选字符串:传入非空字符串时,组件打开并通过既有发送链路自动发送该内容;未配置 sessionId 时原子创建会话并提交首条消息,已配置 sessionId 时作为已有会话的后续消息发送。未传、空字符串或纯空白时仍只打开抽屉,保持原有交互。组件自身负责 40 号官网原型中的固定右侧抽屉、标题、关闭按钮、消息区、输入区和“转人工”入口;右上角关闭按钮和 Escape 会调用组件自身的 close() 并释放连接。Host 不应再包裹另一层抽屉、标题、介绍卡片或安全提示。
数字员工的 assistant 回复按 Markdown 渲染,支持标题、强调、删除线、行内代码、代码块、引用、有序/无序列表、分隔线及安全链接;用户消息、系统消息和错误消息仍按普通文本显示。原始 HTML 不会注入 DOM,非 HTTP(S) 或 mailto: 链接不会生成可点击链接。
用户消息提交后、AI 首条正式回复到达前,消息区底部固定保留一行数字员工工作动态。Core 会把实时协议中的 thinking、tool_use、tool_result、status_update、output 和 loading 转为安全单行反馈;组件按到达顺序轮播,并以流光文字和脉冲圆点提示仍在工作。首条 assistant 内容到达后立即停止并隐藏,减少长时间等待时的空白感。工作动态最多保留 120 个字符,控制字符、Bearer 凭据和 JWT 形态内容会脱敏,工具原始请求与结果不会直接进入 DOM;Core 使用 work-activity 事件把净化后的 { sessionId, text } 交给组件,Web Component 不向 Host 派发新的公开 CustomEvent。
当 KanbanBackend 在最终 assistant 回复尾部附加规范的 assistant_action/open_customer_support JSON 时,组件会隐藏原始 JSON,并在该消息下显示“发起人工客服”。解析不依赖 aeCode 或 agentCode;只接受 type/action/transaction-id/session-id/trigger/entry 六个字段完整且无额外字段、固定值正确、transaction-id 为 UUID4、session-id 为 1 至 64 位安全字符,且动作作为独立尾部 JSON 出现的结构。旧五字段动作不再执行。只有动作 session-id 与承载消息的会话 ID 完全相等时才渲染按钮;不一致时不渲染、不改写,只记录不含实际标识的稳定诊断日志。SDK 同时导出 parseAiEmployeeAssistantContent(),供其他消息视图复用同一安全解析规则。
最终回复尾部也可以连续附加一个或多个 assistant_action/open_file。文件动作必须且只能包含 type/action/transaction-id/session-id/name/size/path,其中 UUID4 使用规范小写格式,name 等于安全相对路径的最后一段。合法动作会隐藏原始 JSON 并分别渲染文件项;非法、重复或跨会话动作只显示稳定错误,不执行且不暴露路径或事务 ID。点击文件项时,组件先用固定的 APPLICATION/sessions/{session-id} 文件空间调用 UniplatBase filesystem.api/list_entries,按 relative_path 精确且唯一地取得服务端签名 file_id,再调用 create_download_ticket 下载,绝不把动作 path 当作下载凭证。
标准登录场景无需额外配置,组件默认复用 transport.getAccessToken() 并请求同源 /api/general/project/uniplat_base/service/filesystem.api。官网匿名会话显式配置 requestMode: 'anonymous',组件改用匿名文件接口和 Host 的匿名凭据 Cookie,不读取或发送 JWT。非标准网关也可以传入自定义 transport:
chat.configure({
realtimeUrl,
aeCode,
agentCode,
transport,
fileActions: {
// 官网匿名会话使用:requestMode: 'anonymous'
serviceBaseUrl: '/api/general/project/uniplat_base/service',
},
})用户点击该动作后,组件调用客服 transport 的 createHandoffSession(),把动作 session-id 映射为 source_session_id、把 transaction-id 映射为 action_transaction_id,不从当前选中会话、路由或全局状态补值。请求期间按钮禁用,失败后可使用同一动作 UUID 重试;成功后组件释放数字员工连接,并使用接口返回的客服会话 ID 在同一抽屉打开人工客服。动作 UUID 不会进入 DOM、公共事件、URL、日志或错误文案。
点击“转人工”后,数字员工连接会先释放,再由同一抽屉切换为 <uniplat-customer-support-chat>;不会向数字员工发送一条模拟转人工文本,也不要求 Host 创建第二个抽屉。默认情况下,组件复用数字员工的 realtimeUrl 和 transport.getAccessToken(),并请求同源 /api/general/project/uniplat_base/service/customer_support_api,因此使用标准网关路径的 Host 无需增加配置。只有客服网关地址或鉴权提供者不同时,才显式传入 customerSupport 覆盖默认配置:
import { createUniplatBaseCustomerSupportTransport } from '@csntgao/ai-employee-web-components'
const customerSupportTransport = createUniplatBaseCustomerSupportTransport({
serviceBaseUrl: '/api/general/project/uniplat_base/service/customer_support_api',
getAccessToken: () => auth.getCurrentAccessToken(),
})
chat.configure({
realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
aeCode: 'recruit',
agentCode: 'default',
transport,
customerSupport: {
realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
transport: customerSupportTransport,
},
})Customer Support Chat
<uniplat-customer-support-chat> 也可以独立使用。它直接复用 UniplatBase 的 customer_support_api:user_session_page、user_session_create、user_session_handoff_create、user_session_detail、user_message_page、user_message_send 和 user_mark_read,实时连接订阅 Kanban 的 customer_support channel。首条消息通过 user_session_create.initial_message 原子创建会话,后续消息使用 user_message_send;数字员工职责越界交接由 transport 的可选 createHandoffSession({ sourceSessionId, actionTransactionId }) 调用 user_session_handoff_create。打开抽屉或点击“新对话”只进入本地空白态,不提前创建等待中会话;历史遗留的无消息 waiting 会话也不会被自动恢复。只有首条消息成功创建会话后,界面才显示“等待客服接入”。
<uniplat-customer-support-chat id="customer-support"></uniplat-customer-support-chat>
<script type="module">
import { createUniplatBaseCustomerSupportTransport } from '@csntgao/ai-employee-web-components'
const customerSupport = document.querySelector('#customer-support')
customerSupport.configure({
realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
transport: createUniplatBaseCustomerSupportTransport({
serviceBaseUrl: '/api/general/project/uniplat_base/service/customer_support_api',
getAccessToken: () => auth.getCurrentAccessToken(),
}),
})
await customerSupport.open()
</script>组件派发 ready、message-sent、message-received、connection-state-change、ai-chat-requested、closed 和 error。点击左下角“转AI客服”时派发不含 detail 数据的 ai-chat-requested;当组件嵌入 <uniplat-ai-employee-chat> 时,由父组件内部接管该事件、释放人工客服连接并恢复 AI 客服,无需 Host 处理。独立使用时,Host 可监听该事件切换自己的界面。JWT 每次请求时重新取得,只进入 HTTP Authorization header 和 WebSocket authenticate 首帧;不会进入 URL、DOM、事件或日志。
人工客服标题下方固定用于显示当前会话状态:尚未建立、等待接入、人工客服已接入或会话已结束。其下方的独立状态栏再结合实时连接状态,显示客服通道连接、实时连接恢复、等待接入、人工客服在线或会话结束,不依赖额外的后端在线字段。
客服客户端按会话维护已确认、提交中和待提交的已读游标。重复或回退的游标不会再次调用 markRead();提交期间只保留更大的待提交游标,失败后仅在后续真实消息刷新或通道恢复时重试。实时事件按语义处理:support_message_created 刷新消息历史,support_read_state_changed 不触发历史刷新,会话、成员和持麦等状态事件只刷新会话投影,channel_resync_required 才执行完整恢复,从而避免“已读回执 → 历史刷新 → 再次已读”的反馈环。
标题栏右侧、关闭按钮左侧的“历史会话”按钮会切换到原型“我的会话”视图。Host 可在现有 transport 上实现可选 listSessions(),返回 sessionId、标题、最后消息摘要、更新时间、未读数和可选最后消息 ID;用户点击列表项后,组件自行调用 core bindSession() 加载历史并切换实时订阅。可选 markSessionRead(sessionId, latestMessageId) 用于复用 Host 已有的已读接口。当前选定会话始终按已读处理,在聊天界面和“我的会话”列表中都不显示未读角标,也不计入标题入口的聚合未读数;历史加载、列表加载、当前会话的新实时回复以及从会话列表返回都会立即清除当前会话本地角标并异步同步 Host。历史消息 ID 与列表 latestMessageId 不一致时,组件会用列表返回的 ID 再次同步;同步失败不阻断聊天,并会在下一次触发时重试。其他未选定会话的新回复仍保留未读。旧 transport 未实现会话列表,或真实列表暂时加载失败时,聊天和发送能力不受影响,面板至少显示组件已绑定的当前会话。
标题栏副标题最多显示 12 个字符,超出后追加 ...;完整文案保留在元素的 title 中。
组件派发可冒泡、可穿过 Shadow DOM 的 ready、message-sent、message-received、connection-state-change 和 error 事件。事件只包含会话、消息、状态或经过净化的错误信息,不包含认证凭据。
可用的样式变量包括:
uniplat-ai-employee-chat {
--uniplat-chat-primary: #2e6bf5;
--uniplat-chat-primary-contrast: white;
--uniplat-chat-background: white;
--uniplat-chat-surface: #f7fafd;
--uniplat-chat-text: #25303f;
--uniplat-chat-muted: #8b98a6;
--uniplat-chat-border: #e4ebf3;
--uniplat-chat-error: #b42318;
--uniplat-chat-font-family: system-ui, sans-serif;
--uniplat-chat-width: 420px;
--uniplat-chat-height: 100dvh;
--uniplat-chat-radius: 0;
--uniplat-chat-z-index: 500;
}主要 ::part:container、header、session-list-button、session-header、session-back-button、session-list、session-item、session-item-current、session-list-loading、session-list-empty、session-list-error、close-button、status、messages、time-marker、message、message-user、message-assistant、message-system、message-error、assistant-action、assistant-action-button、composer、input、send-button、human-service-button、empty、loading、activity 和 error。
Vue 和 Nuxt
Vue 模板可以直接使用自定义元素,并在 Vite 配置中声明:
// vite.config.ts
vue({
template: {
compilerOptions: {
isCustomElement: (tag) => tag === 'uniplat-ai-employee-chat',
},
},
})Nuxt v0.1 仅在客户端注册,例如创建 plugins/ai-employee.client.ts:
export default defineNuxtPlugin(async () => {
await import('@csntgao/ai-employee-web-components')
})页面中用 <ClientOnly> 包裹组件,不要求服务端渲染聊天内容。
实时协议与安全边界
客户端只允许无 query、无 hash、无用户凭据的 ws: / wss: URL。连接打开后,第一帧是 authenticate,收到 realtime_ready 后才订阅 ai_employee 的当前 session 并启动服务端指定间隔的心跳。4401 仅用于 JWT 无效或过期,并触发重新获取 JWT 和指数退避重连;4400 / 4403 被视为不可自动恢复的协议或权限错误。服务端暂不可用使用 1013,最多自动重连 3 次;认证错误帧中的 SERVICE_*、KANBAN_AUTH_FAILED 与 REALTIME_UNAVAILABLE 也按该规则处理,不会误报为 JWT 已失效。
JWT 不进入 URL、DOM、CustomEvent、localStorage 或日志。SDK 不接收可信的 org_id、user_id、角色或内部 HMAC 密钥;身份完全以 Kanban/UniplatBase 的服务端鉴权结果为准。组件对后端错误正文和认证异常只显示固定的安全提示。
生产接入必须使用 wss:。ws: 仅用于本机开发环境。
