import * as React from 'react'; import { useShallow } from 'zustand/react/shallow'; import TimeAgo from 'react-timeago'; import type { SxProps } from '@mui/joy/styles/types'; import { Box, Button, ButtonGroup, CircularProgress, IconButton, ListDivider, ListItem, ListItemDecorator, MenuItem, Switch, Tooltip, Typography } from '@mui/joy'; import { ClickAwayListener, Popper } from '@mui/base'; import AccountTreeOutlinedIcon from '@mui/icons-material/AccountTreeOutlined'; import CheckRoundedIcon from '@mui/icons-material/CheckRounded'; import CloseRoundedIcon from '@mui/icons-material/CloseRounded'; import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; import DifferenceIcon from '@mui/icons-material/Difference'; import EditRoundedIcon from '@mui/icons-material/EditRounded'; import ForkRightIcon from '@mui/icons-material/ForkRight'; import FormatPaintOutlinedIcon from '@mui/icons-material/FormatPaintOutlined'; import MoreVertIcon from '@mui/icons-material/MoreVert'; import RecordVoiceOverOutlinedIcon from '@mui/icons-material/RecordVoiceOverOutlined'; import ReplayIcon from '@mui/icons-material/Replay'; import ReplyRoundedIcon from '@mui/icons-material/ReplyRounded'; import StarOutlineRoundedIcon from '@mui/icons-material/StarOutlineRounded'; import StarRoundedIcon from '@mui/icons-material/StarRounded'; import TelegramIcon from '@mui/icons-material/Telegram'; import VerticalAlignBottomIcon from '@mui/icons-material/VerticalAlignBottom'; import { ChatBeamIcon } from '~/common/components/icons/ChatBeamIcon'; import { CloseableMenu } from '~/common/components/CloseableMenu'; import { DMessage, DMessageId, DMessageUserFlag, messageFragmentsReduceText, messageHasUserFlag } from '~/common/stores/chat/chat.message'; import { KeyStroke } from '~/common/components/KeyStroke'; import { adjustContentScaling, themeScalingMap, themeZIndexPageBar } from '~/common/app.theme'; import { animationColorRainbow } from '~/common/util/animUtils'; import { copyToClipboard } from '~/common/util/clipboardUtils'; import { createTextContentFragment, DMessageAttachmentFragment, DMessageContentFragment, DMessageFragment, DMessageFragmentId, isAttachmentFragment, isContentFragment, isImageRefPart } from '~/common/stores/chat/chat.fragments'; import { prettyBaseModel } from '~/common/util/modelUtils'; import { useUIPreferencesStore } from '~/common/state/store-ui'; import { ContentFragments } from './fragments-content/ContentFragments'; import { DocumentFragments } from './fragments-attachment-doc/DocumentFragments'; import { ImageAttachmentFragments } from './fragments-attachment-image/ImageAttachmentFragments'; import { ReplyToBubble } from './ReplyToBubble'; import { avatarIconSx, makeMessageAvatarIcon, messageAsideColumnSx, messageBackground, messageZenAsideColumnSx } from './messageUtils'; import { useChatShowTextDiff } from '../../store-app-chat'; // Enable the menu on text selection const ENABLE_CONTEXT_MENU = false; const ENABLE_BUBBLE = true; const BUBBLE_MIN_TEXT_LENGTH = 3; // Enable the hover button to copy the whole message. The Copy button is also available in Blocks, or in the Avatar Menu. const ENABLE_COPY_MESSAGE_OVERLAY: boolean = false; export type ChatMessageTextPartEditState = { [fragmentId: DMessageFragmentId]: string }; export const ChatMessageMemo = React.memo(ChatMessage); /** * The Message component is a customizable chat message UI component that supports * different roles (user, assistant, and system), text editing, syntax highlighting, * and code execution using Sandpack for TypeScript, JavaScript, and HTML code blocks. * The component also provides options for copying code to clipboard and expanding * or collapsing long user messages. * */ export function ChatMessage(props: { message: DMessage, diffPreviousText?: string, fitScreen: boolean, isMobile?: boolean, isBottom?: boolean, isImagining?: boolean, isSpeaking?: boolean, hideAvatar?: boolean, showBlocksDate?: boolean, showUnsafeHtml?: boolean, adjustContentScaling?: number, topDecorator?: React.ReactNode, onMessageAssistantFrom?: (messageId: string, offset: number) => Promise, onMessageBeam?: (messageId: string) => Promise, onMessageBranch?: (messageId: string) => void, onMessageDelete?: (messageId: string) => void, onMessageFragmentAppend?: (messageId: DMessageId, fragment: DMessageFragment) => void onMessageFragmentDelete?: (messageId: DMessageId, fragmentId: DMessageFragmentId) => void, onMessageFragmentReplace?: (messageId: DMessageId, fragmentId: DMessageFragmentId, newFragment: DMessageFragment) => void, onMessageToggleUserFlag?: (messageId: string, flag: DMessageUserFlag) => void, onMessageTruncate?: (messageId: string) => void, onReplyTo?: (messageId: string, selectedText: string) => void, onTextDiagram?: (messageId: string, text: string) => Promise, onTextImagine?: (text: string) => Promise, onTextSpeak?: (text: string) => Promise, sx?: SxProps, }) { // state const blocksRendererRef = React.useRef(null); const [isHovering, setIsHovering] = React.useState(false); const [selText, setSelText] = React.useState(null); const [bubbleAnchor, setBubbleAnchor] = React.useState(null); const [contextMenuAnchor, setContextMenuAnchor] = React.useState(null); const [opsMenuAnchor, setOpsMenuAnchor] = React.useState(null); const [textContentEditState, setTextContentEditState] = React.useState(null); // external state const { isZenMode, contentScaling, doubleClickToEdit, renderMarkdown } = useUIPreferencesStore(useShallow(state => ({ isZenMode: state.zenMode === 'cleaner', contentScaling: adjustContentScaling(state.contentScaling, props.adjustContentScaling), doubleClickToEdit: state.doubleClickToEdit, renderMarkdown: state.renderMarkdown, }))); const [showDiff, setShowDiff] = useChatShowTextDiff(); // derived state const { id: messageId, role: messageRole, fragments: messageFragments, pendingIncomplete: messagePendingIncomplete, avatar: messageAvatar, purposeId: messagePurposeId, originLLM: messageOriginLLM, metadata: messageMetadata, created: messageCreated, updated: messageUpdated, } = props.message; // split the fragments: Image Attachments are rendered as cards, Content is the body (sequence of parts), and other attachment fragments as documents const contentFragments: DMessageContentFragment[] = []; const imageAttachments: DMessageAttachmentFragment[] = []; const nonImageAttachments: DMessageAttachmentFragment[] = []; messageFragments.forEach(fragment => { if (isContentFragment(fragment)) contentFragments.push(fragment); else if (isAttachmentFragment(fragment)) { if (isImageRefPart(fragment.part)) imageAttachments.push(fragment); else nonImageAttachments.push(fragment); } else console.warn('Unexpected fragment type:', fragment.ft); }); const isUserStarred = messageHasUserFlag(props.message, 'starred'); const fromAssistant = messageRole === 'assistant'; const fromSystem = messageRole === 'system'; const wasEdited = !!messageUpdated; const textSel = selText ? selText : messageFragmentsReduceText(contentFragments); const isSpecialT2I = textSel.startsWith('https://images.prodia.xyz/') || textSel.startsWith('/draw ') || textSel.startsWith('/imagine ') || textSel.startsWith('/img '); const couldDiagram = textSel.length >= 100 && !isSpecialT2I; const couldImagine = textSel.length >= 3 && !isSpecialT2I; const couldSpeak = couldImagine; // TODO: fix the diffing // const textDiffs = useSanityTextDiffs(messageText, props.diffPreviousText, showDiff); const { onMessageFragmentAppend, onMessageFragmentDelete, onMessageFragmentReplace } = props; const handleFragmentNew = React.useCallback(() => { onMessageFragmentAppend?.(messageId, createTextContentFragment('')); }, [messageId, onMessageFragmentAppend]); const handleFragmentDelete = React.useCallback((fragmentId: DMessageFragmentId) => { onMessageFragmentDelete?.(messageId, fragmentId); }, [messageId, onMessageFragmentDelete]); const handleFragmentReplace = React.useCallback((fragmentId: DMessageFragmentId, newFragment: DMessageFragment) => { onMessageFragmentReplace?.(messageId, fragmentId, newFragment); }, [messageId, onMessageFragmentReplace]); // Text Editing const isEditingText = !!textContentEditState; const handleEditsApply = React.useCallback(() => { const state = textContentEditState || {}; setTextContentEditState(null); Object.entries(state).forEach(([fragmentId, editedText]) => { if (editedText.length > 0) handleFragmentReplace(fragmentId, createTextContentFragment(editedText)); else handleFragmentDelete(fragmentId); }); }, [handleFragmentDelete, handleFragmentReplace, textContentEditState]); const handleEditsBegin = React.useCallback(() => setTextContentEditState({}), []); const handleEditsCancel = React.useCallback(() => setTextContentEditState(null), []); const handleEditSetText = React.useCallback((fragmentId: DMessageFragmentId, editedText: string) => setTextContentEditState((prev): ChatMessageTextPartEditState => ({ ...prev, [fragmentId]: editedText || '' })), []); // Message Operations Menu const { onMessageToggleUserFlag } = props; const handleOpsMenuToggle = React.useCallback((event: React.MouseEvent) => { event.preventDefault(); // added for the Right mouse click (to prevent the menu) setOpsMenuAnchor(anchor => anchor ? null : event.currentTarget); }, []); const handleCloseOpsMenu = React.useCallback(() => setOpsMenuAnchor(null), []); const handleOpsCopy = (e: React.MouseEvent) => { copyToClipboard(textSel, 'Text'); e.preventDefault(); handleCloseOpsMenu(); closeContextMenu(); closeBubble(); }; const handleOpsEditToggle = React.useCallback((e: React.MouseEvent) => { if (messagePendingIncomplete && !isEditingText) return; // don't allow editing while incomplete if (isEditingText) handleEditsCancel(); else handleEditsBegin(); e.preventDefault(); handleCloseOpsMenu(); }, [handleCloseOpsMenu, handleEditsBegin, handleEditsCancel, isEditingText, messagePendingIncomplete]); const handleOpsToggleStarred = React.useCallback(() => { onMessageToggleUserFlag?.(messageId, 'starred'); }, [messageId, onMessageToggleUserFlag]); const handleOpsAssistantFrom = async (e: React.MouseEvent) => { e.preventDefault(); handleCloseOpsMenu(); await props.onMessageAssistantFrom?.(messageId, fromAssistant ? -1 : 0); }; const handleOpsBeamFrom = async (e: React.MouseEvent) => { e.stopPropagation(); handleCloseOpsMenu(); await props.onMessageBeam?.(messageId); }; const handleOpsBranch = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); // to try to not steal the focus from the banched conversation props.onMessageBranch?.(messageId); handleCloseOpsMenu(); }; const handleOpsToggleShowDiff = () => setShowDiff(!showDiff); const handleOpsDiagram = async (e: React.MouseEvent) => { e.preventDefault(); if (props.onTextDiagram) { await props.onTextDiagram(messageId, textSel); handleCloseOpsMenu(); closeContextMenu(); closeBubble(); } }; const handleOpsImagine = async (e: React.MouseEvent) => { e.preventDefault(); if (props.onTextImagine) { await props.onTextImagine(textSel); handleCloseOpsMenu(); closeContextMenu(); closeBubble(); } }; const handleOpsReplyTo = (e: React.MouseEvent) => { e.preventDefault(); if (props.onReplyTo && textSel.trim().length >= BUBBLE_MIN_TEXT_LENGTH) { props.onReplyTo(messageId, textSel.trim()); handleCloseOpsMenu(); closeContextMenu(); closeBubble(); } }; const handleOpsSpeak = async (e: React.MouseEvent) => { e.preventDefault(); if (props.onTextSpeak) { await props.onTextSpeak(textSel); handleCloseOpsMenu(); closeContextMenu(); closeBubble(); } }; const handleOpsTruncate = (_e: React.MouseEvent) => { props.onMessageTruncate?.(messageId); handleCloseOpsMenu(); }; const handleOpsDelete = (_e: React.MouseEvent) => { props.onMessageDelete?.(messageId); }; // Context Menu const removeContextAnchor = React.useCallback(() => { if (contextMenuAnchor) { try { document.body.removeChild(contextMenuAnchor); } catch (e) { // ignore... } } }, [contextMenuAnchor]); const openContextMenu = React.useCallback((event: MouseEvent, selectedText: string) => { event.stopPropagation(); event.preventDefault(); // remove any stray anchor removeContextAnchor(); // create a temporary fixed anchor element to position the menu const anchorEl = document.createElement('div'); anchorEl.style.position = 'fixed'; anchorEl.style.left = `${event.clientX}px`; anchorEl.style.top = `${event.clientY}px`; document.body.appendChild(anchorEl); setContextMenuAnchor(anchorEl); setSelText(selectedText); }, [removeContextAnchor]); const closeContextMenu = React.useCallback(() => { // window.getSelection()?.removeAllRanges?.(); removeContextAnchor(); setContextMenuAnchor(null); setSelText(null); }, [removeContextAnchor]); const handleContextMenu = React.useCallback((event: MouseEvent) => { const selection = window.getSelection(); if (selection && selection.rangeCount > 0) { const range = selection.getRangeAt(0); const selectedText = range.toString().trim(); if (selectedText.length > 0) openContextMenu(event, selectedText); } }, [openContextMenu]); // Bubble const closeBubble = React.useCallback((anchorEl?: HTMLElement) => { window.getSelection()?.removeAllRanges?.(); try { const anchor = anchorEl || bubbleAnchor; anchor && document.body.removeChild(anchor); } catch (e) { // ignore... } setBubbleAnchor(null); setSelText(null); }, [bubbleAnchor]); // restore blocksRendererRef const handleOpenBubble = React.useCallback((_event: MouseEvent) => { // check for selection const selection = window.getSelection(); if (!selection || selection.rangeCount <= 0) return; // check for enought selection const selectionText = selection.toString().trim(); if (selectionText.length < BUBBLE_MIN_TEXT_LENGTH) return; // check for the selection being inside the blocks renderer (core of the message) const selectionRange = selection.getRangeAt(0); const blocksElement = blocksRendererRef.current; if (!blocksElement || !blocksElement.contains(selectionRange.commonAncestorContainer)) return; const rangeRects = selectionRange.getClientRects(); if (rangeRects.length <= 0) return; const firstRect = rangeRects[0]; const anchorEl = document.createElement('div'); anchorEl.style.position = 'fixed'; anchorEl.style.left = `${firstRect.left + window.scrollX}px`; anchorEl.style.top = `${firstRect.top + window.scrollY}px`; document.body.appendChild(anchorEl); anchorEl.setAttribute('role', 'dialog'); // auto-close logic on unselect const closeOnUnselect = () => { const selection = window.getSelection(); if (!selection || selection.toString().trim() === '') { closeBubble(anchorEl); document.removeEventListener('selectionchange', closeOnUnselect); } }; document.addEventListener('selectionchange', closeOnUnselect); setBubbleAnchor(anchorEl); setSelText(selectionText); /* TODO: operate on the underlying content, not the rendered text */ }, [closeBubble]); // Blocks renderer const handleBlocksContextMenu = React.useCallback((event: React.MouseEvent) => { handleContextMenu(event.nativeEvent); }, [handleContextMenu]); const handleBlocksDoubleClick = React.useCallback((event: React.MouseEvent) => { doubleClickToEdit && props.onMessageFragmentReplace && handleOpsEditToggle(event); }, [doubleClickToEdit, handleOpsEditToggle, props.onMessageFragmentReplace]); const handleBlocksMouseUp = React.useCallback((event: React.MouseEvent) => { handleOpenBubble(event.nativeEvent); }, [handleOpenBubble]); // style const backgroundColor = messageBackground(messageRole, wasEdited, false /*isAssistantError && !errorMessage*/); // avatar const showAvatarIcon = !props.hideAvatar && !isZenMode; const avatarIconEl: React.JSX.Element | null = React.useMemo( () => showAvatarIcon ? makeMessageAvatarIcon(messageAvatar, messageRole, messageOriginLLM, messagePurposeId, !!messagePendingIncomplete, true) : null, [messageAvatar, messageOriginLLM, messagePendingIncomplete, messagePurposeId, messageRole, showAvatarIcon], ); return ( button': { opacity: 1 }, // layout display: 'block', // this is Needed, otherwise there will be a horizontal overflow ...props.sx, }} // className={messagePendingIncomplete ? 'agi-border-4' /* CSS Effect while in progress */ : undefined} > {/* (Optional) underlayed top decorator */} {props.topDecorator} {/* Message Row: Aside, Fragment[][], Aside2 */} {/* [aside A] Editing: Apply */} {isEditingText && ( {/* */} Done )} {/* [aside B] Avatar (Persona) */} {!props.hideAvatar && !isEditingText && ( {/* Persona Avatar or Menu Button */} { event.shiftKey && console.log(props.message); handleOpsMenuToggle(event); }} onContextMenu={handleOpsMenuToggle} onMouseEnter={props.isMobile ? undefined : () => setIsHovering(true)} onMouseLeave={props.isMobile ? undefined : () => setIsHovering(false)} sx={{ display: 'flex' }} > {!isHovering && !opsMenuAnchor && !isZenMode ? ( avatarIconEl ) : ( )} {/* Assistant (llm/function) name */} {fromAssistant && !isZenMode && ( {prettyBaseModel(messageOriginLLM)} )} )} {/* (many-type) Fragment Classes */} {/* (optional) Message date */} {(props.showBlocksDate === true && !!(messageUpdated || messageCreated)) && ( )} {/* Reply-To Bubble */} {!!messageMetadata?.inReplyToText && ( )} {/* Image Attachment Fragments (just for a prettier display on top of the message) */} {imageAttachments.length >= 1 && !isEditingText && ( )} {/* Content Fragments (iterating all to preserve the index) */} {/* If editing and there's no content, have a button to create a new TextContentFragment */} {isEditingText && !contentFragments.length && ( )} {/* Document Fragments */} {nonImageAttachments.length >= 1 && !isEditingText && ( )} {/* Editing: Cancel */} {isEditingText && ( {/* */} Cancel )} {/* Overlay copy icon */} {ENABLE_COPY_MESSAGE_OVERLAY && !fromSystem && !isEditingText && ( )} {/* Message Operations Menu (3 dots) */} {!!opsMenuAnchor && ( {fromSystem && ( System message )} {/* Edit / Copy */} {/* Edit */} {!!props.onMessageFragmentReplace && ( {isEditingText ? : } {isEditingText ? 'Discard' : 'Edit'} )} {/* Copy */} Copy {/* Starred */} {!!onMessageToggleUserFlag && ( {isUserStarred ? : } )} {/* Delete / Branch / Truncate */} {!!props.onMessageBranch && } {!!props.onMessageBranch && ( Branch {!props.isBottom && from here} )} {!!props.onMessageDelete && ( Delete message )} {!!props.onMessageTruncate && ( Truncate after this )} {/* Diagram / Draw / Speak */} {!!props.onTextDiagram && } {!!props.onTextDiagram && ( Auto-Diagram ... )} {!!props.onTextImagine && ( {props.isImagining ? : } Auto-Draw )} {!!props.onTextSpeak && ( {props.isSpeaking ? : } Speak )} {/* Diff Viewer */} {!!props.diffPreviousText && } {!!props.diffPreviousText && ( Show difference )} {/* Beam/Restart */} {(!!props.onMessageAssistantFrom || !!props.onMessageBeam) && } {!!props.onMessageAssistantFrom && ( {fromAssistant ? : } {!fromAssistant ? <>Restart from here : !props.isBottom ? <>Retry from here : Retry} )} {!!props.onMessageBeam && ( {!fromAssistant ? <>Beam from here : !props.isBottom ? <>Beam this message : Beam} )} )} {/* Bubble */} {ENABLE_BUBBLE && !!bubbleAnchor && ( closeBubble()}> button': { '--Icon-fontSize': '1rem', minHeight: '2.5rem', minWidth: '2.75rem', }, }} > {!!props.onReplyTo && fromAssistant && } {/*{!!props.onMessageBeam && fromAssistant && */} {/* */} {/* */} {/* */} {/*}*/} {!!props.onReplyTo && fromAssistant && } {(!!props.onTextDiagram || !!props.onTextSpeak) && } {!!props.onTextDiagram && } {!!props.onTextImagine && {!props.isImagining ? : } } {!!props.onTextSpeak && {!props.isSpeaking ? : } } )} {/* Context (Right-click) Menu */} {!!contextMenuAnchor && ( Copy {!!props.onTextDiagram && } {!!props.onTextDiagram && Auto-Diagram ... } {!!props.onTextImagine && {props.isImagining ? : } Auto-Draw } {!!props.onTextSpeak && {props.isSpeaking ? : } Speak } )} ); }