"use client"; import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { ConversationTransferDialog } from "@/components/conversation-actions/transfer-dialog"; import { ImMessageHTML } from "@/components/im-message-html"; import { useImageLightbox } from "@/components/image-lightbox"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup, } from "@/components/ui/resizable"; import { useIsLgUp } from "@/hooks/use-lg-media"; import { assignAgentConversation, type AgentMessage, } from "@/lib/api/agent"; import { readSession } from "@/lib/auth"; import { renderIMMessageHTML } from "@/lib/im-message"; import { agentConversationSelectors, useAgentConversationsStore, type AgentConversationFilterKey, } from "@/lib/stores/agent-conversations"; import { formatDateTime } from "@/lib/utils"; import { AgentMessageEditor } from "./agent-message-editor"; const EMPTY_AGENT_MESSAGES: AgentMessage[] = []; export function ChatPanel() { const conversation = useAgentConversationsStore( agentConversationSelectors.selectedConversation, ); const messages = useAgentConversationsStore((state) => state.messages) ?? EMPTY_AGENT_MESSAGES; const loading = useAgentConversationsStore((state) => state.messagesLoading); const sending = useAgentConversationsStore((state) => state.sending); const uploadingAsset = useAgentConversationsStore( (state) => state.uploadingAsset, ); const sendMessage = useAgentConversationsStore((state) => state.sendMessage); const uploadImage = useAgentConversationsStore((state) => state.uploadImage); const sendAttachment = useAgentConversationsStore((state) => state.sendAttachment); const markSelectedConversationRead = useAgentConversationsStore( (state) => state.markSelectedConversationRead, ); const recallMessage = useAgentConversationsStore((state) => state.recallMessage); const recallingMessageId = useAgentConversationsStore( (state) => state.recallingMessageId, ); const loadConversations = useAgentConversationsStore((state) => state.loadConversations); const loadMessages = useAgentConversationsStore((state) => state.loadMessages); const loadOlderMessages = useAgentConversationsStore( (state) => state.loadOlderMessages, ); const messagesHasMore = useAgentConversationsStore( (state) => state.messagesHasMore, ); const messagesLoadingMore = useAgentConversationsStore( (state) => state.messagesLoadingMore, ); const conversationFilter = useAgentConversationsStore((state) => state.conversationFilter); const setConversationFilter = useAgentConversationsStore( (state) => state.setConversationFilter, ); const messagesContainerRef = useRef(null); const messagesContentRef = useRef(null); const scrollBottomRafRef = useRef(null); const shouldStickToBottomRef = useRef(true); const prependScrollAnchorRef = useRef<{ height: number; top: number } | null>( null, ); const [claiming, setClaiming] = useState(false); const [claimDialogOpen, setClaimDialogOpen] = useState(false); const [transferDialogOpen, setTransferDialogOpen] = useState(false); const isLgUp = useIsLgUp(); const isClosedConversation = conversation?.status === 4; const isPendingConversation = conversation?.status === 2; const showMessageEditor = !isClosedConversation && !isPendingConversation; const currentUserId = readSession()?.user?.id ?? 0; const switchToMyActiveIfNeeded = () => { if (conversationFilter !== "pending") { return; } setConversationFilter("active" satisfies AgentConversationFilterKey); }; const getViewport = useCallback( () => messagesContainerRef.current, [], ); const isNearBottom = useCallback( (element: HTMLElement, threshold = 80) => element.scrollHeight - element.scrollTop - element.clientHeight <= threshold, [], ); const scrollToBottom = useCallback(() => { const viewport = getViewport(); if (!viewport) { return; } viewport.scrollTop = viewport.scrollHeight; }, [getViewport]); /** * 与 widget 消息列表一致:在单条调度链内多帧滚底直到 scrollHeight 稳定, * 避免多段滚底叠加导致滚动条抖动。 */ const scheduleScrollToBottom = useCallback( (attempts = 4) => { if (scrollBottomRafRef.current !== null) { cancelAnimationFrame(scrollBottomRafRef.current); } const run = (remaining: number, previousHeight = -1) => { scrollBottomRafRef.current = requestAnimationFrame(() => { const viewport = getViewport(); if (!viewport) { scrollBottomRafRef.current = null; return; } const currentHeight = viewport.scrollHeight; scrollToBottom(); if (remaining > 1 && currentHeight !== previousHeight) { run(remaining - 1, currentHeight); return; } scrollBottomRafRef.current = null; }); }; run(attempts); }, [getViewport, scrollToBottom], ); const handleImageSettled = useCallback(() => { if (!shouldStickToBottomRef.current) { return; } scheduleScrollToBottom(); }, [scheduleScrollToBottom]); const maybeMarkConversationRead = useCallback(() => { const viewport = getViewport(); if (!viewport || !conversation || loading) { return; } if ( typeof document !== "undefined" && document.visibilityState !== "visible" ) { return; } if (!isNearBottom(viewport)) { return; } void markSelectedConversationRead().catch((error) => { toast.error(error instanceof Error ? error.message : "设置已读失败"); }); }, [ conversation, getViewport, isNearBottom, loading, markSelectedConversationRead, ]); useEffect(() => { const viewport = getViewport(); if (!viewport) { return; } const handleScroll = () => { shouldStickToBottomRef.current = isNearBottom(viewport); if (shouldStickToBottomRef.current) { maybeMarkConversationRead(); } }; handleScroll(); viewport.addEventListener("scroll", handleScroll); return () => { viewport.removeEventListener("scroll", handleScroll); }; }, [conversation?.id, getViewport, isNearBottom, maybeMarkConversationRead]); useLayoutEffect(() => { shouldStickToBottomRef.current = true; scheduleScrollToBottom(); return () => { if (scrollBottomRafRef.current !== null) { cancelAnimationFrame(scrollBottomRafRef.current); scrollBottomRafRef.current = null; } }; }, [conversation?.id, scheduleScrollToBottom]); useLayoutEffect(() => { const viewport = getViewport(); if (!viewport) { return; } const anchor = prependScrollAnchorRef.current; if (anchor) { prependScrollAnchorRef.current = null; const nextHeight = viewport.scrollHeight; viewport.scrollTop = nextHeight - anchor.height + anchor.top; return; } if (shouldStickToBottomRef.current) { scheduleScrollToBottom(); } }, [messages, getViewport, scheduleScrollToBottom]); useEffect(() => { const content = messagesContentRef.current; if (!content) { return; } const observer = new ResizeObserver(() => { if (!shouldStickToBottomRef.current) { return; } scheduleScrollToBottom(); }); observer.observe(content); return () => { observer.disconnect(); }; }, [conversation?.id, scheduleScrollToBottom]); 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]); const handleLoadOlder = async () => { const viewport = getViewport(); if (!viewport || messagesLoadingMore || !messagesHasMore) { return; } prependScrollAnchorRef.current = { height: viewport.scrollHeight, top: viewport.scrollTop, }; try { await loadOlderMessages(); } catch (error) { prependScrollAnchorRef.current = null; toast.error(error instanceof Error ? error.message : "加载历史消息失败"); } }; const handleSend = async (html: string) => { if (!conversation || sending || isClosedConversation) return; try { shouldStickToBottomRef.current = true; await sendMessage(html); } catch (error) { toast.error(error instanceof Error ? error.message : "发送消息失败"); } }; const handleClaim = async () => { if (!conversation || claiming) return; const session = readSession(); if (!session?.user?.id) { toast.error("未登录或登录已过期"); return; } setClaiming(true); try { await assignAgentConversation( conversation.id, session.user.id, "认领会话", ); switchToMyActiveIfNeeded(); setClaimDialogOpen(false); toast.success("认领成功"); await reloadConversationData(conversation.id); } catch (error) { toast.error(error instanceof Error ? error.message : "认领会话失败"); } finally { setClaiming(false); } }; const reloadConversationData = async (conversationId: number) => { await loadConversations(); await loadMessages(conversationId, { forceLoading: true, reset: true }); }; if (!conversation) { return (

暂无会话

点击左上角菜单打开列表并选择会话

请从左侧选择会话开始聊天

); } const messagesScroll = (
{!loading && messages.length > 0 && messagesHasMore ? (
) : null} {loading ? (
加载中...
) : messages.length > 0 ? ( messages.map((message) => ( { await recallMessage(messageId); }} /> )) ) : (
暂无消息
)}
); const bottomPanel = (
{isClosedConversation ? (
当前会话已关闭
) : conversation?.status === 1 ? (
当前会话由 AI 接待中,转人工后才能由客服发送消息
) : isPendingConversation ? (
) : (
{ shouldStickToBottomRef.current = true; const uploaded = await uploadImage(file); return uploaded; }} onSendAttachment={async (file) => { shouldStickToBottomRef.current = true; try { await sendAttachment(file); } catch (error) { toast.error(error instanceof Error ? error.message : "发送附件失败"); } }} />
)}
); return (
{isLgUp ? ( {messagesScroll} {bottomPanel} ) : (
{messagesScroll}
{bottomPanel}
)} { if (claiming) { return; } setClaimDialogOpen(open); }} > 确认认领会话 {conversation ? `确认认领“${conversation.customerName || `客户 #${conversation.customerId || conversation.id}`}”吗?认领后会话会进入我的列表。` : "确认认领当前会话吗?"} { await reloadConversationData(conversation.id); }} />
); } type MessageItemProps = { message: AgentMessage; onImageSettled: () => void; canRecall: boolean; recalling: boolean; onRecall: (messageId: number) => Promise; }; const MessageItem = memo( function MessageItem({ message, onImageSettled, canRecall, recalling, onRecall, }: MessageItemProps) { const { open: openImageLightbox } = useImageLightbox(); const isCustomer = message.senderType === "customer"; const isAi = message.senderType === "ai"; const isAgentSide = message.senderType === "agent" || isAi; const isRecalled = Boolean(message.recalledAt) || message.sendStatus === 6; const senderName = isCustomer ? message.senderName || "客户" : isAi ? "AI" : message.senderName || "客服"; const agentAvatarSrc = isAgentSide && !isAi && message.senderAvatar?.trim() ? message.senderAvatar.trim() : undefined; const avatarFallback = isAi ? "AI" : senderName.charAt(0); const htmlContent = isRecalled ? "

该消息已撤回

" : buildMessageHTML(message); const bubbleClassName = isAi ? "border border-primary/15 bg-primary/5 text-foreground shadow-sm" : isAgentSide ? "bg-emerald-600 text-white shadow-sm" : "border border-border/70 bg-muted/60 text-foreground shadow-sm"; const htmlClassName = isAi ? "[&_a]:text-foreground [&_a]:underline [&_img]:rounded-md" : isAgentSide ? "[&_p]:text-white [&_a]:text-white [&_a]:underline [&_img]:rounded-md" : "[&_a]:text-foreground [&_a]:underline [&_img]:rounded-md"; const avatarClassName = isAi ? "border border-primary/20 bg-primary/10 text-xs text-foreground" : isAgentSide ? "bg-emerald-600 text-xs text-white" : "border border-border/70 bg-muted/60 text-xs text-foreground"; const recalledBubbleClassName = isAgentSide ? "border border-dashed border-emerald-200 bg-emerald-50 text-emerald-800" : "border border-dashed border-border/70 bg-muted/40 text-muted-foreground"; const recalledHtmlClassName = isAgentSide ? "[&_p]:text-emerald-800" : "[&_p]:text-muted-foreground"; const showRecallAction = canRecall && !isRecalled; return (
{isAgentSide ? ( <>
{senderName}
{formatDateTime(message.sentAt || "")} {isRecalled ? 已撤回 : null} {message.sendStatus === 2 && !isRecalled && ( {message.customerRead ? "客户已读" : "客户未读"} )} {showRecallAction ? ( ) : null}
{avatarFallback} ) : ( <>
{senderName}
{formatDateTime(message.sentAt || "")} {isRecalled ? 已撤回 : null}
)}
); }, (prevProps, nextProps) => prevProps.message === nextProps.message && prevProps.onImageSettled === nextProps.onImageSettled && prevProps.canRecall === nextProps.canRecall && prevProps.recalling === nextProps.recalling && prevProps.onRecall === nextProps.onRecall, ); function buildMessageHTML(message: { messageType: string; content: string; payload?: string; }) { return renderIMMessageHTML(message); }