Spaces:
Build error
Build error
| /* | |
| Copyright (C) 2025 QuantumNous | |
| This program is free software: you can redistribute it and/or modify | |
| it under the terms of the GNU Affero General Public License as | |
| published by the Free Software Foundation, either version 3 of the | |
| License, or (at your option) any later version. | |
| This program is distributed in the hope that it will be useful, | |
| but WITHOUT ANY WARRANTY; without even the implied warranty of | |
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
| GNU Affero General Public License for more details. | |
| You should have received a copy of the GNU Affero General Public License | |
| along with this program. If not, see <https://www.gnu.org/licenses/>. | |
| For commercial licensing, please contact support@quantumnous.com | |
| */ | |
| import React, { useRef, useEffect } from 'react'; | |
| import { Typography, TextArea, Button } from '@douyinfe/semi-ui'; | |
| import MarkdownRenderer from '../common/markdown/MarkdownRenderer'; | |
| import ThinkingContent from './ThinkingContent'; | |
| import { Loader2, Check, X } from 'lucide-react'; | |
| import { useTranslation } from 'react-i18next'; | |
| const MessageContent = ({ | |
| message, | |
| className, | |
| styleState, | |
| onToggleReasoningExpansion, | |
| isEditing = false, | |
| onEditSave, | |
| onEditCancel, | |
| editValue, | |
| onEditValueChange, | |
| }) => { | |
| const { t } = useTranslation(); | |
| const previousContentLengthRef = useRef(0); | |
| const lastContentRef = useRef(''); | |
| const isThinkingStatus = | |
| message.status === 'loading' || message.status === 'incomplete'; | |
| useEffect(() => { | |
| if (!isThinkingStatus) { | |
| previousContentLengthRef.current = 0; | |
| lastContentRef.current = ''; | |
| } | |
| }, [isThinkingStatus]); | |
| if (message.status === 'error') { | |
| let errorText; | |
| if (Array.isArray(message.content)) { | |
| const textContent = message.content.find((item) => item.type === 'text'); | |
| errorText = | |
| textContent && textContent.text && typeof textContent.text === 'string' | |
| ? textContent.text | |
| : t('请求发生错误'); | |
| } else if (typeof message.content === 'string') { | |
| errorText = message.content; | |
| } else { | |
| errorText = t('请求发生错误'); | |
| } | |
| return ( | |
| <div className={`${className}`}> | |
| <Typography.Text className='text-white'>{errorText}</Typography.Text> | |
| </div> | |
| ); | |
| } | |
| let currentExtractedThinkingContent = null; | |
| let currentDisplayableFinalContent = ''; | |
| let thinkingSource = null; | |
| const getTextContent = (content) => { | |
| if (Array.isArray(content)) { | |
| const textItem = content.find((item) => item.type === 'text'); | |
| return textItem && textItem.text && typeof textItem.text === 'string' | |
| ? textItem.text | |
| : ''; | |
| } else if (typeof content === 'string') { | |
| return content; | |
| } | |
| return ''; | |
| }; | |
| currentDisplayableFinalContent = getTextContent(message.content); | |
| if (message.role === 'assistant') { | |
| let baseContentForDisplay = getTextContent(message.content); | |
| let combinedThinkingContent = ''; | |
| if (message.reasoningContent) { | |
| combinedThinkingContent = message.reasoningContent; | |
| thinkingSource = 'reasoningContent'; | |
| } | |
| if (baseContentForDisplay.includes('<think>')) { | |
| const thinkTagRegex = /<think>([\s\S]*?)<\/think>/g; | |
| let match; | |
| let thoughtsFromPairedTags = []; | |
| let replyParts = []; | |
| let lastIndex = 0; | |
| while ((match = thinkTagRegex.exec(baseContentForDisplay)) !== null) { | |
| replyParts.push( | |
| baseContentForDisplay.substring(lastIndex, match.index), | |
| ); | |
| thoughtsFromPairedTags.push(match[1]); | |
| lastIndex = match.index + match[0].length; | |
| } | |
| replyParts.push(baseContentForDisplay.substring(lastIndex)); | |
| if (thoughtsFromPairedTags.length > 0) { | |
| const pairedThoughtsStr = thoughtsFromPairedTags.join('\n\n---\n\n'); | |
| if (combinedThinkingContent) { | |
| combinedThinkingContent += '\n\n---\n\n' + pairedThoughtsStr; | |
| } else { | |
| combinedThinkingContent = pairedThoughtsStr; | |
| } | |
| thinkingSource = thinkingSource | |
| ? thinkingSource + ' & <think> tags' | |
| : '<think> tags'; | |
| } | |
| baseContentForDisplay = replyParts.join(''); | |
| } | |
| if (isThinkingStatus) { | |
| const lastOpenThinkIndex = baseContentForDisplay.lastIndexOf('<think>'); | |
| if (lastOpenThinkIndex !== -1) { | |
| const fragmentAfterLastOpen = | |
| baseContentForDisplay.substring(lastOpenThinkIndex); | |
| if (!fragmentAfterLastOpen.includes('</think>')) { | |
| const unclosedThought = fragmentAfterLastOpen | |
| .substring('<think>'.length) | |
| .trim(); | |
| if (unclosedThought) { | |
| if (combinedThinkingContent) { | |
| combinedThinkingContent += '\n\n---\n\n' + unclosedThought; | |
| } else { | |
| combinedThinkingContent = unclosedThought; | |
| } | |
| thinkingSource = thinkingSource | |
| ? thinkingSource + ' + streaming <think>' | |
| : 'streaming <think>'; | |
| } | |
| baseContentForDisplay = baseContentForDisplay.substring( | |
| 0, | |
| lastOpenThinkIndex, | |
| ); | |
| } | |
| } | |
| } | |
| currentExtractedThinkingContent = combinedThinkingContent || null; | |
| currentDisplayableFinalContent = baseContentForDisplay | |
| .replace(/<\/?think>/g, '') | |
| .trim(); | |
| } | |
| const finalExtractedThinkingContent = currentExtractedThinkingContent; | |
| const finalDisplayableFinalContent = currentDisplayableFinalContent; | |
| if ( | |
| message.role === 'assistant' && | |
| isThinkingStatus && | |
| !finalExtractedThinkingContent && | |
| (!finalDisplayableFinalContent || | |
| finalDisplayableFinalContent.trim() === '') | |
| ) { | |
| return ( | |
| <div | |
| className={`${className} flex items-center gap-2 sm:gap-4 bg-gradient-to-r from-purple-50 to-indigo-50`} | |
| > | |
| <div className='w-5 h-5 rounded-full bg-gradient-to-br from-purple-500 to-indigo-600 flex items-center justify-center shadow-lg'> | |
| <Loader2 | |
| className='animate-spin text-white' | |
| size={styleState.isMobile ? 16 : 20} | |
| /> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div className={className}> | |
| {message.role === 'system' && ( | |
| <div className='mb-2 sm:mb-4'> | |
| <div | |
| className='flex items-center gap-2 p-2 sm:p-3 bg-gradient-to-r from-amber-50 to-orange-50 rounded-lg' | |
| style={{ border: '1px solid var(--semi-color-border)' }} | |
| > | |
| <div className='w-4 h-4 sm:w-5 sm:h-5 rounded-full bg-gradient-to-br from-amber-500 to-orange-600 flex items-center justify-center shadow-sm'> | |
| <Typography.Text className='text-white text-xs font-bold'> | |
| S | |
| </Typography.Text> | |
| </div> | |
| <Typography.Text className='text-amber-700 text-xs sm:text-sm font-medium'> | |
| {t('系统消息')} | |
| </Typography.Text> | |
| </div> | |
| </div> | |
| )} | |
| {message.role === 'assistant' && ( | |
| <ThinkingContent | |
| message={message} | |
| finalExtractedThinkingContent={finalExtractedThinkingContent} | |
| thinkingSource={thinkingSource} | |
| styleState={styleState} | |
| onToggleReasoningExpansion={onToggleReasoningExpansion} | |
| /> | |
| )} | |
| {isEditing ? ( | |
| <div className='space-y-3'> | |
| <TextArea | |
| value={editValue} | |
| onChange={(value) => onEditValueChange(value)} | |
| placeholder={t('请输入消息内容...')} | |
| autosize={{ minRows: 3, maxRows: 12 }} | |
| style={{ | |
| resize: 'vertical', | |
| fontSize: styleState.isMobile ? '14px' : '15px', | |
| lineHeight: '1.6', | |
| }} | |
| className='!border-blue-200 focus:!border-blue-400 !bg-blue-50/50' | |
| /> | |
| <div className='flex items-center gap-2 w-full'> | |
| <Button | |
| size='small' | |
| type='danger' | |
| theme='light' | |
| icon={<X size={14} />} | |
| onClick={onEditCancel} | |
| className='flex-1' | |
| > | |
| {t('取消')} | |
| </Button> | |
| <Button | |
| size='small' | |
| type='warning' | |
| theme='solid' | |
| icon={<Check size={14} />} | |
| onClick={onEditSave} | |
| disabled={!editValue || editValue.trim() === ''} | |
| className='flex-1' | |
| > | |
| {t('保存')} | |
| </Button> | |
| </div> | |
| </div> | |
| ) : ( | |
| (() => { | |
| if (Array.isArray(message.content)) { | |
| const textContent = message.content.find( | |
| (item) => item.type === 'text', | |
| ); | |
| const imageContents = message.content.filter( | |
| (item) => item.type === 'image_url', | |
| ); | |
| return ( | |
| <div> | |
| {imageContents.length > 0 && ( | |
| <div className='mb-3 space-y-2'> | |
| {imageContents.map((imgItem, index) => ( | |
| <div key={index} className='max-w-sm'> | |
| <img | |
| src={imgItem.image_url.url} | |
| alt={`用户上传的图片 ${index + 1}`} | |
| className='rounded-lg max-w-full h-auto shadow-sm border' | |
| style={{ maxHeight: '300px' }} | |
| onError={(e) => { | |
| e.target.style.display = 'none'; | |
| e.target.nextSibling.style.display = 'block'; | |
| }} | |
| /> | |
| <div | |
| className='text-red-500 text-sm p-2 bg-red-50 rounded-lg border border-red-200' | |
| style={{ display: 'none' }} | |
| > | |
| 图片加载失败: {imgItem.image_url.url} | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| {textContent && | |
| textContent.text && | |
| typeof textContent.text === 'string' && | |
| textContent.text.trim() !== '' && ( | |
| <div | |
| className={`prose prose-xs sm:prose-sm prose-gray max-w-none overflow-x-auto text-xs sm:text-sm ${message.role === 'user' ? 'user-message' : ''}`} | |
| > | |
| <MarkdownRenderer | |
| content={textContent.text} | |
| className={ | |
| message.role === 'user' ? 'user-message' : '' | |
| } | |
| animated={false} | |
| previousContentLength={0} | |
| /> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| if (typeof message.content === 'string') { | |
| if (message.role === 'assistant') { | |
| if ( | |
| finalDisplayableFinalContent && | |
| finalDisplayableFinalContent.trim() !== '' | |
| ) { | |
| // 获取上一次的内容长度 | |
| let prevLength = 0; | |
| if (isThinkingStatus && lastContentRef.current) { | |
| // 只有当前内容包含上一次内容时,才使用上一次的长度 | |
| if ( | |
| finalDisplayableFinalContent.startsWith( | |
| lastContentRef.current, | |
| ) | |
| ) { | |
| prevLength = lastContentRef.current.length; | |
| } | |
| } | |
| // 更新最后内容的引用 | |
| if (isThinkingStatus) { | |
| lastContentRef.current = finalDisplayableFinalContent; | |
| } | |
| return ( | |
| <div className='prose prose-xs sm:prose-sm prose-gray max-w-none overflow-x-auto text-xs sm:text-sm'> | |
| <MarkdownRenderer | |
| content={finalDisplayableFinalContent} | |
| className='' | |
| animated={isThinkingStatus} | |
| previousContentLength={prevLength} | |
| /> | |
| </div> | |
| ); | |
| } | |
| } else { | |
| return ( | |
| <div | |
| className={`prose prose-xs sm:prose-sm prose-gray max-w-none overflow-x-auto text-xs sm:text-sm ${message.role === 'user' ? 'user-message' : ''}`} | |
| > | |
| <MarkdownRenderer | |
| content={message.content} | |
| className={message.role === 'user' ? 'user-message' : ''} | |
| animated={false} | |
| previousContentLength={0} | |
| /> | |
| </div> | |
| ); | |
| } | |
| } | |
| return null; | |
| })() | |
| )} | |
| </div> | |
| ); | |
| }; | |
| export default MessageContent; | |