import type { CSAgentConfig, CSAgentWidget, KefuChatRuntimeConfig, } from "./config-types" type NormalizedCSAgentConfig = CSAgentConfig & { baseUrl: string channelId: string position: "left" | "right" themeColor: string width: string } type WidgetState = { button: HTMLButtonElement | null frame: HTMLIFrameElement | null frameLoaded: boolean frameReady: boolean initSent: boolean isOpen: boolean isMaximized: boolean configLoading: boolean frameHideTimer: number | null frameDestroyTimer: number | null config: NormalizedCSAgentConfig | null frameConfig: KefuChatRuntimeConfig | null frameUrl: URL | null animationDuration: number listenerBound?: boolean } type WidgetConfigResponse = { success?: boolean data?: Partial> } type FrameMessage = | { type: "cs-agent:init"; payload: KefuChatRuntimeConfig } | { type: "cs-agent:open" } | { type: "cs-agent:minimize" } | { type: "cs-agent:maximized"; payload: { isMaximized: boolean } } (function () { const DEFAULT_CONFIG: Pick< NormalizedCSAgentConfig, "position" | "themeColor" | "width" > = { position: "right", themeColor: "#0f6cbd", width: "380px", } const existingState = window.__CS_AGENT_WIDGET_STATE__ as WidgetState | undefined const state: WidgetState = existingState || { button: null, frame: null, frameLoaded: false, frameReady: false, initSent: false, isOpen: false, isMaximized: false, configLoading: false, frameHideTimer: null, frameDestroyTimer: null, config: null, frameConfig: null, frameUrl: null, animationDuration: 260, } if (!existingState) { window.__CS_AGENT_WIDGET_STATE__ = state } function normalizeConfig(config?: CSAgentConfig): NormalizedCSAgentConfig { const merged: Record = { ...DEFAULT_CONFIG, ...(config || {}) } merged.baseUrl = String(merged.baseUrl || window.location.origin).replace(/\/$/, "") if (merged.apiBaseUrl) { merged.apiBaseUrl = String(merged.apiBaseUrl).replace(/\/$/, "") } else { delete merged.apiBaseUrl } merged.channelId = String(merged.channelId || "") if (merged.externalId) { merged.externalId = String(merged.externalId) } if (typeof merged.getUserToken !== "function") { delete merged.getUserToken } return merged as NormalizedCSAgentConfig } function resolveWidgetBaseUrl(config: NormalizedCSAgentConfig) { const currentScript = document.currentScript as HTMLScriptElement | null if (currentScript?.src) { return currentScript.src.replace(/\/sdk\/cs-ai-agent-sdk\.min\.js(?:\?.*)?$/, "") } return String(config.widgetBaseUrl || config.baseUrl || window.location.origin).replace(/\/$/, "") } function createFrameUrl(config: NormalizedCSAgentConfig, userToken: string) { const widgetBaseUrl = resolveWidgetBaseUrl(config) const frameUrl = new URL(`${widgetBaseUrl}/kefu/chat/`) frameUrl.searchParams.set("channelId", config.channelId) frameUrl.searchParams.set("baseUrl", config.baseUrl) if (config.apiBaseUrl) frameUrl.searchParams.set("apiBaseUrl", config.apiBaseUrl) if (config.externalId) frameUrl.searchParams.set("externalId", config.externalId) if (config.externalName) frameUrl.searchParams.set("externalName", config.externalName) if (userToken) frameUrl.searchParams.set("userToken", userToken) return frameUrl } function createFrameConfig( config: NormalizedCSAgentConfig, userToken: string ): KefuChatRuntimeConfig { const { getUserToken: _getUserToken, ...payload } = config if (userToken) { return { ...payload, userToken } } return payload } function resolveUserToken() { const config = state.config if (typeof config?.getUserToken !== "function") { return Promise.resolve("") } try { return Promise.resolve(config.getUserToken()).then((token) => String(token || "").trim() ) } catch (error) { return Promise.reject(error) } } function prepareFrameUrl() { return resolveUserToken().then((userToken) => { if (!state.config) { throw new Error("channelId is required") } state.frameUrl = createFrameUrl(state.config, userToken) state.frameConfig = createFrameConfig(state.config, userToken) return state.frameUrl }) } function mergeWidgetConfig( config: NormalizedCSAgentConfig, remoteConfig?: WidgetConfigResponse["data"] ) { if (!remoteConfig) { return config } const merged: NormalizedCSAgentConfig = { ...config } const remoteKeys = ["title", "subtitle", "themeColor", "position", "width"] as const remoteKeys.forEach((key) => { const value = remoteConfig[key] if (value !== undefined && value !== null) { ;(merged[key] as typeof value) = value } }) return merged } function fetchWidgetConfig(config: NormalizedCSAgentConfig) { const baseUrl = String(config.apiBaseUrl || config.baseUrl || "").replace(/\/$/, "") if (!baseUrl || !config.channelId || typeof fetch !== "function") { return Promise.resolve(config) } const url = `${baseUrl}/api/channel/config?channelId=${encodeURIComponent(config.channelId)}` return fetch(url, { method: "GET", cache: "no-store", headers: { "X-Channel-Id": config.channelId, }, }) .then((response) => response.json() as Promise) .then((payload) => { if (!payload || payload.success === false) { return config } return mergeWidgetConfig(config, payload.data || {}) }) .catch(() => config) } function clearFrameTimers() { if (state.frameHideTimer) { window.clearTimeout(state.frameHideTimer) state.frameHideTimer = null } if (state.frameDestroyTimer) { window.clearTimeout(state.frameDestroyTimer) state.frameDestroyTimer = null } } function applyFrameLayout() { const frame = state.frame const config = state.config if (!frame || !config) { return } frame.style.position = "fixed" frame.style.border = "0" frame.style.overflow = "hidden" frame.style.background = "#fff" frame.style.zIndex = "2147483000" frame.style.boxShadow = "0 28px 80px rgba(15, 35, 65, 0.28)" frame.style.willChange = "top,right,bottom,left,width,height,opacity,transform,border-radius" frame.style.transition = "top 260ms cubic-bezier(0.22, 1, 0.36, 1), right 260ms cubic-bezier(0.22, 1, 0.36, 1), bottom 260ms cubic-bezier(0.22, 1, 0.36, 1), left 260ms cubic-bezier(0.22, 1, 0.36, 1), width 260ms cubic-bezier(0.22, 1, 0.36, 1), height 260ms cubic-bezier(0.22, 1, 0.36, 1), opacity 220ms ease, transform 260ms cubic-bezier(0.22, 1, 0.36, 1), border-radius 260ms cubic-bezier(0.22, 1, 0.36, 1), box-shadow 260ms ease" frame.style.transformOrigin = config.position === "left" ? "left bottom" : "right bottom" if (state.isMaximized) { frame.style.top = "20px" frame.style.right = "20px" frame.style.bottom = "20px" frame.style.left = "20px" frame.style.width = "calc(100vw - 40px)" frame.style.maxWidth = "none" frame.style.height = "calc(100vh - 40px)" frame.style.borderRadius = "24px" return } frame.style.top = "" frame.style.bottom = "112px" frame.style.right = config.position === "left" ? "" : "24px" frame.style.left = config.position === "left" ? "24px" : "" frame.style.width = config.width || "380px" frame.style.maxWidth = "calc(100vw - 24px)" frame.style.height = "min(760px, calc(100vh - 136px))" frame.style.borderRadius = "28px" } function postToFrame(message: FrameMessage) { if (!state.frame?.contentWindow || !state.frameUrl) { return } try { state.frame.contentWindow.postMessage(message, state.frameUrl.origin) } catch (error) { console.error("[cs-agent-widget] postMessage failed", error) } } function flushFrameState() { if (!state.frame || !state.frameLoaded || !state.frameReady || !state.config) { return } if (!state.initSent) { state.initSent = true postToFrame({ type: "cs-agent:init", payload: state.frameConfig || createFrameConfig(state.config, ""), }) } postToFrame({ type: state.isOpen ? "cs-agent:open" : "cs-agent:minimize" }) postToFrame({ type: "cs-agent:maximized", payload: { isMaximized: state.isMaximized }, }) } function syncFrameVisibility() { const frame = state.frame if (!frame) { return } clearFrameTimers() applyFrameLayout() frame.style.display = "block" if (state.isOpen) { frame.style.visibility = "visible" frame.style.pointerEvents = "auto" state.frameHideTimer = window.setTimeout(() => { if (!state.frame) { return } state.frame.style.opacity = "1" state.frame.style.transform = "translate3d(0, 0, 0) scale(1)" }, 16) flushFrameState() return } frame.style.pointerEvents = "none" frame.style.opacity = "0" frame.style.transform = state.isMaximized ? "translate3d(0, 10px, 0) scale(0.985)" : "translate3d(0, 16px, 0) scale(0.96)" state.frameHideTimer = window.setTimeout(() => { if (!state.frame || state.isOpen) { return } state.frame.style.visibility = "hidden" }, state.animationDuration) flushFrameState() } function destroyFrame() { if (!state.frame) { return } clearFrameTimers() state.frame.style.pointerEvents = "none" state.frame.style.opacity = "0" state.frame.style.transform = "translate3d(0, 18px, 0) scale(0.94)" state.frame.style.visibility = "hidden" state.frameDestroyTimer = window.setTimeout(() => { if (!state.frame) { return } if (state.frame.parentNode) { state.frame.parentNode.removeChild(state.frame) } state.frame = null state.frameLoaded = false state.frameReady = false state.initSent = false state.isOpen = false state.isMaximized = false clearFrameTimers() }, state.animationDuration) } function createFrame() { if (state.frame) { return state.frame } if (!state.frameUrl || !state.config) { return null } state.frame = document.createElement("iframe") state.frame.dataset.csAgentWidget = "frame" state.frame.title = state.config.title || "在线客服" state.frame.src = state.frameUrl.toString() applyFrameLayout() state.frame.style.display = "block" state.frame.style.visibility = "hidden" state.frame.style.pointerEvents = "none" state.frame.style.opacity = "0" state.frame.style.transform = "translate3d(0, 18px, 0) scale(0.96)" state.frame.addEventListener("load", () => { state.frameLoaded = true syncFrameVisibility() }) document.body.appendChild(state.frame) return state.frame } function handleWindowMessage(event: MessageEvent) { if (!state.frame || event.source !== state.frame.contentWindow) { return } const data = (event.data || {}) as { type?: string } if (data.type === "cs-agent:ready") { state.frameReady = true flushFrameState() return } if (data.type === "cs-agent:request-minimize") { state.isOpen = false syncFrameVisibility() return } if (data.type === "cs-agent:request-close") { destroyFrame() return } if (data.type === "cs-agent:request-toggle-maximize") { state.isMaximized = !state.isMaximized syncFrameVisibility() } } function createLauncher() { if (state.button) { return state.button } const config = state.config if (!config) { return null } const button = document.createElement("button") const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg") const iconPaths = [ "M3 11a9 9 0 1 1 18 0", "M3 11h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z", "M21 11h-3a2 2 0 0 0-2 2v3a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2z", "M21 16v2a4 4 0 0 1-4 4h-5", ] const text = document.createElement("span") button.type = "button" button.dataset.csAgentWidget = "launcher" button.setAttribute("aria-label", config.title || "在线客服") icon.setAttribute("viewBox", "0 0 24 24") icon.setAttribute("fill", "none") icon.setAttribute("stroke", "currentColor") icon.setAttribute("stroke-width", "2") icon.setAttribute("stroke-linecap", "round") icon.setAttribute("stroke-linejoin", "round") icon.setAttribute("aria-hidden", "true") icon.style.width = "24px" icon.style.height = "24px" icon.style.flex = "0 0 auto" iconPaths.forEach((pathData) => { const path = document.createElementNS("http://www.w3.org/2000/svg", "path") path.setAttribute("d", pathData) icon.appendChild(path) }) text.textContent = "客服" text.style.display = "block" button.style.position = "fixed" button.style.bottom = "24px" button.style.right = config.position === "left" ? "" : "24px" button.style.left = config.position === "left" ? "24px" : "" button.style.zIndex = "2147483000" button.style.display = "inline-flex" button.style.flexDirection = "column" button.style.alignItems = "center" button.style.justifyContent = "center" button.style.gap = "4px" button.style.width = "64px" button.style.height = "64px" button.style.border = "0" button.style.borderRadius = "999px" button.style.padding = "0" button.style.background = config.themeColor || "#0f6cbd" button.style.color = "#fff" button.style.font = "600 13px/1 sans-serif" button.style.boxShadow = "0 18px 40px rgba(15, 35, 65, 0.24)" button.style.cursor = "pointer" button.appendChild(icon) button.appendChild(text) button.addEventListener("click", () => { if (state.isOpen) { state.isOpen = false syncFrameVisibility() return } void openWidget() }) document.body.appendChild(button) state.button = button return button } function mount(config?: CSAgentConfig) { const rawConfig = config || window.CSAgentConfig || { channelId: "" } state.config = normalizeConfig(rawConfig) const widgetBaseUrl = resolveWidgetBaseUrl(state.config) if (!rawConfig.baseUrl) { state.config.baseUrl = widgetBaseUrl } if (!state.config.channelId) { console.error("[cs-agent-widget] channelId is required") return } state.configLoading = true fetchWidgetConfig(state.config).then((nextConfig) => { state.configLoading = false state.config = normalizeConfig(nextConfig) if (state.button?.parentNode) { state.button.parentNode.removeChild(state.button) state.button = null } createLauncher() }) } function destroy() { clearFrameTimers() if (state.frame?.parentNode) { state.frame.parentNode.removeChild(state.frame) } if (state.button?.parentNode) { state.button.parentNode.removeChild(state.button) } state.button = null state.frame = null state.frameLoaded = false state.frameReady = false state.initSent = false state.isOpen = false state.isMaximized = false state.configLoading = false state.frameConfig = null state.frameUrl = null } function openWidget() { return prepareFrameUrl() .then(() => { if (!state.frame) { createFrame() } if (!state.frame) { return } state.isOpen = true syncFrameVisibility() }) .catch((error) => { console.error("[cs-agent-widget] open failed", error) }) } window.CSAgentWidget = { mount, destroy, open: () => openWidget(), close: () => { state.isOpen = false syncFrameVisibility() }, getChatUrl: () => { if (!state.config) { mount(window.CSAgentConfig || { channelId: "" }) } if (!state.config?.channelId) { return Promise.reject(new Error("channelId is required")) } return prepareFrameUrl().then((frameUrl) => frameUrl.toString()) }, } satisfies CSAgentWidget if (!state.listenerBound) { window.addEventListener("message", handleWindowMessage) state.listenerBound = true } if (window.CSAgentConfig) { mount(window.CSAgentConfig) } })()