diff --git a/web/app/kefu/page.tsx b/web/app/kefu/page.tsx
new file mode 100644
index 0000000..5a038af
--- /dev/null
+++ b/web/app/kefu/page.tsx
@@ -0,0 +1,6 @@
+import { KefuWidgetDemo } from "@/components/kefu/widget-demo"
+
+export default function Page() {
+ return
+}
+
diff --git a/web/components/kefu/chat-shell.tsx b/web/components/kefu/chat-shell.tsx
index 689eaa4..8183fcb 100644
--- a/web/components/kefu/chat-shell.tsx
+++ b/web/components/kefu/chat-shell.tsx
@@ -79,6 +79,7 @@ export function KefuChatShell() {
closeConversation: state.closeConversation,
}))
)
+ const safeMessages = Array.isArray(messages) ? messages : []
const maybeMarkConversationRead = useCallback(() => {
if (!isVisible || !conversation || typeof document === "undefined") {
@@ -119,7 +120,7 @@ export function KefuChatShell() {
useEffect(() => {
maybeMarkConversationRead()
- }, [maybeMarkConversationRead, messages.length])
+ }, [maybeMarkConversationRead, safeMessages.length])
useEffect(() => {
const handleVisibilityChange = () => {
@@ -252,7 +253,7 @@ export function KefuChatShell() {
)
}
-
diff --git a/web/components/kefu/message-list.tsx b/web/components/kefu/message-list.tsx
index f41639b..819caab 100644
--- a/web/components/kefu/message-list.tsx
+++ b/web/components/kefu/message-list.tsx
@@ -18,7 +18,7 @@ import { renderIMMessageHTML } from "@/lib/im-message"
import { cn, formatDateTime } from "@/lib/utils"
type KefuMessageListProps = {
- messages: ImMessage[]
+ messages?: ImMessage[] | null
onNearBottomVisible?: () => void
hasMoreOlder?: boolean
loadingOlder?: boolean
@@ -76,7 +76,8 @@ export const KefuMessageList = forwardRef(null)
const frameRef = useRef(null)
const shouldStickToBottomRef = useRef(true)
- const lastMessageId = messages.at(-1)?.id
+ const safeMessages = Array.isArray(messages) ? messages : []
+ const lastMessageId = safeMessages.at(-1)?.id
const isNearBottom = useCallback(
(element: HTMLElement, threshold = 80) =>
@@ -221,8 +222,8 @@ export const KefuMessageList = forwardRef
) : null}
- {messages.map((message, index) => {
- const previousMessage = index > 0 ? messages[index - 1] : null
+ {safeMessages.map((message, index) => {
+ const previousMessage = index > 0 ? safeMessages[index - 1] : null
const showTimeline =
index === 0 ||
getDayKey(previousMessage?.sentAt) !== getDayKey(message.sentAt)
diff --git a/web/components/kefu/widget-demo.tsx b/web/components/kefu/widget-demo.tsx
new file mode 100644
index 0000000..d1b8150
--- /dev/null
+++ b/web/components/kefu/widget-demo.tsx
@@ -0,0 +1,283 @@
+"use client"
+
+import { useEffect, useMemo, useState } from "react"
+
+import type { KefuWidgetHostConfig } from "@/lib/kefu-widget-config"
+import { generateUUID } from "@/lib/utils"
+
+const STORAGE_KEY = "cs-agent-web-widget-test-config"
+const INITIAL_CONFIG: KefuWidgetHostConfig = {
+ channelId: "",
+ baseUrl: "",
+ apiBaseUrl: "",
+ externalSource: "web_chat",
+ title: "在线客服",
+ subtitle: "欢迎咨询",
+ position: "right",
+ themeColor: "#2563eb",
+ width: "680px",
+ subject: "",
+}
+
+declare global {
+ interface Window {
+ CSAgentWidget?: {
+ mount: (config: KefuWidgetHostConfig) => void
+ destroy: () => void
+ open: () => void
+ close: () => void
+ }
+ }
+}
+
+function generateRandomSubject() {
+ return `访客-${generateUUID().replace(/-/g, "").slice(0, 8)}`
+}
+
+function getDefaultConfig(): KefuWidgetHostConfig {
+ if (typeof window === "undefined") {
+ return INITIAL_CONFIG
+ }
+
+ const savedText = window.localStorage.getItem(STORAGE_KEY)
+ const savedConfig = savedText
+ ? (JSON.parse(savedText) as Partial)
+ : {}
+ const query = new URLSearchParams(window.location.search)
+ const origin = window.location.origin
+
+ return {
+ channelId: query.get("channelId") ?? savedConfig.channelId ?? "",
+ baseUrl: query.get("baseUrl") ?? savedConfig.baseUrl ?? origin,
+ apiBaseUrl:
+ query.get("apiBaseUrl") ??
+ savedConfig.apiBaseUrl ??
+ savedConfig.baseUrl ??
+ origin,
+ externalSource:
+ query.get("externalSource") ?? savedConfig.externalSource ?? "web_chat",
+ title: query.get("title") ?? savedConfig.title ?? "在线客服",
+ subtitle: query.get("subtitle") ?? savedConfig.subtitle ?? "欢迎咨询",
+ position:
+ (query.get("position") as "left" | "right" | null) ??
+ savedConfig.position ??
+ "right",
+ themeColor: query.get("themeColor") ?? savedConfig.themeColor ?? "#2563eb",
+ width: query.get("width") ?? savedConfig.width ?? "680px",
+ subject: query.get("subject") ?? savedConfig.subject ?? generateRandomSubject(),
+ }
+}
+
+function removeMountedWidget() {
+ if (typeof window === "undefined") {
+ return
+ }
+
+ window.CSAgentWidget?.destroy()
+ document
+ .querySelectorAll(
+ '[data-cs-agent-widget="launcher"], [data-cs-agent-widget="frame"], [data-cs-agent-widget="script"]'
+ )
+ .forEach((node) => node.remove())
+
+ delete window.CSAgentConfig
+ delete window.__CS_AGENT_WIDGET_CONFIG__
+ delete window.__CS_AGENT_WIDGET_STATE__
+ delete window.CSAgentWidget
+}
+
+function injectWidget(config: KefuWidgetHostConfig) {
+ removeMountedWidget()
+ window.CSAgentConfig = config
+
+ const script = document.createElement("script")
+ script.async = true
+ script.src = `${window.location.origin}/sdk/cs-agent-widget.js`
+ script.dataset.csAgentWidget = "script"
+ document.body.appendChild(script)
+}
+
+export function KefuWidgetDemo() {
+ const [config, setConfig] = useState(INITIAL_CONFIG)
+ const [status, setStatus] = useState("请填写 channelId")
+
+ useEffect(() => {
+ const initialConfig = getDefaultConfig()
+ setConfig(initialConfig)
+ setStatus(initialConfig.channelId ? "Widget 已挂载" : "请填写 channelId")
+
+ if (initialConfig.channelId) {
+ injectWidget(initialConfig)
+ }
+
+ return () => {
+ removeMountedWidget()
+ }
+ }, [])
+
+ const snippet = useMemo(() => {
+ const scriptSrc = config.baseUrl
+ ? `${config.baseUrl.replace(/\/$/, "")}/sdk/cs-agent-widget.js`
+ : "/sdk/cs-agent-widget.js"
+
+ return `
+`
+ }, [config])
+
+ function updateField(
+ key: K,
+ value: KefuWidgetHostConfig[K]
+ ) {
+ setConfig((current) => ({ ...current, [key]: value }))
+ }
+
+ function handleMount() {
+ const nextConfig: KefuWidgetHostConfig = {
+ ...config,
+ channelId: config.channelId.trim(),
+ baseUrl: config.baseUrl.trim() || window.location.origin,
+ apiBaseUrl:
+ config.apiBaseUrl?.trim() || config.baseUrl.trim() || window.location.origin,
+ externalSource: config.externalSource?.trim() || "web_chat",
+ title: config.title?.trim() || "在线客服",
+ subtitle: config.subtitle?.trim() || "",
+ themeColor: config.themeColor?.trim() || "#2563eb",
+ width: config.width?.trim() || "380px",
+ subject: config.subject?.trim() || generateRandomSubject(),
+ }
+
+ setConfig(nextConfig)
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(nextConfig))
+
+ if (!nextConfig.channelId) {
+ removeMountedWidget()
+ setStatus("请填写 channelId")
+ return
+ }
+
+ injectWidget(nextConfig)
+ setStatus("Widget 已挂载")
+ }
+
+ return (
+
+
+
+ Widget 挂载测试
+ {status}
+
+
+ updateField("channelId", value)}
+ />
+ updateField("baseUrl", value)}
+ />
+ updateField("apiBaseUrl", value)}
+ />
+ updateField("title", value)}
+ />
+ updateField("subtitle", value)}
+ />
+ updateField("themeColor", value)}
+ />
+ updateField("width", value)}
+ />
+ updateField("subject", value)}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+function TextField({
+ label,
+ value,
+ onChange,
+}: {
+ label: string
+ value?: string
+ onChange: (value: string) => void
+}) {
+ return (
+
+ )
+}
diff --git a/web/lib/api/client.ts b/web/lib/api/client.ts
index 5079c32..aa89953 100644
--- a/web/lib/api/client.ts
+++ b/web/lib/api/client.ts
@@ -13,6 +13,7 @@ type JsonResult = {
type RequestOptions = RequestInit & {
skipAuth?: boolean
retryOnAuthError?: boolean
+ baseUrl?: string
}
async function parseResult(response: Response) {
@@ -57,8 +58,9 @@ export async function request(
options: RequestOptions = {},
retryOnAuthError = true
): Promise {
- const { headers, skipAuth, ...rest } = options
+ const { headers, skipAuth, baseUrl, ...rest } = options
delete (rest as RequestOptions).retryOnAuthError
+ delete (rest as RequestOptions).baseUrl
const session = readSession()
const authHeaders = new Headers(headers)
@@ -73,7 +75,8 @@ export async function request(
authHeaders.set("Content-Type", "application/json")
}
- const response = await fetch(`${API_BASE_URL}${path}`, {
+ const requestBaseUrl = baseUrl !== undefined ? baseUrl : API_BASE_URL
+ const response = await fetch(`${requestBaseUrl}${path}`, {
...rest,
headers: authHeaders,
cache: "no-store",
diff --git a/web/lib/api/im.ts b/web/lib/api/im.ts
index 5cfed8a..8d676c2 100644
--- a/web/lib/api/im.ts
+++ b/web/lib/api/im.ts
@@ -1,4 +1,5 @@
import { request } from "@/lib/api/client"
+import { readKefuWidgetConfig } from "@/lib/kefu-widget-config"
import { generateUUID } from "@/lib/utils"
export type Paging = {
@@ -8,7 +9,7 @@ export type Paging = {
}
export type PageResult = {
- results: T[]
+ results?: T[] | null
page: Paging
cursor?: string
hasMore?: boolean
@@ -133,11 +134,46 @@ export function getImVisitorId() {
return visitorId
}
-function createImHeaders() {
+function getRuntimeImConfig() {
+ const widgetConfig = readKefuWidgetConfig()
+ const baseUrl = (widgetConfig.apiBaseUrl || widgetConfig.baseUrl || API_BASE_URL)
+ .trim()
+ .replace(/\/$/, "")
return {
- "X-External-Source": OPEN_IM_EXTERNAL_SOURCE,
+ baseUrl,
+ channelId: widgetConfig.channelId || OPEN_IM_CHANNEL_ID,
+ externalSource:
+ (widgetConfig.externalSource || OPEN_IM_EXTERNAL_SOURCE).trim() || "web_chat",
+ externalName: (widgetConfig.subject || "").trim(),
+ }
+}
+
+function createImHeaders() {
+ const config = getRuntimeImConfig()
+ const headers: Record = {
+ "X-External-Source": config.externalSource,
"X-External-Id": getImVisitorId(),
- "X-Channel-Id": OPEN_IM_CHANNEL_ID,
+ "X-Channel-Id": config.channelId,
+ }
+ if (config.externalName) {
+ headers["X-External-Name"] = encodeURIComponent(config.externalName)
+ }
+ return {
+ ...headers,
+ }
+}
+
+function createRequestOptions(
+ init?: RequestInit
+): RequestInit & { baseUrl?: string; skipAuth?: boolean } {
+ return {
+ ...init,
+ skipAuth: true,
+ headers: {
+ ...createImHeaders(),
+ ...(init?.headers as Record | undefined),
+ },
+ baseUrl: getRuntimeImConfig().baseUrl,
}
}
@@ -159,7 +195,7 @@ function toQueryString(query?: Record) {
export function fetchImConversationDetail(id: number) {
return request(`/api/open/im/conversation/${id}`, {
- headers: createImHeaders(),
+ ...createRequestOptions(),
})
}
@@ -168,34 +204,32 @@ export function fetchImMessages(
) {
return request>(
`/api/open/im/message/list${toQueryString(query)}`,
- { headers: createImHeaders() }
+ createRequestOptions()
)
}
/** 外部身份仅通过 createImHeaders()(X-External-*)传递,无 JSON body */
export function createOrMatchImConversation() {
return request("/api/open/im/conversation/create_or_match", {
- method: "POST",
- headers: createImHeaders(),
+ ...createRequestOptions({ method: "POST" }),
})
}
export function fetchImWidgetConfig() {
return request(
`/api/open/im/widget/config${toQueryString({
- channelId: OPEN_IM_CHANNEL_ID,
+ channelId: getRuntimeImConfig().channelId,
})}`,
- {
- headers: createImHeaders(),
- }
+ createRequestOptions()
)
}
export function closeImConversation(conversationId: number) {
return request("/api/open/im/conversation/close", {
- method: "POST",
- headers: createImHeaders(),
- body: JSON.stringify({ conversationId }),
+ ...createRequestOptions({
+ method: "POST",
+ body: JSON.stringify({ conversationId }),
+ }),
})
}
@@ -207,17 +241,19 @@ export function sendImMessage(payload: {
clientMsgId?: string
}) {
return request("/api/open/im/message/send", {
- method: "POST",
- headers: createImHeaders(),
- body: JSON.stringify(payload),
+ ...createRequestOptions({
+ method: "POST",
+ body: JSON.stringify(payload),
+ }),
})
}
export function markImMessageRead(conversationId: number, messageId = 0) {
return request("/api/open/im/message/read", {
- method: "POST",
- headers: createImHeaders(),
- body: JSON.stringify({ conversationId, messageId }),
+ ...createRequestOptions({
+ method: "POST",
+ body: JSON.stringify({ conversationId, messageId }),
+ }),
})
}
@@ -226,9 +262,10 @@ export function uploadImImage(conversationId: number, file: File) {
formData.set("conversationId", String(conversationId))
formData.set("file", file)
return request("/api/open/im/message/upload_image", {
- method: "POST",
- headers: createImHeaders(),
- body: formData,
+ ...createRequestOptions({
+ method: "POST",
+ body: formData,
+ }),
})
}
@@ -237,8 +274,9 @@ export function uploadImAttachment(conversationId: number, file: File) {
formData.set("conversationId", String(conversationId))
formData.set("file", file)
return request("/api/open/im/message/upload_attachment", {
- method: "POST",
- headers: createImHeaders(),
- body: formData,
+ ...createRequestOptions({
+ method: "POST",
+ body: formData,
+ }),
})
}
diff --git a/web/lib/im-realtime.ts b/web/lib/im-realtime.ts
index b370061..175a0d5 100644
--- a/web/lib/im-realtime.ts
+++ b/web/lib/im-realtime.ts
@@ -1,10 +1,6 @@
import { createWebSocketBaseUrl } from "@/lib/api/websocket"
import { getImVisitorId } from "@/lib/api/im"
-
-const OPEN_IM_CHANNEL_ID =
- process.env.NEXT_PUBLIC_OPEN_IM_CHANNEL_ID?.trim() || ""
-const OPEN_IM_EXTERNAL_SOURCE =
- process.env.NEXT_PUBLIC_OPEN_IM_EXTERNAL_SOURCE?.trim() || "web_chat"
+import { readKefuWidgetConfig } from "@/lib/kefu-widget-config"
export type ImRealtimeEnvelope = {
type: string
@@ -20,11 +16,22 @@ export type ImRealtimeEnvelope = {
}
export function createImRealtimeConnection() {
- const baseUrl = createWebSocketBaseUrl()
+ const config = readKefuWidgetConfig()
+ const apiBaseUrl = (config.apiBaseUrl || config.baseUrl || "").trim()
+ const baseUrl = apiBaseUrl
+ ? apiBaseUrl.replace(/^http/, "ws").replace(/\/$/, "")
+ : createWebSocketBaseUrl()
const externalId = encodeURIComponent(getImVisitorId())
- const externalSource = encodeURIComponent(OPEN_IM_EXTERNAL_SOURCE)
- const channelId = encodeURIComponent(OPEN_IM_CHANNEL_ID)
+ const externalSource = encodeURIComponent(
+ (config.externalSource ?? "web_chat").trim() || "web_chat"
+ )
+ const channelId = encodeURIComponent(config.channelId || "")
+ const externalName = (config.subject ?? "").trim()
+ const nameQuery =
+ externalName !== ""
+ ? `&externalName=${encodeURIComponent(externalName)}`
+ : ""
return new WebSocket(
- `${baseUrl}/api/open/im/ws?externalId=${externalId}&externalSource=${externalSource}&channelId=${channelId}`
+ `${baseUrl}/api/open/im/ws?externalId=${externalId}&externalSource=${externalSource}&channelId=${channelId}${nameQuery}`
)
}
diff --git a/web/lib/kefu-host-bridge.ts b/web/lib/kefu-host-bridge.ts
index 0fb3478..9586371 100644
--- a/web/lib/kefu-host-bridge.ts
+++ b/web/lib/kefu-host-bridge.ts
@@ -1,9 +1,15 @@
+import {
+ setKefuWidgetConfig,
+ type KefuWidgetHostConfig,
+} from "@/lib/kefu-widget-config"
+
type HostBridgeOptions = {
onOpen?: () => void
onMinimize?: () => void
onMaximizedChange?: (isMaximized: boolean) => void
}
+const INIT_MESSAGE_TYPE = "cs-agent:init"
const OPEN_MESSAGE_TYPE = "cs-agent:open"
const MINIMIZE_MESSAGE_TYPE = "cs-agent:minimize"
const MAXIMIZED_MESSAGE_TYPE = "cs-agent:maximized"
@@ -25,13 +31,18 @@ export function bindKefuHostBridge(options: HostBridgeOptions = {}) {
const data = event.data as
| {
type?: string
- payload?: { isMaximized?: boolean }
+ payload?: KefuWidgetHostConfig | { isMaximized?: boolean }
}
| undefined
if (!data?.type) {
return
}
+ if (data.type === INIT_MESSAGE_TYPE && data.payload) {
+ setKefuWidgetConfig(data.payload as KefuWidgetHostConfig)
+ return
+ }
+
if (data.type === OPEN_MESSAGE_TYPE) {
options.onOpen?.()
return
@@ -43,7 +54,8 @@ export function bindKefuHostBridge(options: HostBridgeOptions = {}) {
}
if (data.type === MAXIMIZED_MESSAGE_TYPE) {
- options.onMaximizedChange?.(Boolean(data.payload?.isMaximized))
+ const payload = data.payload as { isMaximized?: boolean } | undefined
+ options.onMaximizedChange?.(Boolean(payload?.isMaximized))
}
}
@@ -71,4 +83,3 @@ export function requestKefuHostClose() {
export function requestKefuHostToggleMaximize() {
postToParent(REQUEST_TOGGLE_MAXIMIZE_MESSAGE_TYPE)
}
-
diff --git a/web/lib/kefu-widget-config.ts b/web/lib/kefu-widget-config.ts
new file mode 100644
index 0000000..f80a057
--- /dev/null
+++ b/web/lib/kefu-widget-config.ts
@@ -0,0 +1,67 @@
+export type KefuWidgetHostConfig = {
+ channelId: string
+ baseUrl: string
+ apiBaseUrl?: string
+ /** 与后端 enums.ExternalSource 一致,默认 web_chat */
+ externalSource?: string
+ title?: string
+ subtitle?: string
+ position?: "left" | "right"
+ themeColor?: string
+ width?: string
+ /** 访客展示名,随请求以 X-External-Name / WS query externalName 传给后端 */
+ subject?: string
+}
+
+declare global {
+ interface Window {
+ CSAgentConfig?: KefuWidgetHostConfig
+ __CS_AGENT_WIDGET_CONFIG__?: KefuWidgetHostConfig
+ __CS_AGENT_WIDGET_STATE__?: unknown
+ }
+}
+
+export function readKefuWidgetConfig(): KefuWidgetHostConfig {
+ if (typeof window === "undefined") {
+ return {
+ channelId: "",
+ baseUrl: "",
+ apiBaseUrl: "",
+ }
+ }
+
+ const query = new URLSearchParams(window.location.search)
+ const fallback: KefuWidgetHostConfig = {
+ channelId:
+ query.get("channelId") ??
+ process.env.NEXT_PUBLIC_OPEN_IM_CHANNEL_ID?.trim() ??
+ "",
+ baseUrl:
+ query.get("baseUrl") ??
+ process.env.NEXT_PUBLIC_API_BASE_URL?.trim() ??
+ window.location.origin,
+ apiBaseUrl:
+ query.get("apiBaseUrl") ??
+ process.env.NEXT_PUBLIC_API_BASE_URL?.trim() ??
+ undefined,
+ externalSource:
+ query.get("externalSource") ??
+ process.env.NEXT_PUBLIC_OPEN_IM_EXTERNAL_SOURCE?.trim() ??
+ undefined,
+ title: query.get("title") ?? undefined,
+ subtitle: query.get("subtitle") ?? undefined,
+ position: (query.get("position") as "left" | "right" | null) ?? undefined,
+ themeColor: query.get("themeColor") ?? undefined,
+ width: query.get("width") ?? undefined,
+ subject: query.get("subject") ?? undefined,
+ }
+
+ return window.__CS_AGENT_WIDGET_CONFIG__ ?? window.CSAgentConfig ?? fallback
+}
+
+export function setKefuWidgetConfig(config: KefuWidgetHostConfig) {
+ if (typeof window === "undefined") {
+ return
+ }
+ window.__CS_AGENT_WIDGET_CONFIG__ = config
+}
diff --git a/web/lib/stores/kefu-chat.ts b/web/lib/stores/kefu-chat.ts
index fa6edb6..bc48dae 100644
--- a/web/lib/stores/kefu-chat.ts
+++ b/web/lib/stores/kefu-chat.ts
@@ -72,6 +72,10 @@ function mergeMessagesByIdAsc(a: ImMessage[], b: ImMessage[]): ImMessage[] {
return Array.from(byId.values()).sort((x, y) => x.id - y.id)
}
+function ensureMessageList(value: ImMessage[] | null | undefined): ImMessage[] {
+ return Array.isArray(value) ? value : []
+}
+
function parseCursorId(cursor: string): number {
const value = Number.parseInt(cursor, 10)
return Number.isFinite(value) && value > 0 ? value : 0
@@ -394,10 +398,11 @@ export const useKefuChatStore = create((set, get) => {
conversationId,
limit: DEFAULT_PAGE_LIMIT,
})
+ const results = ensureMessageList(page.results)
set({
- messages: page.results,
- messagesCursor: cursorFromLoadedMessages(page.results) || page.cursor || "",
- messagesHasMore: Boolean(page.hasMore) || page.results.length >= DEFAULT_PAGE_LIMIT,
+ messages: results,
+ messagesCursor: cursorFromLoadedMessages(results) || page.cursor || "",
+ messagesHasMore: Boolean(page.hasMore) || results.length >= DEFAULT_PAGE_LIMIT,
})
} catch (error) {
set({
@@ -418,7 +423,7 @@ export const useKefuChatStore = create((set, get) => {
conversationId,
limit: DEFAULT_PAGE_LIMIT,
})
- const batch = page.results
+ const batch = ensureMessageList(page.results)
if (batch.length === 0) {
return
}
@@ -466,12 +471,13 @@ export const useKefuChatStore = create((set, get) => {
cursor: cursorId,
limit: DEFAULT_PAGE_LIMIT,
})
+ const results = ensureMessageList(page.results)
set((state) => {
- const merged = mergeMessagesByIdAsc(page.results, state.messages)
+ const merged = mergeMessagesByIdAsc(results, ensureMessageList(state.messages))
return {
messages: merged,
messagesCursor: cursorFromLoadedMessages(merged) || page.cursor || "",
- messagesHasMore: Boolean(page.hasMore) || page.results.length >= DEFAULT_PAGE_LIMIT,
+ messagesHasMore: Boolean(page.hasMore) || results.length >= DEFAULT_PAGE_LIMIT,
messagesLoadingMore: false,
}
})
diff --git a/web/public/sdk/cs-agent-widget.js b/web/public/sdk/cs-agent-widget.js
new file mode 100644
index 0000000..8dc53b1
--- /dev/null
+++ b/web/public/sdk/cs-agent-widget.js
@@ -0,0 +1,363 @@
+(function () {
+ var DEFAULT_CONFIG = {
+ position: "right",
+ themeColor: "#0f6cbd",
+ width: "380px",
+ externalSource: "web_chat",
+ };
+
+ var state = window.__CS_AGENT_WIDGET_STATE__;
+ if (!state) {
+ state = {
+ button: null,
+ frame: null,
+ frameLoaded: false,
+ frameReady: false,
+ initSent: false,
+ isOpen: false,
+ isMaximized: false,
+ frameHideTimer: null,
+ frameDestroyTimer: null,
+ config: 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(/\/$/, "");
+ merged.apiBaseUrl = String(merged.apiBaseUrl || merged.baseUrl).replace(/\/$/, "");
+ merged.channelId = String(merged.channelId || "");
+ merged.externalSource = String(merged.externalSource || "web_chat");
+ return merged;
+ }
+
+ function resolveWidgetBaseUrl(config) {
+ var currentScript = document.currentScript;
+ if (currentScript && currentScript.src) {
+ return currentScript.src.replace(/\/sdk\/cs-agent-widget\.js(?:\?.*)?$/, "");
+ }
+ return String(config.widgetBaseUrl || config.baseUrl || window.location.origin).replace(/\/$/, "");
+ }
+
+ function createFrameUrl(config) {
+ 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.externalSource) frameUrl.searchParams.set("externalSource", config.externalSource);
+ if (config.title) frameUrl.searchParams.set("title", config.title);
+ if (config.subtitle) frameUrl.searchParams.set("subtitle", config.subtitle);
+ if (config.position) frameUrl.searchParams.set("position", config.position);
+ if (config.themeColor) frameUrl.searchParams.set("themeColor", config.themeColor);
+ if (config.width) frameUrl.searchParams.set("width", config.width);
+ if (config.subject) frameUrl.searchParams.set("subject", config.subject);
+ return frameUrl;
+ }
+
+ 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 = "88px";
+ 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 - 112px))";
+ 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.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;
+ }
+
+ 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");
+ button.type = "button";
+ button.dataset.csAgentWidget = "launcher";
+ button.setAttribute("aria-label", config.title || "在线客服");
+ button.textContent = config.title || "在线客服";
+ 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.border = "0";
+ button.style.borderRadius = "999px";
+ button.style.padding = "14px 18px";
+ button.style.background = config.themeColor || "#0f6cbd";
+ button.style.color = "#fff";
+ button.style.font = "600 14px/1 sans-serif";
+ button.style.boxShadow = "0 18px 40px rgba(15, 35, 65, 0.24)";
+ button.style.cursor = "pointer";
+
+ button.addEventListener("click", function () {
+ if (!state.frame) {
+ createFrame();
+ }
+ state.isOpen = !state.isOpen;
+ syncFrameVisibility();
+ });
+
+ document.body.appendChild(button);
+ state.button = button;
+ return button;
+ }
+
+ function mount(config) {
+ state.config = normalizeConfig(config || window.CSAgentConfig || {});
+ if (!state.config.channelId || !state.config.baseUrl) {
+ console.error("[cs-agent-widget] channelId and baseUrl are required");
+ return;
+ }
+
+ state.frameUrl = createFrameUrl(state.config);
+ 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;
+ }
+
+ window.CSAgentWidget = {
+ mount: mount,
+ destroy: destroy,
+ open: function () {
+ if (!state.frame) {
+ createFrame();
+ }
+ state.isOpen = true;
+ syncFrameVisibility();
+ },
+ close: function () {
+ state.isOpen = false;
+ syncFrameVisibility();
+ },
+ };
+
+ if (!state.listenerBound) {
+ window.addEventListener("message", handleWindowMessage);
+ state.listenerBound = true;
+ }
+
+ if (window.CSAgentConfig) {
+ mount(window.CSAgentConfig);
+ }
+})();
+