feat: add Kefu widget and related components for customer service integration

This commit is contained in:
mlogclub
2026-04-24 19:17:07 +08:00
parent 4bcfd36620
commit 92fd571f28
11 changed files with 839 additions and 54 deletions
+5 -2
View File
@@ -13,6 +13,7 @@ type JsonResult<T> = {
type RequestOptions = RequestInit & {
skipAuth?: boolean
retryOnAuthError?: boolean
baseUrl?: string
}
async function parseResult<T>(response: Response) {
@@ -57,8 +58,9 @@ export async function request<T>(
options: RequestOptions = {},
retryOnAuthError = true
): Promise<T> {
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<T>(
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",
+65 -27
View File
@@ -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<T> = {
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<string, string> = {
"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<string, string> | undefined),
},
baseUrl: getRuntimeImConfig().baseUrl,
}
}
@@ -159,7 +195,7 @@ function toQueryString(query?: Record<string, string | number | undefined>) {
export function fetchImConversationDetail(id: number) {
return request<ImConversationDetail>(`/api/open/im/conversation/${id}`, {
headers: createImHeaders(),
...createRequestOptions(),
})
}
@@ -168,34 +204,32 @@ export function fetchImMessages(
) {
return request<PageResult<ImMessage>>(
`/api/open/im/message/list${toQueryString(query)}`,
{ headers: createImHeaders() }
createRequestOptions()
)
}
/** 外部身份仅通过 createImHeaders()X-External-*)传递,无 JSON body */
export function createOrMatchImConversation() {
return request<ImConversation>("/api/open/im/conversation/create_or_match", {
method: "POST",
headers: createImHeaders(),
...createRequestOptions({ method: "POST" }),
})
}
export function fetchImWidgetConfig() {
return request<ImWidgetConfig>(
`/api/open/im/widget/config${toQueryString({
channelId: OPEN_IM_CHANNEL_ID,
channelId: getRuntimeImConfig().channelId,
})}`,
{
headers: createImHeaders(),
}
createRequestOptions()
)
}
export function closeImConversation(conversationId: number) {
return request<void>("/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<ImMessage>("/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<void>("/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<ImAsset>("/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<ImAsset>("/api/open/im/message/upload_attachment", {
method: "POST",
headers: createImHeaders(),
body: formData,
...createRequestOptions({
method: "POST",
body: formData,
}),
})
}
+16 -9
View File
@@ -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}`
)
}
+14 -3
View File
@@ -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)
}
+67
View File
@@ -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
}
+12 -6
View File
@@ -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<KefuChatStore>((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<KefuChatStore>((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<KefuChatStore>((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,
}
})