index.tsx 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. import React, { useEffect } from 'react';
  2. import './style.less';
  3. import ReactMarkdown from 'react-markdown';
  4. import { createFromIconfontCN, LoadingOutlined } from '@ant-design/icons';
  5. import { Spin, Tooltip } from 'antd';
  6. import { Column, Line, Pie } from '@ant-design/charts';
  7. export interface Message {
  8. id?: string; // 创建消息时赋予唯一 id
  9. sender: 'user' | 'bot';
  10. thinkContent?: string;
  11. content: string; // 可能包含纯文本或 HTML
  12. loading?: boolean;
  13. duration?: number; // 回答耗时(秒)
  14. collapse?: boolean; // 保存折叠状态
  15. complete?: boolean;
  16. contentType?: string;
  17. charts?: {
  18. type: 'LineChart' | 'ColumnChart' | 'PieChart';
  19. xField: string;
  20. yField: string;
  21. angleField: string;
  22. colorField: string;
  23. data: any[];
  24. };
  25. }
  26. const IconFont = createFromIconfontCN({
  27. scriptUrl: '', // 请填写图标脚本地址
  28. });
  29. const antIcon = <LoadingOutlined style={{ fontSize: 14, color: '#17181A' }} spin />;
  30. // 单独封装一个 memoized 的 Spinner 组件,避免频繁重绘导致动画重置
  31. const SpinnerComponent: React.FC<{ loading: boolean }> = React.memo(({ loading }) => {
  32. return loading ? <Spin indicator={antIcon} style={{ paddingRight: 8 }} /> : null;
  33. });
  34. // 拆分机器人消息中的状态部分(头像旁边那块)
  35. // 新增属性 hasDeepThinking,根据该属性显示不同的文案
  36. interface BotMessageStatusProps {
  37. loading: boolean;
  38. duration?: number;
  39. collapse: boolean;
  40. onToggleCollapse: () => void;
  41. hasDeepThinking: boolean;
  42. }
  43. const BotMessageStatus: React.FC<BotMessageStatusProps> = React.memo(
  44. ({ loading, duration, collapse, onToggleCollapse, hasDeepThinking }) => {
  45. return (
  46. <div className="bot-message-status">
  47. {loading ? (
  48. <>
  49. <SpinnerComponent loading={loading} />
  50. {hasDeepThinking ? '深度思考中…' : '内容输出中…'}
  51. </>
  52. ) : (
  53. <>
  54. {hasDeepThinking ? `已深度思考${duration ? `(用时${duration}秒)` : ''}` : `内容已输出完毕${duration ? `(用时${duration}秒)` : ''}`}
  55. {hasDeepThinking && <IconFont type={collapse ? 'iconxiangxia' : 'iconxiangshang'} onClick={onToggleCollapse} style={{ cursor: 'pointer', paddingLeft: 8, fontSize: 16 }} />}
  56. </>
  57. )}
  58. </div>
  59. );
  60. },
  61. (prevProps, nextProps) =>
  62. prevProps.loading === nextProps.loading && prevProps.duration === nextProps.duration && prevProps.collapse === nextProps.collapse && prevProps.hasDeepThinking === nextProps.hasDeepThinking,
  63. );
  64. // 拆分机器人消息中的文本内容部分,同时处理图表展示
  65. interface BotMessageContentProps {
  66. thinkContent?: string;
  67. content: string;
  68. msg: Message;
  69. charts?: {
  70. type: string;
  71. xField: string;
  72. yField: string;
  73. angleField: string;
  74. colorField: string;
  75. data: any[];
  76. };
  77. // 新增 onShowDetail 回调属性,用于点击详情按钮时触发
  78. onShowDetail?: (charts: any, content: string) => void;
  79. }
  80. const BotMessageContent: React.FC<BotMessageContentProps> = React.memo(({ thinkContent, content, charts, onShowDetail, msg }) => {
  81. return (
  82. <div className="bot-message-text">
  83. <div className="think-text">{thinkContent}</div>
  84. <div className="msg-content">
  85. {/* 判断是否存在图表数据 */}
  86. {charts && charts.data && charts.data.length > 0 && (
  87. <div className={msg.content.length > 0 && msg.complete ? 'charts-content canReadDetail' : 'charts-content'}>
  88. <div className="charts-detail-btn" onClick={() => onShowDetail && onShowDetail(charts, content)}>
  89. <IconFont type={'iconchakan'} style={{ fontSize: 16, paddingRight: 4 }} />
  90. 详情
  91. </div>
  92. {(charts.type === 'LineChart' && charts) && <Line {...charts} />}
  93. {charts.type === 'ColumnChart' && <Column {...charts} />}
  94. {charts.type === 'PieChart' && <Pie {...charts} />}
  95. </div>
  96. )}
  97. <ReactMarkdown skipHtml={false}>{content}</ReactMarkdown>
  98. </div>
  99. </div>
  100. );
  101. });
  102. // 整体的机器人消息组件
  103. interface BotMessageProps {
  104. msg: Message;
  105. onToggleCollapse: () => void;
  106. handleCopy: (text: string) => void;
  107. ifCopied: boolean;
  108. // 新增 onShowDetail 回调属性
  109. onShowDetail?: (charts: any, content: string) => void;
  110. }
  111. const BotMessage: React.FC<BotMessageProps> = ({ msg, onToggleCollapse, handleCopy, ifCopied, onShowDetail }) => {
  112. const isCollapsed = msg.collapse || false;
  113. // 通过判断 thinkContent 是否存在且非空来确定是否存在深度思考内容
  114. const hasDeepThinking = !!(msg.thinkContent && msg.thinkContent.trim().length > 0);
  115. return (
  116. <div className="bot-message-content">
  117. <div className="bot-message-avatar">
  118. <img src={require('../images/ai-avator.png')} alt="" />
  119. <BotMessageStatus loading={msg.loading || false} duration={msg.duration} collapse={isCollapsed} onToggleCollapse={onToggleCollapse} hasDeepThinking={hasDeepThinking} />
  120. </div>
  121. <BotMessageContent
  122. thinkContent={isCollapsed ? '' : msg.thinkContent}
  123. content={msg.content}
  124. msg={msg}
  125. charts={msg.charts} // 传递图表数据
  126. onShowDetail={onShowDetail}
  127. />
  128. {msg.content.length > 0 && msg.complete && (
  129. <Tooltip title={ifCopied ? '已复制' : '点击复制'}>
  130. <div className="copyBtn" onClick={() => handleCopy(msg.content)}>
  131. <IconFont type={'iconfuzhi'} style={{ cursor: 'pointer', fontSize: 16 }} />
  132. </div>
  133. </Tooltip>
  134. )}
  135. </div>
  136. );
  137. };
  138. interface ChatProps {
  139. messages: Message[];
  140. chatContainerRef: React.RefObject<HTMLDivElement>;
  141. setMessages: React.Dispatch<React.SetStateAction<Message[]>>;
  142. // 新增 onShowDetail 回调属性,用于将详情数据传递到上层组件(例如 AiChat)
  143. onShowDetail?: (charts: any, content: string) => void;
  144. }
  145. const Chat: React.FC<ChatProps> = ({ messages, chatContainerRef, setMessages, onShowDetail }) => {
  146. const [ifCopied, setIfCopied] = React.useState<boolean>(false);
  147. const fallbackCopyTextToClipboard = (text: string) => {
  148. const textArea = document.createElement('textarea');
  149. textArea.value = text;
  150. textArea.style.position = 'fixed';
  151. textArea.style.top = '0';
  152. textArea.style.left = '0';
  153. textArea.style.width = '2em';
  154. textArea.style.height = '2em';
  155. textArea.style.padding = '0';
  156. textArea.style.border = 'none';
  157. textArea.style.outline = 'none';
  158. textArea.style.boxShadow = 'none';
  159. textArea.style.background = 'transparent';
  160. document.body.appendChild(textArea);
  161. textArea.select();
  162. try {
  163. const successful = document.execCommand('copy');
  164. if (successful) setIfCopied(true);
  165. } catch (err) {
  166. console.error('复制失败', err);
  167. }
  168. document.body.removeChild(textArea);
  169. };
  170. const handleCopy = (text: string) => {
  171. if (!text) {
  172. console.error('未获取到文本内容');
  173. return;
  174. }
  175. if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
  176. navigator.clipboard
  177. .writeText(text)
  178. .then(() => {
  179. setIfCopied(true);
  180. })
  181. .catch((err) => {
  182. console.error('使用 Clipboard API 复制失败,尝试备用方案:', err);
  183. fallbackCopyTextToClipboard(text);
  184. });
  185. } else {
  186. fallbackCopyTextToClipboard(text);
  187. }
  188. };
  189. useEffect(() => {
  190. if (ifCopied) {
  191. const timer = setTimeout(() => {
  192. setIfCopied(false);
  193. }, 2000);
  194. return () => clearTimeout(timer);
  195. }
  196. }, [ifCopied]);
  197. return (
  198. <div className="chat-container" ref={chatContainerRef}>
  199. {messages.map((msg, idx) => {
  200. const key = msg.id || idx;
  201. if (msg.sender === 'user') {
  202. return (
  203. <div key={key} className="chat-message user-message">
  204. <div>{msg.content}</div>
  205. </div>
  206. );
  207. } else {
  208. return (
  209. <div key={key} className="chat-message bot-message">
  210. <BotMessage
  211. msg={msg}
  212. onToggleCollapse={() => {
  213. setMessages((prevMessages) => prevMessages.map((m, i) => (i === idx ? { ...m, collapse: !m.collapse } : m)));
  214. }}
  215. handleCopy={handleCopy}
  216. ifCopied={ifCopied}
  217. onShowDetail={onShowDetail}
  218. />
  219. </div>
  220. );
  221. }
  222. })}
  223. </div>
  224. );
  225. };
  226. export default Chat;