|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
|