import * as React from 'react'; import Prism from 'prismjs'; import 'prismjs/themes/prism.css'; import 'prismjs/components/prism-bash'; import 'prismjs/components/prism-java'; import 'prismjs/components/prism-javascript'; import 'prismjs/components/prism-json'; import 'prismjs/components/prism-markdown'; import 'prismjs/components/prism-python'; import 'prismjs/components/prism-typescript'; import { Alert, Avatar, Box, Button, IconButton, ListDivider, ListItem, ListItemDecorator, Menu, MenuItem, Stack, Textarea, Tooltip, Typography, useTheme } from '@mui/joy'; import { SxProps, Theme } from '@mui/joy/styles/types'; import ClearIcon from '@mui/icons-material/Clear'; import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import EditIcon from '@mui/icons-material/Edit'; import Face6Icon from '@mui/icons-material/Face6'; import FastForwardIcon from '@mui/icons-material/FastForward'; import MoreVertIcon from '@mui/icons-material/MoreVert'; import SettingsSuggestIcon from '@mui/icons-material/SettingsSuggest'; import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined'; import { DMessage } from '@/lib/store-chats'; import { Link } from './util/Link'; import { cssRainbowColorKeyframes, foolsMode } from '@/lib/theme'; import { prettyBaseModel } from '@/lib/export-conversation'; /// Utilities to parse messages into blocks of text and code type Block = TextBlock | CodeBlock; type TextBlock = { type: 'text'; content: string; }; type CodeBlock = { type: 'code'; content: string; language: string | null; complete: boolean; code: string; }; const inferCodeLanguage = (markdownLanguage: string, code: string): string | null => { // we have an hint if (markdownLanguage) { // no dot: assume is the syntax-highlight name if (!markdownLanguage.includes('.')) return markdownLanguage; // dot: there's probably a file extension const extension = markdownLanguage.split('.').pop(); if (extension) { const languageMap: { [key: string]: string } = { cs: 'csharp', html: 'html', java: 'java', js: 'javascript', json: 'json', jsx: 'javascript', md: 'markdown', py: 'python', sh: 'bash', ts: 'typescript', tsx: 'typescript', xml: 'xml', }; const language = languageMap[extension]; if (language) return language; } } // based on how the code starts, return the language if (code.startsWith(' { if (forceText) return [{ type: 'text', content: text }]; const codeBlockRegex = /`{3,}([\w\\.+]+)?\n([\s\S]*?)(`{3,}|$)/g; const result: Block[] = []; let lastIndex = 0; let match; while ((match = codeBlockRegex.exec(text)) !== null) { const markdownLanguage = (match[1] || '').trim(); const code = match[2].trim(); const blockEnd: string = match[3]; // Load the specified language if it's not loaded yet // NOTE: this is commented out because it inflates the size of the bundle by 200k // if (!Prism.languages[language]) { // try { // require(`prismjs/components/prism-${language}`); // } catch (e) { // console.warn(`Prism language '${language}' not found, falling back to 'typescript'`); // } // } const codeLanguage = inferCodeLanguage(markdownLanguage, code); const highlightLanguage = codeLanguage || 'typescript'; const highlightedCode = Prism.highlight( code, Prism.languages[highlightLanguage] || Prism.languages.typescript, highlightLanguage, ); result.push({ type: 'text', content: text.slice(lastIndex, match.index) }); result.push({ type: 'code', content: highlightedCode, language: codeLanguage, complete: blockEnd.startsWith('```'), code }); lastIndex = match.index + match[0].length; } if (lastIndex < text.length) { result.push({ type: 'text', content: text.slice(lastIndex) }); } return result; }; /// Renderers for the different types of message blocks function RenderCode({ codeBlock, theme, sx }: { codeBlock: CodeBlock, theme: Theme, sx?: SxProps }) { const handleCopyToClipboard = () => copyToClipboard(codeBlock.code); return button': { opacity: 1 }, }}> ; } const RenderText = ({ textBlock, onDoubleClick, sx }: { textBlock: TextBlock, onDoubleClick: (e: React.MouseEvent) => void, sx?: SxProps }) => {textBlock.content} ; function copyToClipboard(text: string) { if (typeof navigator !== 'undefined') navigator.clipboard.writeText(text) .then(() => console.log('Message copied to clipboard')) .catch((err) => console.error('Failed to copy message: ', err)); } function explainErrorInMessage(text: string, isAssistant: boolean, modelId?: string) { let errorMessage: JSX.Element | null = null; const isAssistantError = isAssistant && (text.startsWith('Error: ') || text.startsWith('OpenAI API error: ')); if (isAssistantError) { if (text.startsWith('OpenAI API error: 429 Too Many Requests')) { // TODO: retry at the api/chat level a few times instead of showing this error errorMessage = <> The model appears to be occupied at the moment. Kindly select GPT-3.5 Turbo, or give it another go by selecting Run again from the message menu. ; } else if (text.includes('"model_not_found"')) { // note that "model_not_found" is different than "The model `gpt-xyz` does not exist" message errorMessage = <> Your API key appears to be unauthorized for {modelId || 'this model'}. You can change to GPT-3.5 Turbo and simultaneously request access to the desired model. ; } else if (text.includes('"context_length_exceeded"')) { // TODO: propose to summarize or split the input? const pattern: RegExp = /maximum context length is (\d+) tokens.+resulted in (\d+) tokens/; const match = pattern.exec(text); const usedText = match ? ` (${match[2]} tokens, max ${match[1]})` : ''; errorMessage = <> This thread surpasses the maximum size allowed for {modelId || 'this model'}{usedText}. Please consider removing some earlier messages from the conversation, start a new conversation, choose a model with larger context, or submit a shorter new message. ; } else if (text.includes('"invalid_api_key"')) { errorMessage = <> The API key appears to not be correct or to have expired. Please check your API key and update it in the Settings menu. ; } } return { errorMessage, isAssistantError }; } /** * 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, disableSend: boolean, onDelete: () => void, onEdit: (text: string) => void, onRunAgain: () => void }) { const theme = useTheme(); const { text: messageText, sender: messageSender, avatar: messageAvatar, typing: messageTyping, role: messageRole, modelId: messageModelId, // purposeId: messagePurposeId, updated: messageUpdated, } = props.message; const fromAssistant = messageRole === 'assistant'; const fromSystem = messageRole === 'system'; const fromUser = messageRole === 'user'; const wasEdited = !!messageUpdated; // viewing const [forceExpanded, setForceExpanded] = React.useState(false); // editing const [isHovering, setIsHovering] = React.useState(false); const [menuAnchor, setMenuAnchor] = React.useState(null); const [isEditing, setIsEditing] = React.useState(false); const [editedText, setEditedText] = React.useState(''); const closeOperationsMenu = () => setMenuAnchor(null); const handleMenuCopy = (e: React.MouseEvent) => { copyToClipboard(messageText); e.preventDefault(); closeOperationsMenu(); }; const handleMenuEdit = (e: React.MouseEvent) => { if (!isEditing) setEditedText(messageText); setIsEditing(!isEditing); e.preventDefault(); closeOperationsMenu(); }; const handleMenuRunAgain = (e: React.MouseEvent) => { if (!props.disableSend) { props.onRunAgain(); e.preventDefault(); closeOperationsMenu(); } }; const handleEditTextChanged = (e: React.ChangeEvent) => setEditedText(e.target.value); const handleEditKeyPressed = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey && !e.altKey) { e.preventDefault(); setIsEditing(false); props.onEdit(editedText); } }; const handleEditBlur = () => { setIsEditing(false); if (editedText !== messageText && editedText?.trim()) props.onEdit(editedText); }; const handleExpand = () => setForceExpanded(true); // soft error handling const { isAssistantError, errorMessage } = explainErrorInMessage(messageText, fromAssistant, messageModelId); // theming let background = theme.vars.palette.background.body; let textBackground: string | undefined = undefined; switch (messageRole) { case 'system': // background = theme.vars.palette.background.body; // textBackground = wasEdited ? theme.vars.palette.warning.plainHoverBg : theme.vars.palette.neutral.plainHoverBg; background = wasEdited ? theme.vars.palette.warning.plainHoverBg : theme.vars.palette.background.popup; break; case 'user': background = theme.vars.palette.primary.plainHoverBg; break; case 'assistant': background = (isAssistantError && !errorMessage) ? theme.vars.palette.danger.softBg : theme.vars.palette.background.body; break; } // avatar const avatarEl: JSX.Element = React.useMemo( () => { if (typeof messageAvatar === 'string' && messageAvatar) return ; switch (messageRole) { case 'system': return ; // https://em-content.zobj.net/thumbs/120/apple/325/robot_1f916.png case 'assistant': // display a gif avatar when the assistant is typing (fools mode) if (foolsMode && messageTyping) return ; return ; // https://mui.com/static/images/avatar/2.jpg case 'user': return ; // https://www.svgrepo.com/show/306500/openai.svg } return ; }, [messageAvatar, messageRole, messageSender, messageTyping], ); // text box css const chatFontCss = { my: 'auto', fontFamily: fromAssistant ? theme.fontFamily.code : theme.fontFamily.body, fontSize: fromAssistant ? '14px' : '16px', lineHeight: 1.75, }; // user message truncation let collapsedText = messageText; let isCollapsed = false; if (fromUser && !forceExpanded) { const lines = messageText.split('\n'); if (lines.length > 10) { collapsedText = lines.slice(0, 10).join('\n'); isCollapsed = true; } } return ( button': { opacity: 1 }, }}> {/* Author */} setIsHovering(true)} onMouseLeave={() => setIsHovering(false)} onClick={event => setMenuAnchor(event.currentTarget)}> {isHovering ? ( ) : ( avatarEl )} {fromAssistant && ( {prettyBaseModel(messageModelId)} )} {/* Edit / Blocks */} {!isEditing ? ( {fromSystem && wasEdited && modified by user - auto-update disabled} {parseBlocks(fromSystem, collapsedText).map((block, index) => block.type === 'code' ? : , )} {errorMessage && {errorMessage}} {isCollapsed && } ) : (