import { Fragment, useMemo } from "react"; import { ChatMessage, useChatStore } from "@/app/store/chat"; import { CHAT_PAGE_SIZE } from "@/app/constant"; import Locale from "@/app/locales"; import styles from "./index.module.scss"; import { copyToClipboard, getMessageImages, getMessageTextContent, selectOrCopy, } from "@/app/utils"; import { IconButton } from "@/app/components/button"; import { showPrompt, showToast } from "@/app/components/ui-lib"; import CopyIcon from "@/app/icons/copy.svg"; import ResetIcon from "@/app/icons/reload.svg"; import DeleteIcon from "@/app/icons/clear.svg"; import PinIcon from "@/app/icons/pin.svg"; import EditIcon from "@/app/icons/rename.svg"; import StopIcon from "@/app/icons/pause.svg"; import LoadingIcon from "@/app/icons/three-dots.svg"; import { MultimodalContent } from "@/app/client/api"; import { Avatar } from "@/app/components/emoji"; import { MaskAvatar } from "@/app/components/mask"; import { useAppConfig } from "@/app/store/config"; import ChatAction from "./ChatAction"; import { ChatControllerPool } from "@/app/client/controller"; import ClearContextDivider from "./ClearContextDivider"; import dynamic from "next/dynamic"; export type RenderMessage = ChatMessage & { preview?: boolean }; export interface ChatMessagePanelProps { scrollRef: React.RefObject; inputRef: React.RefObject; isMobileScreen: boolean; msgRenderIndex: number; userInput: string; context: any[]; renderMessages: RenderMessage[]; setAutoScroll?: (value: boolean) => void; setMsgRenderIndex?: (newIndex: number) => void; setHitBottom?: (value: boolean) => void; setUserInput?: (v: string) => void; setIsLoading?: (value: boolean) => void; setShowPromptModal?: (value: boolean) => void; } const Markdown = dynamic( async () => (await import("@/app/components/markdown")).Markdown, { loading: () => , }, ); export default function ChatMessagePanel(props: ChatMessagePanelProps) { const { scrollRef, inputRef, setAutoScroll, setMsgRenderIndex, isMobileScreen, msgRenderIndex, setHitBottom, setUserInput, userInput, context, renderMessages, setIsLoading, setShowPromptModal, } = props; const chatStore = useChatStore(); const session = chatStore.currentSession(); const config = useAppConfig(); const fontSize = config.fontSize; const onChatBodyScroll = (e: HTMLElement) => { const bottomHeight = e.scrollTop + e.clientHeight; const edgeThreshold = e.clientHeight; const isTouchTopEdge = e.scrollTop <= edgeThreshold; const isTouchBottomEdge = bottomHeight >= e.scrollHeight - edgeThreshold; const isHitBottom = bottomHeight >= e.scrollHeight - (isMobileScreen ? 4 : 10); const prevPageMsgIndex = msgRenderIndex - CHAT_PAGE_SIZE; const nextPageMsgIndex = msgRenderIndex + CHAT_PAGE_SIZE; if (isTouchTopEdge && !isTouchBottomEdge) { setMsgRenderIndex?.(prevPageMsgIndex); } else if (isTouchBottomEdge) { setMsgRenderIndex?.(nextPageMsgIndex); } setHitBottom?.(isHitBottom); setAutoScroll?.(isHitBottom); }; const onRightClick = (e: any, message: ChatMessage) => { // copy to clipboard if (selectOrCopy(e.currentTarget, getMessageTextContent(message))) { if (userInput.length === 0) { setUserInput?.(getMessageTextContent(message)); } e.preventDefault(); } }; const deleteMessage = (msgId?: string) => { chatStore.updateCurrentSession( (session) => (session.messages = session.messages.filter((m) => m.id !== msgId)), ); }; const onDelete = (msgId: string) => { deleteMessage(msgId); }; const onResend = (message: ChatMessage) => { // when it is resending a message // 1. for a user's message, find the next bot response // 2. for a bot's message, find the last user's input // 3. delete original user input and bot's message // 4. resend the user's input const resendingIndex = session.messages.findIndex( (m) => m.id === message.id, ); if (resendingIndex < 0 || resendingIndex >= session.messages.length) { console.error("[Chat] failed to find resending message", message); return; } let userMessage: ChatMessage | undefined; let botMessage: ChatMessage | undefined; if (message.role === "assistant") { // if it is resending a bot's message, find the user input for it botMessage = message; for (let i = resendingIndex; i >= 0; i -= 1) { if (session.messages[i].role === "user") { userMessage = session.messages[i]; break; } } } else if (message.role === "user") { // if it is resending a user's input, find the bot's response userMessage = message; for (let i = resendingIndex; i < session.messages.length; i += 1) { if (session.messages[i].role === "assistant") { botMessage = session.messages[i]; break; } } } if (userMessage === undefined) { console.error("[Chat] failed to resend", message); return; } // delete the original messages deleteMessage(userMessage.id); deleteMessage(botMessage?.id); // resend the message setIsLoading?.(true); const textContent = getMessageTextContent(userMessage); const images = getMessageImages(userMessage); chatStore .onUserInput(textContent, images) .then(() => setIsLoading?.(false)); inputRef.current?.focus(); }; const onPinMessage = (message: ChatMessage) => { chatStore.updateCurrentSession((session) => session.mask.context.push(message), ); showToast(Locale.Chat.Actions.PinToastContent, { text: Locale.Chat.Actions.PinToastAction, onClick: () => { setShowPromptModal?.(true); }, }); }; // clear context index = context length + index in messages const clearContextIndex = (session.clearContextIndex ?? -1) >= 0 ? session.clearContextIndex! + context.length - msgRenderIndex : -1; const messages = useMemo(() => { const endRenderIndex = Math.min( msgRenderIndex + 3 * CHAT_PAGE_SIZE, renderMessages.length, ); return renderMessages.slice(msgRenderIndex, endRenderIndex); }, [msgRenderIndex, renderMessages]); // stop response const onUserStop = (messageId: string) => { ChatControllerPool.stop(session.id, messageId); }; return (
onChatBodyScroll(e.currentTarget)} onMouseDown={() => inputRef.current?.blur()} onTouchStart={() => { inputRef.current?.blur(); setAutoScroll?.(false); }} > {messages.map((message, i) => { const isUser = message.role === "user"; const isContext = i < context.length; const showActions = i > 0 && !(message.preview || message.content.length === 0) && !isContext; const showTyping = message.preview || message.streaming; const shouldShowClearContextDivider = i === clearContextIndex - 1; return (
} onClick={async () => { const newMessage = await showPrompt( Locale.Chat.Actions.Edit, getMessageTextContent(message), 10, ); let newContent: string | MultimodalContent[] = newMessage; const images = getMessageImages(message); if (images.length > 0) { newContent = [{ type: "text", text: newMessage }]; for (let i = 0; i < images.length; i++) { newContent.push({ type: "image_url", image_url: { url: images[i], }, }); } } chatStore.updateCurrentSession((session) => { const m = session.mask.context .concat(session.messages) .find((m) => m.id === message.id); if (m) { m.content = newContent; } }); }} >
{isUser ? ( ) : ( <> {["system"].includes(message.role) ? ( ) : ( )} )}
{showActions && (
{message.streaming ? ( } onClick={() => onUserStop(message.id ?? i)} /> ) : ( <> } onClick={() => onResend(message)} /> } onClick={() => onDelete(message.id ?? i)} /> } onClick={() => onPinMessage(message)} /> } onClick={() => copyToClipboard(getMessageTextContent(message)) } /> )}
)}
{showTyping && (
{Locale.Chat.Typing}
)}
onRightClick(e, message)} onDoubleClickCapture={() => { if (!isMobileScreen) return; setUserInput?.(getMessageTextContent(message)); }} fontSize={fontSize} parentRef={scrollRef} defaultShow={i >= messages.length - 6} /> {getMessageImages(message).length == 1 && ( )} {getMessageImages(message).length > 1 && (
{getMessageImages(message).map((image, index) => { return ( ); })}
)}
{isContext ? Locale.Chat.IsContext : message.date.toLocaleString()}
{shouldShowClearContextDivider && }
); })}
); }