feat: add Kefu widget and related components for customer service integration
This commit is contained in:
@@ -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
@@ -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
@@ -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}`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user