"use client"; import { memo, type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState, } from "react"; import { AlertTriangleIcon, BotIcon, LockKeyholeIcon, TimerIcon, UserCheckIcon, WorkflowIcon, } from "lucide-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 { JsonTreeViewer } from "@/components/json-tree-viewer"; import { ProjectDialog } from "@/components/project-dialog"; import { useI18n } from "@/i18n/provider"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; 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 { fetchAIWorkflowRun, type AIWorkflowNodeRun, type AIWorkflowRun, } from "@/lib/api/admin"; 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[] = []; type ComposerNoticeTone = "muted" | "ai" | "action"; type ComposerNoticeProps = { icon: ReactNode; message: string; tone?: ComposerNoticeTone; action?: ReactNode; }; function getComposerNoticeClassName(tone: ComposerNoticeTone) { if (tone === "ai") { return { wrap: "border-primary/15 bg-primary/5", icon: "bg-primary/10 text-primary", }; } if (tone === "action") { return { wrap: "border-border bg-muted/35", icon: "bg-background text-foreground shadow-sm", }; } return { wrap: "border-border bg-muted/30", icon: "bg-muted text-muted-foreground", }; } function ComposerNotice({ icon, message, tone = "muted", action, }: ComposerNoticeProps) { const className = getComposerNoticeClassName(tone); return (
{icon}
{message}
{action ?
{action}
: null}
); } export function ChatPanel() { const t = useI18n(); 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 [workflowRunDialogOpen, setWorkflowRunDialogOpen] = useState(false); const [workflowRunLoading, setWorkflowRunLoading] = useState(false); const [activeWorkflowRun, setActiveWorkflowRun] = useState(null); 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]); /** * Match the widget message list: keep scrolling for a few frames until * scrollHeight stabilizes, which prevents stacked scroll jumps. */ 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 : t("conversation.markReadFailed")); }); }, [ conversation, getViewport, isNearBottom, loading, markSelectedConversationRead, t, ]); 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 : t("conversation.loadHistoryFailed")); } }; 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 : t("conversation.sendMessageFailed")); } }; const handleClaim = async () => { if (!conversation || claiming) return; const session = readSession(); if (!session?.user?.id) { toast.error(t("conversation.claimRequiresSignIn")); return; } setClaiming(true); try { await assignAgentConversation( conversation.id, session.user.id, t("conversation.claimReason"), ); switchToMyActiveIfNeeded(); setClaimDialogOpen(false); toast.success(t("conversation.claimSuccess")); await reloadConversationData(conversation.id); } catch (error) { toast.error(error instanceof Error ? error.message : t("conversation.claimFailed")); } finally { setClaiming(false); } }; const reloadConversationData = async (conversationId: number) => { await loadConversations(); await loadMessages(conversationId, { forceLoading: true, reset: true }); }; const openWorkflowRunDetail = useCallback( async (runId: number) => { setWorkflowRunDialogOpen(true); setWorkflowRunLoading(true); try { const data = await fetchAIWorkflowRun(runId); setActiveWorkflowRun(data); } catch (error) { toast.error(error instanceof Error ? error.message : "加载 AI 执行详情失败"); setWorkflowRunDialogOpen(false); } finally { setWorkflowRunLoading(false); } }, [], ); if (!conversation) { return (

{t("conversation.empty")}

{t("conversation.noConversationMobile")}

{t("conversation.selectConversationToChat")}

); } const messagesScroll = (
{!loading && messages.length > 0 && messagesHasMore ? (
) : null} {loading ? (
{t("conversation.loading")}
) : messages.length > 0 ? ( messages.map((message) => ( { await recallMessage(messageId); }} onOpenWorkflowRun={openWorkflowRunDetail} /> )) ) : (
{t("conversation.emptyMessages")}
)}
); const bottomPanel = (
{isClosedConversation ? ( } message={t("conversation.closedNotice")} /> ) : conversation?.status === 1 ? ( } message={t("conversation.aiServingNotice")} tone="ai" /> ) : isPendingConversation ? ( } message={t("conversation.claimCurrent")} tone="action" action={ } /> ) : (
{ 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 : t("conversation.sendAttachmentFailed")); } }} />
)}
); return (
{isLgUp ? ( {messagesScroll} {bottomPanel} ) : (
{messagesScroll}
{bottomPanel}
)} { if (claiming) { return; } setClaimDialogOpen(open); }} > {t("conversation.claimTitle")} {conversation ? `${t("conversation.claimConfirmPrefix")}${ conversation.customerName || `${t("conversation.customerFallbackPrefix")}${conversation.customerId || conversation.id}` }${t("conversation.claimConfirmSuffix")}` : t("conversation.claimCurrent")} { await reloadConversationData(conversation.id); }} /> { setWorkflowRunDialogOpen(open); if (!open) { setActiveWorkflowRun(null); } }} />
); } type MessageItemProps = { message: AgentMessage; onImageSettled: () => void; canRecall: boolean; recalling: boolean; onRecall: (messageId: number) => Promise; onOpenWorkflowRun: (runId: number) => Promise; }; const MessageItem = memo( function MessageItem({ message, onImageSettled, canRecall, recalling, onRecall, onOpenWorkflowRun, }: MessageItemProps) { const t = useI18n(); 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 || t("conversation.customerSender") : isAi ? "AI" : message.senderName || t("conversation.agentSender"); const agentAvatarSrc = isAgentSide && !isAi && message.senderAvatar?.trim() ? message.senderAvatar.trim() : undefined; const avatarFallback = isAi ? "AI" : senderName.charAt(0); const htmlContent = isRecalled ? `

${t("conversation.messageRecalledHtml")}

` : 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 ? {t("conversation.messageRecalled")} : null} {message.sendStatus === 2 && !isRecalled && ( {message.customerRead ? t("conversation.customerRead") : t("conversation.customerUnread")} )} {showRecallAction ? ( ) : null} {isAi && message.workflowRunId ? ( ) : null}
{avatarFallback} ) : ( <> {t("conversation.customerAvatar")}
{senderName}
{formatDateTime(message.sentAt || "")} {isRecalled ? {t("conversation.messageRecalled")} : null}
)}
); }, (prevProps, nextProps) => prevProps.message === nextProps.message && prevProps.onImageSettled === nextProps.onImageSettled && prevProps.canRecall === nextProps.canRecall && prevProps.recalling === nextProps.recalling && prevProps.onRecall === nextProps.onRecall && prevProps.onOpenWorkflowRun === nextProps.onOpenWorkflowRun, ); function buildMessageHTML(message: { messageType: string; content: string; payload?: string; }) { return renderIMMessageHTML(message); } function WorkflowRunDetailDialog({ open, loading, run, onOpenChange, }: { open: boolean; loading: boolean; run: AIWorkflowRun | null; onOpenChange: (open: boolean) => void; }) { return ( AI 执行详情 } description={run ? `Run #${run.id}` : "Workflow 执行链路"} size="xl" allowFullscreen footer={ } > {loading ? (
加载执行详情中
) : run ? (
{run.errorMessage ? (
{run.errorMessage}
) : null}
{(run.nodes ?? []).map((node) => ( ))} {!run.nodes || run.nodes.length === 0 ? (

暂无节点记录

) : null}
) : (
未找到执行记录
)}
); } function WorkflowRunDetailRow({ label, value, }: { label: string; value: string; }) { const empty = !value.trim(); return (
{label} {empty ? "—" : value}
); } function WorkflowNodeRunBlock({ node }: { node: AIWorkflowNodeRun }) { const inputValue = safeParseJSON(node.inputPreview); const outputValue = safeParseJSON(node.outputPreview); return (
{node.nodeId || `Node #${node.id}`}
{node.nodeType || "unknown"}
{node.durationMs} ms
{node.errorMessage ? (
{node.errorMessage}
) : null}
); } function WorkflowPreviewBlock({ title, raw, value, }: { title: string; raw: string; value: unknown; }) { return (
{title}
{value !== null ? ( ) : raw.trim() ? (
          {raw}
        
) : (
)}
); } function WorkflowRunStatusBadge({ statusName }: { statusName: string }) { const normalized = statusName.trim(); const variant = normalized === "failed" ? "destructive" : normalized === "interrupted" ? "outline" : "secondary"; return ( {normalized || "unknown"} ); } function safeParseJSON(raw: string): unknown | null { const trimmed = raw.trim(); if (!trimmed) { return null; } try { return JSON.parse(trimmed) as unknown; } catch { return null; } }