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