From 4bcfd3662011c46c2b0ea16a79dd0baed7323caf Mon Sep 17 00:00:00 2001 From: mlogclub Date: Fri, 24 Apr 2026 14:34:21 +0800 Subject: [PATCH] feat: implement Kefu message list component and related functionality - Add KefuMessageList component for displaying chat messages with support for loading older messages and scrolling behavior. - Introduce ImWidgetConfig type and fetchImWidgetConfig function for retrieving widget configuration. - Create im-realtime module for managing WebSocket connections and handling real-time events. - Implement kefu-host-bridge for communication between the chat widget and the host application. - Establish kefu-chat store using Zustand for managing chat state, including message handling, socket connection, and notifications. - Enhance message handling with support for image uploads and attachments. --- web/app/kefu/chat/page.tsx | 5 + web/components/kefu/chat-shell.tsx | 314 ++++++++++ web/components/kefu/connection-status.tsx | 44 ++ web/components/kefu/message-editor.tsx | 368 ++++++++++++ web/components/kefu/message-list.tsx | 322 ++++++++++ web/lib/api/im.ts | 28 + web/lib/im-realtime.ts | 30 + web/lib/kefu-host-bridge.ts | 74 +++ web/lib/stores/kefu-chat.ts | 686 ++++++++++++++++++++++ 9 files changed, 1871 insertions(+) create mode 100644 web/app/kefu/chat/page.tsx create mode 100644 web/components/kefu/chat-shell.tsx create mode 100644 web/components/kefu/connection-status.tsx create mode 100644 web/components/kefu/message-editor.tsx create mode 100644 web/components/kefu/message-list.tsx create mode 100644 web/lib/im-realtime.ts create mode 100644 web/lib/kefu-host-bridge.ts create mode 100644 web/lib/stores/kefu-chat.ts diff --git a/web/app/kefu/chat/page.tsx b/web/app/kefu/chat/page.tsx new file mode 100644 index 0000000..f75b827 --- /dev/null +++ b/web/app/kefu/chat/page.tsx @@ -0,0 +1,5 @@ +import { KefuChatShell } from "@/components/kefu/chat-shell" + +export default function Page() { + return +} diff --git a/web/components/kefu/chat-shell.tsx b/web/components/kefu/chat-shell.tsx new file mode 100644 index 0000000..689eaa4 --- /dev/null +++ b/web/components/kefu/chat-shell.tsx @@ -0,0 +1,314 @@ +"use client" + +import { + Maximize2Icon, + Minimize2Icon, + MinusIcon, + RotateCwIcon, + XIcon, +} from "lucide-react" +import { useCallback, useEffect, useRef, useState } from "react" +import { useShallow } from "zustand/react/shallow" + +import { KefuConnectionStatus } from "@/components/kefu/connection-status" +import { KefuMessageEditor } from "@/components/kefu/message-editor" +import { + KefuMessageList, + type KefuMessageListHandle, +} from "@/components/kefu/message-list" +import { + bindKefuHostBridge, + requestKefuHostClose, + requestKefuHostMinimize, + requestKefuHostToggleMaximize, +} from "@/lib/kefu-host-bridge" +import { useKefuChatStore } from "@/lib/stores/kefu-chat" + +export function KefuChatShell() { + const messageListRef = useRef(null) + const [isMaximized, setIsMaximized] = useState(false) + const [isCloseDialogOpen, setIsCloseDialogOpen] = useState(false) + const [isClosingConversation, setIsClosingConversation] = useState(false) + + const { + title, + subtitle, + themeColor, + conversation, + messages, + messagesHasMore, + messagesLoadingMore, + loadOlderMessages, + status, + error, + isOpen, + isVisible, + setIsOpen, + setIsVisible, + bootstrap, + handleSendMessage, + uploadMessageImage, + sendAttachment, + retry, + disconnectSocket, + markConversationRead, + closeConversation, + } = useKefuChatStore( + useShallow((state) => ({ + title: state.title, + subtitle: state.subtitle, + themeColor: state.themeColor, + conversation: state.conversation, + messages: state.messages, + messagesHasMore: state.messagesHasMore, + messagesLoadingMore: state.messagesLoadingMore, + loadOlderMessages: state.loadOlderMessages, + status: state.status, + error: state.error, + isOpen: state.isOpen, + isVisible: state.isVisible, + setIsOpen: state.setIsOpen, + setIsVisible: state.setIsVisible, + bootstrap: state.bootstrap, + handleSendMessage: state.handleSendMessage, + uploadMessageImage: state.uploadMessageImage, + sendAttachment: state.sendAttachment, + retry: state.retry, + disconnectSocket: state.disconnectSocket, + markConversationRead: state.markConversationRead, + closeConversation: state.closeConversation, + })) + ) + + const maybeMarkConversationRead = useCallback(() => { + if (!isVisible || !conversation || typeof document === "undefined") { + return + } + if (document.visibilityState !== "visible") { + return + } + void markConversationRead().catch((readError) => { + console.error("Failed to mark kefu conversation read", readError) + }) + }, [conversation, isVisible, markConversationRead]) + + useEffect(() => { + return bindKefuHostBridge({ + onOpen: () => { + setIsOpen(true) + setIsVisible(true) + }, + onMinimize: () => { + setIsVisible(false) + }, + onMaximizedChange: (nextIsMaximized) => { + setIsMaximized(nextIsMaximized) + }, + }) + }, [setIsOpen, setIsVisible]) + + useEffect(() => { + bootstrap() + + return () => { + if (!isOpen) { + disconnectSocket() + } + } + }, [isOpen, bootstrap, disconnectSocket]) + + useEffect(() => { + maybeMarkConversationRead() + }, [maybeMarkConversationRead, messages.length]) + + useEffect(() => { + const handleVisibilityChange = () => { + if (document.visibilityState === "visible") { + maybeMarkConversationRead() + } + } + const handleFocus = () => { + maybeMarkConversationRead() + } + + document.addEventListener("visibilitychange", handleVisibilityChange) + window.addEventListener("focus", handleFocus) + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange) + window.removeEventListener("focus", handleFocus) + } + }, [maybeMarkConversationRead]) + + async function handleSend(content: string) { + await handleSendMessage(content) + messageListRef.current?.scrollToBottom() + } + + function handleMinimize() { + setIsVisible(false) + requestKefuHostMinimize() + } + + function handleToggleMaximize() { + requestKefuHostToggleMaximize() + } + + async function confirmCloseConversation() { + if (isClosingConversation) { + return + } + setIsClosingConversation(true) + try { + if (conversation?.id) { + await closeConversation() + } + setIsCloseDialogOpen(false) + requestKefuHostClose() + } catch (closeError) { + window.alert(closeError instanceof Error ? closeError.message : "关闭会话失败") + } finally { + setIsClosingConversation(false) + } + } + + useEffect(() => { + if (!isCloseDialogOpen) { + return + } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape" && !isClosingConversation) { + setIsCloseDialogOpen(false) + } + } + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [isCloseDialogOpen, isClosingConversation]) + + return ( +
+
+
+
+
+
+
+ {title} +
+
{subtitle}
+
+
+ {status !== "connected" ? ( + + ) : null} +
+ + + + +
+
+
+
+ +
+ + +
+ + {error ? ( +
+ {error} +
+ ) : null} +
+ + {isCloseDialogOpen ? ( +
+
+
+
+
+ 结束当前对话? +
+
+ 结束会话,客服将无法再查看您的消息记录,如需再次联系请重新发起对话。 +
+
+
+ +
+ + +
+
+
+ ) : null} +
+ ) +} + diff --git a/web/components/kefu/connection-status.tsx b/web/components/kefu/connection-status.tsx new file mode 100644 index 0000000..02dd3f7 --- /dev/null +++ b/web/components/kefu/connection-status.tsx @@ -0,0 +1,44 @@ +"use client" + +import { cn } from "@/lib/utils" + +type KefuConnectionStatusProps = { + status: "connecting" | "connected" | "disconnected" +} + +const statusText: Record = { + connecting: "连接中", + connected: "在线服务", + disconnected: "连接已断开", +} + +export function KefuConnectionStatus({ status }: KefuConnectionStatusProps) { + const toneClass = + status === "connected" + ? "border-emerald-200/80 bg-emerald-50 text-emerald-700" + : status === "connecting" + ? "border-amber-200/80 bg-amber-50 text-amber-700" + : "border-slate-200/80 bg-slate-100 text-slate-600" + + return ( +
+ + {statusText[status]} +
+ ) +} + diff --git a/web/components/kefu/message-editor.tsx b/web/components/kefu/message-editor.tsx new file mode 100644 index 0000000..e8a7379 --- /dev/null +++ b/web/components/kefu/message-editor.tsx @@ -0,0 +1,368 @@ +"use client" + +import { useEffect, useRef, useState } from "react" +import { EditorContent, useEditor } from "@tiptap/react" +import Image from "@tiptap/extension-image" +import Placeholder from "@tiptap/extension-placeholder" +import StarterKit from "@tiptap/starter-kit" +import { ImageIcon, PaperclipIcon, SendHorizonalIcon } from "lucide-react" + +import { generateUUID } from "@/lib/utils" + +type UploadedImage = { + assetId: string + provider: string + storageKey: string + url: string + filename?: string +} + +const MessageImage = Image.extend({ + addAttributes() { + return { + ...this.parent?.(), + dataAssetId: { + default: null, + parseHTML: (element) => element.getAttribute("data-asset-id"), + renderHTML: (attributes) => + attributes.dataAssetId ? { "data-asset-id": attributes.dataAssetId } : {}, + }, + dataProvider: { + default: null, + parseHTML: (element) => element.getAttribute("data-provider"), + renderHTML: (attributes) => + attributes.dataProvider ? { "data-provider": attributes.dataProvider } : {}, + }, + dataStorageKey: { + default: null, + parseHTML: (element) => element.getAttribute("data-storage-key"), + renderHTML: (attributes) => + attributes.dataStorageKey ? { "data-storage-key": attributes.dataStorageKey } : {}, + }, + } + }, +}) + +type KefuMessageEditorProps = { + disabled?: boolean + uploadingAsset?: boolean + onSend: (html: string) => Promise + onUploadImage: (file: File) => Promise + onSendAttachment: (file: File) => Promise +} + +export function KefuMessageEditor({ + disabled = false, + uploadingAsset = false, + onSend, + onUploadImage, + onSendAttachment, +}: KefuMessageEditorProps) { + const [localUploading, setLocalUploading] = useState(false) + const imageInputRef = useRef(null) + const attachmentInputRef = useRef(null) + const onSendRef = useRef(onSend) + const onUploadImageRef = useRef(onUploadImage) + const onSendAttachmentRef = useRef(onSendAttachment) + const shouldRestoreFocusRef = useRef(false) + const isUploading = uploadingAsset || localUploading + + useEffect(() => { + onSendRef.current = onSend + }, [onSend]) + + useEffect(() => { + onUploadImageRef.current = onUploadImage + }, [onUploadImage]) + + useEffect(() => { + onSendAttachmentRef.current = onSendAttachment + }, [onSendAttachment]) + + const editor = useEditor({ + immediatelyRender: false, + extensions: [ + StarterKit.configure({ + heading: false, + blockquote: false, + codeBlock: false, + bulletList: false, + orderedList: false, + horizontalRule: false, + }), + MessageImage, + Placeholder.configure({ + placeholder: "输入消息,Enter 发送,Shift + Enter 换行", + }), + ], + content: "", + editorProps: { + attributes: { + class: + "min-h-12 max-h-40 overflow-y-auto px-1.5 py-1 text-[13px] leading-6 text-slate-900 outline-none [&_p]:m-0 [&_p+*]:mt-2 [&_img]:my-2 [&_img]:max-h-64 [&_img]:rounded-xl [&_img]:object-contain", + }, + handleKeyDown: (_view, event) => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault() + void handleSend() + return true + } + return false + }, + handlePaste: (_view, event) => { + if (disabled || isUploading) { + return false + } + const imageFile = getClipboardImageFile(event.clipboardData) + if (!imageFile) { + return false + } + event.preventDefault() + void insertUploadedImage(imageFile) + return true + }, + }, + }) + + useEffect(() => { + if (!editor) { + return + } + editor.setEditable(!disabled && !isUploading) + }, [disabled, editor, isUploading]) + + useEffect(() => { + if (!editor || disabled || isUploading || !shouldRestoreFocusRef.current) { + return + } + requestAnimationFrame(() => { + editor.commands.focus() + }) + }, [disabled, editor, isUploading]) + + async function handleSend() { + if (!editor || disabled || isUploading) { + return + } + const html = editor.getHTML() + if (!isMeaningfulHTML(html)) { + return + } + await onSendRef.current(html) + editor.commands.clearContent(true) + } + + async function handleSelectImage(event: React.ChangeEvent) { + const file = event.target.files?.[0] + event.target.value = "" + if (!file || !editor || disabled || isUploading) { + if (editor && shouldRestoreFocusRef.current) { + requestAnimationFrame(() => { + editor.commands.focus() + }) + } + return + } + await insertUploadedImage(file) + } + + async function insertUploadedImage(file: File) { + if (!editor || disabled || isUploading) { + return + } + + shouldRestoreFocusRef.current = true + const objectUrl = URL.createObjectURL(file) + const placeholderId = `uploading-${generateUUID()}` + editor + .chain() + .focus() + .setImage({ + src: objectUrl, + alt: file.name || "uploading-image", + title: placeholderId, + }) + .run() + + try { + setLocalUploading(true) + const uploaded = await onUploadImageRef.current(file) + if (!uploaded?.url) { + removeImageByTitle(editor, placeholderId) + return + } + replaceImageSourceByTitle(editor, placeholderId, uploaded) + } finally { + setLocalUploading(false) + URL.revokeObjectURL(objectUrl) + requestAnimationFrame(() => { + if (!disabled && shouldRestoreFocusRef.current) { + editor.commands.focus() + } + }) + } + } + + async function handleSelectAttachment( + event: React.ChangeEvent + ) { + 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 : "刷新失败", + }) + } + }, + } +})