MessageContent.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  1. /*
  2. Copyright (C) 2025 QuantumNous
  3. This program is free software: you can redistribute it and/or modify
  4. it under the terms of the GNU Affero General Public License as
  5. published by the Free Software Foundation, either version 3 of the
  6. License, or (at your option) any later version.
  7. This program is distributed in the hope that it will be useful,
  8. but WITHOUT ANY WARRANTY; without even the implied warranty of
  9. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  10. GNU Affero General Public License for more details.
  11. You should have received a copy of the GNU Affero General Public License
  12. along with this program. If not, see <https://www.gnu.org/licenses/>.
  13. For commercial licensing, please contact support@quantumnous.com
  14. */
  15. import React, { useRef, useEffect } from 'react';
  16. import {
  17. Typography,
  18. TextArea,
  19. Button,
  20. } from '@douyinfe/semi-ui';
  21. import MarkdownRenderer from '../common/markdown/MarkdownRenderer';
  22. import ThinkingContent from './ThinkingContent';
  23. import {
  24. Loader2,
  25. Check,
  26. X,
  27. } from 'lucide-react';
  28. import { useTranslation } from 'react-i18next';
  29. const MessageContent = ({
  30. message,
  31. className,
  32. styleState,
  33. onToggleReasoningExpansion,
  34. isEditing = false,
  35. onEditSave,
  36. onEditCancel,
  37. editValue,
  38. onEditValueChange
  39. }) => {
  40. const { t } = useTranslation();
  41. const previousContentLengthRef = useRef(0);
  42. const lastContentRef = useRef('');
  43. const isThinkingStatus = message.status === 'loading' || message.status === 'incomplete';
  44. useEffect(() => {
  45. if (!isThinkingStatus) {
  46. previousContentLengthRef.current = 0;
  47. lastContentRef.current = '';
  48. }
  49. }, [isThinkingStatus]);
  50. if (message.status === 'error') {
  51. let errorText;
  52. if (Array.isArray(message.content)) {
  53. const textContent = message.content.find(item => item.type === 'text');
  54. errorText = textContent && textContent.text && typeof textContent.text === 'string'
  55. ? textContent.text
  56. : t('请求发生错误');
  57. } else if (typeof message.content === 'string') {
  58. errorText = message.content;
  59. } else {
  60. errorText = t('请求发生错误');
  61. }
  62. return (
  63. <div className={`${className} flex items-center p-4 bg-red-50 rounded-xl`}>
  64. <Typography.Text type="danger" className="text-sm">
  65. {errorText}
  66. </Typography.Text>
  67. </div>
  68. );
  69. }
  70. let currentExtractedThinkingContent = null;
  71. let currentDisplayableFinalContent = "";
  72. let thinkingSource = null;
  73. const getTextContent = (content) => {
  74. if (Array.isArray(content)) {
  75. const textItem = content.find(item => item.type === 'text');
  76. return textItem && textItem.text && typeof textItem.text === 'string' ? textItem.text : '';
  77. } else if (typeof content === 'string') {
  78. return content;
  79. }
  80. return '';
  81. };
  82. currentDisplayableFinalContent = getTextContent(message.content);
  83. if (message.role === 'assistant') {
  84. let baseContentForDisplay = getTextContent(message.content);
  85. let combinedThinkingContent = "";
  86. if (message.reasoningContent) {
  87. combinedThinkingContent = message.reasoningContent;
  88. thinkingSource = 'reasoningContent';
  89. }
  90. if (baseContentForDisplay.includes('<think>')) {
  91. const thinkTagRegex = /<think>([\s\S]*?)<\/think>/g;
  92. let match;
  93. let thoughtsFromPairedTags = [];
  94. let replyParts = [];
  95. let lastIndex = 0;
  96. while ((match = thinkTagRegex.exec(baseContentForDisplay)) !== null) {
  97. replyParts.push(baseContentForDisplay.substring(lastIndex, match.index));
  98. thoughtsFromPairedTags.push(match[1]);
  99. lastIndex = match.index + match[0].length;
  100. }
  101. replyParts.push(baseContentForDisplay.substring(lastIndex));
  102. if (thoughtsFromPairedTags.length > 0) {
  103. const pairedThoughtsStr = thoughtsFromPairedTags.join('\n\n---\n\n');
  104. if (combinedThinkingContent) {
  105. combinedThinkingContent += '\n\n---\n\n' + pairedThoughtsStr;
  106. } else {
  107. combinedThinkingContent = pairedThoughtsStr;
  108. }
  109. thinkingSource = thinkingSource ? thinkingSource + ' & <think> tags' : '<think> tags';
  110. }
  111. baseContentForDisplay = replyParts.join('');
  112. }
  113. if (isThinkingStatus) {
  114. const lastOpenThinkIndex = baseContentForDisplay.lastIndexOf('<think>');
  115. if (lastOpenThinkIndex !== -1) {
  116. const fragmentAfterLastOpen = baseContentForDisplay.substring(lastOpenThinkIndex);
  117. if (!fragmentAfterLastOpen.includes('</think>')) {
  118. const unclosedThought = fragmentAfterLastOpen.substring('<think>'.length).trim();
  119. if (unclosedThought) {
  120. if (combinedThinkingContent) {
  121. combinedThinkingContent += '\n\n---\n\n' + unclosedThought;
  122. } else {
  123. combinedThinkingContent = unclosedThought;
  124. }
  125. thinkingSource = thinkingSource ? thinkingSource + ' + streaming <think>' : 'streaming <think>';
  126. }
  127. baseContentForDisplay = baseContentForDisplay.substring(0, lastOpenThinkIndex);
  128. }
  129. }
  130. }
  131. currentExtractedThinkingContent = combinedThinkingContent || null;
  132. currentDisplayableFinalContent = baseContentForDisplay.replace(/<\/?think>/g, '').trim();
  133. }
  134. const finalExtractedThinkingContent = currentExtractedThinkingContent;
  135. const finalDisplayableFinalContent = currentDisplayableFinalContent;
  136. if (message.role === 'assistant' &&
  137. isThinkingStatus &&
  138. !finalExtractedThinkingContent &&
  139. (!finalDisplayableFinalContent || finalDisplayableFinalContent.trim() === '')) {
  140. return (
  141. <div className={`${className} flex items-center gap-2 sm:gap-4 bg-gradient-to-r from-purple-50 to-indigo-50`}>
  142. <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">
  143. <Loader2 className="animate-spin text-white" size={styleState.isMobile ? 16 : 20} />
  144. </div>
  145. </div>
  146. );
  147. }
  148. return (
  149. <div className={className}>
  150. {message.role === 'system' && (
  151. <div className="mb-2 sm:mb-4">
  152. <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)' }}>
  153. <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">
  154. <Typography.Text className="text-white text-xs font-bold">S</Typography.Text>
  155. </div>
  156. <Typography.Text className="text-amber-700 text-xs sm:text-sm font-medium">
  157. {t('系统消息')}
  158. </Typography.Text>
  159. </div>
  160. </div>
  161. )}
  162. {message.role === 'assistant' && (
  163. <ThinkingContent
  164. message={message}
  165. finalExtractedThinkingContent={finalExtractedThinkingContent}
  166. thinkingSource={thinkingSource}
  167. styleState={styleState}
  168. onToggleReasoningExpansion={onToggleReasoningExpansion}
  169. />
  170. )}
  171. {isEditing ? (
  172. <div className="space-y-3">
  173. <TextArea
  174. value={editValue}
  175. onChange={(value) => onEditValueChange(value)}
  176. placeholder={t('请输入消息内容...')}
  177. autosize={{ minRows: 3, maxRows: 12 }}
  178. style={{
  179. resize: 'vertical',
  180. fontSize: styleState.isMobile ? '14px' : '15px',
  181. lineHeight: '1.6',
  182. }}
  183. className="!border-blue-200 focus:!border-blue-400 !bg-blue-50/50"
  184. />
  185. <div className="flex items-center gap-2 w-full">
  186. <Button
  187. size="small"
  188. type="danger"
  189. theme="light"
  190. icon={<X size={14} />}
  191. onClick={onEditCancel}
  192. className="flex-1"
  193. >
  194. {t('取消')}
  195. </Button>
  196. <Button
  197. size="small"
  198. type="warning"
  199. theme="solid"
  200. icon={<Check size={14} />}
  201. onClick={onEditSave}
  202. disabled={!editValue || editValue.trim() === ''}
  203. className="flex-1"
  204. >
  205. {t('保存')}
  206. </Button>
  207. </div>
  208. </div>
  209. ) : (
  210. (() => {
  211. if (Array.isArray(message.content)) {
  212. const textContent = message.content.find(item => item.type === 'text');
  213. const imageContents = message.content.filter(item => item.type === 'image_url');
  214. return (
  215. <div>
  216. {imageContents.length > 0 && (
  217. <div className="mb-3 space-y-2">
  218. {imageContents.map((imgItem, index) => (
  219. <div key={index} className="max-w-sm">
  220. <img
  221. src={imgItem.image_url.url}
  222. alt={`用户上传的图片 ${index + 1}`}
  223. className="rounded-lg max-w-full h-auto shadow-sm border"
  224. style={{ maxHeight: '300px' }}
  225. onError={(e) => {
  226. e.target.style.display = 'none';
  227. e.target.nextSibling.style.display = 'block';
  228. }}
  229. />
  230. <div
  231. className="text-red-500 text-sm p-2 bg-red-50 rounded-lg border border-red-200"
  232. style={{ display: 'none' }}
  233. >
  234. 图片加载失败: {imgItem.image_url.url}
  235. </div>
  236. </div>
  237. ))}
  238. </div>
  239. )}
  240. {textContent && textContent.text && typeof textContent.text === 'string' && textContent.text.trim() !== '' && (
  241. <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' : ''}`}>
  242. <MarkdownRenderer
  243. content={textContent.text}
  244. className={message.role === 'user' ? 'user-message' : ''}
  245. animated={false}
  246. previousContentLength={0}
  247. />
  248. </div>
  249. )}
  250. </div>
  251. );
  252. }
  253. if (typeof message.content === 'string') {
  254. if (message.role === 'assistant') {
  255. if (finalDisplayableFinalContent && finalDisplayableFinalContent.trim() !== '') {
  256. // 获取上一次的内容长度
  257. let prevLength = 0;
  258. if (isThinkingStatus && lastContentRef.current) {
  259. // 只有当前内容包含上一次内容时,才使用上一次的长度
  260. if (finalDisplayableFinalContent.startsWith(lastContentRef.current)) {
  261. prevLength = lastContentRef.current.length;
  262. }
  263. }
  264. // 更新最后内容的引用
  265. if (isThinkingStatus) {
  266. lastContentRef.current = finalDisplayableFinalContent;
  267. }
  268. return (
  269. <div className="prose prose-xs sm:prose-sm prose-gray max-w-none overflow-x-auto text-xs sm:text-sm">
  270. <MarkdownRenderer
  271. content={finalDisplayableFinalContent}
  272. className=""
  273. animated={isThinkingStatus}
  274. previousContentLength={prevLength}
  275. />
  276. </div>
  277. );
  278. }
  279. } else {
  280. return (
  281. <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' : ''}`}>
  282. <MarkdownRenderer
  283. content={message.content}
  284. className={message.role === 'user' ? 'user-message' : ''}
  285. animated={false}
  286. previousContentLength={0}
  287. />
  288. </div>
  289. );
  290. }
  291. }
  292. return null;
  293. })()
  294. )}
  295. </div>
  296. );
  297. };
  298. export default MessageContent;