feat: add runtime configuration for Kefu Chat SDK
- Introduced `readKefuChatRuntimeConfig` and `setKefuChatRuntimeConfig` functions to manage runtime configuration for the Kefu Chat SDK. - Updated `useKefuChatStore` to utilize the new runtime configuration functions instead of the previous widget config methods. - Modified the SDK build process to compile TypeScript instead of JavaScript, enhancing type safety and maintainability. - Updated the minified SDK file to reflect the changes in configuration handling and TypeScript compilation.
This commit is contained in:
@@ -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<void>
|
||||
close: () => void
|
||||
getChatUrl: () => Promise<string>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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: "",
|
||||
|
||||
+2
-2
@@ -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(/\/$/, "")
|
||||
|
||||
@@ -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(/\/$/, "")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<string>
|
||||
title?: string
|
||||
subtitle?: string
|
||||
position?: "left" | "right"
|
||||
themeColor?: string
|
||||
width?: string
|
||||
}
|
||||
|
||||
export type KefuChatRuntimeConfig = Omit<CSAgentConfig, "getUserToken"> & {
|
||||
/** 仅用于 /kefu/chat 运行时换取客服会话 token,不属于 CSAgentConfig 接入参数 */
|
||||
userToken?: string
|
||||
}
|
||||
|
||||
export type CSAgentWidget = {
|
||||
mount: (config?: CSAgentConfig) => void
|
||||
destroy: () => void
|
||||
open: () => Promise<void>
|
||||
close: () => void
|
||||
getChatUrl: () => Promise<string>
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
CSAgentConfig?: CSAgentConfig
|
||||
CSAgentWidget?: CSAgentWidget
|
||||
__CS_AGENT_WIDGET_CONFIG__?: KefuChatRuntimeConfig
|
||||
__CS_AGENT_WIDGET_STATE__?: unknown
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
@@ -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
|
||||
|
||||
@@ -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<Pick<
|
||||
CSAgentConfig,
|
||||
"title" | "subtitle" | "themeColor" | "position" | "width"
|
||||
>>
|
||||
}
|
||||
|
||||
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<string, unknown> = { ...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<WidgetConfigResponse>)
|
||||
.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)
|
||||
}
|
||||
})()
|
||||
@@ -1,34 +1,6 @@
|
||||
export type KefuWidgetHostConfig = {
|
||||
channelId: string
|
||||
baseUrl: string
|
||||
apiBaseUrl?: string
|
||||
/** 外部访客稳定标识;未传时使用浏览器本地访客 ID */
|
||||
externalId?: string
|
||||
/** 访客展示名,仅用于首次换取客服会话 token */
|
||||
externalName?: string
|
||||
/** 打开客服前按需获取业务系统签发的前台用户 JWT */
|
||||
getUserToken?: () => string | Promise<string>
|
||||
title?: string
|
||||
subtitle?: string
|
||||
position?: "left" | "right"
|
||||
themeColor?: string
|
||||
width?: string
|
||||
}
|
||||
import type { KefuChatRuntimeConfig } from "@/lib/sdk/config-types"
|
||||
|
||||
export type KefuWidgetRuntimeConfig = Omit<KefuWidgetHostConfig, "getUserToken"> & {
|
||||
/** 仅用于 /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
|
||||
}
|
||||
@@ -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<KefuChatStore>((set, get) => {
|
||||
}
|
||||
|
||||
if (widgetConfig.channelId) {
|
||||
setKefuWidgetConfig({
|
||||
...readKefuWidgetConfig(),
|
||||
channelId: widgetConfig.channelId || readKefuWidgetConfig().channelId,
|
||||
setKefuChatRuntimeConfig({
|
||||
...readKefuChatRuntimeConfig(),
|
||||
channelId:
|
||||
widgetConfig.channelId || readKefuChatRuntimeConfig().channelId,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user