+ ) {
+ const file = event.target.files?.[0]
+ event.target.value = ""
+ if (!file || disabled || isUploading) {
+ if (editor && shouldRestoreFocusRef.current) {
+ requestAnimationFrame(() => {
+ editor.commands.focus()
+ })
+ }
+ return
+ }
+
+ shouldRestoreFocusRef.current = editor?.isFocused ?? true
+ setLocalUploading(true)
+ try {
+ await onSendAttachmentRef.current(file)
+ } finally {
+ setLocalUploading(false)
+ requestAnimationFrame(() => {
+ if (editor && !disabled && shouldRestoreFocusRef.current) {
+ editor.commands.focus()
+ }
+ })
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Enter 发送
+
+
+
+
+
+ )
+}
+
+function isMeaningfulHTML(html: string) {
+ const normalized = html
+ .replace(/<\/p>/g, "")
+ .replace(/
<\/p>/g, "")
+ .replace(/\s+/g, "")
+ if (/
/i.test(normalized)) {
+ return true
+ }
+ const plainText = normalized.replace(/<[^>]+>/g, "").trim()
+ return plainText !== ""
+}
+
+function getClipboardImageFile(data: DataTransfer | null) {
+ if (!data) {
+ return null
+ }
+
+ for (const item of Array.from(data.items)) {
+ if (item.kind === "file" && item.type.startsWith("image/")) {
+ return item.getAsFile()
+ }
+ }
+ return null
+}
+
+function removeImageByTitle(editor: NonNullable>, title: string) {
+ const { state } = editor
+ let targetPos: number | null = null
+ state.doc.descendants((node, pos) => {
+ if (node.type.name === "image" && node.attrs.title === title) {
+ targetPos = pos
+ return false
+ }
+ return true
+ })
+ if (targetPos === null) {
+ return
+ }
+ editor.chain().focus().deleteRange({ from: targetPos, to: targetPos + 1 }).run()
+}
+
+function replaceImageSourceByTitle(
+ editor: NonNullable>,
+ title: string,
+ uploaded: UploadedImage
+) {
+ const { state, view } = editor
+ let targetPos: number | null = null
+ state.doc.descendants((node, pos) => {
+ if (node.type.name === "image" && node.attrs.title === title) {
+ targetPos = pos
+ return false
+ }
+ return true
+ })
+ if (targetPos === null) {
+ return
+ }
+ const transaction = view.state.tr.setNodeMarkup(targetPos, undefined, {
+ ...view.state.doc.nodeAt(targetPos)?.attrs,
+ src: uploaded.url,
+ alt: uploaded.filename || "image",
+ dataAssetId: uploaded.assetId,
+ dataProvider: uploaded.provider,
+ dataStorageKey: uploaded.storageKey,
+ title: "",
+ })
+ view.dispatch(transaction)
+}
diff --git a/web/components/kefu/message-list.tsx b/web/components/kefu/message-list.tsx
new file mode 100644
index 0000000..f41639b
--- /dev/null
+++ b/web/components/kefu/message-list.tsx
@@ -0,0 +1,322 @@
+"use client"
+
+import Image from "next/image"
+import {
+ forwardRef,
+ memo,
+ useCallback,
+ useEffect,
+ useImperativeHandle,
+ useLayoutEffect,
+ useRef,
+} from "react"
+
+import { ImMessageHTML } from "@/components/im-message-html"
+import { useImageLightbox } from "@/components/image-lightbox"
+import type { ImMessage } from "@/lib/api/im"
+import { renderIMMessageHTML } from "@/lib/im-message"
+import { cn, formatDateTime } from "@/lib/utils"
+
+type KefuMessageListProps = {
+ messages: ImMessage[]
+ onNearBottomVisible?: () => void
+ hasMoreOlder?: boolean
+ loadingOlder?: boolean
+ onLoadOlder?: () => Promise
+}
+
+export type KefuMessageListHandle = {
+ scrollToBottom: () => void
+}
+
+function getDayKey(value?: string) {
+ if (!value) {
+ return "unknown"
+ }
+ const date = new Date(value)
+ if (Number.isNaN(date.getTime())) {
+ return value.slice(0, 10)
+ }
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(
+ date.getDate()
+ ).padStart(2, "0")}`
+}
+
+function getTimelineLabel(value?: string) {
+ if (!value) {
+ return "刚刚"
+ }
+ const date = new Date(value)
+ if (Number.isNaN(date.getTime())) {
+ return value
+ }
+ const currentDayKey = getDayKey(value)
+ const todayDayKey = getDayKey(new Date().toISOString())
+ const timeText = `${String(date.getHours()).padStart(2, "0")}:${String(
+ date.getMinutes()
+ ).padStart(2, "0")}`
+ if (currentDayKey === todayDayKey) {
+ return `今天 ${timeText}`
+ }
+ return `${currentDayKey} ${timeText}`
+}
+
+export const KefuMessageList = forwardRef(
+ function KefuMessageList(
+ {
+ messages,
+ onNearBottomVisible,
+ hasMoreOlder = false,
+ loadingOlder = false,
+ onLoadOlder,
+ },
+ ref
+ ) {
+ const containerRef = useRef(null)
+ const contentRef = useRef(null)
+ const frameRef = useRef(null)
+ const shouldStickToBottomRef = useRef(true)
+ const lastMessageId = messages.at(-1)?.id
+
+ const isNearBottom = useCallback(
+ (element: HTMLElement, threshold = 80) =>
+ element.scrollHeight - element.scrollTop - element.clientHeight <= threshold,
+ []
+ )
+
+ const scrollToBottom = useCallback(() => {
+ const container = containerRef.current
+ if (!container) {
+ return
+ }
+ container.scrollTop = container.scrollHeight
+ }, [])
+
+ const scheduleScrollToBottom = useCallback(
+ (attempts = 4) => {
+ if (frameRef.current !== null) {
+ cancelAnimationFrame(frameRef.current)
+ }
+
+ const run = (remaining: number, previousHeight = -1) => {
+ frameRef.current = requestAnimationFrame(() => {
+ const container = containerRef.current
+ if (!container) {
+ frameRef.current = null
+ return
+ }
+
+ const currentHeight = container.scrollHeight
+ scrollToBottom()
+ if (remaining > 1 && currentHeight !== previousHeight) {
+ run(remaining - 1, currentHeight)
+ return
+ }
+ frameRef.current = null
+ })
+ }
+
+ run(attempts)
+ },
+ [scrollToBottom]
+ )
+
+ const handleImageSettled = useCallback(() => {
+ if (shouldStickToBottomRef.current) {
+ scheduleScrollToBottom()
+ onNearBottomVisible?.()
+ }
+ }, [onNearBottomVisible, scheduleScrollToBottom])
+
+ useImperativeHandle(ref, () => ({
+ scrollToBottom,
+ }))
+
+ useLayoutEffect(() => {
+ shouldStickToBottomRef.current = true
+ scheduleScrollToBottom()
+ return () => {
+ if (frameRef.current !== null) {
+ cancelAnimationFrame(frameRef.current)
+ frameRef.current = null
+ }
+ }
+ }, [lastMessageId, scheduleScrollToBottom])
+
+ useEffect(() => {
+ const container = containerRef.current
+ const content = contentRef.current
+ if (!container || !content) {
+ return
+ }
+
+ const handleScroll = () => {
+ shouldStickToBottomRef.current = isNearBottom(container)
+ if (shouldStickToBottomRef.current) {
+ onNearBottomVisible?.()
+ }
+ }
+
+ const resizeObserver = new ResizeObserver(() => {
+ if (shouldStickToBottomRef.current) {
+ scheduleScrollToBottom()
+ }
+ })
+
+ handleScroll()
+ container.addEventListener("scroll", handleScroll)
+ resizeObserver.observe(content)
+ scrollToBottom()
+
+ return () => {
+ container.removeEventListener("scroll", handleScroll)
+ resizeObserver.disconnect()
+ }
+ }, [isNearBottom, onNearBottomVisible, scheduleScrollToBottom, scrollToBottom])
+
+ const handleLoadOlder = useCallback(async () => {
+ if (!onLoadOlder || loadingOlder || !hasMoreOlder) {
+ return
+ }
+ const container = containerRef.current
+ if (!container) {
+ return
+ }
+ const anchor = {
+ height: container.scrollHeight,
+ top: container.scrollTop,
+ }
+ try {
+ await onLoadOlder()
+ } catch {
+ return
+ }
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ const current = containerRef.current
+ if (!current) {
+ return
+ }
+ current.scrollTop = current.scrollHeight - anchor.height + anchor.top
+ })
+ })
+ }, [hasMoreOlder, loadingOlder, onLoadOlder])
+
+ return (
+
+
+ {hasMoreOlder && onLoadOlder ? (
+
+
+
+ ) : null}
+
+ {messages.map((message, index) => {
+ const previousMessage = index > 0 ? messages[index - 1] : null
+ const showTimeline =
+ index === 0 ||
+ getDayKey(previousMessage?.sentAt) !== getDayKey(message.sentAt)
+
+ return (
+
+ )
+ })}
+
+
+ )
+ }
+)
+
+type MessageItemProps = {
+ message: ImMessage
+ showTimeline: boolean
+ onImageSettled: () => void
+}
+
+const MessageItem = memo(
+ function MessageItem({ message, showTimeline, onImageSettled }: MessageItemProps) {
+ const { open } = useImageLightbox()
+ const isCustomer = message.senderType === "customer"
+ const senderName = isCustomer ? "我" : message.senderName?.trim() || "客服"
+ const avatarSrc =
+ !isCustomer && message.senderAvatar?.trim() ? message.senderAvatar.trim() : undefined
+ const htmlContent = renderIMMessageHTML(message)
+
+ return (
+
+ {showTimeline ? (
+
+
+ {getTimelineLabel(message.sentAt)}
+
+
+ ) : null}
+
+
+ {!isCustomer && avatarSrc ? (
+
+ ) : null}
+
+
+
+ {senderName}
+ {formatDateTime(message.sentAt)}
+ {isCustomer ? (
+ {message.agentRead ? "客服已读" : "客服未读"}
+ ) : null}
+
+
+
+
+
+
+
+ )
+ },
+ (prevProps, nextProps) =>
+ prevProps.message === nextProps.message &&
+ prevProps.showTimeline === nextProps.showTimeline &&
+ prevProps.onImageSettled === nextProps.onImageSettled
+)
diff --git a/web/lib/api/im.ts b/web/lib/api/im.ts
index cbbe0d4..5cfed8a 100644
--- a/web/lib/api/im.ts
+++ b/web/lib/api/im.ts
@@ -10,6 +10,8 @@ export type Paging = {
export type PageResult = {
results: T[]
page: Paging
+ cursor?: string
+ hasMore?: boolean
}
export type ImConversationTag = {
@@ -99,6 +101,13 @@ export type ImAsset = {
updateUserName: string
}
+export type ImWidgetConfig = {
+ title?: string
+ subtitle?: string
+ welcomeText?: string
+ themeColor?: string
+}
+
const VISITOR_STORAGE_KEY = "cs_agent_im_visitor_id"
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL?.trim() || ""
@@ -171,6 +180,25 @@ export function createOrMatchImConversation() {
})
}
+export function fetchImWidgetConfig() {
+ return request(
+ `/api/open/im/widget/config${toQueryString({
+ channelId: OPEN_IM_CHANNEL_ID,
+ })}`,
+ {
+ headers: createImHeaders(),
+ }
+ )
+}
+
+export function closeImConversation(conversationId: number) {
+ return request("/api/open/im/conversation/close", {
+ method: "POST",
+ headers: createImHeaders(),
+ body: JSON.stringify({ conversationId }),
+ })
+}
+
export function sendImMessage(payload: {
conversationId: number
messageType: string
diff --git a/web/lib/im-realtime.ts b/web/lib/im-realtime.ts
new file mode 100644
index 0000000..b370061
--- /dev/null
+++ b/web/lib/im-realtime.ts
@@ -0,0 +1,30 @@
+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"
+
+export type ImRealtimeEnvelope = {
+ type: string
+ topic?: string
+ data?: {
+ conversationId?: number
+ messageId?: number
+ }
+ payload?: {
+ conversationId?: number
+ messageId?: number
+ }
+}
+
+export function createImRealtimeConnection() {
+ const baseUrl = createWebSocketBaseUrl()
+ const externalId = encodeURIComponent(getImVisitorId())
+ const externalSource = encodeURIComponent(OPEN_IM_EXTERNAL_SOURCE)
+ const channelId = encodeURIComponent(OPEN_IM_CHANNEL_ID)
+ return new WebSocket(
+ `${baseUrl}/api/open/im/ws?externalId=${externalId}&externalSource=${externalSource}&channelId=${channelId}`
+ )
+}
diff --git a/web/lib/kefu-host-bridge.ts b/web/lib/kefu-host-bridge.ts
new file mode 100644
index 0000000..0fb3478
--- /dev/null
+++ b/web/lib/kefu-host-bridge.ts
@@ -0,0 +1,74 @@
+type HostBridgeOptions = {
+ onOpen?: () => void
+ onMinimize?: () => void
+ onMaximizedChange?: (isMaximized: boolean) => void
+}
+
+const OPEN_MESSAGE_TYPE = "cs-agent:open"
+const MINIMIZE_MESSAGE_TYPE = "cs-agent:minimize"
+const MAXIMIZED_MESSAGE_TYPE = "cs-agent:maximized"
+const READY_MESSAGE_TYPE = "cs-agent:ready"
+const REQUEST_MINIMIZE_MESSAGE_TYPE = "cs-agent:request-minimize"
+const REQUEST_CLOSE_MESSAGE_TYPE = "cs-agent:request-close"
+const REQUEST_TOGGLE_MAXIMIZE_MESSAGE_TYPE = "cs-agent:request-toggle-maximize"
+
+export function bindKefuHostBridge(options: HostBridgeOptions = {}) {
+ if (typeof window === "undefined") {
+ return () => undefined
+ }
+
+ if (window.parent && window.parent !== window) {
+ window.parent.postMessage({ type: READY_MESSAGE_TYPE }, "*")
+ }
+
+ const handleMessage = (event: MessageEvent) => {
+ const data = event.data as
+ | {
+ type?: string
+ payload?: { isMaximized?: boolean }
+ }
+ | undefined
+ if (!data?.type) {
+ return
+ }
+
+ if (data.type === OPEN_MESSAGE_TYPE) {
+ options.onOpen?.()
+ return
+ }
+
+ if (data.type === MINIMIZE_MESSAGE_TYPE) {
+ options.onMinimize?.()
+ return
+ }
+
+ if (data.type === MAXIMIZED_MESSAGE_TYPE) {
+ options.onMaximizedChange?.(Boolean(data.payload?.isMaximized))
+ }
+ }
+
+ window.addEventListener("message", handleMessage)
+ return () => window.removeEventListener("message", handleMessage)
+}
+
+function postToParent(type: string) {
+ if (typeof window === "undefined") {
+ return
+ }
+ if (window.parent && window.parent !== window) {
+ window.parent.postMessage({ type }, "*")
+ }
+}
+
+export function requestKefuHostMinimize() {
+ postToParent(REQUEST_MINIMIZE_MESSAGE_TYPE)
+}
+
+export function requestKefuHostClose() {
+ postToParent(REQUEST_CLOSE_MESSAGE_TYPE)
+}
+
+export function requestKefuHostToggleMaximize() {
+ postToParent(REQUEST_TOGGLE_MAXIMIZE_MESSAGE_TYPE)
+}
+
diff --git a/web/lib/stores/kefu-chat.ts b/web/lib/stores/kefu-chat.ts
new file mode 100644
index 0000000..fa6edb6
--- /dev/null
+++ b/web/lib/stores/kefu-chat.ts
@@ -0,0 +1,686 @@
+"use client"
+
+import { create } from "zustand"
+
+import {
+ closeImConversation,
+ createOrMatchImConversation,
+ fetchImMessages,
+ fetchImWidgetConfig,
+ markImMessageRead,
+ sendImMessage,
+ uploadImAttachment,
+ uploadImImage,
+ type ImAsset,
+ type ImConversation,
+ type ImMessage,
+ type ImWidgetConfig,
+} from "@/lib/api/im"
+import {
+ createImRealtimeConnection,
+ type ImRealtimeEnvelope,
+} from "@/lib/im-realtime"
+import { summarizeIMMessage } from "@/lib/im-message"
+import { generateUUID } from "@/lib/utils"
+
+type ChatStatus = "connecting" | "connected" | "disconnected"
+
+const RECONNECT_BASE_DELAY = 2000
+const RECONNECT_MAX_DELAY = 30000
+const DEFAULT_PAGE_LIMIT = 50
+
+function getNotificationBody(message: ImMessage): string {
+ return summarizeIMMessage(message)
+}
+
+function showNotification(title: string, body: string, onClick?: () => void) {
+ if (typeof window === "undefined" || !("Notification" in window)) {
+ return
+ }
+
+ const create = () => {
+ const notification = new Notification(title, { body })
+ notification.onclick = () => {
+ window.focus()
+ onClick?.()
+ notification.close()
+ }
+ }
+
+ if (Notification.permission === "granted") {
+ create()
+ return
+ }
+
+ if (Notification.permission === "default") {
+ void Notification.requestPermission().then((permission) => {
+ if (permission === "granted") {
+ create()
+ }
+ })
+ }
+}
+
+function mergeMessagesByIdAsc(a: ImMessage[], b: ImMessage[]): ImMessage[] {
+ const byId = new Map()
+ for (const message of a) {
+ byId.set(message.id, message)
+ }
+ for (const message of b) {
+ byId.set(message.id, message)
+ }
+ return Array.from(byId.values()).sort((x, y) => x.id - y.id)
+}
+
+function parseCursorId(cursor: string): number {
+ const value = Number.parseInt(cursor, 10)
+ return Number.isFinite(value) && value > 0 ? value : 0
+}
+
+function cursorFromLoadedMessages(messages: ImMessage[]): string {
+ if (messages.length === 0) {
+ return ""
+ }
+ return String(Math.min(...messages.map((message) => message.id)))
+}
+
+function minMessageId(messages: ImMessage[]): number | null {
+ if (messages.length === 0) {
+ return null
+ }
+ return Math.min(...messages.map((message) => message.id))
+}
+
+function hasMoreAfterLatestSyncMerge(args: {
+ previousMessages: ImMessage[]
+ previousHasMore: boolean
+ merged: ImMessage[]
+ apiHasMore: boolean
+}): boolean {
+ const prevMin = minMessageId(args.previousMessages)
+ const mergedMin = minMessageId(args.merged)
+
+ if (mergedMin === null) {
+ return Boolean(args.apiHasMore)
+ }
+
+ if (!args.previousHasMore && prevMin !== null && mergedMin >= prevMin) {
+ return false
+ }
+
+ return args.previousHasMore || Boolean(args.apiHasMore)
+}
+
+export type KefuChatStore = {
+ title: string
+ subtitle: string
+ welcomeText: string
+ themeColor: string
+ conversation: ImConversation | null
+ messages: ImMessage[]
+ messagesCursor: string
+ messagesHasMore: boolean
+ messagesLoadingMore: boolean
+ initialized: boolean
+ status: ChatStatus
+ error: string
+ sending: boolean
+ uploadingAsset: boolean
+ closingConversation: boolean
+ isOpen: boolean
+ isVisible: boolean
+ socket: WebSocket | null
+ readingMessageId: number
+
+ setIsOpen: (isOpen: boolean) => void
+ setIsVisible: (isVisible: boolean) => void
+ bootstrap: () => void
+ disconnectSocket: () => void
+ refreshMessages: () => Promise
+ syncLatestMessages: () => Promise
+ loadOlderMessages: () => Promise
+ markConversationRead: () => Promise
+ handleSendMessage: (content: string) => Promise
+ sendMessage: (content: string) => Promise
+ uploadMessageImage: (file: File) => Promise
+ sendAttachment: (file: File) => Promise
+ closeConversation: () => Promise
+ retry: () => Promise
+}
+
+let bootstrapToken = 0
+
+export const useKefuChatStore = create((set, get) => {
+ let reconnectTimer: number | null = null
+ let pingTimer: number | null = null
+ let reconnectAttempt = 0
+ let shouldReconnect = false
+
+ const clearRealtimeTimers = () => {
+ if (reconnectTimer !== null) {
+ window.clearTimeout(reconnectTimer)
+ reconnectTimer = null
+ }
+ if (pingTimer !== null) {
+ window.clearInterval(pingTimer)
+ pingTimer = null
+ }
+ }
+
+ const scheduleReconnect = () => {
+ if (!shouldReconnect || reconnectTimer !== null) {
+ return
+ }
+
+ const delay = Math.min(
+ RECONNECT_BASE_DELAY * 2 ** reconnectAttempt,
+ RECONNECT_MAX_DELAY
+ )
+ set({ status: "connecting" })
+ reconnectTimer = window.setTimeout(() => {
+ reconnectTimer = null
+ reconnectAttempt += 1
+ if (!shouldReconnect || !get().isOpen) {
+ return
+ }
+ connectSocket()
+ }, delay)
+ }
+
+ const closeSocket = (options?: { reconnect?: boolean }) => {
+ shouldReconnect = options?.reconnect ?? false
+ clearRealtimeTimers()
+ if (!shouldReconnect) {
+ reconnectAttempt = 0
+ }
+
+ const socket = get().socket
+ if (
+ socket &&
+ (socket.readyState === WebSocket.OPEN ||
+ socket.readyState === WebSocket.CONNECTING)
+ ) {
+ socket.close()
+ }
+
+ set({ socket: null })
+ }
+
+ const connectSocket = () => {
+ const conversationId = get().conversation?.id
+ if (!conversationId) {
+ return
+ }
+
+ closeSocket({ reconnect: false })
+ shouldReconnect = true
+
+ const socket = createImRealtimeConnection()
+ set({ socket })
+
+ const handleRealtimeEvent = (event: ImRealtimeEnvelope) => {
+ const payload = event.data ?? event.payload
+ const needsRefresh =
+ event.type === "message.created" ||
+ event.type?.startsWith("conversation.")
+
+ if (needsRefresh && payload?.conversationId === conversationId) {
+ void get()
+ .syncLatestMessages()
+ .then(() => {
+ if (event.type !== "message.created") {
+ return
+ }
+ const state = get()
+ const lastMessage = state.messages.at(-1)
+ if (
+ lastMessage &&
+ lastMessage.senderType !== "customer" &&
+ typeof document !== "undefined" &&
+ document.visibilityState !== "visible"
+ ) {
+ showNotification("新消息", getNotificationBody(lastMessage), () => {
+ state.setIsOpen(true)
+ state.setIsVisible(true)
+ })
+ }
+ })
+ }
+ }
+
+ socket.addEventListener("message", (event) => {
+ try {
+ handleRealtimeEvent(JSON.parse(event.data) as ImRealtimeEnvelope)
+ } catch {
+ return
+ }
+ })
+
+ socket.addEventListener("open", () => {
+ clearRealtimeTimers()
+ reconnectAttempt = 0
+ pingTimer = window.setInterval(() => {
+ if (socket.readyState === WebSocket.OPEN) {
+ socket.send(JSON.stringify({ type: "ping" }))
+ }
+ }, 20000)
+ if (get().isOpen && get().socket === socket) {
+ set({ status: "connected" })
+ }
+ })
+
+ socket.addEventListener("error", () => {
+ if (get().socket === socket) {
+ scheduleReconnect()
+ }
+ })
+
+ socket.addEventListener("close", () => {
+ if (pingTimer !== null) {
+ window.clearInterval(pingTimer)
+ pingTimer = null
+ }
+ if (get().socket === socket) {
+ set({ socket: null })
+ }
+ if (get().isOpen) {
+ if (shouldReconnect) {
+ scheduleReconnect()
+ } else {
+ set({ status: "disconnected" })
+ }
+ }
+ })
+ }
+
+ return {
+ title: "在线客服",
+ subtitle: "",
+ welcomeText: "",
+ themeColor: "#2563eb",
+ conversation: null,
+ messages: [],
+ messagesCursor: "",
+ messagesHasMore: false,
+ messagesLoadingMore: false,
+ initialized: false,
+ status: "connecting",
+ error: "",
+ sending: false,
+ uploadingAsset: false,
+ closingConversation: false,
+ isOpen: typeof window !== "undefined" ? window.self === window.top : false,
+ isVisible:
+ typeof window !== "undefined" ? window.self === window.top : false,
+ socket: null,
+ readingMessageId: 0,
+
+ setIsOpen: (isOpen: boolean) => {
+ set({ isOpen })
+ },
+
+ setIsVisible: (isVisible: boolean) => {
+ set({ isVisible })
+ },
+
+ bootstrap: () => {
+ const token = ++bootstrapToken
+
+ if (!get().isOpen) {
+ closeSocket({ reconnect: false })
+ set({ status: "disconnected" })
+ return
+ }
+
+ const activateChat = async () => {
+ try {
+ set({ error: "", status: "connecting" })
+
+ const widgetConfig: ImWidgetConfig = await fetchImWidgetConfig().catch(
+ () => ({})
+ )
+ if (bootstrapToken !== token || !get().isOpen) {
+ return
+ }
+
+ set({
+ title: widgetConfig.title || "在线客服",
+ subtitle: widgetConfig.subtitle || "",
+ welcomeText: widgetConfig.welcomeText || "",
+ themeColor: widgetConfig.themeColor || "#2563eb",
+ })
+
+ let currentConversation = get().conversation
+ if (!get().initialized || !currentConversation) {
+ currentConversation = await createOrMatchImConversation()
+ if (bootstrapToken !== token || !get().isOpen) {
+ return
+ }
+ set({ initialized: true, conversation: currentConversation })
+ }
+
+ await get().refreshMessages()
+ if (bootstrapToken !== token || !get().isOpen) {
+ return
+ }
+
+ connectSocket()
+ } catch (error) {
+ if (bootstrapToken !== token || !get().isOpen) {
+ return
+ }
+ set({
+ status: "disconnected",
+ error: error instanceof Error ? error.message : "初始化失败",
+ })
+ }
+ }
+
+ void activateChat()
+ },
+
+ disconnectSocket: () => {
+ closeSocket({ reconnect: false })
+ },
+
+ refreshMessages: async () => {
+ const conversationId = get().conversation?.id
+ if (!conversationId) {
+ return
+ }
+
+ try {
+ const page = await fetchImMessages({
+ conversationId,
+ limit: DEFAULT_PAGE_LIMIT,
+ })
+ set({
+ messages: page.results,
+ messagesCursor: cursorFromLoadedMessages(page.results) || page.cursor || "",
+ messagesHasMore: Boolean(page.hasMore) || page.results.length >= DEFAULT_PAGE_LIMIT,
+ })
+ } catch (error) {
+ set({
+ error: error instanceof Error ? error.message : "加载消息失败",
+ })
+ throw error
+ }
+ },
+
+ syncLatestMessages: async () => {
+ const conversationId = get().conversation?.id
+ if (!conversationId) {
+ return
+ }
+
+ try {
+ const page = await fetchImMessages({
+ conversationId,
+ limit: DEFAULT_PAGE_LIMIT,
+ })
+ const batch = page.results
+ if (batch.length === 0) {
+ return
+ }
+ const firstId = batch[0]!.id
+ set((state) => {
+ const preserved = state.messages.filter((message) => message.id < firstId)
+ const merged = mergeMessagesByIdAsc(preserved, batch)
+ return {
+ messages: merged,
+ messagesCursor: cursorFromLoadedMessages(merged) || page.cursor || "",
+ messagesHasMore: hasMoreAfterLatestSyncMerge({
+ previousMessages: state.messages,
+ previousHasMore: state.messagesHasMore,
+ merged,
+ apiHasMore: Boolean(page.hasMore) || batch.length >= DEFAULT_PAGE_LIMIT,
+ }),
+ }
+ })
+ } catch (error) {
+ set({
+ error: error instanceof Error ? error.message : "同步消息失败",
+ })
+ }
+ },
+
+ loadOlderMessages: async () => {
+ const conversationId = get().conversation?.id
+ if (
+ !conversationId ||
+ get().messagesLoadingMore ||
+ !get().messagesHasMore
+ ) {
+ return
+ }
+
+ const cursorId = parseCursorId(get().messagesCursor)
+ if (cursorId <= 0) {
+ return
+ }
+
+ set({ messagesLoadingMore: true })
+ try {
+ const page = await fetchImMessages({
+ conversationId,
+ cursor: cursorId,
+ limit: DEFAULT_PAGE_LIMIT,
+ })
+ set((state) => {
+ const merged = mergeMessagesByIdAsc(page.results, state.messages)
+ return {
+ messages: merged,
+ messagesCursor: cursorFromLoadedMessages(merged) || page.cursor || "",
+ messagesHasMore: Boolean(page.hasMore) || page.results.length >= DEFAULT_PAGE_LIMIT,
+ messagesLoadingMore: false,
+ }
+ })
+ } catch (error) {
+ set({
+ messagesLoadingMore: false,
+ error: error instanceof Error ? error.message : "加载历史消息失败",
+ })
+ throw error
+ }
+ },
+
+ markConversationRead: async () => {
+ const state = get()
+ const conversation = state.conversation
+ const lastMessage = state.messages.at(-1)
+ if (!conversation?.id || !lastMessage) {
+ return
+ }
+
+ if (
+ conversation.customerUnreadCount <= 0 &&
+ conversation.customerLastReadMessageId >= lastMessage.id
+ ) {
+ return
+ }
+ if (state.readingMessageId === lastMessage.id) {
+ return
+ }
+
+ set({ readingMessageId: lastMessage.id })
+ try {
+ await markImMessageRead(conversation.id, lastMessage.id)
+ set((current) => ({
+ readingMessageId: 0,
+ messages: current.messages.map((item) =>
+ (item.seqNo ?? 0) <= (lastMessage.seqNo ?? 0)
+ ? { ...item, customerRead: true }
+ : item
+ ),
+ conversation: current.conversation
+ ? {
+ ...current.conversation,
+ customerUnreadCount: 0,
+ customerLastReadMessageId: lastMessage.id,
+ customerLastReadSeqNo: lastMessage.seqNo,
+ }
+ : null,
+ }))
+ } catch (error) {
+ set({ readingMessageId: 0 })
+ throw error
+ }
+ },
+
+ handleSendMessage: async (content: string) => {
+ const conversationId = get().conversation?.id
+ if (!conversationId) {
+ return
+ }
+
+ set({ error: "", sending: true })
+ try {
+ const nextMessage = await sendImMessage({
+ conversationId,
+ messageType: "html",
+ content,
+ clientMsgId: `kefu_html_${generateUUID()}`,
+ })
+ set((state) => ({
+ sending: false,
+ messages: state.messages.some((message) => message.id === nextMessage.id)
+ ? state.messages.map((message) =>
+ message.id === nextMessage.id ? nextMessage : message
+ )
+ : [...state.messages, nextMessage],
+ conversation: state.conversation
+ ? {
+ ...state.conversation,
+ customerLastReadMessageId: nextMessage.id,
+ customerLastReadSeqNo: nextMessage.seqNo,
+ customerUnreadCount: 0,
+ lastMessageAt: nextMessage.sentAt,
+ lastMessageSummary: summarizeIMMessage(nextMessage),
+ }
+ : null,
+ }))
+ } catch (error) {
+ set({
+ sending: false,
+ error: error instanceof Error ? error.message : "发送消息失败",
+ })
+ throw error
+ }
+ },
+
+ sendMessage: async (content: string) => {
+ return get().handleSendMessage(content)
+ },
+
+ uploadMessageImage: async (file: File) => {
+ const conversationId = get().conversation?.id
+ if (!conversationId) {
+ return null
+ }
+
+ set({ error: "", uploadingAsset: true })
+ try {
+ return await uploadImImage(conversationId, file)
+ } catch (error) {
+ set({
+ error: error instanceof Error ? error.message : "上传图片失败",
+ })
+ return null
+ } finally {
+ set({ uploadingAsset: false })
+ }
+ },
+
+ sendAttachment: async (file: File) => {
+ const conversationId = get().conversation?.id
+ if (!conversationId) {
+ return
+ }
+
+ set({ error: "", uploadingAsset: true })
+ try {
+ const asset = await uploadImAttachment(conversationId, file)
+ const nextMessage = await sendImMessage({
+ conversationId,
+ messageType: "attachment",
+ content: asset.filename,
+ payload: JSON.stringify({ assetId: asset.assetId }),
+ clientMsgId: `kefu_attachment_${generateUUID()}`,
+ })
+ set((state) => ({
+ uploadingAsset: false,
+ messages: state.messages.some((message) => message.id === nextMessage.id)
+ ? state.messages.map((message) =>
+ message.id === nextMessage.id ? nextMessage : message
+ )
+ : [...state.messages, nextMessage],
+ conversation: state.conversation
+ ? {
+ ...state.conversation,
+ customerLastReadMessageId: nextMessage.id,
+ customerLastReadSeqNo: nextMessage.seqNo,
+ customerUnreadCount: 0,
+ lastMessageAt: nextMessage.sentAt,
+ lastMessageSummary: summarizeIMMessage(nextMessage),
+ }
+ : null,
+ }))
+ } catch (error) {
+ set({
+ uploadingAsset: false,
+ error: error instanceof Error ? error.message : "发送附件失败",
+ })
+ throw error
+ }
+ },
+
+ closeConversation: async () => {
+ const conversationId = get().conversation?.id
+ if (!conversationId) {
+ return
+ }
+
+ set({ error: "", closingConversation: true })
+ try {
+ await closeImConversation(conversationId)
+ closeSocket({ reconnect: false })
+ set((state) => ({
+ closingConversation: false,
+ status: "disconnected",
+ conversation: state.conversation
+ ? {
+ ...state.conversation,
+ status: 2,
+ }
+ : null,
+ }))
+ } catch (error) {
+ set({
+ closingConversation: false,
+ error: error instanceof Error ? error.message : "关闭会话失败",
+ })
+ throw error
+ }
+ },
+
+ retry: async () => {
+ if (!get().conversation?.id) {
+ return
+ }
+
+ set({ error: "", status: "connecting" })
+ try {
+ await get().refreshMessages()
+ if (get().isOpen) {
+ shouldReconnect = true
+ connectSocket()
+ }
+ } catch (error) {
+ set({
+ status: "disconnected",
+ error: error instanceof Error ? error.message : "刷新失败",
+ })
+ }
+ },
+ }
+})