diff --git a/web/components/kefu/widget-demo.tsx b/web/components/kefu/widget-demo.tsx index 564ed4c..c104fe9 100644 --- a/web/components/kefu/widget-demo.tsx +++ b/web/components/kefu/widget-demo.tsx @@ -4,11 +4,11 @@ import { SignJWT } from "jose" import { CheckIcon, CopyIcon } from "lucide-react" import { useEffect, useMemo, useState } from "react" -import type { KefuWidgetHostConfig } from "@/lib/kefu-widget-config" +import type { CSAgentConfig } from "@/lib/sdk/config-types" const STORAGE_KEY = "cs-agent-web-widget-test-config" const DEFAULT_JWT_TTL_MINUTES = "30" -const INITIAL_CONFIG: KefuWidgetHostConfig = { +const INITIAL_CONFIG: CSAgentConfig = { channelId: "", baseUrl: "", apiBaseUrl: "", @@ -16,7 +16,7 @@ const INITIAL_CONFIG: KefuWidgetHostConfig = { type AuthMode = "guest" | "jwt" -type WidgetDemoConfig = KefuWidgetHostConfig & { +type WidgetDemoConfig = CSAgentConfig & { authMode?: AuthMode jwtSecret?: string jwtUserId?: string @@ -24,18 +24,6 @@ type WidgetDemoConfig = KefuWidgetHostConfig & { jwtTtlMinutes?: string } -declare global { - interface Window { - CSAgentWidget?: { - mount: (config: KefuWidgetHostConfig) => void - destroy: () => void - open: () => Promise - close: () => void - getChatUrl: () => Promise - } - } -} - function getDefaultConfig(): WidgetDemoConfig { if (typeof window === "undefined") { return INITIAL_CONFIG @@ -77,7 +65,7 @@ function removeMountedWidget() { delete window.CSAgentWidget } -function injectWidget(config: KefuWidgetHostConfig) { +function injectWidget(config: CSAgentConfig) { removeMountedWidget() window.CSAgentConfig = config @@ -88,8 +76,8 @@ function injectWidget(config: KefuWidgetHostConfig) { document.body.appendChild(script) } -function buildWidgetConfig(config: WidgetDemoConfig): KefuWidgetHostConfig { - const nextConfig: KefuWidgetHostConfig = { +function buildWidgetConfig(config: WidgetDemoConfig): CSAgentConfig { + const nextConfig: CSAgentConfig = { channelId: config.channelId.trim(), baseUrl: "", apiBaseUrl: "", diff --git a/web/lib/api/im.ts b/web/lib/api/im.ts index ea711c4..7297b5d 100644 --- a/web/lib/api/im.ts +++ b/web/lib/api/im.ts @@ -1,5 +1,5 @@ import { request } from "@/lib/api/client" -import { readKefuWidgetConfig } from "@/lib/kefu-widget-config" +import { readKefuChatRuntimeConfig } from "@/lib/sdk/runtime-config" import { generateUUID } from "@/lib/utils" export type Paging = { @@ -157,7 +157,7 @@ export function getGuestId() { } function getRuntimeImConfig() { - const widgetConfig = readKefuWidgetConfig() + const widgetConfig = readKefuChatRuntimeConfig() const baseUrl = (widgetConfig.apiBaseUrl || widgetConfig.baseUrl || API_BASE_URL) .trim() .replace(/\/$/, "") diff --git a/web/lib/im-realtime.ts b/web/lib/im-realtime.ts index 392efe0..5577448 100644 --- a/web/lib/im-realtime.ts +++ b/web/lib/im-realtime.ts @@ -1,6 +1,6 @@ import { createWebSocketBaseUrl } from "@/lib/api/websocket" import { getCustomerSessionToken, type ImMessage } from "@/lib/api/im" -import { readKefuWidgetConfig } from "@/lib/kefu-widget-config" +import { readKefuChatRuntimeConfig } from "@/lib/sdk/runtime-config" import type { RealtimeConversationPatch, RealtimeMessageCreatedPayload, @@ -22,7 +22,7 @@ export type ImRealtimeEnvelope = { } export function createImRealtimeConnection() { - const config = readKefuWidgetConfig() + const config = readKefuChatRuntimeConfig() const apiBaseUrl = (config.apiBaseUrl || "").trim() const baseUrl = apiBaseUrl ? apiBaseUrl.replace(/^http/, "ws").replace(/\/$/, "") diff --git a/web/lib/kefu-host-bridge.ts b/web/lib/kefu-host-bridge.ts index 9d27822..e50c6fe 100644 --- a/web/lib/kefu-host-bridge.ts +++ b/web/lib/kefu-host-bridge.ts @@ -1,7 +1,5 @@ -import { - setKefuWidgetConfig, - type KefuWidgetRuntimeConfig, -} from "@/lib/kefu-widget-config" +import { setKefuChatRuntimeConfig } from "@/lib/sdk/runtime-config" +import type { KefuChatRuntimeConfig } from "@/lib/sdk/config-types" type HostBridgeOptions = { onInit?: () => void @@ -32,7 +30,7 @@ export function bindKefuHostBridge(options: HostBridgeOptions = {}) { const data = event.data as | { type?: string - payload?: KefuWidgetRuntimeConfig | { isMaximized?: boolean } + payload?: KefuChatRuntimeConfig | { isMaximized?: boolean } } | undefined if (!data?.type) { @@ -40,7 +38,7 @@ export function bindKefuHostBridge(options: HostBridgeOptions = {}) { } if (data.type === INIT_MESSAGE_TYPE && data.payload) { - setKefuWidgetConfig(data.payload as KefuWidgetRuntimeConfig) + setKefuChatRuntimeConfig(data.payload as KefuChatRuntimeConfig) options.onInit?.() return } diff --git a/web/lib/sdk/config-types.ts b/web/lib/sdk/config-types.ts new file mode 100644 index 0000000..0912856 --- /dev/null +++ b/web/lib/sdk/config-types.ts @@ -0,0 +1,39 @@ +export type CSAgentConfig = { + channelId: string + baseUrl?: string + apiBaseUrl?: string + widgetBaseUrl?: string + /** 外部访客稳定标识;未传时使用浏览器本地访客 ID */ + externalId?: string + /** 访客展示名,仅用于首次换取客服会话 token */ + externalName?: string + /** 打开客服前按需获取业务系统签发的前台用户 JWT */ + getUserToken?: () => string | Promise + title?: string + subtitle?: string + position?: "left" | "right" + themeColor?: string + width?: string +} + +export type KefuChatRuntimeConfig = Omit & { + /** 仅用于 /kefu/chat 运行时换取客服会话 token,不属于 CSAgentConfig 接入参数 */ + userToken?: string +} + +export type CSAgentWidget = { + mount: (config?: CSAgentConfig) => void + destroy: () => void + open: () => Promise + close: () => void + getChatUrl: () => Promise +} + +declare global { + interface Window { + CSAgentConfig?: CSAgentConfig + CSAgentWidget?: CSAgentWidget + __CS_AGENT_WIDGET_CONFIG__?: KefuChatRuntimeConfig + __CS_AGENT_WIDGET_STATE__?: unknown + } +} diff --git a/web/lib/sdk/cs-ai-agent-sdk.js b/web/lib/sdk/cs-ai-agent-sdk.js deleted file mode 100644 index c33fc0c..0000000 --- a/web/lib/sdk/cs-ai-agent-sdk.js +++ /dev/null @@ -1,537 +0,0 @@ -(function () { - var DEFAULT_CONFIG = { - position: "right", - themeColor: "#0f6cbd", - width: "380px", - }; - - var state = window.__CS_AGENT_WIDGET_STATE__; - if (!state) { - state = { - 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, - }; - window.__CS_AGENT_WIDGET_STATE__ = state; - } - - function normalizeConfig(config) { - var merged = {}; - var key; - for (key in DEFAULT_CONFIG) { - if (Object.prototype.hasOwnProperty.call(DEFAULT_CONFIG, key)) { - merged[key] = DEFAULT_CONFIG[key]; - } - } - config = config || {}; - for (key in config) { - if (Object.prototype.hasOwnProperty.call(config, key)) { - merged[key] = config[key]; - } - } - 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; - } - - function resolveWidgetBaseUrl(config) { - var currentScript = document.currentScript; - if (currentScript && 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, userToken) { - var widgetBaseUrl = resolveWidgetBaseUrl(config); - var 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, userToken) { - var payload = {}; - var key; - for (key in config) { - if ( - Object.prototype.hasOwnProperty.call(config, key) && - key !== "getUserToken" - ) { - payload[key] = config[key]; - } - } - if (userToken) { - payload.userToken = userToken; - } - return payload; - } - - function resolveUserToken() { - var config = state.config || {}; - if (typeof config.getUserToken !== "function") { - return Promise.resolve(""); - } - try { - return Promise.resolve(config.getUserToken()).then(function (token) { - return String(token || "").trim(); - }); - } catch (error) { - return Promise.reject(error); - } - } - - function prepareFrameUrl() { - return resolveUserToken().then(function (userToken) { - state.frameUrl = createFrameUrl(state.config, userToken); - state.frameConfig = createFrameConfig(state.config, userToken); - return state.frameUrl; - }); - } - - function mergeWidgetConfig(config, remoteConfig) { - if (!remoteConfig) { - return config; - } - var merged = {}; - var key; - for (key in config) { - if (Object.prototype.hasOwnProperty.call(config, key)) { - merged[key] = config[key]; - } - } - var remoteKeys = ["title", "subtitle", "themeColor", "position", "width"]; - for (var i = 0; i < remoteKeys.length; i += 1) { - key = remoteKeys[i]; - if ( - Object.prototype.hasOwnProperty.call(remoteConfig, key) && - remoteConfig[key] !== undefined && - remoteConfig[key] !== null - ) { - merged[key] = remoteConfig[key]; - } - } - return merged; - } - - function fetchWidgetConfig(config) { - var baseUrl = String(config.apiBaseUrl || config.baseUrl || "").replace(/\/$/, ""); - if (!baseUrl || !config.channelId || typeof fetch !== "function") { - return Promise.resolve(config); - } - var url = baseUrl + "/api/channel/config?channelId=" + encodeURIComponent(config.channelId); - return fetch(url, { - method: "GET", - cache: "no-store", - headers: { - "X-Channel-Id": config.channelId, - }, - }) - .then(function (response) { - return response.json(); - }) - .then(function (payload) { - if (!payload || payload.success === false) { - return config; - } - return mergeWidgetConfig(config, payload.data || {}); - }) - .catch(function () { - return 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() { - var frame = state.frame; - var 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) { - if (!state.frame || !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) { - 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() { - var 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(function () { - 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(function () { - 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(function () { - 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", function () { - state.frameLoaded = true; - syncFrameVisibility(); - }); - - document.body.appendChild(state.frame); - return state.frame; - } - - function handleWindowMessage(event) { - if (!state.frame || event.source !== state.frame.contentWindow) { - return; - } - - var data = event.data || {}; - 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; - } - - var config = state.config; - var button = document.createElement("button"); - var icon = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - var 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', - ]; - var 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(function (pathData) { - var 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", function () { - if (state.isOpen) { - state.isOpen = false; - syncFrameVisibility(); - return; - } - - openWidget(); - }); - - document.body.appendChild(button); - state.button = button; - return button; - } - - function mount(config) { - var rawConfig = config || window.CSAgentConfig || {}; - state.config = normalizeConfig(rawConfig); - var 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(function (nextConfig) { - state.configLoading = false; - state.config = normalizeConfig(nextConfig); - if (state.button && state.button.parentNode) { - state.button.parentNode.removeChild(state.button); - state.button = null; - } - createLauncher(); - }); - } - - function destroy() { - clearFrameTimers(); - if (state.frame && state.frame.parentNode) { - state.frame.parentNode.removeChild(state.frame); - } - if (state.button && 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(function () { - if (!state.frame) { - createFrame(); - } - if (!state.frame) { - return; - } - state.isOpen = true; - syncFrameVisibility(); - }) - .catch(function (error) { - console.error("[cs-agent-widget] open failed", error); - }); - } - - window.CSAgentWidget = { - mount: mount, - destroy: destroy, - open: function () { - return openWidget(); - }, - close: function () { - state.isOpen = false; - syncFrameVisibility(); - }, - getChatUrl: function () { - if (!state.config) { - mount(window.CSAgentConfig || {}); - } - if (!state.config || !state.config.channelId) { - return Promise.reject(new Error("channelId is required")); - } - return prepareFrameUrl().then(function (frameUrl) { - return frameUrl.toString(); - }); - }, - }; - - if (!state.listenerBound) { - window.addEventListener("message", handleWindowMessage); - state.listenerBound = true; - } - - if (window.CSAgentConfig) { - mount(window.CSAgentConfig); - } -})(); diff --git a/web/lib/sdk/cs-ai-agent-sdk.test.mjs b/web/lib/sdk/cs-ai-agent-sdk.test.mjs index 6d8887f..362639b 100644 --- a/web/lib/sdk/cs-ai-agent-sdk.test.mjs +++ b/web/lib/sdk/cs-ai-agent-sdk.test.mjs @@ -1,6 +1,7 @@ import assert from "node:assert/strict" import { readFile } from "node:fs/promises" import test from "node:test" +import ts from "typescript" import vm from "node:vm" function createElement(tagName) { @@ -36,7 +37,15 @@ function createElement(tagName) { } async function loadSdk(config) { - const source = await readFile(new URL("./cs-ai-agent-sdk.js", import.meta.url), "utf8") + const source = await readFile(new URL("./cs-ai-agent-sdk.ts", import.meta.url), "utf8") + const compiled = ts.transpileModule(source, { + compilerOptions: { + target: ts.ScriptTarget.ES2017, + module: ts.ModuleKind.ESNext, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + }, + fileName: "cs-ai-agent-sdk.ts", + }) const body = createElement("body") const sandbox = { URL, @@ -79,7 +88,8 @@ async function loadSdk(config) { sandbox.window.setTimeout = sandbox.window.setTimeout sandbox.window.clearTimeout = sandbox.window.clearTimeout - vm.runInNewContext(source, sandbox) + const compiledCode = compiled.outputText.replace(/\nexport\s*\{\};?\s*$/, "") + vm.runInNewContext(compiledCode, sandbox) await Promise.resolve() await Promise.resolve() return sandbox diff --git a/web/lib/sdk/cs-ai-agent-sdk.ts b/web/lib/sdk/cs-ai-agent-sdk.ts new file mode 100644 index 0000000..3e4c001 --- /dev/null +++ b/web/lib/sdk/cs-ai-agent-sdk.ts @@ -0,0 +1,560 @@ +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) + } +})() diff --git a/web/lib/kefu-widget-config.ts b/web/lib/sdk/runtime-config.ts similarity index 55% rename from web/lib/kefu-widget-config.ts rename to web/lib/sdk/runtime-config.ts index c30f28a..92689cf 100644 --- a/web/lib/kefu-widget-config.ts +++ b/web/lib/sdk/runtime-config.ts @@ -1,34 +1,6 @@ -export type KefuWidgetHostConfig = { - channelId: string - baseUrl: string - apiBaseUrl?: string - /** 外部访客稳定标识;未传时使用浏览器本地访客 ID */ - externalId?: string - /** 访客展示名,仅用于首次换取客服会话 token */ - externalName?: string - /** 打开客服前按需获取业务系统签发的前台用户 JWT */ - getUserToken?: () => string | Promise - title?: string - subtitle?: string - position?: "left" | "right" - themeColor?: string - width?: string -} +import type { KefuChatRuntimeConfig } from "@/lib/sdk/config-types" -export type KefuWidgetRuntimeConfig = Omit & { - /** 仅用于 /kefu/chat 运行时换取客服会话 token,不属于 CSAgentConfig 接入参数 */ - userToken?: string -} - -declare global { - interface Window { - CSAgentConfig?: KefuWidgetHostConfig - __CS_AGENT_WIDGET_CONFIG__?: KefuWidgetRuntimeConfig - __CS_AGENT_WIDGET_STATE__?: unknown - } -} - -export function readKefuWidgetConfig(): KefuWidgetRuntimeConfig { +export function readKefuChatRuntimeConfig(): KefuChatRuntimeConfig { if (typeof window === "undefined") { return { channelId: "", @@ -38,7 +10,7 @@ export function readKefuWidgetConfig(): KefuWidgetRuntimeConfig { } const query = new URLSearchParams(window.location.search) - const fallback: KefuWidgetRuntimeConfig = { + const fallback: KefuChatRuntimeConfig = { channelId: query.get("channelId") ?? process.env.NEXT_PUBLIC_OPEN_IM_CHANNEL_ID?.trim() ?? @@ -74,7 +46,7 @@ export function readKefuWidgetConfig(): KefuWidgetRuntimeConfig { return fallback } -export function setKefuWidgetConfig(config: KefuWidgetRuntimeConfig) { +export function setKefuChatRuntimeConfig(config: KefuChatRuntimeConfig) { if (typeof window === "undefined") { return } diff --git a/web/lib/stores/kefu-chat.ts b/web/lib/stores/kefu-chat.ts index d1f3a8e..6e95dd1 100644 --- a/web/lib/stores/kefu-chat.ts +++ b/web/lib/stores/kefu-chat.ts @@ -37,7 +37,10 @@ import { import { summarizeIMMessage } from "@/lib/im-message" import { createRealtimeConnectionManager } from "@/lib/realtime-connection" import { generateUUID } from "@/lib/utils" -import { readKefuWidgetConfig, setKefuWidgetConfig } from "@/lib/kefu-widget-config" +import { + readKefuChatRuntimeConfig, + setKefuChatRuntimeConfig, +} from "@/lib/sdk/runtime-config" type ChatStatus = "connecting" | "connected" | "disconnected" @@ -277,9 +280,10 @@ export const useKefuChatStore = create((set, get) => { } if (widgetConfig.channelId) { - setKefuWidgetConfig({ - ...readKefuWidgetConfig(), - channelId: widgetConfig.channelId || readKefuWidgetConfig().channelId, + setKefuChatRuntimeConfig({ + ...readKefuChatRuntimeConfig(), + channelId: + widgetConfig.channelId || readKefuChatRuntimeConfig().channelId, }) } diff --git a/web/public/sdk/cs-ai-agent-sdk.min.js b/web/public/sdk/cs-ai-agent-sdk.min.js index 0886a1d..1c37b31 100644 --- a/web/public/sdk/cs-ai-agent-sdk.min.js +++ b/web/public/sdk/cs-ai-agent-sdk.min.js @@ -1 +1 @@ -!function(){var e={position:"right",themeColor:"#0f6cbd",width:"380px"},t=window.__CS_AGENT_WIDGET_STATE__;function n(t){var n,r={};for(n in e)Object.prototype.hasOwnProperty.call(e,n)&&(r[n]=e[n]);for(n in t=t||{})Object.prototype.hasOwnProperty.call(t,n)&&(r[n]=t[n]);return r.baseUrl=String(r.baseUrl||window.location.origin).replace(/\/$/,""),r.apiBaseUrl?r.apiBaseUrl=String(r.apiBaseUrl).replace(/\/$/,""):delete r.apiBaseUrl,r.channelId=String(r.channelId||""),r.externalId&&(r.externalId=String(r.externalId)),"function"!=typeof r.getUserToken&&delete r.getUserToken,r}function r(e){var t=document.currentScript;return t&&t.src?t.src.replace(/\/sdk\/cs-ai-agent-sdk\.min\.js(?:\?.*)?$/,""):String(e.widgetBaseUrl||e.baseUrl||window.location.origin).replace(/\/$/,"")}function i(e,t){var n,r={};for(n in e)Object.prototype.hasOwnProperty.call(e,n)&&"getUserToken"!==n&&(r[n]=e[n]);return t&&(r.userToken=t),r}function a(){return function(){var e=t.config||{};if("function"!=typeof e.getUserToken)return Promise.resolve("");try{return Promise.resolve(e.getUserToken()).then(function(e){return String(e||"").trim()})}catch(e){return Promise.reject(e)}}().then(function(e){return t.frameUrl=function(e,t){var n=r(e),i=new URL(n+"/kefu/chat/");return i.searchParams.set("channelId",e.channelId),i.searchParams.set("baseUrl",e.baseUrl),e.apiBaseUrl&&i.searchParams.set("apiBaseUrl",e.apiBaseUrl),e.externalId&&i.searchParams.set("externalId",e.externalId),e.externalName&&i.searchParams.set("externalName",e.externalName),t&&i.searchParams.set("userToken",t),i}(t.config,e),t.frameConfig=i(t.config,e),t.frameUrl})}function o(){t.frameHideTimer&&(window.clearTimeout(t.frameHideTimer),t.frameHideTimer=null),t.frameDestroyTimer&&(window.clearTimeout(t.frameDestroyTimer),t.frameDestroyTimer=null)}function s(){var e=t.frame,n=t.config;if(e&&n){if(e.style.position="fixed",e.style.border="0",e.style.overflow="hidden",e.style.background="#fff",e.style.zIndex="2147483000",e.style.boxShadow="0 28px 80px rgba(15, 35, 65, 0.28)",e.style.willChange="top,right,bottom,left,width,height,opacity,transform,border-radius",e.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",e.style.transformOrigin="left"===n.position?"left bottom":"right bottom",t.isMaximized)return e.style.top="20px",e.style.right="20px",e.style.bottom="20px",e.style.left="20px",e.style.width="calc(100vw - 40px)",e.style.maxWidth="none",e.style.height="calc(100vh - 40px)",void(e.style.borderRadius="24px");e.style.top="",e.style.bottom="112px",e.style.right="left"===n.position?"":"24px",e.style.left="left"===n.position?"24px":"",e.style.width=n.width||"380px",e.style.maxWidth="calc(100vw - 24px)",e.style.height="min(760px, calc(100vh - 136px))",e.style.borderRadius="28px"}}function l(e){if(t.frame&&t.frame.contentWindow&&t.frameUrl)try{t.frame.contentWindow.postMessage(e,t.frameUrl.origin)}catch(e){console.error("[cs-agent-widget] postMessage failed",e)}}function c(){t.frame&&t.frameLoaded&&t.frameReady&&(t.initSent||(t.initSent=!0,l({type:"cs-agent:init",payload:t.frameConfig||i(t.config,"")})),l({type:t.isOpen?"cs-agent:open":"cs-agent:minimize"}),l({type:"cs-agent:maximized",payload:{isMaximized:t.isMaximized}}))}function f(){var e=t.frame;if(e){if(o(),s(),e.style.display="block",t.isOpen)return e.style.visibility="visible",e.style.pointerEvents="auto",t.frameHideTimer=window.setTimeout(function(){t.frame&&(t.frame.style.opacity="1",t.frame.style.transform="translate3d(0, 0, 0) scale(1)")},16),void c();e.style.pointerEvents="none",e.style.opacity="0",e.style.transform=t.isMaximized?"translate3d(0, 10px, 0) scale(0.985)":"translate3d(0, 16px, 0) scale(0.96)",t.frameHideTimer=window.setTimeout(function(){t.frame&&!t.isOpen&&(t.frame.style.visibility="hidden")},t.animationDuration),c()}}function d(e){var i=e||window.CSAgentConfig||{};t.config=n(i);var a=r(t.config);i.baseUrl||(t.config.baseUrl=a),t.config.channelId?(t.configLoading=!0,function(e){var t=String(e.apiBaseUrl||e.baseUrl||"").replace(/\/$/,"");if(!t||!e.channelId||"function"!=typeof fetch)return Promise.resolve(e);var n=t+"/api/channel/config?channelId="+encodeURIComponent(e.channelId);return fetch(n,{method:"GET",cache:"no-store",headers:{"X-Channel-Id":e.channelId}}).then(function(e){return e.json()}).then(function(t){return t&&!1!==t.success?function(e,t){if(!t)return e;var n,r={};for(n in e)Object.prototype.hasOwnProperty.call(e,n)&&(r[n]=e[n]);for(var i=["title","subtitle","themeColor","position","width"],a=0;aString(e||"").trim())}catch(e){return Promise.reject(e)}}().then(e=>{if(!n.config)throw new Error("channelId is required");return n.frameUrl=function(e,t){const n=r(e),i=new URL(`${n}/kefu/chat/`);return i.searchParams.set("channelId",e.channelId),i.searchParams.set("baseUrl",e.baseUrl),e.apiBaseUrl&&i.searchParams.set("apiBaseUrl",e.apiBaseUrl),e.externalId&&i.searchParams.set("externalId",e.externalId),e.externalName&&i.searchParams.set("externalName",e.externalName),t&&i.searchParams.set("userToken",t),i}(n.config,e),n.frameConfig=o(n.config,e),n.frameUrl})}function s(){n.frameHideTimer&&(window.clearTimeout(n.frameHideTimer),n.frameHideTimer=null),n.frameDestroyTimer&&(window.clearTimeout(n.frameDestroyTimer),n.frameDestroyTimer=null)}function l(){const e=n.frame,t=n.config;if(e&&t){if(e.style.position="fixed",e.style.border="0",e.style.overflow="hidden",e.style.background="#fff",e.style.zIndex="2147483000",e.style.boxShadow="0 28px 80px rgba(15, 35, 65, 0.28)",e.style.willChange="top,right,bottom,left,width,height,opacity,transform,border-radius",e.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",e.style.transformOrigin="left"===t.position?"left bottom":"right bottom",n.isMaximized)return e.style.top="20px",e.style.right="20px",e.style.bottom="20px",e.style.left="20px",e.style.width="calc(100vw - 40px)",e.style.maxWidth="none",e.style.height="calc(100vh - 40px)",void(e.style.borderRadius="24px");e.style.top="",e.style.bottom="112px",e.style.right="left"===t.position?"":"24px",e.style.left="left"===t.position?"24px":"",e.style.width=t.width||"380px",e.style.maxWidth="calc(100vw - 24px)",e.style.height="min(760px, calc(100vh - 136px))",e.style.borderRadius="28px"}}function c(e){var t;if((null===(t=n.frame)||void 0===t?void 0:t.contentWindow)&&n.frameUrl)try{n.frame.contentWindow.postMessage(e,n.frameUrl.origin)}catch(e){console.error("[cs-agent-widget] postMessage failed",e)}}function d(){n.frame&&n.frameLoaded&&n.frameReady&&n.config&&(n.initSent||(n.initSent=!0,c({type:"cs-agent:init",payload:n.frameConfig||o(n.config,"")})),c({type:n.isOpen?"cs-agent:open":"cs-agent:minimize"}),c({type:"cs-agent:maximized",payload:{isMaximized:n.isMaximized}}))}function f(){const e=n.frame;if(e){if(s(),l(),e.style.display="block",n.isOpen)return e.style.visibility="visible",e.style.pointerEvents="auto",n.frameHideTimer=window.setTimeout(()=>{n.frame&&(n.frame.style.opacity="1",n.frame.style.transform="translate3d(0, 0, 0) scale(1)")},16),void d();e.style.pointerEvents="none",e.style.opacity="0",e.style.transform=n.isMaximized?"translate3d(0, 10px, 0) scale(0.985)":"translate3d(0, 16px, 0) scale(0.96)",n.frameHideTimer=window.setTimeout(()=>{n.frame&&!n.isOpen&&(n.frame.style.visibility="hidden")},n.animationDuration),d()}}function m(e){const t=e||window.CSAgentConfig||{channelId:""};n.config=i(t);const o=r(n.config);t.baseUrl||(n.config.baseUrl=o),n.config.channelId?(n.configLoading=!0,function(e){const t=String(e.apiBaseUrl||e.baseUrl||"").replace(/\/$/,"");if(!t||!e.channelId||"function"!=typeof fetch)return Promise.resolve(e);const n=`${t}/api/channel/config?channelId=${encodeURIComponent(e.channelId)}`;return fetch(n,{method:"GET",cache:"no-store",headers:{"X-Channel-Id":e.channelId}}).then(e=>e.json()).then(t=>t&&!1!==t.success?function(e,t){if(!t)return e;const n=Object.assign({},e);return["title","subtitle","themeColor","position","width"].forEach(e=>{const i=t[e];null!=i&&(n[e]=i)}),n}(e,t.data||{}):e).catch(()=>e)}(n.config).then(e=>{var t;n.configLoading=!1,n.config=i(e),(null===(t=n.button)||void 0===t?void 0:t.parentNode)&&(n.button.parentNode.removeChild(n.button),n.button=null),function(){if(n.button)return n.button;const e=n.config;if(!e)return null;const t=document.createElement("button"),i=document.createElementNS("http://www.w3.org/2000/svg","svg"),r=document.createElement("span");t.type="button",t.dataset.csAgentWidget="launcher",t.setAttribute("aria-label",e.title||"\u5728\u7ebf\u5ba2\u670d"),i.setAttribute("viewBox","0 0 24 24"),i.setAttribute("fill","none"),i.setAttribute("stroke","currentColor"),i.setAttribute("stroke-width","2"),i.setAttribute("stroke-linecap","round"),i.setAttribute("stroke-linejoin","round"),i.setAttribute("aria-hidden","true"),i.style.width="24px",i.style.height="24px",i.style.flex="0 0 auto",["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"].forEach(e=>{const t=document.createElementNS("http://www.w3.org/2000/svg","path");t.setAttribute("d",e),i.appendChild(t)}),r.textContent="\u5ba2\u670d",r.style.display="block",t.style.position="fixed",t.style.bottom="24px",t.style.right="left"===e.position?"":"24px",t.style.left="left"===e.position?"24px":"",t.style.zIndex="2147483000",t.style.display="inline-flex",t.style.flexDirection="column",t.style.alignItems="center",t.style.justifyContent="center",t.style.gap="4px",t.style.width="64px",t.style.height="64px",t.style.border="0",t.style.borderRadius="999px",t.style.padding="0",t.style.background=e.themeColor||"#0f6cbd",t.style.color="#fff",t.style.font="600 13px/1 sans-serif",t.style.boxShadow="0 18px 40px rgba(15, 35, 65, 0.24)",t.style.cursor="pointer",t.appendChild(i),t.appendChild(r),t.addEventListener("click",()=>{if(n.isOpen)return n.isOpen=!1,void f();u()}),document.body.appendChild(t),n.button=t}()})):console.error("[cs-agent-widget] channelId is required")}function u(){return a().then(()=>{n.frame||(n.frame?n.frame:n.frameUrl&&n.config&&(n.frame=document.createElement("iframe"),n.frame.dataset.csAgentWidget="frame",n.frame.title=n.config.title||"\u5728\u7ebf\u5ba2\u670d",n.frame.src=n.frameUrl.toString(),l(),n.frame.style.display="block",n.frame.style.visibility="hidden",n.frame.style.pointerEvents="none",n.frame.style.opacity="0",n.frame.style.transform="translate3d(0, 18px, 0) scale(0.96)",n.frame.addEventListener("load",()=>{n.frameLoaded=!0,f()}),document.body.appendChild(n.frame),n.frame)),n.frame&&(n.isOpen=!0,f())}).catch(e=>{console.error("[cs-agent-widget] open failed",e)})}t||(window.__CS_AGENT_WIDGET_STATE__=n),window.CSAgentWidget={mount:m,destroy:function(){var e,t;s(),(null===(e=n.frame)||void 0===e?void 0:e.parentNode)&&n.frame.parentNode.removeChild(n.frame),(null===(t=n.button)||void 0===t?void 0:t.parentNode)&&n.button.parentNode.removeChild(n.button),n.button=null,n.frame=null,n.frameLoaded=!1,n.frameReady=!1,n.initSent=!1,n.isOpen=!1,n.isMaximized=!1,n.configLoading=!1,n.frameConfig=null,n.frameUrl=null},open:()=>u(),close:()=>{n.isOpen=!1,f()},getChatUrl:()=>{var e;return n.config||m(window.CSAgentConfig||{channelId:""}),(null===(e=n.config)||void 0===e?void 0:e.channelId)?a().then(e=>e.toString()):Promise.reject(new Error("channelId is required"))}},n.listenerBound||(window.addEventListener("message",function(e){if(!n.frame||e.source!==n.frame.contentWindow)return;const t=e.data||{};return"cs-agent:ready"===t.type?(n.frameReady=!0,void d()):"cs-agent:request-minimize"===t.type?(n.isOpen=!1,void f()):void("cs-agent:request-close"!==t.type?"cs-agent:request-toggle-maximize"===t.type&&(n.isMaximized=!n.isMaximized,f()):n.frame&&(s(),n.frame.style.pointerEvents="none",n.frame.style.opacity="0",n.frame.style.transform="translate3d(0, 18px, 0) scale(0.94)",n.frame.style.visibility="hidden",n.frameDestroyTimer=window.setTimeout(()=>{n.frame&&(n.frame.parentNode&&n.frame.parentNode.removeChild(n.frame),n.frame=null,n.frameLoaded=!1,n.frameReady=!1,n.initSent=!1,n.isOpen=!1,n.isMaximized=!1,s())},n.animationDuration)))}),n.listenerBound=!0),window.CSAgentConfig&&m(window.CSAgentConfig)}(); diff --git a/web/scripts/build-sdk.mjs b/web/scripts/build-sdk.mjs index 5a9bc76..7bb3c3d 100644 --- a/web/scripts/build-sdk.mjs +++ b/web/scripts/build-sdk.mjs @@ -2,16 +2,27 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { minify } from "terser"; +import ts from "typescript"; const currentDir = path.dirname(fileURLToPath(import.meta.url)); const rootDir = path.resolve(currentDir, ".."); -const source = path.join(rootDir, "lib", "sdk", "cs-ai-agent-sdk.js"); +const source = path.join(rootDir, "lib", "sdk", "cs-ai-agent-sdk.ts"); const targetDir = path.join(rootDir, "public", "sdk"); const target = path.join(targetDir, "cs-ai-agent-sdk.min.js"); await mkdir(targetDir, { recursive: true }); const sourceCode = await readFile(source, "utf8"); -const result = await minify(sourceCode, { +const compiled = ts.transpileModule(sourceCode, { + compilerOptions: { + target: ts.ScriptTarget.ES2017, + module: ts.ModuleKind.ESNext, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + removeComments: true, + }, + fileName: source, +}); +const compiledCode = compiled.outputText.replace(/\nexport\s*\{\};?\s*$/, ""); +const result = await minify(compiledCode, { compress: { passes: 2, },